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
98 changes: 98 additions & 0 deletions packages/agents-audit/src/spec-contract-visibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import { validate, validateV4, version } from '@workspacejson/spec';
import type { CoChangeEntry, FragilityEntry, WorkspaceJsonV4 } from '@workspacejson/spec';

/**
* META-244 regression guard: the CLI compiles against the REAL published
* `@workspacejson/spec` declarations, not a local copy of them.
*
* `types/ambient.d.ts` used to carry a handwritten
* `declare module '@workspacejson/spec'`. Ambient module declarations win over
* node_modules typings, so that stub shadowed the real package — and it omitted
* the entire v0.4 contract. Every symbol imported above exists ONLY in the real
* published package and was absent from the stub, so **reintroducing the shadow
* breaks this file at compile time** (TS2305 / TS2724), not at runtime.
*
* That is the point: this is a type-visibility test whose primary assertion is
* that it compiles at all. The runtime expectations below keep it honest under
* a test runner that strips types.
*/
describe('@workspacejson/spec contract visibility', () => {
it('exposes the published version as a value, not a local guess', () => {
// The removed stub declared `version: string`. The real package declares the
// literal "0.4.4" — so this also pins which contract we compiled against.
expect(version).toBe('0.4.4');
});

it('exposes validateV4, which the removed ambient stub did not declare', () => {
expect(validateV4).toBeTypeOf('function');
});

it('accepts a v0.4 artifact through the published validators', () => {
// Under the removed stub, `validate` was typed `data is WorkspaceJsonV3`
// only. The real declaration is `data is WorkspaceJsonV3 | WorkspaceJsonV4`,
// so a v0.4 artifact is a first-class member of the contract here.
const artifact: WorkspaceJsonV4 = {
manual: {},
generated: {
specVersion: '0.4',
generatedAt: '2026-07-26T00:00:00.000Z',
by: { name: 'agents-audit', version: '0.4.4' },
frameworkManifest: [],
fileIndex: {},
coChange: [],
fragility: [],
},
agents: {},
health: { intelligenceState: 'INSUFFICIENT_DATA', observationCount: 0, confidence: 0 },
};

expect(artifact.generated.specVersion).toBe('0.4');
expect(validate(artifact)).toBe(true);
expect(validateV4(artifact)).toBe(true);
});

it('exposes the v0.4 evidence entry types', () => {
// CoChangeEntry and FragilityEntry exist only in the real package. Their
// shapes are asserted structurally so a silent contract change is visible
// here rather than discovered during META-195 producer work.
const coChange: CoChangeEntry = {
files: ['src/a.ts', 'src/b.ts'],
rate: 0.5,
occurrences: 2,
generated: false,
};
const fragility: FragilityEntry = {
file: 'src/a.ts',
changeCount: 10,
revertCount: 2,
revertRate: 0.2,
fragilityScore: 0.4,
excluded: false,
};

// Set semantics: exactly two entries, order not meaningful.
expect(coChange.files).toHaveLength(2);
expect(fragility.revertRate).toBeCloseTo(fragility.revertCount / fragility.changeCount, 5);
});

it('still narrows v0.3 artifacts, so the migration contract is unchanged', () => {
// Guards against the opposite failure: consuming real types must not have
// widened or broken v0.3 handling, which the producer emits today.
const v3 = {
manual: {},
generated: {
specVersion: '0.3',
generatedAt: '2026-07-26T00:00:00.000Z',
by: { name: 'agents-audit', version: '0.4.4' },
frameworkManifest: [],
fileIndex: {},
},
agents: {},
health: { intelligenceState: 'INSUFFICIENT_DATA', observationCount: 0, confidence: 0 },
};

expect(validate(v3)).toBe(true);
expect(validateV4(v3)).toBe(false);
});
});
28 changes: 28 additions & 0 deletions scripts/check-architecture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,34 @@ for (const file of sourceFiles) {
}
}

// ---------------------------------------------------------------------------
// 2b. No ambient re-declaration of a standard-owned package (META-244).
//
// Ambient module declarations win over node_modules typings, so a handwritten
// `declare module '@workspacejson/spec'` silently shadows the real published
// contract — this repository shipped exactly that until META-244, and it hid
// the entire v0.4 surface from the compiler. workspacejson/standard owns those
// types; consume them, never restate them.
// ---------------------------------------------------------------------------
const AMBIENT_FIRST_PARTY = /declare\s+module\s+['"]@workspacejson\/[^'"]+['"]/;

// Comments are stripped first: this rule is about what the compiler sees, and
// the note in types/ambient.d.ts explaining why the shadow was removed
// necessarily quotes the very syntax it forbids.
function stripComments(source) {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^[ \t]*\/\/.*$/gm, "");
}

for (const file of sourceFiles) {
if (SELF_REFERENTIAL.has(file)) continue;
if (!file.endsWith(".d.ts")) continue;
const match = stripComments(readFileSync(file, "utf8")).match(AMBIENT_FIRST_PARTY);
if (match) {
report("shadowed-standard-types", file,
`${match[0]} re-declares a standard-owned contract; consume the published declarations instead (META-244)`);
}
}

// ---------------------------------------------------------------------------
// 3. No host-integration or site implementation in the CLI repository.
// ---------------------------------------------------------------------------
Expand Down
13 changes: 13 additions & 0 deletions scripts/check-architecture.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@ const cases = [
properties: { manual: { type: "object" }, generated: { type: "object" } },
}, null, 2)),
},
{
name: "shadowed-standard-types: ambient re-declaration of @workspacejson/spec",
expect: "shadowed-standard-types",
mutate: (root) => write(join(root, "types/ambient.d.ts"),
readFileSync(join(root, "types/ambient.d.ts"), "utf8")
+ `\ndeclare module '@workspacejson/spec' {\n export const version: string;\n}\n`),
},
{
name: "shadowed-standard-types: ambient re-declaration of @workspacejson/rules",
expect: "shadowed-standard-types",
mutate: (root) => write(join(root, "types/rogue.d.ts"),
`declare module "@workspacejson/rules" {\n export type Finding = unknown;\n}\n`),
},
{
name: "repository-boundary: host-integration code in the CLI repo",
expect: "repository-boundary",
Expand Down
118 changes: 12 additions & 106 deletions types/ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,112 +83,18 @@ declare module 'commander' {
}
}

declare module '@workspacejson/spec' {
export const workspaceJsonSchema: {
readonly $schema: string;
readonly $id: string;
readonly title: string;
readonly type: string;
readonly required: readonly string[];
readonly additionalProperties: boolean;
readonly properties: Record<string, unknown>;
};

export const version: string;

export function validate(data: unknown): data is WorkspaceJsonV3;
export function validateLegacy(data: unknown): boolean;

export interface WorkspacePackage {
name?: string;
path: string;
agentsMd?: string;
dependencies?: string[];
[key: string]: unknown;
}

export interface WorkspaceConvention {
raw: string;
type: 'filename-case' | 'directory-layout' | 'naming' | 'structural' | 'other';
canonical: string;
}

export interface WorkspaceAgentFiles {
agentsMd?: string;
workspaceJson?: string;
}

export interface WorkspaceGitSummary {
nonAgentsCommitCount30Days: number;
filesChangedLast30Days: string[];
}

export interface WorkspaceHygiene {
score: number;
grade: 'A' | 'B' | 'C' | 'D' | 'F';
failCount: number;
warnCount: number;
scannedAt: string;
}

export interface WorkspaceJson {
version: string;
generatedAt?: string;
repository?: string;
topology?: 'single-package' | 'monorepo' | 'polyglot-monorepo';
ciProvider?: 'github-actions' | 'gitlab-ci' | 'circleci' | 'jenkins' | 'none' | 'unknown';
agentFiles?: WorkspaceAgentFiles;
frameworks?: string[];
conventions?: WorkspaceConvention[];
packages?: WorkspacePackage[];
gitSummary?: WorkspaceGitSummary;
hygiene?: WorkspaceHygiene;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}

export interface FrameworkEntry {
name: string;
version?: string;
confidence: number;
}

export interface FileIndexEntry {
fragility?: number;
aiModificationCount?: number;
humanModificationCount?: number;
[key: string]: unknown;
}

export type IntelligenceState = 'INSUFFICIENT_DATA' | 'OBSERVING' | 'CONFIDENT';

export interface WorkspaceJsonV3 {
manual: {
fragileFiles?: Array<{ path: string; reason?: string }>;
coChangePatterns?: Array<{ files: string[]; note?: string }>;
[key: string]: unknown;
};
generated: {
specVersion: '0.3';
generatedAt: string;
by: { name: string; version: string };
frameworkManifest: FrameworkEntry[];
fileIndex: Record<string, FileIndexEntry>;
topology?: { packageCount?: number; [key: string]: unknown };
warnings?: string[];
[key: string]: unknown;
};
agents: Record<string, unknown>;
health: {
intelligenceState: IntelligenceState;
observationCount: number;
confidence: number;
averageFragility?: number;
fragileFileCount?: number;
[key: string]: unknown;
};
}
}
// The `@workspacejson/spec` contract is NOT declared here.
//
// It previously was: a handwritten `declare module '@workspacejson/spec'`
// restating that package's type surface. Ambient module declarations win over
// node_modules typings, so the stub shadowed the real published declarations
// even though the dependency is a registry-backed pin — and it silently hid the
// entire v0.4 contract (WorkspaceJsonV4, validateV4, CoChangeEntry,
// FragilityEntry) from this repository's compiler.
//
// workspacejson/standard owns that contract. This repository consumes the real
// published declarations and must not keep a second editable copy (META-244,
// enforcing META-165). scripts/check-architecture.mjs rejects reintroduction.

declare module 'ora' {
export interface Spinner {
Expand Down
Loading