@bongos/core 1.19.801 → 1.19.803
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +34 -19
- package/docs/architecture.md +3 -1
- package/docs/copy-inventory.md +33 -33
- package/docs/copy-registry.json +38 -38
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/task.js +8 -3
- package/modules/lifecycle/db-analytics.js +7 -0
- package/modules/lifecycle/db-ship.js +24 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/artifact-staleness.js +267 -0
- package/src/module-api.js +1 -1
- package/tests/artifact_staleness.mjs +202 -0
- package/tests/hall_record_world.mjs +19 -0
- package/tests/regrade_actual_minutes.mjs +100 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/artifact-staleness.js — are the gitignored generated artifacts on
|
|
3
|
+
// THIS machine actually current with the commit it has checked out? (task 1001930,
|
|
4
|
+
// ADR 0110 pt.5)
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS EXISTS. ADR 0110 moved `docs/repo-map.md` and `docs/session-log-index.md`
|
|
7
|
+
// off the git graph: they are 100%-generated, two parallel branches collided on them
|
|
8
|
+
// constantly, so they became untracked files regenerated at deploy. That trade was
|
|
9
|
+
// deliberate, and the ADR names the debt it took on in its own Consequences:
|
|
10
|
+
//
|
|
11
|
+
// "A generator that breaks at deploy leaves a stale/absent file with no blocking
|
|
12
|
+
// backstop; the LOUD ::ERROR:: line is the alarm (mandatory, not optional)."
|
|
13
|
+
//
|
|
14
|
+
// That alarm is a line in the droplet's journald. Nobody reads journald. The per-PR
|
|
15
|
+
// freshness gate cannot help either — the files are gitignored, so they never appear
|
|
16
|
+
// in a diff for CI to check (`--check-whole`'s own comment says it plainly: "used by
|
|
17
|
+
// nothing hard; deploy owns it"). So if the deploy regen step silently stops running,
|
|
18
|
+
// every agent keeps reading a symbol map of a codebase that has moved on, and nothing
|
|
19
|
+
// anywhere says so. This check is the backstop that posture depends on.
|
|
20
|
+
//
|
|
21
|
+
// WHAT IT COMPARES. For each registered artifact, its own generator's `--check-whole`
|
|
22
|
+
// answers "would regenerating this file change it?" — run against the checkout that is
|
|
23
|
+
// actually serving. A difference means the deploy generator did not run, or ran and
|
|
24
|
+
// broke. Asking the generator rather than reimplementing the comparison is the point:
|
|
25
|
+
// there is exactly one definition of "fresh" per artifact and it lives with the thing
|
|
26
|
+
// that writes it, so this check cannot drift away from what deploy produces.
|
|
27
|
+
//
|
|
28
|
+
// ABSENT AND STALE ARE REPORTED SEPARATELY, and the split is operational, not cosmetic:
|
|
29
|
+
// absent means the regen step never ran here at all (a deploy line was dropped, or a
|
|
30
|
+
// fresh clone was never generated into), stale means it ran once and has not since.
|
|
31
|
+
// Those send someone to two different places, so the report must not blur them.
|
|
32
|
+
//
|
|
33
|
+
// ── WHERE THIS MAY RUN — read before scheduling it anywhere new ───────────────
|
|
34
|
+
//
|
|
35
|
+
// ONLY where the artifacts are SUPPOSED to already exist: a deployed instance, or a
|
|
36
|
+
// dev box after its source-fetch regen. NEVER in CI. A fresh `git clone` legitimately
|
|
37
|
+
// has no `docs/repo-map.md` — it is gitignored, so it is not in the repository at all
|
|
38
|
+
// — and this check would report it absent on every single run. A monitor that is red
|
|
39
|
+
// every run teaches its reader to ignore it, which is the exact failure ADR 0110's
|
|
40
|
+
// warn-only posture already has. That is why this ships as a scheduled routine on the
|
|
41
|
+
// instance and NOT as a `.github/workflows/` job, unlike its sibling publish-watch.js
|
|
42
|
+
// (which reads git and the registry, and so is true anywhere).
|
|
43
|
+
//
|
|
44
|
+
// THE MAIN GUARD. "Fresh" here means "matches what THIS commit generates". On a
|
|
45
|
+
// deployed instance that commit is main, because deploy does `git reset --hard
|
|
46
|
+
// origin/main` — so the question the caller actually wants ("does the served artifact
|
|
47
|
+
// lag main's HEAD?") and the question `--check-whole` answers are the same question.
|
|
48
|
+
// In a feature worktree they are NOT: the artifact would be compared against that
|
|
49
|
+
// branch, and a pass or a fail would both be meaningless. So a checkout that is not at
|
|
50
|
+
// origin/main is INCONCLUSIVE (exit 2), never a finding — a monitor that cannot tell
|
|
51
|
+
// which question it just answered must say so rather than guess.
|
|
52
|
+
'use strict';
|
|
53
|
+
|
|
54
|
+
const { spawnSync } = require('child_process');
|
|
55
|
+
const fs = require('fs');
|
|
56
|
+
const path = require('path');
|
|
57
|
+
|
|
58
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
59
|
+
const GIT_TIMEOUT_MS = 15000;
|
|
60
|
+
const CHECK_TIMEOUT_MS = 120000;
|
|
61
|
+
|
|
62
|
+
// The gitignored, whole-file generated artifacts this check covers.
|
|
63
|
+
//
|
|
64
|
+
// MEMBERSHIP RULE — an artifact belongs here when all three hold: it is gitignored (so
|
|
65
|
+
// no PR gate can see it), it is written by a deploy-time generator (so it can silently
|
|
66
|
+
// stop being written), and its generator exposes `--check-whole` (so freshness has one
|
|
67
|
+
// definition, owned by the writer). tests/artifact_staleness.mjs pins this list against
|
|
68
|
+
// .gitignore and against the generators' real flags, so an artifact that gains a
|
|
69
|
+
// `--check-whole` and is left out of here fails that test rather than going unwatched.
|
|
70
|
+
//
|
|
71
|
+
// DELIBERATELY NOT HERE, and each for a reason that is not "we forgot":
|
|
72
|
+
// • modules/hall-ui/public/atlas.json (gen-atlas.js, ADR 0094) — gitignored and
|
|
73
|
+
// deploy-generated, but its payload embeds `generatedAt: new Date()`, so a
|
|
74
|
+
// regenerate-and-compare differs on every run by construction. Checking it needs a
|
|
75
|
+
// comparison over `views` only, or a freshness-by-age test against the last deploy
|
|
76
|
+
// — a different mechanism, not this one. gen-atlas has no --check mode at all today.
|
|
77
|
+
// • docs/onboarding/diagrams/*.svg|png — rendering them needs a headless Chromium the
|
|
78
|
+
// droplet does not have (the reason ADR 0110 deferred the diagram half in the first
|
|
79
|
+
// place), so freshness cannot be evaluated where this runs. Their FACTS are already
|
|
80
|
+
// backstopped by the diagram-drift routine, which reads assertions.json instead.
|
|
81
|
+
const ARTIFACTS = Object.freeze([
|
|
82
|
+
Object.freeze({
|
|
83
|
+
rel: 'docs/repo-map.md',
|
|
84
|
+
generator: 'gen-repo-map.js',
|
|
85
|
+
what: 'the symbol skeleton agents navigate the codebase by (ADR 0063)',
|
|
86
|
+
}),
|
|
87
|
+
Object.freeze({
|
|
88
|
+
rel: 'docs/session-log-index.md',
|
|
89
|
+
generator: 'gen-session-index.js',
|
|
90
|
+
what: 'the full session-log index the CLAUDE.md §13 snippet links out to (ADR 0062 §8)',
|
|
91
|
+
}),
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
function git(args, run = spawnSync) {
|
|
95
|
+
const raw = gitRaw(args, run);
|
|
96
|
+
return raw === null ? null : raw.trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Untrimmed, because `git status --porcelain` is column-significant: a line is
|
|
100
|
+
// `XY<space>PATH` and an unstaged modification's X is a SPACE (" M path"). Trimming the
|
|
101
|
+
// buffer eats that leading space on the FIRST line only, so a slice(3) then swallows the
|
|
102
|
+
// path's first character — which is exactly how this printed "cripts/gds/…" until
|
|
103
|
+
// tests/artifact_staleness.mjs caught it.
|
|
104
|
+
function gitRaw(args, run = spawnSync) {
|
|
105
|
+
const r = run('git', args, { cwd: REPO_ROOT, encoding: 'utf8', timeout: GIT_TIMEOUT_MS });
|
|
106
|
+
if (!r || r.error || r.status !== 0) return null;
|
|
107
|
+
return String(r.stdout || '');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The paths in a porcelain listing, status columns removed.
|
|
111
|
+
function porcelainPaths(raw) {
|
|
112
|
+
return raw.split('\n').filter((l) => l.length > 3).map((l) => l.slice(3).trim()).filter(Boolean);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Is this checkout a fair thing to compare the artifacts against — at origin/main AND
|
|
116
|
+
// clean? Returns { ok } or { ok:false, reason }; git being unable to answer is equally
|
|
117
|
+
// inconclusive.
|
|
118
|
+
//
|
|
119
|
+
// The CLEAN half is not belt-and-braces, and it was found by running this check against
|
|
120
|
+
// its own uncommitted source file: a generator regenerates from the WORKING TREE, so one
|
|
121
|
+
// uncommitted file that moves a symbol makes repo-map.md "stale" against a main it in
|
|
122
|
+
// fact matches. On the droplet the tree is clean by construction (deploy does
|
|
123
|
+
// `git reset --hard`), so requiring it costs a correct run nothing — while without it
|
|
124
|
+
// every hand-run from a working checkout invents a finding, which is precisely how a
|
|
125
|
+
// monitor teaches its reader to ignore it. Gitignored files (the artifacts themselves)
|
|
126
|
+
// never appear in `--porcelain`, so they cannot trip this.
|
|
127
|
+
function atMain(run = spawnSync) {
|
|
128
|
+
const head = git(['rev-parse', 'HEAD'], run);
|
|
129
|
+
const main = git(['rev-parse', 'origin/main'], run);
|
|
130
|
+
if (!head) return { ok: false, reason: 'could not read HEAD (not a git checkout?)' };
|
|
131
|
+
if (!main) return { ok: false, reason: 'could not read origin/main (no remote ref here?)' };
|
|
132
|
+
if (head !== main) {
|
|
133
|
+
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], run) || '(detached)';
|
|
134
|
+
return { ok: false, reason: `checkout is at ${branch} ${head.slice(0, 8)}, not origin/main ${main.slice(0, 8)}` };
|
|
135
|
+
}
|
|
136
|
+
const dirty = gitRaw(['status', '--porcelain'], run);
|
|
137
|
+
if (dirty === null) return { ok: false, reason: 'could not read the working tree state' };
|
|
138
|
+
const paths = porcelainPaths(dirty);
|
|
139
|
+
if (paths.length) {
|
|
140
|
+
const shown = paths.slice(0, 3).join(', ');
|
|
141
|
+
const more = paths.length > 3 ? `, +${paths.length - 3} more` : '';
|
|
142
|
+
return { ok: false, reason: `working tree is not clean (${shown}${more}) — the generators would compare against those edits, not main` };
|
|
143
|
+
}
|
|
144
|
+
return { ok: true, sha: head };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Ask ONE artifact's own generator whether it is current.
|
|
148
|
+
// fresh exit 0 — regenerating would change nothing
|
|
149
|
+
// stale exit 2 — it would (absent counts as stale; `present` says which)
|
|
150
|
+
// unknown anything else — the generator itself could not answer, which is not a
|
|
151
|
+
// finding about the artifact and must never be reported as one.
|
|
152
|
+
function checkArtifact(a, { root = REPO_ROOT, run = spawnSync, exists = fs.existsSync } = {}) {
|
|
153
|
+
const present = exists(path.join(root, a.rel));
|
|
154
|
+
const r = run(process.execPath, [path.join(root, 'scripts', 'gds', a.generator), '--check-whole'],
|
|
155
|
+
{ cwd: root, encoding: 'utf8', timeout: CHECK_TIMEOUT_MS });
|
|
156
|
+
if (!r || r.error) {
|
|
157
|
+
return { ...a, state: 'unknown', present, detail: r && r.error ? r.error.message : 'generator did not run' };
|
|
158
|
+
}
|
|
159
|
+
if (r.status === 0) return { ...a, state: 'fresh', present };
|
|
160
|
+
if (r.status === 2) return { ...a, state: present ? 'stale' : 'absent', present };
|
|
161
|
+
const said = String(r.stderr || r.stdout || '').trim().split('\n').pop() || `exit ${r.status}`;
|
|
162
|
+
return { ...a, state: 'unknown', present, detail: said };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function check({ root = REPO_ROOT, run = spawnSync, exists = fs.existsSync, artifacts = ARTIFACTS, requireMain = true } = {}) {
|
|
166
|
+
if (requireMain) {
|
|
167
|
+
const m = atMain(run);
|
|
168
|
+
if (!m.ok) { const e = new Error(m.reason); e.inconclusive = true; throw e; }
|
|
169
|
+
}
|
|
170
|
+
const results = artifacts.map((a) => checkArtifact(a, { root, run, exists }));
|
|
171
|
+
return {
|
|
172
|
+
results,
|
|
173
|
+
fresh: results.filter((r) => r.state === 'fresh'),
|
|
174
|
+
stale: results.filter((r) => r.state === 'stale'),
|
|
175
|
+
absent: results.filter((r) => r.state === 'absent'),
|
|
176
|
+
unknown: results.filter((r) => r.state === 'unknown'),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Plain language: this lands in Discord #ship-news, where the audience is the owner,
|
|
181
|
+
// not whoever wrote the generator.
|
|
182
|
+
function report(r) {
|
|
183
|
+
const bad = [...r.absent, ...r.stale];
|
|
184
|
+
if (!bad.length && !r.unknown.length) {
|
|
185
|
+
return `Generated navigation docs are current — ${r.fresh.length} artifact(s) match this commit.`;
|
|
186
|
+
}
|
|
187
|
+
const lines = [];
|
|
188
|
+
if (bad.length) {
|
|
189
|
+
lines.push(`⚠ ${bad.length} generated file(s) on this instance no longer match the code being served.`);
|
|
190
|
+
lines.push('');
|
|
191
|
+
for (const a of r.absent) lines.push(` • ${a.rel} — MISSING entirely. Nothing has generated it here. It is ${a.what}.`);
|
|
192
|
+
for (const a of r.stale) lines.push(` • ${a.rel} — out of date: it no longer matches what this commit would produce. It is ${a.what}.`);
|
|
193
|
+
lines.push('');
|
|
194
|
+
lines.push('These are rebuilt by the deploy step, so this means that step stopped running or broke.');
|
|
195
|
+
lines.push('Nothing blocks on them, which is why it went unreported until now — anyone reading these');
|
|
196
|
+
lines.push('files is being given an out-of-date picture. Check the deploy log for its regen lines');
|
|
197
|
+
lines.push('(they print ::ERROR:: when a generator fails).');
|
|
198
|
+
}
|
|
199
|
+
if (r.unknown.length) {
|
|
200
|
+
if (bad.length) lines.push('');
|
|
201
|
+
lines.push(`Could not check ${r.unknown.length} artifact(s):`);
|
|
202
|
+
for (const a of r.unknown) lines.push(` • ${a.rel} — ${a.detail}`);
|
|
203
|
+
}
|
|
204
|
+
return lines.join('\n');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Deliver the finding here rather than through run-routine.js's `sink: discord`.
|
|
208
|
+
// run-routine treats a non-zero step as a FAILED step and aborts BEFORE it routes
|
|
209
|
+
// stdout, so exiting 1 on a finding would throw that finding away. publish-watch.js
|
|
210
|
+
// and artifact-audit.js carry the same shape for the same reason; their routine steps
|
|
211
|
+
// all stay `sink: none` and the script performs its own side effect.
|
|
212
|
+
async function deliver(text) {
|
|
213
|
+
const { postWebhook } = require(path.join(__dirname, '..', 'discord', 'lib.js'));
|
|
214
|
+
await postWebhook('ship_news', text);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function main() {
|
|
218
|
+
const argv = process.argv.slice(2);
|
|
219
|
+
const json = argv.includes('--json');
|
|
220
|
+
|
|
221
|
+
let r;
|
|
222
|
+
try {
|
|
223
|
+
r = check({ requireMain: !argv.includes('--any-ref') });
|
|
224
|
+
} catch (err) {
|
|
225
|
+
// Exit 2 = COULD NOT RUN, kept distinct from 1 (ran, found something) so a
|
|
226
|
+
// scheduler can go red on the former and merely report the latter.
|
|
227
|
+
console.error(`artifact-staleness could not complete: ${err && err.message ? err.message : err}`);
|
|
228
|
+
if (err && err.inconclusive) {
|
|
229
|
+
console.error(' Freshness is only meaningful against the commit being served. Re-run on the');
|
|
230
|
+
console.error(' deployed instance, or pass --any-ref to compare against whatever is checked out.');
|
|
231
|
+
}
|
|
232
|
+
process.exit(2);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const text = report(r);
|
|
236
|
+
const found = r.stale.length + r.absent.length;
|
|
237
|
+
|
|
238
|
+
if (json) { console.log(JSON.stringify(r, null, 2)); process.exit(found ? 1 : 0); }
|
|
239
|
+
|
|
240
|
+
// A healthy run is deliberately quiet — it tells a person who ran it by hand
|
|
241
|
+
// (stderr) and posts nothing. A daily "all fine" is how a channel becomes wallpaper.
|
|
242
|
+
if (!found) {
|
|
243
|
+
console.error(text);
|
|
244
|
+
// An artifact we could not check is not a finding, but it must not be silent
|
|
245
|
+
// either: a permanently-unknown artifact is unwatched without anyone deciding so.
|
|
246
|
+
process.exit(r.unknown.length ? 2 : 0);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
console.log(text);
|
|
250
|
+
if (argv.includes('--no-post')) process.exit(1);
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
await deliver(text);
|
|
254
|
+
console.error('posted the finding to Discord #ship-news.');
|
|
255
|
+
process.exit(1);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
// Found something and could not say it — the original defect one layer down, so
|
|
258
|
+
// it is the LOUDEST outcome: exit 3 is >= 2, which turns the scheduled run red.
|
|
259
|
+
console.error(`\nFOUND STALE ARTIFACTS AND COULD NOT DELIVER THE FINDING: ${err && err.message ? err.message : err}`);
|
|
260
|
+
console.error('The finding is above. Set DISCORD_SHIP_NEWS_WEBHOOK so this reaches #ship-news.');
|
|
261
|
+
process.exit(3);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (require.main === module) main();
|
|
266
|
+
|
|
267
|
+
module.exports = { ARTIFACTS, atMain, checkArtifact, check, report };
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.803'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// tests/artifact_staleness.mjs
|
|
2
|
+
//
|
|
3
|
+
// Covers scripts/gds/artifact-staleness.js (task 1001930, ADR 0110 pt.5) — the backstop
|
|
4
|
+
// for the gitignored, deploy-generated navigation docs.
|
|
5
|
+
//
|
|
6
|
+
// ADR 0110 untracked docs/repo-map.md + docs/session-log-index.md to kill a merge-collision
|
|
7
|
+
// class, and accepted a debt in writing: a deploy generator that breaks leaves a stale or
|
|
8
|
+
// absent file with no blocking gate, alarming only via a ::ERROR:: line in journald. This
|
|
9
|
+
// check is that backstop, so the properties worth pinning are the ones that decide whether
|
|
10
|
+
// anyone will still be reading it in six months:
|
|
11
|
+
//
|
|
12
|
+
// 1. it distinguishes CANNOT-CHECK from FOUND-SOMETHING (a monitor that cries wolf when
|
|
13
|
+
// it is merely in the wrong place is one nobody reads — the exact ADR 0110 failure);
|
|
14
|
+
// 2. absent and stale stay separate (they send a reader to two different places);
|
|
15
|
+
// 3. the registry cannot silently fall behind the set of gitignored artifacts.
|
|
16
|
+
//
|
|
17
|
+
// `run`/`exists` are injected throughout, so nothing here touches a real generator, git
|
|
18
|
+
// repo, or network. The end-to-end behaviour against the REAL generators was verified by
|
|
19
|
+
// hand at ship time (fresh / absent / stale / both), which fakes cannot prove.
|
|
20
|
+
//
|
|
21
|
+
// Run: node --test tests/artifact_staleness.mjs
|
|
22
|
+
|
|
23
|
+
import { test } from 'node:test';
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const { ARTIFACTS, atMain, checkArtifact, check, report } = require('../scripts/gds/artifact-staleness.js');
|
|
30
|
+
|
|
31
|
+
const SHA = 'a'.repeat(40);
|
|
32
|
+
const OTHER = 'b'.repeat(40);
|
|
33
|
+
|
|
34
|
+
// A fake `run` for git: a table keyed by the git subcommand+args it is asked.
|
|
35
|
+
function fakeGit({ head = SHA, main = SHA, porcelain = '', branch = 'main', fail = null } = {}) {
|
|
36
|
+
return (cmd, args) => {
|
|
37
|
+
const key = args.join(' ');
|
|
38
|
+
if (fail && key.includes(fail)) return { status: 1, stdout: '', stderr: 'boom' };
|
|
39
|
+
if (key === 'rev-parse HEAD') return { status: 0, stdout: `${head}\n` };
|
|
40
|
+
if (key === 'rev-parse origin/main') return { status: 0, stdout: `${main}\n` };
|
|
41
|
+
if (key === 'rev-parse --abbrev-ref HEAD') return { status: 0, stdout: `${branch}\n` };
|
|
42
|
+
if (key === 'status --porcelain') return { status: 0, stdout: porcelain };
|
|
43
|
+
return { status: 0, stdout: '' };
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── the guard: when is a comparison even meaningful? ────────────────────────────
|
|
48
|
+
|
|
49
|
+
test('at origin/main with a clean tree is comparable', () => {
|
|
50
|
+
assert.equal(atMain(fakeGit()).ok, true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a feature branch is INCONCLUSIVE — the artifact would be judged against the wrong code', () => {
|
|
54
|
+
const r = atMain(fakeGit({ head: OTHER, branch: 'claude/some-task' }));
|
|
55
|
+
assert.equal(r.ok, false);
|
|
56
|
+
assert.match(r.reason, /claude\/some-task/);
|
|
57
|
+
assert.match(r.reason, /not origin\/main/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a DIRTY tree is inconclusive — a generator regenerates from the working tree, not from main', () => {
|
|
61
|
+
// The real bug this caught: running the check against its own uncommitted source file
|
|
62
|
+
// reported repo-map.md stale when it in fact matched main.
|
|
63
|
+
const r = atMain(fakeGit({ porcelain: ' M scripts/gds/artifact-staleness.js\n' }));
|
|
64
|
+
assert.equal(r.ok, false);
|
|
65
|
+
assert.match(r.reason, /not clean/);
|
|
66
|
+
assert.match(r.reason, /scripts\/gds\/artifact-staleness\.js/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('every porcelain status column is parsed whole — an unstaged " M" must not lose its first letter', () => {
|
|
70
|
+
// The SECOND bug this file caught: the reader trimmed the buffer, and an unstaged
|
|
71
|
+
// modification's leading space is significant, so the first line printed "cripts/…".
|
|
72
|
+
// One case per column shape, first-line position included, since only the first line
|
|
73
|
+
// was ever affected.
|
|
74
|
+
for (const line of [' M scripts/gds/artifact-staleness.js', 'M scripts/gds/artifact-staleness.js', '?? scripts/gds/artifact-staleness.js', 'A scripts/gds/artifact-staleness.js']) {
|
|
75
|
+
const r = atMain(fakeGit({ porcelain: `${line}\n` }));
|
|
76
|
+
assert.match(r.reason, /\(scripts\/gds\/artifact-staleness\.js\)/, `mangled the path for "${line}"`);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a long dirty list is truncated with a count rather than dumped', () => {
|
|
81
|
+
const porcelain = [' M a.js', ' M b.js', ' M c.js', ' M d.js', ' M e.js'].join('\n') + '\n';
|
|
82
|
+
const r = atMain(fakeGit({ porcelain }));
|
|
83
|
+
assert.match(r.reason, /a\.js, b\.js, c\.js, \+2 more/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('git being unable to answer is inconclusive, never a pass', () => {
|
|
87
|
+
assert.equal(atMain(fakeGit({ fail: 'rev-parse origin/main' })).ok, false);
|
|
88
|
+
assert.equal(atMain(fakeGit({ fail: 'status --porcelain' })).ok, false);
|
|
89
|
+
assert.equal(atMain(fakeGit({ fail: 'rev-parse HEAD' })).ok, false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('check() REFUSES rather than reporting a finding when the checkout is not comparable', () => {
|
|
93
|
+
assert.throws(
|
|
94
|
+
() => check({ run: fakeGit({ head: OTHER }), exists: () => true }),
|
|
95
|
+
(e) => e.inconclusive === true,
|
|
96
|
+
'an inconclusive run must be distinguishable from a finding',
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ── one artifact's verdict ──────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
const A = { rel: 'docs/repo-map.md', generator: 'gen-repo-map.js', what: 'the symbol skeleton' };
|
|
103
|
+
const gen = (status, extra = {}) => () => ({ status, stdout: '', stderr: '', ...extra });
|
|
104
|
+
|
|
105
|
+
test('generator exit 0 → fresh', () => {
|
|
106
|
+
assert.equal(checkArtifact(A, { run: gen(0), exists: () => true }).state, 'fresh');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('generator exit 2 + file present → stale; + file absent → absent', () => {
|
|
110
|
+
assert.equal(checkArtifact(A, { run: gen(2), exists: () => true }).state, 'stale');
|
|
111
|
+
assert.equal(checkArtifact(A, { run: gen(2), exists: () => false }).state, 'absent');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('a generator that cannot answer is UNKNOWN, never stale — it is not a finding about the artifact', () => {
|
|
115
|
+
const crashed = checkArtifact(A, { run: gen(1, { stderr: 'SyntaxError: bad\n' }), exists: () => true });
|
|
116
|
+
assert.equal(crashed.state, 'unknown');
|
|
117
|
+
assert.match(crashed.detail, /SyntaxError/);
|
|
118
|
+
const spawnFailed = checkArtifact(A, { run: () => ({ error: new Error('ENOENT') }), exists: () => true });
|
|
119
|
+
assert.equal(spawnFailed.state, 'unknown');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── the report a human actually reads ───────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
const runFor = (byGenerator) => (cmd, args) => {
|
|
125
|
+
if (cmd === 'git') return fakeGit()(cmd, args);
|
|
126
|
+
const which = String(args[0] || '').split('/').pop();
|
|
127
|
+
return { status: byGenerator[which], stdout: '', stderr: '' };
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
test('all fresh → one quiet line, and no alarm language', () => {
|
|
131
|
+
const r = check({ run: runFor({ 'gen-repo-map.js': 0, 'gen-session-index.js': 0 }), exists: () => true });
|
|
132
|
+
assert.equal(r.stale.length + r.absent.length, 0);
|
|
133
|
+
const text = report(r);
|
|
134
|
+
assert.match(text, /current/);
|
|
135
|
+
assert.ok(!text.includes('⚠'), 'a healthy run must not look like a finding');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('absent and stale are reported as different things, not one blurred count', () => {
|
|
139
|
+
const r = check({
|
|
140
|
+
run: runFor({ 'gen-repo-map.js': 2, 'gen-session-index.js': 2 }),
|
|
141
|
+
exists: (p) => !String(p).endsWith('repo-map.md'), // repo-map gone, session-index present
|
|
142
|
+
});
|
|
143
|
+
assert.deepEqual(r.absent.map((a) => a.rel), ['docs/repo-map.md']);
|
|
144
|
+
assert.deepEqual(r.stale.map((a) => a.rel), ['docs/session-log-index.md']);
|
|
145
|
+
const text = report(r);
|
|
146
|
+
assert.match(text, /docs\/repo-map\.md — MISSING entirely/);
|
|
147
|
+
assert.match(text, /docs\/session-log-index\.md — out of date/);
|
|
148
|
+
assert.match(text, /deploy step/, 'the report must say where to go look');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('an unchecked artifact is surfaced even when nothing is stale — unwatched must not read as healthy', () => {
|
|
152
|
+
const r = check({ run: runFor({ 'gen-repo-map.js': 0, 'gen-session-index.js': 1 }), exists: () => true });
|
|
153
|
+
assert.equal(r.stale.length + r.absent.length, 0);
|
|
154
|
+
assert.equal(r.unknown.length, 1);
|
|
155
|
+
assert.match(report(r), /Could not check 1 artifact/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ── the registry cannot fall behind reality ─────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
test('every registered artifact is really gitignored — a tracked file belongs to the PR gate instead', () => {
|
|
161
|
+
const ignore = readFileSync(new URL('../.gitignore', import.meta.url), 'utf8')
|
|
162
|
+
.split('\n').map((l) => l.trim());
|
|
163
|
+
for (const a of ARTIFACTS) {
|
|
164
|
+
assert.ok(ignore.includes(a.rel), `${a.rel} must be gitignored to belong in this check`);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('every registered generator really exposes --check-whole — the contract this check is built on', () => {
|
|
169
|
+
for (const a of ARTIFACTS) {
|
|
170
|
+
const src = readFileSync(new URL(`../scripts/gds/${a.generator}`, import.meta.url), 'utf8');
|
|
171
|
+
assert.match(src, /--check-whole/, `${a.generator} must support --check-whole`);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('the two ADR 0110 artifacts are both covered, and the known exclusions stay deliberate', () => {
|
|
176
|
+
assert.deepEqual(ARTIFACTS.map((a) => a.rel).sort(), ['docs/repo-map.md', 'docs/session-log-index.md']);
|
|
177
|
+
// atlas.json and the diagram renders are gitignored + deploy-generated too, but neither
|
|
178
|
+
// can be answered by this mechanism (a timestamped payload; a render needing Chromium).
|
|
179
|
+
// The reasons are written out at ARTIFACTS — this asserts they were a decision, so that
|
|
180
|
+
// adding either later is a deliberate act rather than a silent widening.
|
|
181
|
+
const src = readFileSync(new URL('../scripts/gds/artifact-staleness.js', import.meta.url), 'utf8');
|
|
182
|
+
assert.match(src, /atlas\.json/, 'the atlas exclusion must stay documented');
|
|
183
|
+
assert.match(src, /diagrams/, 'the diagram-render exclusion must stay documented');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('this check must NOT be wired into CI — a fresh clone legitimately has none of these files', () => {
|
|
187
|
+
// The artifacts are gitignored, so a CI checkout never has them and this would be red on
|
|
188
|
+
// every single run — the "monitor nobody reads" failure ADR 0110 already has once. The
|
|
189
|
+
// mistake is an easy one to make by analogy with publish-watch.js, which IS a workflow
|
|
190
|
+
// (it reads git + the registry, so it is true anywhere). Assert no workflow invokes it.
|
|
191
|
+
const src = readFileSync(new URL('../scripts/gds/artifact-staleness.js', import.meta.url), 'utf8');
|
|
192
|
+
assert.match(src, /NEVER in CI/, 'the file must state where it may run');
|
|
193
|
+
|
|
194
|
+
let names = [];
|
|
195
|
+
try { names = readdirSync(new URL('../.github/workflows', import.meta.url)); } catch { names = []; }
|
|
196
|
+
const callers = names.filter((n) => {
|
|
197
|
+
try {
|
|
198
|
+
return readFileSync(new URL(`../.github/workflows/${n}`, import.meta.url), 'utf8').includes('artifact-staleness');
|
|
199
|
+
} catch { return false; }
|
|
200
|
+
});
|
|
201
|
+
assert.deepEqual(callers, [], `no workflow may run this check; found: ${callers.join(', ')}`);
|
|
202
|
+
});
|
|
@@ -412,6 +412,25 @@ test('the figure band drops absent figures rather than drawing a dash', async ()
|
|
|
412
412
|
assert.deepEqual(labels, ['credits', 'Estimated', 'Priority'], 'the figures it DOES carry are all drawn, in order');
|
|
413
413
|
});
|
|
414
414
|
|
|
415
|
+
// task 1003964 — 'Actual' is claimed-session time, and the bare label promises
|
|
416
|
+
// more than that: a re-graded task's recovery session is not in the number,
|
|
417
|
+
// because a re-grade has no claim to seal a session_logs row against. The
|
|
418
|
+
// decision is fine; the word alone hiding it is not. Rendered, not grepped —
|
|
419
|
+
// a title that never survives figureBand looks identical in the source.
|
|
420
|
+
test('the Actual figure says what it counts, and only that figure carries a title', async () => {
|
|
421
|
+
const r = renderTask({ ...BASE_TASK });
|
|
422
|
+
await r.boot(); await settle();
|
|
423
|
+
const cells = [...r.html().matchAll(/<div class="rec-fig"([^>]*)>[\s\S]*?<span class="rec-fig__l">([^<]+)</g)]
|
|
424
|
+
.map((m) => ({ attrs: m[1], label: m[2] }));
|
|
425
|
+
const actual = cells.find((c) => c.label === 'Actual');
|
|
426
|
+
assert.ok(actual, 'the figure is drawn at all');
|
|
427
|
+
assert.match(actual.attrs, /title="[^"]*claimed sessions[^"]*"/, 'it names what it measures');
|
|
428
|
+
assert.match(actual.attrs, /re-grade/, 'and what it deliberately leaves out');
|
|
429
|
+
for (const c of cells.filter((x) => x.label !== 'Actual')) {
|
|
430
|
+
assert.equal(c.attrs.trim(), '', `${c.label} carries no stray title`);
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
415
434
|
test('under two figures there is no band at all — one figure is not a row', async () => {
|
|
416
435
|
const r = renderTask({ ...BASE_TASK, credits_reward: null, est_minutes: null, actual_minutes: null });
|
|
417
436
|
await r.boot(); await settle();
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// tests/regrade_actual_minutes.mjs — a re-grade does not re-measure, and the
|
|
2
|
+
// panel is not fed the figure it would misread (task 1003964, goal 1000118).
|
|
3
|
+
//
|
|
4
|
+
// THE MECHANISM. actual_minutes is recomputed in exactly one place — resolveClaim
|
|
5
|
+
// (modules/lifecycle/db-ship.js) — as the SUM of every session_logs row for the
|
|
6
|
+
// task, and session_logs rows are sealed per CLAIM. A --regrade has no claim,
|
|
7
|
+
// which is the defining property of that path (#678): it never reaches
|
|
8
|
+
// resolveClaim, seals no row, and does not recompute. A re-graded task therefore
|
|
9
|
+
// keeps the figure its FIRST, grade-failed attempt measured (observed on task
|
|
10
|
+
// 1002343: actual_minutes=19 from 2026-09-04, shipped on the 2026-09-07 redo).
|
|
11
|
+
//
|
|
12
|
+
// THE POSTURE, and why it is a decision rather than a patch. A claimless window
|
|
13
|
+
// has no defensible start time. The prior grade's timestamp puts the entire gap
|
|
14
|
+
// between attempts into the sum — for task 1002343 that is ~3 days, roughly
|
|
15
|
+
// 4300 minutes in place of 19, which is worse than stale and poisons the same
|
|
16
|
+
// calibration the fix was meant to protect. The ship's own session digest
|
|
17
|
+
// (scripts/gds/session-digest-build.js) measures a Claude Code SESSION, which
|
|
18
|
+
// routinely spans several tasks and is absent outside an interactive one. An
|
|
19
|
+
// opt-in --minutes flag would be omitted by default, which is the present
|
|
20
|
+
// silence with a flag beside it. So: actual_minutes means CLAIMED-session time,
|
|
21
|
+
// stated at the recompute, in the schema map, in the calibration reader, and on
|
|
22
|
+
// the hall figure — the four places someone meets the number.
|
|
23
|
+
// The hall half is pinned in tests/hall_record_world.mjs, which owns the render
|
|
24
|
+
// harness that proves the title survives figureBand rather than merely existing.
|
|
25
|
+
//
|
|
26
|
+
// WHAT THIS FILE PINS is the half that can silently become untrue. The bug row
|
|
27
|
+
// was filed believing the grader scores an est/actual ratio against the stale
|
|
28
|
+
// figure; on current main it cannot, because the live panel never receives
|
|
29
|
+
// actual_minutes at all. That is the whole reason the posture is safe, so it is
|
|
30
|
+
// the thing that needs a tripwire: wire actual_minutes into the panel prompt and
|
|
31
|
+
// this file reds, and the decision has to be made again with the risk real.
|
|
32
|
+
import { strict as assert } from 'node:assert';
|
|
33
|
+
import { test } from 'node:test';
|
|
34
|
+
import { createRequire } from 'node:module';
|
|
35
|
+
import { readFileSync } from 'node:fs';
|
|
36
|
+
|
|
37
|
+
const require = createRequire(import.meta.url);
|
|
38
|
+
const { buildPrompt } = require('../modules/grading/grader-prompt.js');
|
|
39
|
+
|
|
40
|
+
// A task carrying an unmistakable actual_minutes — a value that cannot appear by
|
|
41
|
+
// coincidence in a prompt full of ids, line counts and minute figures.
|
|
42
|
+
const TASK = {
|
|
43
|
+
id: 1003964,
|
|
44
|
+
title: 'A re-graded task keeps the first attempt figure',
|
|
45
|
+
kind: 'bug',
|
|
46
|
+
discipline: 'engineer',
|
|
47
|
+
est_minutes: 60,
|
|
48
|
+
actual_minutes: 31337,
|
|
49
|
+
scoping_confidence: 4,
|
|
50
|
+
description: 'the description',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const BUNDLE = {
|
|
54
|
+
task: TASK,
|
|
55
|
+
valueSummary: 'a value summary',
|
|
56
|
+
diff: 'diff --git a/x.js b/x.js\n+one line\n',
|
|
57
|
+
changedFiles: ['x.js'],
|
|
58
|
+
diffStats: { files: 1, insertions: 1, deletions: 0 },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
test('the panel prompt does not carry actual_minutes — the tripwire', () => {
|
|
62
|
+
// THE reason a stale figure is harmless today. If this ever starts failing,
|
|
63
|
+
// the panel is scoring a number that on a re-grade comes from a superseded
|
|
64
|
+
// attempt, and "documented as excluded" stops being a sufficient answer.
|
|
65
|
+
const prompt = buildPrompt(BUNDLE);
|
|
66
|
+
assert.ok(!prompt.includes('31337'), 'the stored figure never reaches the panel');
|
|
67
|
+
assert.doesNotMatch(prompt, /actual[_ ]minutes/i, 'nor the field by name');
|
|
68
|
+
// And the assertion is not vacuous: the prompt DOES carry the estimate, which
|
|
69
|
+
// is the neighbouring field and the one the panel is meant to weigh.
|
|
70
|
+
assert.match(prompt, /60 min/, 'est_minutes is what the panel is given instead');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a task with no actual_minutes at all builds the same prompt', () => {
|
|
74
|
+
// The inverse of the tripwire: if the two prompts ever differ, the field is
|
|
75
|
+
// reaching the panel through some path this file is not watching.
|
|
76
|
+
const withFigure = buildPrompt(BUNDLE);
|
|
77
|
+
const without = buildPrompt({ ...BUNDLE, task: { ...TASK, actual_minutes: null } });
|
|
78
|
+
assert.equal(withFigure, without, 'actual_minutes changes nothing about what the panel reads');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('scoreEstActualBand is reachable only from the retroactive audit', () => {
|
|
82
|
+
// The one scorer that DOES read the figure. It lives in gradeDeterministic,
|
|
83
|
+
// which the grader module's own comment calls the legacy v1 entry — its single
|
|
84
|
+
// non-test caller is scripts/gds/audit-ships.js, a process audit run by hand
|
|
85
|
+
// over already-shipped tasks, not a gate anything passes through. A new caller
|
|
86
|
+
// is the same risk as wiring the figure into the prompt.
|
|
87
|
+
const grader = require('../modules/grading/grader.js');
|
|
88
|
+
assert.equal(typeof grader.gradeDeterministic, 'function', 'the legacy entry still exists');
|
|
89
|
+
assert.equal(grader.gradeSubagent.length > 0, true, 'and the live entry is a different function');
|
|
90
|
+
|
|
91
|
+
// gradeSubagent / gradeSubagentV3MultiShot take a single options object, so the
|
|
92
|
+
// guard that matters is which KEYS they destructure. Neither may take minutes.
|
|
93
|
+
const src = readFileSync(new URL('../modules/grading/grader.js', import.meta.url), 'utf8');
|
|
94
|
+
for (const fn of ['async function gradeSubagent(', 'async function gradeSubagentV3MultiShot(']) {
|
|
95
|
+
const at = src.indexOf(fn);
|
|
96
|
+
assert.ok(at !== -1, `${fn} is still the entry point this pins`);
|
|
97
|
+
const sig = src.slice(at, src.indexOf(')', at));
|
|
98
|
+
assert.ok(!/actualMinutes/.test(sig), `${fn} does not accept actualMinutes`);
|
|
99
|
+
}
|
|
100
|
+
});
|