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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,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",
Expand Down
4 changes: 3 additions & 1 deletion scripts/layering/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand Down
29 changes: 29 additions & 0 deletions scripts/lib/run-as-main.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 136 additions & 0 deletions scripts/repo-health/README.md
Original file line number Diff line number Diff line change
@@ -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 changes: 132 additions & 0 deletions scripts/repo-health/components.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading