From 5ec639602b51088b9a977564eb44fd35cd160193 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Sun, 17 May 2026 22:10:58 +0200 Subject: [PATCH] fix(ingest): gate final wiki references --- .../context/src/ingest/artifact-gates.test.ts | 80 ++++++++++++++----- packages/context/src/ingest/artifact-gates.ts | 27 +++++++ 2 files changed, 87 insertions(+), 20 deletions(-) diff --git a/packages/context/src/ingest/artifact-gates.test.ts b/packages/context/src/ingest/artifact-gates.test.ts index 523a6bf7..cc786409 100644 --- a/packages/context/src/ingest/artifact-gates.test.ts +++ b/packages/context/src/ingest/artifact-gates.test.ts @@ -1,19 +1,38 @@ import { describe, expect, it, vi } from 'vitest'; import { validateFinalIngestArtifacts, validateProvenanceRawPaths } from './artifact-gates.js'; +function wikiServiceWithPages( + pages: Record, +) { + 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/, + ); + }); }); diff --git a/packages/context/src/ingest/artifact-gates.ts b/packages/context/src/ingest/artifact-gates.ts index 51db7380..44f7a66f 100644 --- a/packages/context/src/ingest/artifact-gates.ts +++ b/packages/context/src/ingest/artifact-gates.ts @@ -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 { + 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 { 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);