diff --git a/migration/parity-expected-differences.txt b/migration/parity-expected-differences.txt index c84a67d..ba96787 100644 --- a/migration/parity-expected-differences.txt +++ b/migration/parity-expected-differences.txt @@ -37,14 +37,38 @@ exports # # v0.4 is a strict superset of v0.3 (workspacejson/standard docs/versioning.md), # and the regenerated artifact validates against published @workspacejson/spec@0.4.4 -# via both validate() and validateV4(). Verified diffs are ONLY: -# "specVersion": "0.3" -> "0.4" -# + "conventions": [...] +# via both validate() and validateV4(). +# +# META-195 then populated the two fields the producer had been emitting empty or +# unbacked. Verified diffs are now ONLY: +# "specVersion": "0.3" -> "0.4" (META-203) +# + "conventions": [...] (META-203) +# "fileIndex": {} -> the tracked file inventory (META-195) +# "frameworkManifest": corroborated entries only (META-195) +# +# On fileIndex: keys are repository-root-relative POSIX paths from `git ls-files`, +# entries are empty. The per-file values the schema names are all behavioral and +# only derivable from git history, which META-195's experimental boundary keeps +# out of the stable contract pending the VR-526 ruling. +# +# On frameworkManifest: the old producer emitted every AGENTS.md framework token +# at a hardcoded confidence 0.5 — below the >= 0.7 floor the schema documents for +# this field, so a consumer filtering at the threshold read an empty manifest. +# Entries are now those a declared manifest dependency corroborates by exact +# case-insensitive name, at 0.9. The count can therefore DROP against the frozen +# source: a token nothing backs is omitted rather than published at a confidence +# that fails its own contract. Matching is exact because containment is not +# detection — `vite` is a substring of `vitest`, so a repository installing only +# vitest would have published `vite` at 0.9. This under-reports tokens whose +# dependency is named differently (next.js -> next, tailwind -> tailwindcss), +# because @workspacejson/rules keeps its token -> dependency map internal and +# re-typing it here would fork standard-owned knowledge. +# # The producer stamp, manual preservation, refusal/force behavior, exit codes and # every other command are unchanged. agents-audit generate --dry-run -# Same change, observed on the written artifact rather than the printed -# projection. `generate`, `generate --check` and `generate --force` console -# output are all unchanged; only the artifact bytes differ. +# Same changes (META-203 and META-195), observed on the written artifact rather +# than the printed projection. `generate`, `generate --check` and `generate +# --force` console output are all unchanged; only the artifact bytes differ. artifact-equivalence diff --git a/packages/cli/src/producer/evidence.test.ts b/packages/cli/src/producer/evidence.test.ts new file mode 100644 index 0000000..283351b --- /dev/null +++ b/packages/cli/src/producer/evidence.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { buildFileIndex, buildFrameworkManifest } from './evidence.js'; + +/** `RepoState['manifests']` shape, narrowed to what the builder reads. */ +function manifests(...dependencyLists: string[][]) { + return dependencyLists.map((dependencies, i) => ({ + type: 'package.json' as const, + path: `pkg-${i}/package.json`, + dependencies, + })); +} + +describe('buildFileIndex', () => { + it('keys every tracked file by repository-root-relative POSIX path', () => { + expect(Object.keys(buildFileIndex(['src/a.ts', './src/b.ts', 'src\\c.ts']))).toEqual([ + 'src/a.ts', + 'src/b.ts', + 'src/c.ts', + ]); + }); + + it('orders keys deterministically, so the drift gate stays usable', () => { + // Insertion order is what JSON.stringify writes, so an unsorted index would + // churn the artifact bytes between runs even with identical input. + const scrambled = ['z.ts', 'a.ts', 'M.ts', 'src/b.ts', '.github/workflows/ci.yml']; + const keys = Object.keys(buildFileIndex(scrambled)); + expect(keys).toEqual([...keys].sort()); + expect(keys).toEqual(Object.keys(buildFileIndex([...scrambled].reverse()))); + }); + + it('is byte-stable regardless of the order the scanner reports files in', () => { + // Serialized bytes, not just key sets: `git ls-files` order is not a + // contract, and JSON.stringify writes insertion order. + const files = ['src/b.ts', 'README.md', 'src/a.ts']; + expect(JSON.stringify(buildFileIndex(files))).toBe(JSON.stringify(buildFileIndex([...files].reverse()))); + }); + + it('claims nothing about a file it cannot observe', () => { + // The schema's per-file values (fragility, modification counts) are all + // behavioral and only derivable from git, which META-195's experimental + // boundary keeps out of the stable contract pending VR-526. Keys are the + // contribution; an empty entry asserts existence and nothing more. + expect(Object.values(buildFileIndex(['src/a.ts']))).toEqual([{}]); + }); + + it('dedupes and drops empty entries', () => { + expect(Object.keys(buildFileIndex(['src/a.ts', './src/a.ts', '', 'src/a.ts']))).toEqual(['src/a.ts']); + }); + + it('returns an empty index for a repository with no tracked files', () => { + expect(buildFileIndex([])).toEqual({}); + }); +}); + +describe('buildFrameworkManifest', () => { + it('emits a corroborated framework above the schema-documented 0.7 floor', () => { + const entries = buildFrameworkManifest(['react'], manifests(['react', 'typescript'])); + expect(entries).toEqual([{ name: 'react', confidence: 0.9 }]); + expect(entries[0]!.confidence).toBeGreaterThanOrEqual(0.7); + }); + + it('omits a token no dependency corroborates', () => { + // The pre-META-195 emitter published every AGENTS.md token at a hardcoded + // 0.5 — below the floor the schema documents, so a consumer filtering at + // >= 0.7 read an empty manifest. An unbacked mention is not detection. + expect(buildFrameworkManifest(['react'], manifests(['express']))).toEqual([]); + }); + + it('emits nothing when the repository declares no manifests at all', () => { + expect(buildFrameworkManifest(['react', 'vue'], [])).toEqual([]); + }); + + it('corroborates case-insensitively and across every manifest', () => { + expect(buildFrameworkManifest(['django', 'Flask'], manifests(['Django'], ['flask']))).toEqual([ + { name: 'django', confidence: 0.9 }, + { name: 'flask', confidence: 0.9 }, + ]); + }); + + it('does not accept a dependency that merely contains the token', () => { + // Substring containment is not detection. `vite` is contained in `vitest`, + // and a repository that installs only vitest is entirely ordinary — so + // substring matching published a framework the repository does not use, at + // the confidence that tells a consumer to trust it. + expect(buildFrameworkManifest(['vite'], manifests(['vitest']))).toEqual([]); + expect(buildFrameworkManifest(['rest'], manifests(['interest']))).toEqual([]); + expect(buildFrameworkManifest(['next.js'], manifests(['next']))).toEqual([]); + }); + + it('still corroborates the token when the dependency is exactly it', () => { + // The guard above must not be so strict that nothing survives it. + expect(buildFrameworkManifest(['vite'], manifests(['vite', 'vitest']))).toEqual([ + { name: 'vite', confidence: 0.9 }, + ]); + }); + + it('dedupes tokens that differ only by case', () => { + expect(buildFrameworkManifest(['React', 'react'], manifests(['react']))).toEqual([ + { name: 'react', confidence: 0.9 }, + ]); + }); + + it('orders entries deterministically, so the drift gate stays usable', () => { + // `stable()` sorts object keys but preserves ARRAY order, so this array is + // inside the material projection unsorted unless the builder sorts it. + const deps = manifests(['vite', 'react', 'zod']); + const entries = buildFrameworkManifest(['zod', 'react', 'vite'], deps); + expect(entries.map((e) => e.name)).toEqual(['react', 'vite', 'zod']); + expect(entries).toEqual(buildFrameworkManifest(['vite', 'zod', 'react'], deps)); + }); + + it('omits a token whose dependency is published under a different name', () => { + // `tailwind` is a known token but the package is `tailwindcss`, so exact + // matching drops it. This is the accepted cost of not forking the variant + // map out of @workspacejson/rules: absent, not wrong. When AGENTS.md says + // "tailwindcss" the parser also yields that token, which does corroborate. + expect(buildFrameworkManifest(['tailwind'], manifests(['tailwindcss']))).toEqual([]); + expect(buildFrameworkManifest(['tailwind', 'tailwindcss'], manifests(['tailwindcss']))).toEqual([ + { name: 'tailwindcss', confidence: 0.9 }, + ]); + }); +}); diff --git a/packages/cli/src/producer/evidence.ts b/packages/cli/src/producer/evidence.ts new file mode 100644 index 0000000..d8eae6e --- /dev/null +++ b/packages/cli/src/producer/evidence.ts @@ -0,0 +1,97 @@ +import type { FileIndexEntry, FrameworkEntry } from '@workspacejson/spec'; +import type { RepoState } from '@workspacejson/rules'; + +/** + * Both builders here emit into `generated`, which sits INSIDE the material + * projection (`generate.ts`'s `generatedProjection` excludes only `generatedAt` + * and `by`). Anything non-deterministic they produce makes every run report + * drift, rewrite the artifact and break `generate --check` as a CI gate. + * + * Two consequences shape everything below: + * + * - Ordering is explicit. `stable()` sorts object keys but PRESERVES array + * order, so arrays must be sorted here or the gate is unusable. + * - Nothing may derive from wall-clock time. `RepoState.gitHistory` is a + * moving 30-day window (`git log --since=30 days ago`) that changes with no + * commits at all, and its fallback — when git is absent or the checkout is + * shallow — is "every file". Neither builder touches it. + * + * Sorting uses the default comparator (UTF-16 code unit order), never + * `localeCompare`, which varies with the host locale and would make the written + * bytes environment-dependent. + */ + +/** Repository-root-relative POSIX, no leading "./" — the `fileIndex` key contract. */ +function toIndexKey(file: string): string { + return file.replace(/\\/g, '/').replace(/^\.\//, ''); +} + +/** + * The repository's tracked file inventory, keyed per `@workspacejson/spec`'s + * `fileIndex` contract. + * + * Entries are intentionally empty. The per-file *values* the schema names — + * `fragility`, `aiModificationCount`, `humanModificationCount` — are all + * behavioral and the only evidence available for them is git-derived, which + * META-195's experimental boundary keeps harness-side pending the VR-526 + * ruling. Emitting a weak git-scan number into the stable contract is the + * precise thing that dispute is about, so this emits the keys and claims + * nothing about them. + * + * Keys alone are the load-bearing part. The consumer adapter extracted under + * 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(); + const index: Record = {}; + for (const key of keys) index[key] = {}; + return index; +} + +/** + * Frameworks corroborated by a declared dependency. + * + * The schema describes this field as "Detected frameworks (confidence >= 0.7)", + * so a token that nothing corroborates does not belong in it. Every entry the + * producer emitted before META-195 was a bare AGENTS.md token at a hardcoded + * `0.5` — below that floor, universally — which meant a consumer filtering at + * the documented threshold read an empty manifest. + * + * Corroboration is case-insensitive EXACT equality against the union of + * manifest dependencies. Substring containment was tried and is wrong at this + * confidence: `vite` is contained in `vitest`, so a repository that installs + * only `vitest` — an entirely ordinary setup — would publish `vite` at 0.9. + * Lexical overlap is not detection, and a false entry is far worse here than a + * missing one, because 0.9 tells a consumer to trust it. + * + * It deliberately under-reports. `@workspacejson/rules` keeps its token -> + * dependency variant map internal (neither `FRAMEWORK_MANIFEST_MAP` nor + * `KNOWN_FRAMEWORKS` is exported), and re-typing that table here would fork + * standard-owned knowledge — the split-brain META-200 exists to prevent. So a + * token whose dependency is published under a different name (`next.js` -> + * `next`, `nestjs` -> `@nestjs/core`, `drizzle` -> `drizzle-orm`, `tailwind` -> + * `tailwindcss`) is omitted rather than guessed. Omission is the safe failure: + * absent, not wrong. Exporting that map from `workspacejson/standard` is the + * real fix and is deliberately left to a follow-up, so this does not couple a + * producer change to a cross-repository contract change. + * + * Note this is NOT the same test `frameworkDrift` performs. That rule looks a + * token up in the variant map, skips it entirely when unmapped, and only then + * compares — so it never matches on a raw token the way this must. + */ +export function buildFrameworkManifest( + frameworkTokens: string[], + manifests: RepoState['manifests'], +): FrameworkEntry[] { + const dependencies = new Set( + manifests.flatMap((manifest) => manifest.dependencies).map((d) => d.toLowerCase()), + ); + // Normalize before deduping so `React` and `react` cannot both survive. + const corroborated = [ + ...new Set( + frameworkTokens.map((token) => token.toLowerCase()).filter((token) => dependencies.has(token)), + ), + ].sort(); + return corroborated.map((name) => ({ name, confidence: 0.9 })); +} diff --git a/packages/cli/src/producer/generate.ts b/packages/cli/src/producer/generate.ts index 17e25f6..78b6a85 100644 --- a/packages/cli/src/producer/generate.ts +++ b/packages/cli/src/producer/generate.ts @@ -19,6 +19,7 @@ import { } from '@workspacejson/rules'; import type { RuleContext } from '@workspacejson/rules'; import { DEFAULT_PRODUCER_CONFIG, detectCiProvider, type ProducerConfig } from './config.js'; +import { buildFileIndex, buildFrameworkManifest } from './evidence.js'; import { findAgentsMdPath, readTextOrEmpty } from './fs.js'; const _require = createRequire(import.meta.url); @@ -213,7 +214,7 @@ export async function generateWorkspaceJson( specVersion: '0.4', generatedAt: now, by: { name: producer.name, version: producer.version }, - frameworkManifest: agentsMd.frameworkTokens.map((name) => ({ name, confidence: 0.5 })), + frameworkManifest: buildFrameworkManifest(agentsMd.frameworkTokens, repo.manifests), // Restored from the expression a3fa85a deleted (META-203). Sorted by // source line because `conventions` sits INSIDE the material projection // and `stable()` preserves array order — unsorted output would make every @@ -222,7 +223,7 @@ export async function generateWorkspaceJson( .slice() .sort((a, b) => a.lineNumber - b.lineNumber) .map((c) => ({ raw: c.raw, type: c.type, canonical: c.canonical })), - fileIndex: {}, + fileIndex: buildFileIndex(repo.files), topology: { packageCount: repo.packages.length, type: repo.isMonorepo ? 'monorepo' : 'single-package', diff --git a/packages/cli/src/producer/producer-conformance.test.ts b/packages/cli/src/producer/producer-conformance.test.ts index 3db5907..c8ccd0d 100644 --- a/packages/cli/src/producer/producer-conformance.test.ts +++ b/packages/cli/src/producer/producer-conformance.test.ts @@ -1,5 +1,7 @@ +import { execFileSync } from 'node:child_process'; import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; +import { dirname, resolve } from 'node:path'; +import { WorkspaceJsonValidator } from '@workspacejson/rules'; import { afterEach, describe, expect, it } from 'vitest'; import { GenerateRefusalError, generateWorkspaceJson, writeWorkspaceAtomically } from './generate.js'; @@ -226,3 +228,128 @@ describe('generateWorkspaceJson — conventions emitter (META-203)', () => { expect(result.content.generated.fragility).toBeUndefined(); }); }); + +describe('generateWorkspaceJson — fileIndex and frameworkManifest (META-195)', () => { + const clean: string[] = []; + + /** + * A real git repository with tracked files. `RepoScanner` reads the inventory + * via `git ls-files`, so a plain directory yields an empty one — these cases + * are about what lands once there is actually something to index. + */ + async function trackedRepo(files: Record): Promise { + const root = resolve(process.cwd(), `.tmp-idx-${Date.now()}-${Math.random().toString(36).slice(2)}`); + clean.push(root); + await mkdir(root, { recursive: true }); + for (const [path, content] of Object.entries(files)) { + await mkdir(dirname(resolve(root, path)), { recursive: true }); + await writeFile(resolve(root, path), content, 'utf8'); + } + // `stdio: 'pipe'` captures rather than prints, so this stays quiet. It is + // also the only value `types/ambient.d.ts` declares for `node:child_process` + // — the same shadowing of Node builtins OWNERSHIP.md flags as a follow-up. + const git = (...args: string[]): void => { + execFileSync('git', args, { cwd: root, stdio: 'pipe' }); + }; + git('init', '-q'); + git('config', 'user.email', 'test@example.com'); + git('config', 'user.name', 'Test'); + git('add', '-A'); + git('commit', '-qm', 'fixture'); + return root; + } + + afterEach(async () => { + await Promise.all(clean.splice(0).map((path) => rm(path, { recursive: true, force: true }))); + }); + + it('indexes every tracked file by repository-root-relative POSIX path', async () => { + const root = await trackedRepo({ + 'src/a.ts': 'export const a = 1;\n', + 'src/nested/b.ts': 'export const b = 2;\n', + 'README.md': '# fixture\n', + }); + + const result = await generateWorkspaceJson(root, {}, { dryRun: true }); + const fileIndex = result.content.generated.fileIndex as Record; + + expect(Object.keys(fileIndex)).toEqual(['README.md', 'src/a.ts', 'src/nested/b.ts']); + }); + + it('no longer emits an empty fileIndex, which is why downstream joins returned zero rows', async () => { + const root = await trackedRepo({ 'models/customers.sql': 'select 1\n' }); + + const result = await generateWorkspaceJson(root, {}, { dryRun: true }); + const fileIndex = result.content.generated.fileIndex as Record; + + // The consumer adapter extracted under META-248 joined by key presence + // alone — `hasOwnProperty`, never a value. An empty index made every such + // join silently return 0/N. + expect(Object.prototype.hasOwnProperty.call(fileIndex, 'models/customers.sql')).toBe(true); + }); + + it('emits only frameworks a declared dependency corroborates', async () => { + const root = await trackedRepo({ + 'package.json': JSON.stringify({ name: 'f', dependencies: { react: '18.0.0' } }, null, 2), + 'AGENTS.md': '# Agents\n\nBuilt with react. We also considered vue.\n', + }); + + const result = await generateWorkspaceJson(root, {}, { dryRun: true }); + const manifest = result.content.generated.frameworkManifest as Array<{ name: string; confidence: number }>; + + expect(manifest.map((e) => e.name)).toEqual(['react']); + // Every pre-META-195 entry was a bare token at 0.5 — under the floor the + // schema documents ("Detected frameworks (confidence >= 0.7)"), so a + // consumer filtering at the threshold saw nothing. + for (const entry of manifest) expect(entry.confidence).toBeGreaterThanOrEqual(0.7); + }); + + it('stays materially unchanged across runs, so generate --check survives as a CI gate', async () => { + const root = await trackedRepo({ + 'package.json': JSON.stringify({ name: 'f', dependencies: { react: '18.0.0' } }, null, 2), + 'AGENTS.md': '# Agents\n\nBuilt with react.\n', + 'src/a.ts': 'export const a = 1;\n', + 'src/b.ts': 'export const b = 2;\n', + }); + + await generateWorkspaceJson(root); + const second = await generateWorkspaceJson(root); + + // The whole point of the determinism constraint: both fields sit inside the + // material projection, so any churn here rewrites the artifact every run. + expect(second.skipped).toBe(true); + expect(second.drift).toBe(false); + expect(second.written).toBe(false); + }); + + it('emits byte-identical fields on repeated generation of an unchanged repository', async () => { + const root = await trackedRepo({ 'src/a.ts': 'export const a = 1;\n' }); + + const first = await generateWorkspaceJson(root, {}, { dryRun: true }); + const later = await generateWorkspaceJson(root, {}, { dryRun: true }); + + // This does NOT prove immunity to wall-clock movement — git runs in a + // subprocess, so no in-process clock fake reaches `git log --since=30 days + // ago`. That immunity is structural instead: neither builder is passed + // `RepoState.gitHistory`, which is the moving 30-day window whose no-git + // fallback is "every file". Their signatures take only `files` and + // `(tokens, manifests)`, so there is nothing time-varying to read. + expect(JSON.stringify(first.content.generated.fileIndex)) + .toBe(JSON.stringify(later.content.generated.fileIndex)); + expect(JSON.stringify(first.content.generated.frameworkManifest)) + .toBe(JSON.stringify(later.content.generated.frameworkManifest)); + }); + + it('still validates against the published schema once populated', async () => { + const root = await trackedRepo({ + 'package.json': JSON.stringify({ name: 'f', dependencies: { react: '18.0.0' } }, null, 2), + 'AGENTS.md': '# Agents\n\nBuilt with react.\n', + 'src/a.ts': 'export const a = 1;\n', + }); + + await generateWorkspaceJson(root); + const artifact = JSON.parse(await readFile(resolve(root, '.agents/workspace.json'), 'utf8')); + + expect(new WorkspaceJsonValidator().validate(artifact)).toEqual({ valid: true, errors: [] }); + }); +});