From 596c050dc1ea89f4bf7b343fed25b8becb100cdf Mon Sep 17 00:00:00 2001 From: Qwynn Marcelle Date: Sun, 26 Jul 2026 15:53:43 -0400 Subject: [PATCH 1/2] feat(producer): populate fileIndex and frameworkManifest (META-195) fileIndex was emitted as `{}` unconditionally and frameworkManifest as bare AGENTS.md tokens at a hardcoded confidence 0.5. Both now derive from repository evidence, deterministically. fileIndex is the tracked file inventory, keyed by repository-root-relative POSIX path, entries empty. Keys are the load-bearing part: the consumer adapter extracted under META-248 joined by key presence and never read a value, so an empty index made every such join return zero rows. The per-file values the schema names are behavioral and only derivable from git history, which META-195's experimental boundary keeps out of the stable contract pending the VR-526 ruling. frameworkManifest now emits only frameworks a declared manifest dependency corroborates, at 0.9. The schema documents this field as "Detected frameworks (confidence >= 0.7)", so every entry the producer emitted before sat under its own contract's floor and a consumer filtering at the threshold read an empty manifest. Corroboration matches the fallback branch frameworkDrift already uses, so the emitter and the rule that audits it agree. Both fields sit inside the material projection, so both are explicitly sorted with a locale-independent comparator and neither touches RepoState.gitHistory, which is a moving 30-day window whose no-git fallback is "every file". Updates the ratified parity baseline: artifact-equivalence and `generate --dry-run` were already expected differences from META-203, but their recorded reason no longer described the full diff. --- migration/parity-expected-differences.txt | 33 ++++- packages/cli/src/producer/evidence.test.ts | 91 +++++++++++++ packages/cli/src/producer/evidence.ts | 85 ++++++++++++ packages/cli/src/producer/generate.ts | 5 +- .../src/producer/producer-conformance.test.ts | 126 +++++++++++++++++- 5 files changed, 331 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/producer/evidence.test.ts create mode 100644 packages/cli/src/producer/evidence.ts diff --git a/migration/parity-expected-differences.txt b/migration/parity-expected-differences.txt index c84a67d..463927c 100644 --- a/migration/parity-expected-differences.txt +++ b/migration/parity-expected-differences.txt @@ -37,14 +37,35 @@ 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, 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. +# This under-reports tokens whose dependency is named differently (next.js -> +# next), 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..4e8b2bb --- /dev/null +++ b/packages/cli/src/producer/evidence.test.ts @@ -0,0 +1,91 @@ +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 across repeated builds of the same input', () => { + const files = ['src/b.ts', 'src/a.ts', 'README.md']; + expect(JSON.stringify(buildFileIndex(files))).toBe(JSON.stringify(buildFileIndex(files))); + }); + + 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: 'Flask', confidence: 0.9 }, + { name: 'django', 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('dedupes overlapping tokens', () => { + const entries = buildFrameworkManifest(['tailwind', 'tailwind'], manifests(['tailwindcss'])); + expect(entries).toEqual([{ name: 'tailwind', 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..28971a3 --- /dev/null +++ b/packages/cli/src/producer/evidence.ts @@ -0,0 +1,85 @@ +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 substring containment against the union of + * manifest dependencies, matching the fallback branch `frameworkDrift` already + * uses so the emitter and the rule that audits it agree. 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. Tokens whose dependency is + * named differently (`next.js` -> `next`, `nestjs` -> `@nestjs/core`) are + * therefore omitted rather than guessed. Omission is the safe failure: absent, + * not wrong. Exporting the map from `workspacejson/standard` closes the gap. + */ +export function buildFrameworkManifest( + frameworkTokens: string[], + manifests: RepoState['manifests'], +): FrameworkEntry[] { + const dependencies = manifests.flatMap((manifest) => manifest.dependencies).map((d) => d.toLowerCase()); + const corroborated = [ + ...new Set( + frameworkTokens.filter((token) => + dependencies.some((dependency) => dependency.includes(token.toLowerCase())), + ), + ), + ].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..34ae06b 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,125 @@ 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('does not drift when only wall-clock time moves', 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 }); + + // `RepoState.gitHistory` is a moving 30-day window and its no-git fallback + // is "every file". Neither field may derive from it, or an untouched + // repository reports drift as commits age out. + 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: [] }); + }); +}); From c8505ad6b99d184ac9c7244972460eae9ca54f42 Mon Sep 17 00:00:00 2001 From: Qwynn Marcelle Date: Sun, 26 Jul 2026 16:13:06 -0400 Subject: [PATCH 2/2] fix(producer): corroborate frameworks by exact name, not substring (META-195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substring containment awarded 0.9 confidence on lexical overlap. `vite` is a substring of `vitest`, so a repository installing only vitest — an ordinary setup, not an edge case — published `vite` at the confidence that tells a consumer to trust it. That inverts the field's whole point and contradicts this change's own "absent, not wrong" policy. Corroboration is now exact case-insensitive dependency equality, and tokens are normalized before deduping so `React` and `react` cannot both survive. Adds negative regressions (vite/vitest, rest/interest, next.js/next) plus a positive control so the guard cannot pass by rejecting everything. The tailwind -> tailwindcss expectation codified the old behavior as desirable; it now asserts the omission, alongside the corroborating `tailwindcss` token. Corrects two claims that were wrong rather than merely imprecise: - the comment said this matched `frameworkDrift`. It does not: that rule looks a token up in the internal variant map and skips it when unmapped, so it never matches on a raw token. - a conformance test was named for wall-clock movement it never performed. Git runs in a subprocess, so no in-process clock fake reaches it. The immunity is structural — neither builder is passed RepoState.gitHistory — and the test is renamed to what it actually establishes. --- migration/parity-expected-differences.txt | 15 ++++--- packages/cli/src/producer/evidence.test.ts | 45 ++++++++++++++++--- packages/cli/src/producer/evidence.ts | 40 +++++++++++------ .../src/producer/producer-conformance.test.ts | 11 +++-- 4 files changed, 80 insertions(+), 31 deletions(-) diff --git a/migration/parity-expected-differences.txt b/migration/parity-expected-differences.txt index 463927c..ba96787 100644 --- a/migration/parity-expected-differences.txt +++ b/migration/parity-expected-differences.txt @@ -54,12 +54,15 @@ exports # 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, 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. -# This under-reports tokens whose dependency is named differently (next.js -> -# next), because @workspacejson/rules keeps its token -> dependency map internal -# and re-typing it here would fork standard-owned knowledge. +# 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. diff --git a/packages/cli/src/producer/evidence.test.ts b/packages/cli/src/producer/evidence.test.ts index 4e8b2bb..283351b 100644 --- a/packages/cli/src/producer/evidence.test.ts +++ b/packages/cli/src/producer/evidence.test.ts @@ -28,9 +28,11 @@ describe('buildFileIndex', () => { expect(keys).toEqual(Object.keys(buildFileIndex([...scrambled].reverse()))); }); - it('is byte-stable across repeated builds of the same input', () => { - const files = ['src/b.ts', 'src/a.ts', 'README.md']; - expect(JSON.stringify(buildFileIndex(files))).toBe(JSON.stringify(buildFileIndex(files))); + 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', () => { @@ -70,8 +72,31 @@ describe('buildFrameworkManifest', () => { it('corroborates case-insensitively and across every manifest', () => { expect(buildFrameworkManifest(['django', 'Flask'], manifests(['Django'], ['flask']))).toEqual([ - { name: 'Flask', confidence: 0.9 }, { 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 }, ]); }); @@ -84,8 +109,14 @@ describe('buildFrameworkManifest', () => { expect(entries).toEqual(buildFrameworkManifest(['vite', 'zod', 'react'], deps)); }); - it('dedupes overlapping tokens', () => { - const entries = buildFrameworkManifest(['tailwind', 'tailwind'], manifests(['tailwindcss'])); - expect(entries).toEqual([{ name: 'tailwind', confidence: 0.9 }]); + 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 index 28971a3..d8eae6e 100644 --- a/packages/cli/src/producer/evidence.ts +++ b/packages/cli/src/producer/evidence.ts @@ -58,27 +58,39 @@ export function buildFileIndex(files: string[]): Record * `0.5` — below that floor, universally — which meant a consumer filtering at * the documented threshold read an empty manifest. * - * Corroboration is case-insensitive substring containment against the union of - * manifest dependencies, matching the fallback branch `frameworkDrift` already - * uses so the emitter and the rule that audits it agree. 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. Tokens whose dependency is - * named differently (`next.js` -> `next`, `nestjs` -> `@nestjs/core`) are - * therefore omitted rather than guessed. Omission is the safe failure: absent, - * not wrong. Exporting the map from `workspacejson/standard` closes the gap. + * 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 = manifests.flatMap((manifest) => manifest.dependencies).map((d) => d.toLowerCase()); + 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.filter((token) => - dependencies.some((dependency) => dependency.includes(token.toLowerCase())), - ), + 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/producer-conformance.test.ts b/packages/cli/src/producer/producer-conformance.test.ts index 34ae06b..c8ccd0d 100644 --- a/packages/cli/src/producer/producer-conformance.test.ts +++ b/packages/cli/src/producer/producer-conformance.test.ts @@ -322,15 +322,18 @@ describe('generateWorkspaceJson — fileIndex and frameworkManifest (META-195)', expect(second.written).toBe(false); }); - it('does not drift when only wall-clock time moves', async () => { + 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 }); - // `RepoState.gitHistory` is a moving 30-day window and its no-git fallback - // is "every file". Neither field may derive from it, or an untouched - // repository reports drift as commits age out. + // 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))