obs: repo-health snapshot command aggregating existing analyzers into one JSON (#1471)

* obs: add repo-health snapshot command aggregating existing analyzers into one JSON

Adds `pnpm repo-health [--json]`, an offline command that aggregates the
signals this repo already computes into one deterministic JSON snapshot by
reusing each analyzer (depgraph, layering ratchets, coverage json-summary,
size-report, fallow baselines, slow-test ratchet, bench/skillgym registries)
rather than reimplementing any metric.

Carries mandatory v1 provenance (schemaVersion, commit, per-analyzer content
hashes, input provenance) so #1424 can persist history. Component metrics
(instability/abstractness/main-sequence distance) are observatory-only. The
only gating behaviour is the depgraph-vs-layering R6 consistency assertion,
already wired into the Layering Guard job via scripts/depgraph/model.test.ts.

Closes #1423

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* repo-health: hash read artifacts and flag staleness in provenance

Coverage and size are artifacts repo-health reads but does not produce, so
they carry no producing-commit stamp. Previously provenance recorded only
their paths and the current HEAD, so a snapshot taken after a source edit
without rerunning those producers would pair prior metrics with the new SHA —
#1424 would persist a false commit-indexed history entry.

Now each read artifact is bound via artifactProvenance() to its content hash
(so history keys on the bytes the metrics came from, not a commit they may
predate) and an explicit `stale` flag (true when a production source file is
newer than the artifact). The coverage/size analyzer-config hashes are added
to provenance.tool, and the human summary marks stale artifacts. Adds a pure
stale-artifact regression test.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* repo-health: bind artifact freshness to the whole producer input set

The stale check derived freshness only from listSourceFiles() (src/*.ts) and
applied it to both coverage and size. Coverage also depends on tests and vitest
config; size-report.mjs reads package.json and runs npm pack — so a change to a
non-src producer input could leave an old artifact marked stale:false while
provenance recorded the current HEAD, a commit the metrics predate.

Each read artifact now declares its full producer input set (PRODUCER_INPUTS)
and is flagged stale when ANY tracked input in that set is newer than the
artifact (via git ls-files mtimes). Freshness that cannot be proven — an empty
observable input set — is reported stale, never falsely fresh. The input set is
recorded as provenance.inputs.*.producerInputs. Adds isArtifactStale() with a
regression covering a non-src input newer than the artifact and the unprovable
case.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* repo-health: prove artifact freshness from a stamped producer commit; route git through runCmdSync

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot]
2026-07-28 20:07:45 +02:00
committed by GitHub
parent d6d2e09529
commit 6544e9a0c5
12 changed files with 1260 additions and 13 deletions
+1
View File
@@ -132,6 +132,7 @@
"check:layering": "node --experimental-strip-types --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts && node --experimental-strip-types scripts/layering/check.ts",
"depgraph": "node --experimental-strip-types scripts/depgraph/build.ts",
"depgraph:test": "node --experimental-strip-types --test scripts/depgraph/model.test.ts scripts/depgraph/affected.test.ts",
"repo-health": "node --experimental-strip-types scripts/repo-health/run.ts",
"check:production-exports": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --fail-on-issues",
"check:bundle-owner-files": "node --experimental-strip-types scripts/check-bundle-owner-files.ts",
"check:command-docs": "vitest run --project unit-core src/__tests__/command-doc-coverage.test.ts",
+3 -1
View File
@@ -261,7 +261,9 @@ function checkTypeInversions(edges: readonly ResolvedImportEdge[]): Violation[]
// moves here bring it to 102. Measured on the rebased tree — an earlier 87 was taken against an
// older main and was stale rather than violated, which is exactly the kind of drift a ratchet
// measured at merge time prevents.
const TYPE_CYCLE_BASELINE = 102;
// Exported so the repo-health snapshot (scripts/repo-health) can record the R9 ratchet value as
// observatory data without re-deriving it. The gate remains the authority; the snapshot follows.
export const TYPE_CYCLE_BASELINE = 102;
function checkTypeCycleGrowth(actual: number): Violation[] {
if (actual <= TYPE_CYCLE_BASELINE) return [];
+29
View File
@@ -0,0 +1,29 @@
import { pathToFileURL } from 'node:url';
/**
* Run `main` as the process entrypoint, but only when `moduleUrl` (pass `import.meta.url`) is the
* script node was actually invoked with — so importing the module for its exports never runs it.
* Exits with `main`'s status and turns a throw into a `label`-prefixed non-zero exit. Keeps the
* `import.meta.url === pathToFileURL(process.argv[1])` guard from being copy-pasted per script.
*/
function isEntrypoint(moduleUrl: string): boolean {
return moduleUrl === pathToFileURL(process.argv[1] ?? '').href;
}
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function runAsMain(
moduleUrl: string,
label: string,
main: (argv: string[]) => number,
): void {
if (!isEntrypoint(moduleUrl)) return;
try {
process.exit(main(process.argv.slice(2)));
} catch (error: unknown) {
process.stderr.write(`${label}: ${messageOf(error)}\n`);
process.exit(1);
}
}
+136
View File
@@ -0,0 +1,136 @@
# Repo-health snapshot
```sh
pnpm repo-health # human-readable summary
pnpm repo-health --json # the raw JSON snapshot on stdout
pnpm repo-health --out p # also write the JSON snapshot to a file
```
One JSON snapshot aggregating the signals this repo already computes across 24+ CI checks, so an
agent can query the repo's state in one read instead of scraping job logs. It is **data, not a
dashboard** (#1409's lesson): there is no rendering, and it draws no conclusions for you.
It reuses each analyzer rather than reimplementing any metric, runs from a clean checkout in well
under two minutes, and needs no network — everything comes from the working tree, `git`, and the
artifacts other gates already write.
## What it does NOT do
- **No history / trends / PR comments.** That is the follow-up quality-delta issue (#1424); this
snapshot is the input it persists.
- **No gating on component metrics.** Instability / abstractness / main-sequence distance are
observatory data to locate concrete high-fan-in modules worth pinning harder (CONTEXT.md
"Principles and their gates"). They must **never** become CI thresholds.
- **The one thing that can fail the command** is the depgraph-vs-layering R6 consistency
assertion — see below.
## The R6 consistency assertion (the only wired-into-CI part)
The dependency graph the snapshot builds must reproduce the layering gate's
`TYPE_INVERSION_BASELINE` (`scripts/layering/check.ts`), pair for pair (#1410). `pnpm repo-health`
exits non-zero and prints the difference when they diverge. The **identical** assertion is already
wired into CI by the **Layering Guard** job, which runs `scripts/depgraph/model.test.ts` — so no
new CI job or cost is added here. The gate stays the authority: a mismatch means the tree or the
baseline changed, never the snapshot.
## Schema (`schemaVersion: 1`)
Field names are the stable **trend/delta contract** consumed by #1424. Any change bumps
`schemaVersion`; #1424 refuses to diff across schema versions without a migration note.
```jsonc
{
"schemaVersion": 1,
"provenance": {
"schemaVersion": 1,
"commit": "<full HEAD sha>",
"ref": "<branch or GITHUB_REF_NAME>",
"node": "v22.x",
"generatedAt": "<ISO 8601>",
// Content hash per analyzer/config, standing in for a version: a source change is a version bump.
"tool": { "depgraph": "…", "layering": "…", "fallow": "…", "coverage": "…", "size": "…",
"slowTest": "…", "bench": "…", "skillgym": "…" },
"inputs": {
"sourceFiles": 932, // production files fed to the graph build
"lockfile": { "path": "pnpm-lock.yaml", "sha256": "…" },
// coverage and size are read artifacts this command does NOT produce. Their freshness relative
// to `commit` is proven ONLY from a producing commit the artifact stamps in its own bytes —
// never mtime (a copy/restore/CI-cache download resets it) nor an enumerated input list (never
// provably complete). status: "fresh" = stamped commit == HEAD; "stale" = stamped commit !=
// HEAD; "unknown" = no stamp. Today neither producer stamps a commit, so both report "unknown"
// (honest — cannot be proven current); if one starts emitting `commit`, it is verified. The
// bytes are still hashed so #1424 keys history on the exact metrics. A consumer must treat
// "unknown"/"stale" alike as not-current, never as `commit`.
"coverageSummary": { "path": "coverage/coverage-summary.json", "sha256": "…",
"producerCommit": null, "status": "unknown" } | null,
"sizeReport": { "path": ".tmp/size-report.json", "sha256": "…",
"producerCommit": null, "status": "unknown" } | null
}
},
"metrics": {
"depgraph": { // from scripts/depgraph (reuses the layering edge model)
"files": 932, "edges": 4791,
"valueCycles": 0, "typeCycles": 7, "dynamicCycles": 1,
"redundantEdges": 1366, // value edges also reachable at distance >= 2 (NOT removable)
"backEdges": 0, // R5 ranked-spine value back-edges (gate keeps this at 0)
"typeInversionEdges": 7 // R6 type-only spine inversions, summed
},
"layering": { // ratchet values from scripts/layering/check.ts
"typeInversionBaseline": { "commands -> client": 3, "…": 0 },
"typeInversionTotal": 7, // R6 ratchet (may only shrink)
"typeCycleBaseline": 102 // R9 largest-type-cycle ceiling (growth-only)
},
"coverage": { // from coverage/coverage-summary.json (json-summary reporter)
"available": true,
"lines": { "total": …, "covered": …, "pct": … },
"statements": …, "functions": …, "branches": …
}, // { "available": false } when the artifact is absent
"size": { // from .tmp/size-report.json (scripts/size-report.mjs)
"available": true,
"jsRawBytes": …, "jsGzipBytes": …, "npmTarballBytes": …, "npmUnpackedBytes": …
}, // { "available": false } when the artifact is absent
"fallow": { // from fallow-baselines/*.json + .fallowrc.json
"deadCodeFindings": 0, "healthFindings": 161,
"suppressions": { "ignoredExports": …, "ignoredDependencies": …, "total": 19 }
},
"slowTest": { // ratchet state from scripts/vitest-slow-test-reporter.ts
"unitBudgetMs": 2500, "integrationBudgetMs": 15000, "enforceFactor": 2
},
"bench": { "cases": 19, "topics": 10 }, // scripts/help-conformance-cases.mjs registry
"skillgym": { "cases": 5 }, // test/skillgym/suites/agent-device-smoke-suite.ts
"components": { // OBSERVATORY ONLY — never a CI threshold
"byZone": [
{ "zone": "kernel", "rank": 0, "classification": "ranked",
"files": …, "loc": …,
"afferent": 764, "efferent": 0, // cross-zone couplings (Ca / Ce)
"instability": 0.0, // I = Ce / (Ca + Ce)
"abstractness": 0.36, // A ≈ type-only share of afferent edges (approx.)
"distance": 0.64 } // |A + I - 1|
]
},
"mainSequence": { // OBSERVATORY ONLY
"concreteHighFanIn": [ // concrete (A < 0.5), fan-in >= 10, most-depended-on first
{ "file": "utils/exec.ts", "zone": "utils", "fanIn": 81, "fanOut": 3,
"instability": 0.04, "abstractness": 0.17, "distance": 0.79, "concrete": true }
]
}
},
"consistency": { "r6": { "ok": true, "expected": { … }, "actual": { … } } }
}
```
### Coverage and size availability
`coverage` and `size` are read from the artifacts the coverage and size gates already produce
(`coverage/coverage-summary.json` via the vitest `json-summary` reporter; `.tmp/size-report.json`
via `pnpm size:markdown`). Recomputing either would blow the two-minute / no-network budget, so a
clean checkout that has run neither gets `{ "available": false }` with the producing command
named in the summary. Run `pnpm test:coverage` and/or `pnpm size:markdown` first (or point #1424's
history job at a run that already did) to populate them.
### Abstractness is a first approximation
Per the issue, `abstractness` approximates a component's type-only export share by the share of
its **incoming edges that are type-only**. A module most of whose dependents import it for values
(e.g. `src/utils/exec.ts`, `src/daemon/ref-frame.ts`) reads as concrete; when its fan-in is also
high, it is a foundation worth pinning harder. This is a locator, not a verdict.
+132
View File
@@ -0,0 +1,132 @@
// Component metrics for the repo-health snapshot (#1423): instability, abstractness, and
// main-sequence distance derived from the dependency graph's fan-in/fan-out.
//
// These are OBSERVATORY DATA ONLY. They locate concrete, high-fan-in modules worth pinning harder;
// they must NEVER become CI thresholds (CONTEXT.md "Principles and their gates"). Kept in their own
// module so that constraint — and the approximations these metrics make — stays visible instead of
// blending into the analyzer adapters that feed the gate-adjacent numbers.
import { zoneRank } from '../layering/model.ts';
import type { GraphData } from '../depgraph/model.ts';
export type ZoneComponent = {
zone: string;
rank: number | null;
classification: string;
files: number;
loc: number;
/** Afferent couplings: cross-zone edges INTO the zone (how much depends on it). */
afferent: number;
/** Efferent couplings: cross-zone edges OUT of the zone (how much it depends on). */
efferent: number;
/** Instability I = efferent / (afferent + efferent); 0 when the zone has no cross-zone edges. */
instability: number;
/**
* Abstractness A, approximated by the type-only share of afferent edges (first approximation
* per the issue). 0 when nothing depends on the zone.
*/
abstractness: number;
/** Distance from the main sequence, |A + I - 1|. Lower is "more balanced". */
distance: number;
};
function round(value: number): number {
return Math.round(value * 1000) / 1000;
}
function zoneMapOf(graph: GraphData): Map<string, string> {
return new Map(graph.nodes.map((node) => [node.id, node.zone]));
}
/**
* Per-zone instability / abstractness / main-sequence distance from the graph's cross-zone edges.
* Observatory data — never a CI threshold.
*/
export function zoneComponents(graph: GraphData): ZoneComponent[] {
const zoneOf = zoneMapOf(graph);
const afferent = new Map<string, number>();
const efferent = new Map<string, number>();
const typeAfferent = new Map<string, number>();
const bump = (map: Map<string, number>, key: string): void =>
map.set(key, (map.get(key) ?? 0) + 1);
for (const edge of graph.edges) {
const from = zoneOf.get(edge.from);
const to = zoneOf.get(edge.to);
if (from === undefined || to === undefined || from === to) continue;
bump(efferent, from);
bump(afferent, to);
if (edge.kind === 'type') bump(typeAfferent, to);
}
return graph.zones
.map((zone) => {
const ca = afferent.get(zone.id) ?? 0;
const ce = efferent.get(zone.id) ?? 0;
const instability = ca + ce === 0 ? 0 : ce / (ca + ce);
const abstractness = ca === 0 ? 0 : (typeAfferent.get(zone.id) ?? 0) / ca;
return {
zone: zone.id,
rank: zoneRank(zone.id),
classification: zone.classification,
files: zone.files,
loc: zone.loc,
afferent: ca,
efferent: ce,
instability: round(instability),
abstractness: round(abstractness),
distance: round(Math.abs(abstractness + instability - 1)),
};
})
.sort((left, right) => right.afferent - left.afferent);
}
export type MainSequenceModule = {
file: string;
zone: string;
fanIn: number;
fanOut: number;
instability: number;
abstractness: number;
distance: number;
concrete: boolean;
};
/**
* The main-sequence report: concrete, high-fan-in modules worth pinning harder. Per-file
* abstractness is approximated by the module's type-only dependent share; a module most of whose
* dependents import it for VALUES (e.g. src/utils/exec.ts, src/daemon/ref-frame.ts) is concrete
* and, when its fan-in is high, a foundation the tree leans on. Observatory data only.
*/
export function mainSequenceModules(
graph: GraphData,
options: { minFanIn?: number; limit?: number } = {},
): MainSequenceModule[] {
const minFanIn = options.minFanIn ?? 10;
const limit = options.limit ?? 40;
const typeIn = new Map<string, number>();
for (const edge of graph.edges) {
if (edge.kind === 'type') typeIn.set(edge.to, (typeIn.get(edge.to) ?? 0) + 1);
}
return graph.nodes
.filter((node) => node.fanIn >= minFanIn)
.map((node) => {
const abstractness = node.fanIn === 0 ? 0 : (typeIn.get(node.id) ?? 0) / node.fanIn;
const total = node.fanIn + node.fanOut;
const instability = total === 0 ? 0 : node.fanOut / total;
return {
file: node.id.replace(/^src\//, ''),
zone: node.zone,
fanIn: node.fanIn,
fanOut: node.fanOut,
instability: round(instability),
abstractness: round(abstractness),
distance: round(Math.abs(abstractness + instability - 1)),
concrete: abstractness < 0.5,
};
})
.filter((module) => module.concrete)
.sort((left, right) => right.fanIn - left.fanIn)
.slice(0, limit);
}
+215
View File
@@ -0,0 +1,215 @@
import { readFileSync } from 'node:fs';
import { describe, expect, test } from 'vitest';
import { listSourceFiles, TYPE_INVERSION_BASELINE } from '../layering/check.ts';
import { resolveImportEdges } from '../layering/model.ts';
import { buildGraph, type GraphData } from '../depgraph/model.ts';
import { mainSequenceModules, zoneComponents } from './components.ts';
import {
collectArtifactProvenance,
benchMetrics,
buildSnapshot,
coverageMetrics,
depgraphMetrics,
fallowMetrics,
layeringRatchets,
r6Consistency,
ratchetTotal,
sizeMetrics,
SNAPSHOT_SCHEMA_VERSION,
type Provenance,
} from './model.ts';
/** Build the report's graph over the real production tree, the way the command does. */
function realGraph(): GraphData {
const files = listSourceFiles();
const sources = new Map(files.map((file) => [file, readFileSync(file, 'utf8')]));
return buildGraph(sources, resolveImportEdges(sources));
}
/**
* A tiny hand-made graph: two zones depend on kernel for VALUES and one for TYPES, so kernel is
* concrete (abstractness 1/3) with fan-in 3 — a miniature of the real exec.ts/ref-frame.ts shape.
*/
function fixtureGraph(): GraphData {
const sources = new Map([
['src/kernel/errors.ts', 'export const fail = 1;\nexport type Err = string;\n'],
['src/core/run.ts', "import { fail } from '../kernel/errors.ts';\nexport const run = 1;\n"],
['src/core/wrap.ts', "import { fail } from '../kernel/errors.ts';\nexport const wrap = 1;\n"],
[
'src/commands/tap.ts',
[
"import { run } from '../core/run.ts';",
"import type { Err } from '../kernel/errors.ts';",
].join('\n'),
],
]);
return buildGraph(sources, resolveImportEdges(sources));
}
test('depgraphMetrics reuses the graph rather than recomputing any number', () => {
const metrics = depgraphMetrics(fixtureGraph());
expect(metrics.files).toBe(4);
expect(metrics.edges).toBe(4);
expect(metrics.valueCycles).toBe(0);
// commands -> kernel is a type-only import; the ranked spine outranks kernel so it is an R6
// inversion, not a value/back edge.
expect(metrics.backEdges).toBe(0);
});
test('ratchetTotal sums a per-pair ratchet map', () => {
expect(ratchetTotal({ a: 3, b: 1, c: 2 })).toBe(6);
});
test('layeringRatchets reports the gate ratchets without re-deriving them', () => {
const ratchets = layeringRatchets();
expect(ratchets.typeInversionBaseline).toBe(TYPE_INVERSION_BASELINE);
expect(ratchets.typeInversionTotal).toBe(ratchetTotal(TYPE_INVERSION_BASELINE));
expect(ratchets.typeCycleBaseline).toBeGreaterThan(0);
});
describe('component metrics are derived from fan-in/fan-out', () => {
test('zoneComponents computes instability, abstractness and main-sequence distance', () => {
const byZone = zoneComponents(fixtureGraph());
const kernel = byZone.find((zone) => zone.zone === 'kernel')!;
// Nothing in the fixture leaves kernel, and three files depend on it (one type-only).
expect(kernel.efferent).toBe(0);
expect(kernel.afferent).toBe(3);
expect(kernel.instability).toBe(0);
expect(kernel.abstractness).toBe(0.333);
});
test('mainSequenceModules surfaces concrete high-fan-in modules only', () => {
const modules = mainSequenceModules(fixtureGraph(), { minFanIn: 1 });
const kernel = modules.find((module) => module.file === 'kernel/errors.ts')!;
expect(kernel.fanIn).toBe(3);
// Most dependents import it for values, so abstractness is 1/3 and it is concrete.
expect(kernel.abstractness).toBe(0.333);
expect(kernel.concrete).toBe(true);
});
});
test('r6Consistency passes when the graph reproduces the baseline and fails on drift', () => {
const passing = r6Consistency(TYPE_INVERSION_BASELINE);
expect(passing.ok).toBe(true);
const drifted = r6Consistency({ ...TYPE_INVERSION_BASELINE, 'commands -> client': 99 });
expect(drifted.ok).toBe(false);
expect(drifted.expected).toEqual(passing.expected);
});
test('fallowMetrics counts findings from the baselines and suppressions from the config', () => {
const metrics = fallowMetrics(
{ unused_exports: [{}, {}], circular_dependencies: [], unused_files: [{}] },
{
finding_counts: {
'src/a.ts': { complexity_critical: { count: 1 }, crap_moderate: { count: 2 } },
'src/b.ts': { crap_moderate: { count: 1 } },
},
},
{
ignoreExports: [{ exports: ['x', 'y'] }, { exports: ['z'] }],
ignoreDependencies: ['@theme'],
},
);
expect(metrics.deadCodeFindings).toBe(3);
expect(metrics.healthFindings).toBe(4);
expect(metrics.suppressions).toEqual({ ignoredExports: 3, ignoredDependencies: 1, total: 4 });
});
test('coverage and size degrade to unavailable when the artifact is absent', () => {
expect(coverageMetrics(null)).toEqual({ available: false });
expect(sizeMetrics(null)).toEqual({ available: false });
expect(
coverageMetrics({ total: { lines: { total: 10, covered: 8, pct: 80 } } as never }).available,
).toBe(true);
});
// Drives the real collector (parse → read stamped commit → hash → status), so removing the
// commit-verification breaks these — unlike a fabricated status. Freshness is proven ONLY from a
// commit the artifact stamps, never mtime or a producer-input list.
test('collectArtifactProvenance proves freshness only from a commit the artifact stamps', () => {
const path = 'coverage/coverage-summary.json';
// A real coverage-summary shape stamps no commit — we cannot prove it matches HEAD, so `unknown`.
const coverageShaped = JSON.stringify({ total: { lines: { total: 10, covered: 8, pct: 80 } } });
const unknown = collectArtifactProvenance(path, coverageShaped, 'abc123');
expect(unknown?.status).toBe('unknown');
expect(unknown?.producerCommit).toBeNull();
expect(unknown?.sha256).toMatch(/^[0-9a-f]{12}$/); // bytes still hashed for #1424
// An artifact that stamps the current commit is proven current.
expect(
collectArtifactProvenance(path, JSON.stringify({ commit: 'abc123', total: {} }), 'abc123')
?.status,
).toBe('fresh');
// A stamped commit that differs is stale — metrics predate HEAD.
const stale = collectArtifactProvenance(path, JSON.stringify({ commit: 'old999' }), 'abc123');
expect(stale?.status).toBe('stale');
expect(stale?.producerCommit).toBe('old999');
// Absent artifact degrades cleanly to null (coverage/size become { available: false }).
expect(collectArtifactProvenance(path, null, 'abc123')).toBeNull();
});
test('benchMetrics counts cases and distinct topics from the case registry', () => {
expect(benchMetrics([{ docs: ['a', 'b'] }, { docs: ['b'] }, { docs: ['c'] }])).toEqual({
cases: 3,
topics: 3,
});
});
const PROVENANCE: Provenance = {
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
commit: 'abc',
ref: 'main',
node: 'v22',
generatedAt: '2026-07-28T00:00:00.000Z',
tool: {},
inputs: { sourceFiles: 3, lockfile: null, coverageSummary: null, sizeReport: null },
};
test('buildSnapshot assembles every metric family and pins the schema version', () => {
const snapshot = buildSnapshot({
provenance: PROVENANCE,
graph: fixtureGraph(),
fallow: { deadCode: {}, health: {}, config: {} },
coverage: null,
size: null,
slowTest: { unitBudgetMs: 2500, integrationBudgetMs: 15000, enforceFactor: 2 },
benchCases: [{ docs: ['a'] }],
skillgymCaseCount: 5,
});
expect(snapshot.schemaVersion).toBe(SNAPSHOT_SCHEMA_VERSION);
expect(Object.keys(snapshot.metrics).sort()).toEqual([
'bench',
'components',
'coverage',
'depgraph',
'fallow',
'layering',
'mainSequence',
'size',
'skillgym',
'slowTest',
]);
expect(snapshot.metrics.skillgym.cases).toBe(5);
});
// The acceptance spot-check (issue #1423): the main-sequence report must locate the concrete,
// high-fan-in foundations. src/utils/exec.ts (the process-exec seam) and src/daemon/ref-frame.ts
// both have many value dependents, so both must appear as concrete. Guards the metric against a
// sign flip or an abstractness inversion that would make the report point at the wrong modules.
test('the real-tree main-sequence report surfaces exec.ts and ref-frame.ts as concrete', () => {
const graph = realGraph();
const concrete = new Map(mainSequenceModules(graph).map((module) => [module.file, module]));
for (const file of ['utils/exec.ts', 'daemon/ref-frame.ts']) {
const module = concrete.get(file);
expect(module, `${file} should surface as a concrete high-fan-in module`).toBeDefined();
expect(module!.concrete).toBe(true);
expect(module!.fanIn).toBeGreaterThanOrEqual(10);
}
// And the graph the report builds must reproduce the gate's R6 baseline over the real tree.
expect(r6Consistency(graph.typeInversions).ok).toBe(true);
});
+376
View File
@@ -0,0 +1,376 @@
// Repo-health snapshot model — pure aggregation over the analyzers this repo already runs.
//
// The snapshot is DATA for agents to query (issue #1423, Track C of #1412), not a dashboard and
// not a new gate. Every metric here is REUSED from an existing analyzer rather than recomputed:
// the dependency graph comes from scripts/depgraph (which itself reuses the layering gate's edge
// model), the R6/R9 ratchet values come from scripts/layering/check.ts, fallow counts come from
// the checked-in baselines, coverage/size come from the artifacts their own gates already write,
// the slow-test ratchet comes from the vitest reporter, and the bench/skillgym counts come from
// their case registries.
//
// Field names are the trend/delta contract consumed by the follow-up quality-delta comment
// (#1424): they must stay stable, and any change bumps SNAPSHOT_SCHEMA_VERSION so a consumer
// refuses to diff across schema versions without a migration note.
//
// The component metrics (instability / abstractness / main-sequence distance) are OBSERVATORY
// DATA ONLY. They locate concrete, high-fan-in modules worth pinning harder; they must NEVER
// become CI thresholds (CONTEXT.md "Principles and their gates"). The single thing wired into CI
// is the depgraph-vs-layering R6 consistency assertion, which lives in the Layering Guard job.
import { createHash } from 'node:crypto';
import { TYPE_CYCLE_BASELINE, TYPE_INVERSION_BASELINE } from '../layering/check.ts';
import type { EdgeKind, GraphData } from '../depgraph/model.ts';
import {
mainSequenceModules,
zoneComponents,
type MainSequenceModule,
type ZoneComponent,
} from './components.ts';
export const SNAPSHOT_SCHEMA_VERSION = 1;
/** Total across a per-pair ratchet map, e.g. TYPE_INVERSION_BASELINE. */
export function ratchetTotal(byPair: Readonly<Record<string, number>>): number {
return Object.values(byPair).reduce((sum, count) => sum + count, 0);
}
export type DepgraphMetrics = {
files: number;
edges: number;
valueCycles: number;
typeCycles: number;
dynamicCycles: number;
/** Value edges whose target is ALSO reachable at distance >= 2. Reachability, not removability. */
redundantEdges: number;
/** R5 ranked-spine back-edges over value edges (the gate keeps this at zero). */
backEdges: number;
/** R6 type-only spine-inversion edges, summed across zone pairs. */
typeInversionEdges: number;
};
export function depgraphMetrics(graph: GraphData): DepgraphMetrics {
const cyclesByKind = (kind: EdgeKind): number =>
graph.cycles.filter((cycle) => cycle.kind === kind).length;
return {
files: graph.nodes.length,
edges: graph.edges.length,
valueCycles: cyclesByKind('value'),
typeCycles: cyclesByKind('type'),
dynamicCycles: cyclesByKind('dynamic'),
redundantEdges: graph.edges.filter((edge) => edge.transitivelyReachable).length,
backEdges: graph.edges.filter((edge) => edge.backEdge !== null).length,
typeInversionEdges: ratchetTotal(graph.typeInversions),
};
}
export type LayeringRatchets = {
/** R6 baseline, per zone pair, and its total. The counts may only shrink (gate authority). */
typeInversionBaseline: Readonly<Record<string, number>>;
typeInversionTotal: number;
/** R9 largest-type-cycle ceiling (growth-only ratchet). */
typeCycleBaseline: number;
};
export function layeringRatchets(): LayeringRatchets {
return {
typeInversionBaseline: TYPE_INVERSION_BASELINE,
typeInversionTotal: ratchetTotal(TYPE_INVERSION_BASELINE),
typeCycleBaseline: TYPE_CYCLE_BASELINE,
};
}
/**
* The depgraph-vs-layering R6 cross-check (#1410): the graph the report builds over the real tree
* must reproduce the gate's TYPE_INVERSION_BASELINE, pair for pair. `pnpm repo-health` fails when
* this is false, and the identical assertion is wired into the Layering Guard CI job via
* scripts/depgraph/model.test.ts. The gate stays the authority; a mismatch means the tree or the
* baseline changed, never this check.
*/
export type R6Consistency = {
ok: boolean;
expected: Readonly<Record<string, number>>;
actual: Readonly<Record<string, number>>;
};
function sortedEntries(byPair: Readonly<Record<string, number>>): [string, number][] {
return Object.entries(byPair).sort(([left], [right]) => left.localeCompare(right));
}
export function r6Consistency(
graphTypeInversions: Readonly<Record<string, number>>,
): R6Consistency {
const expected = Object.fromEntries(sortedEntries(TYPE_INVERSION_BASELINE));
const actual = Object.fromEntries(sortedEntries(graphTypeInversions));
return {
ok: JSON.stringify(actual) === JSON.stringify(expected),
expected,
actual,
};
}
// ---- Fallow -----------------------------------------------------------------------------------
/** The fallow dead-code baseline: arrays of findings keyed by category. */
export type FallowDeadCodeBaseline = Record<string, unknown[]>;
/** The fallow health baseline: per-file maps of finding kind -> { count }. */
export type FallowHealthBaseline = {
finding_counts?: Record<string, Record<string, { count: number }>>;
};
export type FallowConfig = {
ignoreExports?: { exports: string[] }[];
ignoreDependencies?: string[];
};
export type FallowMetrics = {
/** Total dead-code findings recorded in the dead-code baseline. */
deadCodeFindings: number;
/** Total complexity/health findings recorded in the health baseline. */
healthFindings: number;
suppressions: {
/** Distinct export entries suppressed via `ignoreExports` in .fallowrc.json. */
ignoredExports: number;
ignoredDependencies: number;
total: number;
};
};
export function fallowMetrics(
deadCode: FallowDeadCodeBaseline,
health: FallowHealthBaseline,
config: FallowConfig,
): FallowMetrics {
const deadCodeFindings = Object.values(deadCode).reduce(
(sum, list) => sum + (Array.isArray(list) ? list.length : 0),
0,
);
let healthFindings = 0;
for (const perKind of Object.values(health.finding_counts ?? {})) {
for (const finding of Object.values(perKind)) healthFindings += finding.count;
}
const ignoredExports = (config.ignoreExports ?? []).reduce(
(sum, entry) => sum + (entry.exports?.length ?? 0),
0,
);
const ignoredDependencies = (config.ignoreDependencies ?? []).length;
return {
deadCodeFindings,
healthFindings,
suppressions: {
ignoredExports,
ignoredDependencies,
total: ignoredExports + ignoredDependencies,
},
};
}
// ---- Coverage & size (read from the artifacts their own gates already produce) ----------------
type CoverageTotalEntry = { total: number; covered: number; pct: number };
export type CoverageSummary = {
total?: Record<'lines' | 'statements' | 'functions' | 'branches', CoverageTotalEntry>;
};
export type CoverageMetrics = {
available: boolean;
lines?: CoverageTotalEntry;
statements?: CoverageTotalEntry;
functions?: CoverageTotalEntry;
branches?: CoverageTotalEntry;
};
export function coverageMetrics(summary: CoverageSummary | null): CoverageMetrics {
if (!summary?.total) return { available: false };
const { lines, statements, functions, branches } = summary.total;
return { available: true, lines, statements, functions, branches };
}
export type SizeReport = {
js?: { rawBytes: number; gzipBytes: number };
npmPack?: { tarballBytes: number; unpackedBytes: number };
};
export type SizeMetrics = {
available: boolean;
jsRawBytes?: number;
jsGzipBytes?: number;
npmTarballBytes?: number;
npmUnpackedBytes?: number;
};
export function sizeMetrics(report: SizeReport | null): SizeMetrics {
if (!report?.js || !report.npmPack) return { available: false };
return {
available: true,
jsRawBytes: report.js.rawBytes,
jsGzipBytes: report.js.gzipBytes,
npmTarballBytes: report.npmPack.tarballBytes,
npmUnpackedBytes: report.npmPack.unpackedBytes,
};
}
// ---- Slow-test / bench / skillgym (registry-derived counts) -----------------------------------
export type SlowTestRatchet = {
unitBudgetMs: number;
integrationBudgetMs: number;
enforceFactor: number;
};
export type BenchMetrics = {
/** Help-conformance bench cases (scripts/help-conformance-cases.mjs). */
cases: number;
/** Distinct help topics the cases exercise. */
topics: number;
};
export function benchMetrics(cases: readonly { docs: readonly string[] }[]): BenchMetrics {
return {
cases: cases.length,
topics: new Set(cases.flatMap((testCase) => testCase.docs)).size,
};
}
// ---- Provenance -------------------------------------------------------------------------------
/**
* Freshness of a READ artifact relative to the snapshot's `commit`. `fresh` requires proof; anything
* unproven is `unknown`, never silently trusted.
*/
export type ArtifactStatus = 'fresh' | 'stale' | 'unknown';
/**
* Provenance for an analyzer artifact this snapshot READ but did not itself produce (coverage,
* size). Its freshness relative to `commit` is proven ONLY from a producing commit the artifact
* stamps in its own bytes. We deliberately reject the two signals that cannot prove it: mtime (a
* copy, restore, or CI cache download resets it) and an enumerated producer-input list (never
* provably complete — the last review showed it silently omitted inputs). So a stamped commit equal
* to `commit` is `fresh`; a different stamped commit is `stale`; NO stamp is `unknown`. Today
* neither producer stamps a commit, so both report `unknown` — honest, since we cannot prove they
* match; if a producer starts emitting one, it is verified automatically. `sha256` still records the
* bytes so #1424 keys history on the exact metrics read. A consumer must treat `stale` and `unknown`
* alike as not-current, never as `commit`.
*/
export type ArtifactProvenance = {
path: string;
sha256: string;
/** The producing commit read from the artifact's own bytes, or null when it stamps none. */
producerCommit: string | null;
status: ArtifactStatus;
};
/** The producing commit an artifact stamps in its own JSON, if any (`producerCommit` or `commit`). */
function readStampedCommit(raw: string): string | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (parsed === null || typeof parsed !== 'object') return null;
const record = parsed as Record<string, unknown>;
for (const key of ['producerCommit', 'commit']) {
const value = record[key];
if (typeof value === 'string' && value.length > 0) return value;
}
return null;
}
/**
* The whole read-artifact collector: hash the bytes, read any producing commit the artifact stamps,
* and derive freshness against `currentCommit`. Returns null when the artifact is absent. This is
* the function the runner wires to disk, so a test that drives it with real artifact bytes exercises
* the actual freshness path end-to-end (not a fabricated status).
*/
export function collectArtifactProvenance(
path: string,
raw: string | null,
currentCommit: string,
): ArtifactProvenance | null {
if (raw === null) return null;
const producerCommit = readStampedCommit(raw);
const status: ArtifactStatus =
producerCommit === null ? 'unknown' : producerCommit === currentCommit ? 'fresh' : 'stale';
return {
path,
sha256: createHash('sha256').update(raw).digest('hex').slice(0, 12),
producerCommit,
status,
};
}
/**
* What produced the snapshot and from which inputs. Mandatory in v1 (issue #1423 amendment) so
* #1424 can persist history and refuse to diff across schema versions. `tool` holds a content
* hash per analyzer (its own source/config, standing in for a version); `inputs` records the
* lockfile and the read-only artifacts each family of metrics was computed from, each hashed and
* given a proven freshness `status` so a false commit-indexed history entry cannot slip through.
*/
export type Provenance = {
schemaVersion: number;
commit: string;
ref: string | null;
node: string;
generatedAt: string;
tool: Record<string, string>;
inputs: {
/** Production source files fed to the graph build. */
sourceFiles: number;
lockfile: { path: string; sha256: string } | null;
/** Read artifacts, hashed with a proven freshness status; null when absent (degrade cleanly). */
coverageSummary: ArtifactProvenance | null;
sizeReport: ArtifactProvenance | null;
};
};
// ---- Full snapshot ----------------------------------------------------------------------------
export type RepoHealthSnapshot = {
schemaVersion: number;
provenance: Provenance;
/**
* Component metrics are observatory data — they locate concrete high-fan-in modules and must
* NEVER become CI thresholds. Only `consistency.r6` gates anything.
*/
metrics: {
depgraph: DepgraphMetrics;
layering: LayeringRatchets;
coverage: CoverageMetrics;
size: SizeMetrics;
fallow: FallowMetrics;
slowTest: SlowTestRatchet;
bench: BenchMetrics;
skillgym: { cases: number };
components: { byZone: ZoneComponent[] };
mainSequence: { concreteHighFanIn: MainSequenceModule[] };
};
consistency: { r6: R6Consistency };
};
export type SnapshotInputs = {
provenance: Provenance;
graph: GraphData;
fallow: { deadCode: FallowDeadCodeBaseline; health: FallowHealthBaseline; config: FallowConfig };
coverage: CoverageSummary | null;
size: SizeReport | null;
slowTest: SlowTestRatchet;
benchCases: readonly { docs: readonly string[] }[];
skillgymCaseCount: number;
};
export function buildSnapshot(inputs: SnapshotInputs): RepoHealthSnapshot {
return {
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
provenance: inputs.provenance,
metrics: {
depgraph: depgraphMetrics(inputs.graph),
layering: layeringRatchets(),
coverage: coverageMetrics(inputs.coverage),
size: sizeMetrics(inputs.size),
fallow: fallowMetrics(inputs.fallow.deadCode, inputs.fallow.health, inputs.fallow.config),
slowTest: inputs.slowTest,
bench: benchMetrics(inputs.benchCases),
skillgym: { cases: inputs.skillgymCaseCount },
components: { byZone: zoneComponents(inputs.graph) },
mainSequence: { concreteHighFanIn: mainSequenceModules(inputs.graph) },
},
consistency: { r6: r6Consistency(inputs.graph.typeInversions) },
};
}
+70
View File
@@ -0,0 +1,70 @@
// End-to-end coverage of the `pnpm repo-health` CLI. It spawns the command as a subprocess (so it
// exercises argument handling and the file it writes over the real tree), which is why this lives
// in the `subprocess-stub` vitest project rather than `unit-core` — see #1412 coordination rule 4.
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { expect, test } from 'vitest';
import type { RepoHealthSnapshot } from './model.ts';
function runRepoHealth(args: readonly string[]): {
status: number | null;
stdout: string;
stderr: string;
} {
const result = spawnSync(
process.execPath,
['--experimental-strip-types', 'scripts/repo-health/run.ts', ...args],
{ encoding: 'utf8', maxBuffer: 1024 * 1024 * 32 },
);
return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
}
test('the default run prints a summary and exits zero over the real tree', () => {
const { status, stdout } = runRepoHealth([]);
expect(status, stdout).toBe(0);
expect(stdout).toContain('Repo health snapshot');
expect(stdout).toContain('graph reproduces baseline: yes');
expect(stdout).toContain('observatory only');
});
test('--json emits a schema-versioned snapshot with every metric family', () => {
const { status, stdout } = runRepoHealth(['--json']);
expect(status, stdout).toBe(0);
const snapshot = JSON.parse(stdout) as RepoHealthSnapshot;
expect(snapshot.schemaVersion).toBe(1);
// Provenance is mandatory in v1 (issue amendment): schemaVersion, commit, per-analyzer tool
// hashes, and input provenance must all be present so #1424 can persist history.
expect(snapshot.provenance.commit).toMatch(/^[0-9a-f]{7,40}$/);
expect(Object.keys(snapshot.provenance.tool)).toContain('depgraph');
expect(snapshot.provenance.inputs.sourceFiles).toBeGreaterThan(0);
// Field names are the #1424 delta contract — pin the ones the comment consumes.
expect(snapshot.metrics.depgraph.redundantEdges).toBeGreaterThan(0);
expect(snapshot.metrics.layering.typeInversionTotal).toBeGreaterThanOrEqual(0);
expect(snapshot.metrics.slowTest.unitBudgetMs).toBeGreaterThan(0);
expect(snapshot.metrics.fallow.suppressions.total).toBeGreaterThanOrEqual(0);
expect(snapshot.metrics.bench.cases).toBeGreaterThan(0);
expect(snapshot.metrics.skillgym.cases).toBeGreaterThan(0);
// The consistency assertion must pass on a clean tree.
expect(snapshot.consistency.r6.ok).toBe(true);
// The main-sequence spot-check the acceptance names.
const concrete = snapshot.metrics.mainSequence.concreteHighFanIn.map((module) => module.file);
expect(concrete).toContain('utils/exec.ts');
expect(concrete).toContain('daemon/ref-frame.ts');
});
test('--out writes the same JSON snapshot to a file', () => {
const out = join(mkdtempSync(join(tmpdir(), 'repo-health-')), 'snapshot.json');
const { status } = runRepoHealth(['--out', out]);
expect(status).toBe(0);
const written = JSON.parse(readFileSync(out, 'utf8')) as RepoHealthSnapshot;
expect(written.schemaVersion).toBe(1);
expect(written.consistency.r6.ok).toBe(true);
});
+264
View File
@@ -0,0 +1,264 @@
// Repo-health snapshot command (issue #1423, Track C of #1412):
//
// pnpm repo-health # human-readable summary
// pnpm repo-health --json # the raw JSON snapshot on stdout
// pnpm repo-health --out p # also write the JSON snapshot to a file
//
// One JSON snapshot aggregating the signals this repo already computes, by REUSING each analyzer
// rather than reimplementing any metric (see model.ts for which analyzer owns each number). It is
// DATA for agents to query, not a dashboard: no rendering, no history (that is #1424), and the
// component metrics are observatory-only and must never gate.
//
// It runs from a clean checkout in well under two minutes with no network: everything is read
// from the working tree, git, and the artifacts other gates already write. Coverage and package
// size are read from coverage/coverage-summary.json and .tmp/size-report.json when present and
// degrade to `available: false` (with the producing command) when they are not, so a fresh
// checkout still produces a complete, honest snapshot. Those two artifacts carry no producing-commit
// stamp today, so provenance hashes them and reports freshness as `unknown` unless the artifact
// itself stamps a commit equal to HEAD (see collectArtifactProvenance in model.ts) — we never infer
// freshness from mtime or a producer-input list, neither of which can prove it. A consumer like
// #1424 must treat `unknown`/`stale` as not-current, never as HEAD.
//
// The one thing that can FAIL the command is the depgraph-vs-layering R6 consistency assertion:
// the graph built here must reproduce scripts/layering/check.ts's TYPE_INVERSION_BASELINE. The
// identical assertion is wired into CI by the Layering Guard job (scripts/depgraph/model.test.ts).
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { runCmdSync } from '../../src/utils/exec.ts';
import { listSourceFiles } from '../layering/check.ts';
import { resolveImportEdges } from '../layering/model.ts';
import { buildGraph, type GraphData } from '../depgraph/model.ts';
import { SLOW_TEST_RATCHET } from '../vitest-slow-test-budgets.ts';
import { parseScriptArgs } from '../lib/cli-args.ts';
import { runAsMain } from '../lib/run-as-main.ts';
import { CASES } from '../help-conformance-cases.mjs';
import skillgymSuite from '../../test/skillgym/suites/agent-device-smoke-suite.ts';
import {
buildSnapshot,
collectArtifactProvenance,
SNAPSHOT_SCHEMA_VERSION,
type ArtifactProvenance,
type FallowConfig,
type FallowDeadCodeBaseline,
type FallowHealthBaseline,
type Provenance,
type RepoHealthSnapshot,
} from './model.ts';
const USAGE = 'Usage: pnpm repo-health [--json] [--out <path>]\n';
const COVERAGE_SUMMARY_PATH = 'coverage/coverage-summary.json';
const SIZE_REPORT_PATH = '.tmp/size-report.json';
const LOCKFILE_PATH = 'pnpm-lock.yaml';
const repoRoot = runCmdSync('git', ['rev-parse', '--show-toplevel']).stdout.trim();
function absolute(relativePath: string): string {
return path.join(repoRoot, relativePath);
}
function readIfExists(relativePath: string): string | null {
const abs = absolute(relativePath);
return fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
}
function readJsonIfExists<T>(relativePath: string): T | null {
const raw = readIfExists(relativePath);
return raw === null ? null : (JSON.parse(raw) as T);
}
/** A read artifact's raw bytes (for content-hash provenance) and its parsed form (for metrics). */
function readArtifact<T>(relativePath: string): { raw: string | null; parsed: T | null } {
const raw = readIfExists(relativePath);
return { raw, parsed: raw === null ? null : (JSON.parse(raw) as T) };
}
function shortSha(raw: string): string {
return createHash('sha256').update(raw).digest('hex').slice(0, 12);
}
/** Short content hash standing in for an analyzer's version — a source change is a version bump. */
function contentHash(relativePath: string): string {
const raw = readIfExists(relativePath);
return raw === null ? 'absent' : shortSha(raw);
}
function lockfileProvenance(): { path: string; sha256: string } | null {
const raw = readIfExists(LOCKFILE_PATH);
return raw === null ? null : { path: LOCKFILE_PATH, sha256: shortSha(raw) };
}
function gitOutput(args: string[]): string | null {
const result = runCmdSync('git', args, { cwd: repoRoot, allowFailure: true });
return result.exitCode === 0 ? result.stdout.trim() : null;
}
function buildGraphFromTree(files: readonly string[]): GraphData {
const sources = new Map(files.map((file) => [file, fs.readFileSync(absolute(file), 'utf8')]));
return buildGraph(sources, resolveImportEdges(sources));
}
function currentCommit(): string {
return gitOutput(['rev-parse', 'HEAD']) ?? 'unknown';
}
function currentRef(): string | null {
return process.env.GITHUB_REF_NAME ?? gitOutput(['rev-parse', '--abbrev-ref', 'HEAD']);
}
// A content hash per analyzer, standing in for a version: a change to the analyzer's own source
// is a version bump the snapshot records without a real semver to point at.
function toolHashes(): Record<string, string> {
return {
depgraph: contentHash('scripts/depgraph/model.ts'),
layering: contentHash('scripts/layering/check.ts'),
fallow: contentHash('.fallowrc.json'),
coverage: contentHash('vitest.config.ts'),
size: contentHash('scripts/size-report.mjs'),
slowTest: contentHash('scripts/vitest-slow-test-budgets.ts'),
bench: contentHash('scripts/help-conformance-cases.mjs'),
skillgym: contentHash('test/skillgym/suites/agent-device-smoke-suite.ts'),
};
}
function collectProvenance(
graph: GraphData,
commit: string,
coverage: ArtifactProvenance | null,
size: ArtifactProvenance | null,
): Provenance {
return {
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
commit,
ref: currentRef(),
node: process.version,
generatedAt: new Date().toISOString(),
tool: toolHashes(),
inputs: {
sourceFiles: graph.nodes.length,
lockfile: lockfileProvenance(),
coverageSummary: coverage,
sizeReport: size,
},
};
}
function collectSnapshot(): RepoHealthSnapshot {
const graph = buildGraphFromTree(listSourceFiles());
const commit = currentCommit();
const coverage = readArtifact<import('./model.ts').CoverageSummary>(COVERAGE_SUMMARY_PATH);
const size = readArtifact<import('./model.ts').SizeReport>(SIZE_REPORT_PATH);
return buildSnapshot({
provenance: collectProvenance(
graph,
commit,
collectArtifactProvenance(COVERAGE_SUMMARY_PATH, coverage.raw, commit),
collectArtifactProvenance(SIZE_REPORT_PATH, size.raw, commit),
),
graph,
fallow: {
deadCode: readJsonIfExists<FallowDeadCodeBaseline>('fallow-baselines/dead-code.json') ?? {},
health: readJsonIfExists<FallowHealthBaseline>('fallow-baselines/health.json') ?? {},
config: readJsonIfExists<FallowConfig>('.fallowrc.json') ?? {},
},
coverage: coverage.parsed,
size: size.parsed,
slowTest: SLOW_TEST_RATCHET,
benchCases: CASES,
skillgymCaseCount: skillgymSuite.length,
});
}
function pct(entry: { covered: number; total: number; pct: number } | undefined): string {
return entry
? `${entry.pct.toFixed(2)}% (${entry.covered}/${entry.total})`
: 'n/a (run pnpm test:coverage)';
}
function bytes(value: number | undefined): string {
if (value === undefined) return 'n/a (run pnpm size:markdown)';
if (value < 1000) return `${value} B`;
if (value < 1_000_000) return `${(value / 1000).toFixed(1)} kB`;
return `${(value / 1_000_000).toFixed(2)} MB`;
}
function staleMark(artifact: ArtifactProvenance | null): string {
if (artifact === null || artifact.status === 'fresh') return '';
return artifact.status === 'stale'
? ' [STALE — artifact stamps a different commit]'
: ' [UNKNOWN — artifact stamps no commit; may not reflect HEAD]';
}
function renderSummary(snapshot: RepoHealthSnapshot): string {
const { metrics, provenance, consistency } = snapshot;
const lines: string[] = [
`Repo health snapshot (schemaVersion ${snapshot.schemaVersion}) @ ${provenance.commit.slice(0, 12)}`,
` depgraph: ${metrics.depgraph.files} files, ${metrics.depgraph.edges} edges, ` +
`${metrics.depgraph.redundantEdges} redundant; cycles value=${metrics.depgraph.valueCycles} ` +
`type=${metrics.depgraph.typeCycles} dynamic=${metrics.depgraph.dynamicCycles}`,
` layering: R6 type-inversions=${metrics.layering.typeInversionTotal} ` +
`(graph reproduces baseline: ${consistency.r6.ok ? 'yes' : 'NO'}); R9 type-cycle baseline=${metrics.layering.typeCycleBaseline}`,
` coverage: lines ${pct(metrics.coverage.lines)}; statements ${pct(metrics.coverage.statements)}` +
staleMark(provenance.inputs.coverageSummary),
` size: js raw ${bytes(metrics.size.jsRawBytes)}, gzip ${bytes(metrics.size.jsGzipBytes)}; ` +
`npm tarball ${bytes(metrics.size.npmTarballBytes)}` +
staleMark(provenance.inputs.sizeReport),
` fallow: dead-code findings=${metrics.fallow.deadCodeFindings}, health findings=${metrics.fallow.healthFindings}, ` +
`suppressions=${metrics.fallow.suppressions.total}`,
` slow-test: unit ${metrics.slowTest.unitBudgetMs}ms / integration ${metrics.slowTest.integrationBudgetMs}ms (enforce ${metrics.slowTest.enforceFactor}x)`,
` bench: ${metrics.bench.cases} cases across ${metrics.bench.topics} help topics; skillgym ${metrics.skillgym.cases} cases`,
'',
' Component metrics (observatory only — never a CI threshold):',
...metrics.components.byZone
.slice(0, 8)
.map(
(zone) =>
` ${zone.zone.padEnd(16)} Ca=${String(zone.afferent).padStart(4)} Ce=${String(zone.efferent).padStart(4)} ` +
`I=${zone.instability.toFixed(2)} A=${zone.abstractness.toFixed(2)} D=${zone.distance.toFixed(2)}`,
),
' Concrete high-fan-in modules worth pinning harder:',
...metrics.mainSequence.concreteHighFanIn
.slice(0, 8)
.map(
(module) =>
` ${module.file.padEnd(40)} fanIn=${String(module.fanIn).padStart(3)} A=${module.abstractness.toFixed(2)}`,
),
];
return `${lines.join('\n')}\n`;
}
function run(argv: readonly string[]): number {
const values = parseScriptArgs(argv, USAGE, {
json: { type: 'boolean', default: false },
out: { type: 'string' },
});
const snapshot = collectSnapshot();
const serialized = `${JSON.stringify(snapshot, null, 2)}\n`;
if (typeof values.out === 'string') {
const outPath = path.resolve(values.out);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, serialized);
}
process.stdout.write(values.json ? serialized : renderSummary(snapshot));
const { r6 } = snapshot.consistency;
if (!r6.ok) {
process.stderr.write(
'repo-health: the dependency graph disagrees with the layering gate about R6 type-only ' +
'spine inversions.\n' +
` gate baseline (TYPE_INVERSION_BASELINE): ${JSON.stringify(r6.expected)}\n` +
` graph over the current tree: ${JSON.stringify(r6.actual)}\n` +
'The gate is the authority — fix the inverting edge, or update TYPE_INVERSION_BASELINE in ' +
'scripts/layering/check.ts. See scripts/depgraph/README.md.\n',
);
return 1;
}
return 0;
}
runAsMain(import.meta.url, 'repo-health', run);
+21
View File
@@ -0,0 +1,21 @@
// The slow-test ratchet's budgets, as a single source of truth (see docs/agents/testing.md
// "Speed rules"). Kept in a data-only module so consumers that only need the numbers — the
// repo-health snapshot (scripts/repo-health), which records them as observatory data — can import
// them without pulling the vitest reporter (a config-loaded module) into their dependency graph.
// Unit tests must not wait real time; integration scenarios drive a real daemon request path and
// get more room.
export const UNIT_BUDGET_MS = 2_500;
export const INTEGRATION_BUDGET_MS = 15_000;
// Enforcement fires at 2x budget: host load legitimately stretches a borderline test by tens of
// percent, and a wall-clock gate that flakes under contention trains people to ignore it. Between
// budget and 2x budget the gate reports without failing.
export const ENFORCE_FACTOR = 2;
/** The ratchet as one record, for callers that report it rather than enforce it. */
export const SLOW_TEST_RATCHET = {
unitBudgetMs: UNIT_BUDGET_MS,
integrationBudgetMs: INTEGRATION_BUDGET_MS,
enforceFactor: ENFORCE_FACTOR,
} as const;
+6 -12
View File
@@ -1,4 +1,9 @@
import type { Reporter, TestCase, TestModule } from 'vitest/node';
import {
ENFORCE_FACTOR,
INTEGRATION_BUDGET_MS,
UNIT_BUDGET_MS,
} from './vitest-slow-test-budgets.ts';
/**
* Slow-test ratchet (see docs/agents/testing.md "Speed rules").
@@ -7,19 +12,8 @@ import type { Reporter, TestCase, TestModule } from 'vitest/node';
* wall clock was bounded by files whose tests slept through production
* timeouts (a 10.8s test proving "times out" by waiting the full 10s budget).
* This reporter fails the run when a unit test exceeds the enforced budget.
*
* Budgets are per suite family: integration scenarios drive a real daemon
* request path and get more room; unit tests get 2.5s, which is already
* generous for injected-time tests.
* The budgets themselves live in ./vitest-slow-test-budgets.ts.
*/
const UNIT_BUDGET_MS = 2_500;
const INTEGRATION_BUDGET_MS = 15_000;
// Enforcement fires at 2x budget: host load legitimately stretches a
// borderline test by tens of percent, and a wall-clock gate that flakes under
// contention trains people to ignore it. Between budget and 2x budget the
// gate reports without failing.
const ENFORCE_FACTOR = 2;
type Offender = { key: string; durationMs: number; budgetMs: number; enforce: boolean };
function budgetForPath(relativePath: string): number {
+7
View File
@@ -22,6 +22,9 @@ export const SUBPROCESS_STUB_TESTS = [
// Replays the fuzz regression corpus (#1414) through that same worker watchdog, so a promoted
// hang case fails against its per-case budget instead of wedging the unit job.
'scripts/fuzz/corpus-replay.test.ts',
// Spawns the `pnpm repo-health` CLI as a subprocess to exercise its arg handling and the JSON
// it writes over the real tree (#1423).
'scripts/repo-health/run.test.ts',
];
export default defineConfig({
@@ -47,6 +50,10 @@ export default defineConfig({
'scripts/__tests__/help-conformance-error-recovery-coverage.test.ts',
'scripts/__tests__/help-conformance-sample-outputs.test.ts',
'scripts/__tests__/help-conformance-topic-coverage.test.ts',
// Repo-health snapshot model (#1423): pure aggregation over the analyzers, no
// subprocess or device work, so it runs in the fast lane. The CLI's subprocess test
// lives in the subprocess-stub project.
'scripts/repo-health/model.test.ts',
// Parses CI configuration only, so this action guard needs no device or subprocess lane.
'test/ci/upload-agent-device-artifacts.test.ts',
'test/skillgym/suites/local-cli-help-policy.test.ts',