|
14 | 14 | // size are read from coverage/coverage-summary.json and .tmp/size-report.json when present and |
15 | 15 | // degrade to `available: false` (with the producing command) when they are not, so a fresh |
16 | 16 | // 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. |
20 | 21 | // |
21 | 22 | // The one thing that can FAIL the command is the depgraph-vs-layering R6 consistency assertion: |
22 | 23 | // 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 { |
85 | 86 | return raw === null ? null : { path: LOCKFILE_PATH, sha256: shortSha(raw) }; |
86 | 87 | } |
87 | 88 |
|
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[] = []; |
91 | 129 | 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); |
94 | 132 | } |
95 | | - return newest; |
| 133 | + return out; |
96 | 134 | } |
97 | 135 |
|
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. */ |
99 | 137 | function artifactInput( |
100 | 138 | relativePath: string, |
101 | | - newestSourceMtimeMs: number, |
| 139 | + producerInputs: readonly string[], |
102 | 140 | ): ArtifactProvenance | null { |
103 | 141 | const abs = absolute(relativePath); |
104 | 142 | if (!fs.existsSync(abs)) return null; |
105 | 143 | return artifactProvenance({ |
106 | 144 | path: relativePath, |
107 | 145 | sha256: shortSha(fs.readFileSync(abs, 'utf8')), |
| 146 | + producerInputs: [...producerInputs], |
108 | 147 | artifactMtimeMs: fs.statSync(abs).mtimeMs, |
109 | | - newestSourceMtimeMs, |
| 148 | + producerInputMtimesMs: mtimesMs(trackedFiles(producerInputs)), |
110 | 149 | }); |
111 | 150 | } |
112 | 151 |
|
@@ -168,16 +207,14 @@ function collectProvenance( |
168 | 207 | } |
169 | 208 |
|
170 | 209 | function collectSnapshot(): RepoHealthSnapshot { |
171 | | - const files = listSourceFiles(); |
172 | | - const graph = buildGraphFromTree(files); |
173 | | - const newestSource = newestSourceMtimeMs(files); |
| 210 | + const graph = buildGraphFromTree(listSourceFiles()); |
174 | 211 | const coverage = readJsonIfExists<import('./model.ts').CoverageSummary>(COVERAGE_SUMMARY_PATH); |
175 | 212 | const size = readJsonIfExists<import('./model.ts').SizeReport>(SIZE_REPORT_PATH); |
176 | 213 | return buildSnapshot({ |
177 | 214 | provenance: collectProvenance( |
178 | 215 | 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), |
181 | 218 | ), |
182 | 219 | graph, |
183 | 220 | fallow: { |
|
0 commit comments