Skip to content

Commit be01065

Browse files
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>
1 parent 21fb1f8 commit be01065

4 files changed

Lines changed: 113 additions & 42 deletions

File tree

‎scripts/repo-health/README.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,14 @@ Field names are the stable **trend/delta contract** consumed by #1424. Any chang
5555
"lockfile": { "path": "pnpm-lock.yaml", "sha256": "…" },
5656
// coverage and size are read artifacts this command does NOT produce, so they carry no
5757
// producing-commit stamp. They are hashed (so history keys on the bytes, not the commit) and
58-
// flagged `stale` when a source file is newer than the artifact — the tree moved on without
59-
// the producer being rerun. A consumer (#1424) must treat stale metrics as lagging `commit`.
60-
"coverageSummary": { "path": "coverage/coverage-summary.json", "sha256": "…", "stale": false } | null,
61-
"sizeReport": { "path": ".tmp/size-report.json", "sha256": "…", "stale": false } | null
58+
// flagged `stale` when ANY of their `producerInputs` is newer than the artifact — coverage
59+
// tracks sources + tests + vitest config + package.json; size tracks package.json + lockfile +
60+
// tsdown/tsconfig + size-report.mjs, not just `src`. Unprovable freshness (no observable input)
61+
// is reported stale. A consumer (#1424) must treat stale metrics as lagging `commit`.
62+
"coverageSummary": { "path": "coverage/coverage-summary.json", "sha256": "…",
63+
"producerInputs": ["src/**/*.ts", "test", "…"], "stale": false } | null,
64+
"sizeReport": { "path": ".tmp/size-report.json", "sha256": "…",
65+
"producerInputs": ["src/**/*.ts", "package.json", "…"], "stale": false } | null
6266
}
6367
},
6468
"metrics": {

‎scripts/repo-health/model.test.ts‎

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { buildGraph, type GraphData } from '../depgraph/model.ts';
66
import { mainSequenceModules, zoneComponents } from './components.ts';
77
import {
88
artifactProvenance,
9+
isArtifactStale,
910
benchMetrics,
1011
buildSnapshot,
1112
coverageMetrics,
@@ -124,17 +125,32 @@ test('coverage and size degrade to unavailable when the artifact is absent', ()
124125
).toBe(true);
125126
});
126127

127-
test('artifactProvenance binds bytes and flags an artifact older than the tree as stale', () => {
128-
const args = { path: 'coverage/coverage-summary.json', sha256: 'deadbeef' };
129-
// Artifact newer than every source file: the metrics match the current tree.
130-
expect(artifactProvenance({ ...args, artifactMtimeMs: 2000, newestSourceMtimeMs: 1000 })).toEqual(
131-
{ path: args.path, sha256: 'deadbeef', stale: false },
132-
);
133-
// Source edited after the artifact was produced: the metrics predate the tree and must be flagged
134-
// so #1424 cannot persist them against the current commit as if they were fresh.
135-
expect(artifactProvenance({ ...args, artifactMtimeMs: 1000, newestSourceMtimeMs: 2000 })).toEqual(
136-
{ path: args.path, sha256: 'deadbeef', stale: true },
137-
);
128+
test('isArtifactStale flags an artifact predating ANY producer input, incl. non-src ones', () => {
129+
const artifact = 1500;
130+
const srcMtimes = [1000, 1200]; // all older than the artifact
131+
// Freshness judged on src alone would (wrongly) call this fresh — the earlier false-negative.
132+
expect(isArtifactStale(artifact, srcMtimes)).toBe(false);
133+
// A non-src producer input (e.g. package.json / vitest config / tsdown config) edited after the
134+
// artifact was produced must flip the verdict, so a coverage/size number is not paired with a
135+
// commit it predates.
136+
const packageJsonMtime = 1800;
137+
expect(isArtifactStale(artifact, [...srcMtimes, packageJsonMtime])).toBe(true);
138+
// No observable producer input => freshness cannot be proven => stale, never a false "fresh".
139+
expect(isArtifactStale(artifact, [])).toBe(true);
140+
});
141+
142+
test('artifactProvenance binds bytes, the producer input set, and the staleness verdict', () => {
143+
const args = {
144+
path: 'coverage/coverage-summary.json',
145+
sha256: 'deadbeef',
146+
producerInputs: ['src/**/*.ts', 'package.json'],
147+
};
148+
expect(
149+
artifactProvenance({ ...args, artifactMtimeMs: 2000, producerInputMtimesMs: [1000, 1500] }),
150+
).toEqual({ ...args, stale: false });
151+
expect(
152+
artifactProvenance({ ...args, artifactMtimeMs: 1000, producerInputMtimesMs: [1500] }),
153+
).toEqual({ ...args, stale: true });
138154
});
139155

140156
test('benchMetrics counts cases and distinct topics from the case registry', () => {

‎scripts/repo-health/model.ts‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -233,33 +233,47 @@ export function benchMetrics(cases: readonly { docs: readonly string[] }[]): Ben
233233
* Provenance for an analyzer artifact this snapshot READ but did not itself produce (coverage,
234234
* size). These artifacts carry no producing-commit stamp, so the snapshot cannot claim they match
235235
* `commit`. We instead record their content hash — so #1424 keys history on the bytes the metrics
236-
* came from, not on a commit they may predate — and an explicit `stale` flag: true when a
237-
* production source file is newer than the artifact, i.e. the tree moved on without the producer
238-
* being rerun. A consumer must treat stale metrics as lagging `commit`, never as current.
236+
* came from, not on a commit they may predate — the `producerInputs` freshness was judged against,
237+
* and an explicit `stale` flag. Freshness is bound to the artifact's WHOLE producer input set, not
238+
* just `src` (coverage also depends on tests/config; size on package.json and packaging config), so
239+
* a change to any of them re-flags the artifact. A consumer must treat stale metrics as lagging
240+
* `commit`, never as current.
239241
*/
240242
export type ArtifactProvenance = {
241243
path: string;
242244
sha256: string;
245+
/** The producer input pathspecs whose freshness this verdict was computed against. */
246+
producerInputs: string[];
243247
stale: boolean;
244248
};
245249

246250
/**
247-
* Bind an artifact to the bytes and freshness it was read with. `stale` compares mtimes: an
248-
* artifact older than the newest production source predates the current working tree. mtime is a
249-
* local, offline signal (these artifacts are git-ignored, so there is no blob to diff), evaluated
250-
* against a single tree in one run, which is exactly the "edited code but did not rerun the
251-
* producer" case this guards against.
251+
* Whether an artifact predates any of its producer inputs. mtime is a local, offline signal (these
252+
* artifacts are git-ignored, so there is no blob to diff), evaluated against a single tree in one
253+
* run. Freshness we cannot prove is reported as stale: an EMPTY input set (nothing observable) and
254+
* any input newer than the artifact both mean the metrics may lag the tree — we never claim fresh
255+
* on the strength of `src` alone, which was the earlier false-negative.
252256
*/
257+
export function isArtifactStale(
258+
artifactMtimeMs: number,
259+
producerInputMtimesMs: readonly number[],
260+
): boolean {
261+
return producerInputMtimesMs.length === 0 || Math.max(...producerInputMtimesMs) > artifactMtimeMs;
262+
}
263+
264+
/** Bind an artifact to the bytes and the full producer input set its freshness was judged against. */
253265
export function artifactProvenance(params: {
254266
path: string;
255267
sha256: string;
268+
producerInputs: string[];
256269
artifactMtimeMs: number;
257-
newestSourceMtimeMs: number;
270+
producerInputMtimesMs: readonly number[];
258271
}): ArtifactProvenance {
259272
return {
260273
path: params.path,
261274
sha256: params.sha256,
262-
stale: params.newestSourceMtimeMs > params.artifactMtimeMs,
275+
producerInputs: params.producerInputs,
276+
stale: isArtifactStale(params.artifactMtimeMs, params.producerInputMtimesMs),
263277
};
264278
}
265279

‎scripts/repo-health/run.ts‎

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@
1414
// size are read from coverage/coverage-summary.json and .tmp/size-report.json when present and
1515
// degrade to `available: false` (with the producing command) when they are not, so a fresh
1616
// checkout still produces a complete, honest snapshot. Those two artifacts carry no producing-commit
17-
// stamp, so provenance hashes them and flags `stale` when a source file is newer (see
18-
// artifactProvenance in model.ts) — a consumer like #1424 must never pair stale metrics with the
19-
// current commit.
17+
// stamp, so provenance hashes them and flags `stale` when any of their producer inputs is newer
18+
// (PRODUCER_INPUTS below; see artifactProvenance in model.ts) — coverage tracks tests + config, size
19+
// tracks package.json + packaging config, not just `src`. A consumer like #1424 must never pair
20+
// stale metrics with the current commit.
2021
//
2122
// The one thing that can FAIL the command is the depgraph-vs-layering R6 consistency assertion:
2223
// the graph built here must reproduce scripts/layering/check.ts's TYPE_INVERSION_BASELINE. The
@@ -85,28 +86,66 @@ function lockfileProvenance(): { path: string; sha256: string } | null {
8586
return raw === null ? null : { path: LOCKFILE_PATH, sha256: shortSha(raw) };
8687
}
8788

88-
/** The newest mtime across the production source tree, the freshness bar for read artifacts. */
89-
function newestSourceMtimeMs(files: readonly string[]): number {
90-
let newest = 0;
89+
// The tracked inputs whose change can invalidate each READ artifact — NOT just `src`. Coverage is
90+
// the whole test run (sources + tests + vitest config + the scripts package.json wires); size runs
91+
// tsdown + `npm pack`, so packaging metadata and bundler/ts config are inputs. An old artifact is
92+
// flagged stale when ANY of these is newer than it, closing the src-only false-negative.
93+
const PRODUCER_INPUTS = {
94+
coverage: [
95+
'src/*.ts',
96+
'src/**/*.ts',
97+
'test',
98+
'vitest.config.ts',
99+
'vitest.*.config.ts',
100+
'package.json',
101+
],
102+
size: [
103+
'src/*.ts',
104+
'src/**/*.ts',
105+
'package.json',
106+
'pnpm-lock.yaml',
107+
'tsdown.config.ts',
108+
'tsconfig.json',
109+
'tsconfig.lib.json',
110+
'scripts/size-report.mjs',
111+
],
112+
} as const;
113+
114+
/** Tracked files matching the pathspecs (offline, ignores untracked/generated); [] on failure. */
115+
function trackedFiles(pathspecs: readonly string[]): string[] {
116+
try {
117+
const out = execFileSync('git', ['ls-files', '-z', '--', ...pathspecs], {
118+
cwd: repoRoot,
119+
encoding: 'utf8',
120+
});
121+
return out.split('\0').filter(Boolean);
122+
} catch {
123+
return [];
124+
}
125+
}
126+
127+
function mtimesMs(files: readonly string[]): number[] {
128+
const out: number[] = [];
91129
for (const file of files) {
92-
const mtimeMs = fs.statSync(absolute(file)).mtimeMs;
93-
if (mtimeMs > newest) newest = mtimeMs;
130+
const abs = absolute(file);
131+
if (fs.existsSync(abs)) out.push(fs.statSync(abs).mtimeMs);
94132
}
95-
return newest;
133+
return out;
96134
}
97135

98-
/** Hash a read artifact and flag it stale when a source file is newer; null when absent. */
136+
/** Hash a read artifact and judge freshness against its whole producer input set; null when absent. */
99137
function artifactInput(
100138
relativePath: string,
101-
newestSourceMtimeMs: number,
139+
producerInputs: readonly string[],
102140
): ArtifactProvenance | null {
103141
const abs = absolute(relativePath);
104142
if (!fs.existsSync(abs)) return null;
105143
return artifactProvenance({
106144
path: relativePath,
107145
sha256: shortSha(fs.readFileSync(abs, 'utf8')),
146+
producerInputs: [...producerInputs],
108147
artifactMtimeMs: fs.statSync(abs).mtimeMs,
109-
newestSourceMtimeMs,
148+
producerInputMtimesMs: mtimesMs(trackedFiles(producerInputs)),
110149
});
111150
}
112151

@@ -168,16 +207,14 @@ function collectProvenance(
168207
}
169208

170209
function collectSnapshot(): RepoHealthSnapshot {
171-
const files = listSourceFiles();
172-
const graph = buildGraphFromTree(files);
173-
const newestSource = newestSourceMtimeMs(files);
210+
const graph = buildGraphFromTree(listSourceFiles());
174211
const coverage = readJsonIfExists<import('./model.ts').CoverageSummary>(COVERAGE_SUMMARY_PATH);
175212
const size = readJsonIfExists<import('./model.ts').SizeReport>(SIZE_REPORT_PATH);
176213
return buildSnapshot({
177214
provenance: collectProvenance(
178215
graph,
179-
artifactInput(COVERAGE_SUMMARY_PATH, newestSource),
180-
artifactInput(SIZE_REPORT_PATH, newestSource),
216+
artifactInput(COVERAGE_SUMMARY_PATH, PRODUCER_INPUTS.coverage),
217+
artifactInput(SIZE_REPORT_PATH, PRODUCER_INPUTS.size),
181218
),
182219
graph,
183220
fallow: {

0 commit comments

Comments
 (0)