fix(ingest): gate final wiki references

This commit is contained in:
Andrey Avtomonov 2026-05-17 22:10:58 +02:00
parent 32128ae3aa
commit 5ec639602b
2 changed files with 87 additions and 20 deletions

View file

@ -1,19 +1,38 @@
import { describe, expect, it, vi } from 'vitest';
import { validateFinalIngestArtifacts, validateProvenanceRawPaths } from './artifact-gates.js';
function wikiServiceWithPages(
pages: Record<string, { refs?: string[]; content?: string; slRefs?: string[] }>,
) {
return {
listPageKeys: vi.fn().mockResolvedValue(Object.keys(pages)),
readPage: vi.fn().mockImplementation((_scope: string, _scopeId: string | null, pageKey: string) => {
const page = pages[pageKey];
if (!page) {
return Promise.resolve(null);
}
return Promise.resolve({
pageKey,
frontmatter: {
summary: pageKey,
usage_mode: 'auto',
refs: page.refs,
sl_refs: page.slRefs,
},
content: page.content ?? '',
});
}),
};
}
describe('artifact gates', () => {
it('fails the final tree when wiki body references a stale semantic-layer measure', async () => {
const wikiService = {
readPage: vi.fn().mockResolvedValue({
pageKey: 'account-segments',
frontmatter: {
summary: 'Account segments',
usage_mode: 'auto',
sl_refs: ['mart_account_segments'],
},
const wikiService = wikiServiceWithPages({
'account-segments': {
slRefs: ['mart_account_segments'],
content: 'ARR is `mart_account_segments.total_contract_arr_cents`.',
}),
};
},
});
const semanticLayerService = {
loadAllSources: vi.fn().mockResolvedValue({
sources: [
@ -54,17 +73,12 @@ describe('artifact gates', () => {
});
it('fails measure-level wiki frontmatter sl_refs that point at missing entities', async () => {
const wikiService = {
readPage: vi.fn().mockResolvedValue({
pageKey: 'account-segments',
frontmatter: {
summary: 'Account segments',
usage_mode: 'auto',
sl_refs: ['mart_account_segments.total_contract_arr_cents'],
},
const wikiService = wikiServiceWithPages({
'account-segments': {
slRefs: ['mart_account_segments.total_contract_arr_cents'],
content: 'ARR uses a renamed measure.',
}),
};
},
});
const semanticLayerService = {
loadAllSources: vi.fn().mockResolvedValue({
sources: [
@ -147,4 +161,30 @@ describe('artifact gates', () => {
{ connectionId: 'warehouse', sourceName: 'segments' },
]);
});
it('fails final gates when a changed wiki page references a missing wiki page', async () => {
const wikiService = wikiServiceWithPages({
'account-segments': {
refs: ['missing-frontmatter-page'],
content: 'See [[missing-inline-page]] for the related process.',
},
});
const semanticLayerService = {
loadAllSources: vi.fn().mockResolvedValue({ sources: [], loadErrors: [] }),
};
await expect(
validateFinalIngestArtifacts({
connectionIds: ['warehouse'],
changedWikiPageKeys: ['account-segments'],
touchedSlSources: [],
wikiService: wikiService as never,
semanticLayerService: semanticLayerService as never,
validateTouchedSources: async () => ({ invalidSources: [], validSources: [] }),
tableExists: async () => true,
}),
).rejects.toThrow(
/wiki references target missing page\(s\): account-segments -> missing-frontmatter-page, account-segments -> missing-inline-page/,
);
});
});

View file

@ -1,6 +1,7 @@
import type { SemanticLayerService } from '../sl/index.js';
import type { TouchedSlSource } from '../tools/index.js';
import type { KnowledgeWikiService } from '../wiki/index.js';
import { findMissingWikiRefs } from '../wiki/wiki-ref-validation.js';
import { findInvalidWikiBodyRefs } from './wiki-body-refs.js';
export interface TouchedValidationResult {
@ -122,11 +123,37 @@ async function validateWikiSlRefs(input: FinalArtifactGateInput): Promise<string
return errors;
}
async function validateWikiRefs(input: FinalArtifactGateInput): Promise<string[]> {
const dangling: string[] = [];
for (const pageKey of input.changedWikiPageKeys) {
const page = await input.wikiService.readPage('GLOBAL', null, pageKey);
if (!page) {
continue;
}
const missingRefs = await findMissingWikiRefs({
wikiService: input.wikiService,
scope: 'GLOBAL',
scopeId: null,
pageKey,
refs: page.frontmatter.refs,
content: page.content,
});
for (const missingRef of missingRefs) {
dangling.push(`${pageKey} -> ${missingRef}`);
}
}
return dangling;
}
export async function validateFinalIngestArtifacts(input: FinalArtifactGateInput): Promise<void> {
const touchedWithDependencies = await expandTouchedSlSourcesWithDirectJoinNeighbors(input);
const validation = await input.validateTouchedSources(touchedWithDependencies);
const errors: string[] = validation.invalidSources.map((source) => `semantic-layer validation failed for ${source}`);
errors.push(...(await validateWikiSlRefs(input)));
const danglingWikiRefs = await validateWikiRefs(input);
if (danglingWikiRefs.length > 0) {
errors.push(`wiki references target missing page(s): ${danglingWikiRefs.join(', ')}`);
}
for (const pageKey of input.changedWikiPageKeys) {
const page = await input.wikiService.readPage('GLOBAL', null, pageKey);