Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/cli/src/producer/evidence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
26 changes: 24 additions & 2 deletions packages/cli/src/producer/evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, FileIndexEntry> {
const keys = [...new Set(files.map(toIndexKey).filter(Boolean))].sort();
export function buildFileIndex(
files: string[],
producerOutputs: readonly string[] = [],
): Record<string, FileIndexEntry> {
// 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<string, FileIndexEntry> = {};
for (const key of keys) index[key] = {};
return index;
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/producer/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading