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
36 changes: 30 additions & 6 deletions migration/parity-expected-differences.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
122 changes: 122 additions & 0 deletions packages/cli/src/producer/evidence.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
]);
});
});
97 changes: 97 additions & 0 deletions packages/cli/src/producer/evidence.ts
Original file line number Diff line number Diff line change
@@ -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<string, FileIndexEntry> {
const keys = [...new Set(files.map(toIndexKey).filter(Boolean))].sort();
const index: Record<string, FileIndexEntry> = {};
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 }));
}
5 changes: 3 additions & 2 deletions packages/cli/src/producer/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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',
Expand Down
Loading
Loading