diff --git a/packages/cli/src/producer/evidence.test.ts b/packages/cli/src/producer/evidence.test.ts index 283351b..686170d 100644 --- a/packages/cli/src/producer/evidence.test.ts +++ b/packages/cli/src/producer/evidence.test.ts @@ -50,6 +50,47 @@ describe('buildFileIndex', () => { it('returns an empty index for a repository with no tracked files', () => { expect(buildFileIndex([])).toEqual({}); }); + + it('excludes the producer\'s own artifact, so the index converges', () => { + // Regression, caught by the META-198 conformance suite. Run 1 emits N keys; + // run 2 emitted N+1 because `.agents/workspace.json` now existed on disk + // and the scanner reported it. The material projection therefore changed + // with no repository change, which breaks `generate --check` as a CI gate + // on every repository's first run after adoption. + const files = ['src/a.ts', '.agents/workspace.json']; + expect(Object.keys(buildFileIndex(files, ['.agents/workspace.json']))).toEqual(['src/a.ts']); + + // Convergence is the property that actually matters: the index a repo + // produces before its artifact exists must equal the one produced after. + expect(JSON.stringify(buildFileIndex(['src/a.ts'], ['.agents/workspace.json']))) + .toBe(JSON.stringify(buildFileIndex(files, ['.agents/workspace.json']))); + }); + + it('excludes a producer-owned directory and everything beneath it', () => { + const files = [ + 'src/a.ts', + '.agents/audit-history/2026-01-01.json', + '.agents/audit-history/nested/2026-01-02.json', + ]; + expect(Object.keys(buildFileIndex(files, ['.agents/audit-history']))).toEqual(['src/a.ts']); + }); + + it('does not exclude a repository file that merely shares a prefix', () => { + // `includes()` or a bare `startsWith` without the separator would drop this + // real file, silently removing evidence the consumer needs. + const files = ['.agents/audit-history-notes.md', '.agents/audit-history/run.json']; + expect(Object.keys(buildFileIndex(files, ['.agents/audit-history']))).toEqual([ + '.agents/audit-history-notes.md', + ]); + }); + + it('indexes everything when no producer outputs are declared', () => { + // The parameter is optional, so existing callers keep their behavior. + expect(Object.keys(buildFileIndex(['src/a.ts', '.agents/workspace.json']))).toEqual([ + '.agents/workspace.json', + 'src/a.ts', + ]); + }); }); describe('buildFrameworkManifest', () => { diff --git a/packages/cli/src/producer/evidence.ts b/packages/cli/src/producer/evidence.ts index d8eae6e..1588598 100644 --- a/packages/cli/src/producer/evidence.ts +++ b/packages/cli/src/producer/evidence.ts @@ -42,8 +42,30 @@ function toIndexKey(file: string): string { * META-248 joined by `hasOwnProperty(fileIndex, key)` and never read a value, * so an empty index made every such join silently return zero rows. */ -export function buildFileIndex(files: string[]): Record { - const keys = [...new Set(files.map(toIndexKey).filter(Boolean))].sort(); +export function buildFileIndex( + files: string[], + producerOutputs: readonly string[] = [], +): Record { + // The producer's own output is not repository evidence, and indexing it makes + // the artifact non-convergent: run 1 emits 6 keys, run 2 emits 7 because + // `.agents/workspace.json` now exists on disk and the scanner sees it. The + // material projection therefore changes with no repository change, which + // makes `generate --check` fail on every repository's first CI run after + // adoption. It self-corrects from run 3 onward, so running `generate` twice + // locally hides it entirely — only generate-then-check exposes it, and that + // is exactly the CI path. Caught by the META-198 conformance suite. + // Entries match either exactly (a file, `.agents/workspace.json`) or as a + // directory prefix (`.agents/audit-history` excludes everything beneath it). + // A bare `includes()` would be wrong: `.agents/audit-history-notes.md` is a + // repository file that merely shares a prefix, and excluding it would drop + // real evidence. + const excluded = producerOutputs.map(toIndexKey).filter(Boolean); + const isProducerOutput = (key: string): boolean => + excluded.some((output) => key === output || key.startsWith(`${output}/`)); + + const keys = [...new Set(files.map(toIndexKey).filter(Boolean))] + .filter((key) => !isProducerOutput(key)) + .sort(); const index: Record = {}; for (const key of keys) index[key] = {}; return index; diff --git a/packages/cli/src/producer/generate.ts b/packages/cli/src/producer/generate.ts index 78b6a85..fb65ea6 100644 --- a/packages/cli/src/producer/generate.ts +++ b/packages/cli/src/producer/generate.ts @@ -223,7 +223,13 @@ export async function generateWorkspaceJson( .slice() .sort((a, b) => a.lineNumber - b.lineNumber) .map((c) => ({ raw: c.raw, type: c.type, canonical: c.canonical })), - fileIndex: buildFileIndex(repo.files), + // Producer-owned outputs are excluded: they are this tool's own writes, + // not repository evidence, and indexing them makes the artifact + // non-convergent. See buildFileIndex for the failure mode. + fileIndex: buildFileIndex(repo.files, [ + relative(resolvedRoot, outputPath), + fullConfig.reportDir, + ]), topology: { packageCount: repo.packages.length, type: repo.isMonorepo ? 'monorepo' : 'single-package',