@bongos/core 1.19.1035 → 1.19.1037
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 +102 -92
- package/.claude/skills/grade-recover/SKILL.md +1 -1
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0302-the-third-identical-failure-stops-the-session-and-asks.md +3 -2
- package/docs/adr/0329-the-rendered-fit-check-is-a-kit-floor-and-an-advisory-ship-check-row.md +2 -2
- package/docs/adr/0339-a-projects-owner-upgrades-it-and-a-hosted-project-hands-off-to-the-hub-door.md +84 -0
- package/docs/adr/README.md +2 -1
- package/docs/api/openapi.json +7 -5
- package/docs/copy-inventory.md +10 -10
- package/docs/copy-registry.json +13 -13
- package/docs/module-api-changelog.md +4 -0
- package/modules/economy/credits.js +1 -1
- package/modules/grading/grader-config.js +3 -12
- package/modules/grading/grader-no-artifact.js +227 -2
- package/modules/grading/grader-prompt.js +1 -1
- package/modules/grading/grader-rubric.json +1 -86
- package/modules/grading/grader-score.js +1 -169
- package/modules/grading/grader-subagent.js +1 -51
- package/modules/grading/grader.js +26 -211
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/people.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +36 -5
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/lifecycle/db-ship.js +3 -3
- package/modules/provisioning/core-upgrade-authz.js +85 -0
- package/modules/provisioning/core-upgrade.js +2 -1
- package/modules/provisioning/provisioning.js +4 -4
- package/modules/provisioning/routes/core-upgrade.js +12 -6
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/scripts/gds/claim.js +2 -1
- package/scripts/gds/grade-smoke.js +3 -3
- package/scripts/gds/provision-core-upgrade.js +12 -4
- package/scripts/gds/render-check.js +1 -1
- package/scripts/gds/ship-check.js +1 -1
- package/scripts/gds/ship-flow.js +1 -1
- package/scripts/gds/ship-grade-steps.js +2 -2
- package/scripts/gds/visual-review-nudge.js +2 -1
- package/src/bongos/auth.js +8 -2
- package/src/bongos/routes/core-update.js +25 -3
- package/src/module-api.js +1 -1
- package/tests/adr_0162_gate_citation.mjs +167 -0
- package/tests/core_upgrade_owner_door.mjs +444 -0
- package/tests/grader.mjs +48 -126
- package/tests/grader_unbacked_score.mjs +1 -1
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +28 -2
- package/tests/regrade_actual_minutes.mjs +18 -17
- package/tests/render_check.mjs +1 -1
- package/tests/scouting_page_gate.mjs +11 -3
- package/tests/skill_grade_audit.mjs +3 -3
- package/tests/skill_grader_health.mjs +2 -2
- package/scripts/gds/audit-ships.js +0 -319
- package/tests/audit_ships_git_argv.mjs +0 -173
|
@@ -1,319 +0,0 @@
|
|
|
1
|
-
// Retroactive audit script for task #204.
|
|
2
|
-
//
|
|
3
|
-
// Loads every task shipped in the last `days` (default 2), fetches full
|
|
4
|
-
// task data via the authenticated /tasks/:id API, derives diff stats from
|
|
5
|
-
// git (one commit per task, found by the `<VERSION> #<id>:` subject prefix),
|
|
6
|
-
// then calls src/bongos/grader.js in-process to get a score.
|
|
7
|
-
//
|
|
8
|
-
// Output: JSON to stdout (--json) or human-readable table (default).
|
|
9
|
-
// Audit-only — does NOT write to task_grades. Pre-grader ships keep clean
|
|
10
|
-
// history.
|
|
11
|
-
|
|
12
|
-
const { execFileSync } = require('node:child_process');
|
|
13
|
-
const grader = require('../../modules/grading/grader');
|
|
14
|
-
const { cliClient } = require('./cli-lib');
|
|
15
|
-
|
|
16
|
-
// EVERY git call here goes through argv, never a shell string (task 1003223 —
|
|
17
|
-
// the class task 1002761 named and task 1002526 fixed in docs-entropy).
|
|
18
|
-
//
|
|
19
|
-
// WHAT WAS WRONG. Five call sites built shell strings. THREE of them interpolated
|
|
20
|
-
// a quoted empty `--format=''`; a POSIX shell strips those quotes and git sees an
|
|
21
|
-
// empty format, but the shell Node uses on Windows does not, so git receives
|
|
22
|
-
// `--format=''` literally and dies with `fatal: invalid --pretty format: ''`.
|
|
23
|
-
// Verified on win32 before fixing: the shell-string form throws, the argv form
|
|
24
|
-
// returns " 1 file changed, 139 insertions(+)". The correct argv spelling is
|
|
25
|
-
// `--format=` — empty value, no quotes, which no shell can mangle because no
|
|
26
|
-
// shell is involved. The other two passed `--format='%H %s'`, which survives a
|
|
27
|
-
// POSIX shell and arrives with literal quotes elsewhere; same class, same fix.
|
|
28
|
-
//
|
|
29
|
-
// WHY IT MATTERED MORE THAN A LOUD CRASH. The originating row asked whether the
|
|
30
|
-
// try/catch around these calls "degrades to a silently empty audit — if so it is
|
|
31
|
-
// the same dangerous shape after all". It did. Every catch returned null or [],
|
|
32
|
-
// so on Windows the audit reported no diff stats and zero downstream impact for
|
|
33
|
-
// EVERY ship, and looked like a clean run. `gitFails` below keeps the same return
|
|
34
|
-
// values but says so on stderr, so an empty audit can never again be mistaken for
|
|
35
|
-
// an uneventful one.
|
|
36
|
-
//
|
|
37
|
-
// One of the five also built a `grep | head | awk` pipeline and pinned
|
|
38
|
-
// `shell: '/bin/bash'` to survive; that filtering is now done in JS, so the script
|
|
39
|
-
// needs no shell at all.
|
|
40
|
-
function gitLines(args, { cwd } = {}) {
|
|
41
|
-
return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
|
|
42
|
-
.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// One place that reports a git failure instead of swallowing it. Returns the
|
|
46
|
-
// caller's fallback so behaviour is unchanged; only the silence is.
|
|
47
|
-
function gitFails(what, err, fallback) {
|
|
48
|
-
const msg = String((err && (err.stderr || err.message)) || err).trim().split('\n')[0];
|
|
49
|
-
console.error(`audit-ships: ${what} failed — ${msg}`);
|
|
50
|
-
return fallback;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const DAYS = Number(process.env.AUDIT_DAYS || 2);
|
|
54
|
-
|
|
55
|
-
// `git log --all` once per RUN, not once per task.
|
|
56
|
-
//
|
|
57
|
-
// The shell pipeline this replaced ended in `head -1`, which let git stop as soon
|
|
58
|
-
// as one subject matched. The argv rewrite has no such early exit — and
|
|
59
|
-
// findCommitForTask is called once per shipped task, up to 500 in a run — so a
|
|
60
|
-
// naive port turned one bounded call into 500 full history walks. The commit list
|
|
61
|
-
// cannot change mid-run, so it is read once and reused. Caught by the grader on
|
|
62
|
-
// the first round of this task, which is a fair catch: replacing a shell pipeline
|
|
63
|
-
// means inheriting the work its operators were doing, not just its output.
|
|
64
|
-
let commitSubjectsCache = null;
|
|
65
|
-
function commitSubjects() {
|
|
66
|
-
if (commitSubjectsCache) return commitSubjectsCache;
|
|
67
|
-
commitSubjectsCache = gitLines(['log', '--all', '--format=%H %s']);
|
|
68
|
-
return commitSubjectsCache;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function findCommitForTask(taskId) {
|
|
72
|
-
// Matches both subject conventions: the legacy ' #NNN:' prefix and the de-#-ed
|
|
73
|
-
// 'task NNN:' form ship.js writes since task 916 (a bare "#NNN" in commit
|
|
74
|
-
// subjects autolinked to dead GitHub issue numbers).
|
|
75
|
-
//
|
|
76
|
-
// The grep|head|awk pipeline this replaces needed `shell: '/bin/bash'` to run at
|
|
77
|
-
// all — a hard dependency on a POSIX shell at a fixed path, for filtering JS can
|
|
78
|
-
// do. Doing it here also makes the id match EXACT: the old regex was
|
|
79
|
-
// interpolated unescaped, so a task id was pasted straight into a regex.
|
|
80
|
-
const re = new RegExp(` (?:#|task )${String(taskId).replace(/[^0-9]/g, '')}:`);
|
|
81
|
-
let lines;
|
|
82
|
-
try {
|
|
83
|
-
lines = commitSubjects();
|
|
84
|
-
} catch (e) {
|
|
85
|
-
return gitFails(`finding the commit for task ${taskId}`, e, null);
|
|
86
|
-
}
|
|
87
|
-
const hit = lines.find((l) => re.test(l));
|
|
88
|
-
return hit ? (hit.split(' ')[0] || null) : null;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function gitDiffStatsForCommit(sha) {
|
|
92
|
-
if (!sha) return null;
|
|
93
|
-
let raw;
|
|
94
|
-
try {
|
|
95
|
-
raw = execFileSync('git', ['show', '--shortstat', '--format=', sha], { encoding: 'utf8' });
|
|
96
|
-
} catch (e) {
|
|
97
|
-
return gitFails(`reading diff stats for ${String(sha).slice(0, 8)}`, e, null);
|
|
98
|
-
}
|
|
99
|
-
// Example shortstat line: " 15 files changed, 1171 insertions(+), 65 deletions(-)"
|
|
100
|
-
const m = raw.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/);
|
|
101
|
-
if (!m) return null;
|
|
102
|
-
return {
|
|
103
|
-
files_changed: Number(m[1] || 0),
|
|
104
|
-
lines_added: Number(m[2] || 0),
|
|
105
|
-
lines_removed: Number(m[3] || 0),
|
|
106
|
-
commit_sha: sha,
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function filesTouchedByCommit(sha) {
|
|
111
|
-
if (!sha) return [];
|
|
112
|
-
try {
|
|
113
|
-
return gitLines(['show', '--name-only', '--format=', sha]);
|
|
114
|
-
} catch (e) {
|
|
115
|
-
return gitFails(`listing files touched by ${String(sha).slice(0, 8)}`, e, []);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Downstream-impact heuristic: number of distinct commits on main reachable
|
|
120
|
-
// from origin/main that landed AFTER `sha` and touched any of `files`. High
|
|
121
|
-
// count = subsequent work has built on top of this change, so a "redo"
|
|
122
|
-
// risks conflicts. Excludes `sha` itself.
|
|
123
|
-
const NO_DOWNSTREAM = Object.freeze({
|
|
124
|
-
commits_after_touching_files: 0, distinct_files_touched: 0, sample_subjects: [],
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
function downstreamImpact(sha, files) {
|
|
128
|
-
if (!sha || files.length === 0) return { ...NO_DOWNSTREAM };
|
|
129
|
-
// The hand-rolled POSIX shell-quoting this replaces ("'" + escape + "'") was
|
|
130
|
-
// both unnecessary and wrong on Windows, where that quoting style is not the
|
|
131
|
-
// shell's. In argv form a path with spaces, quotes or a `$` is just an element.
|
|
132
|
-
const range = `${sha}..origin/main`;
|
|
133
|
-
let lines;
|
|
134
|
-
try {
|
|
135
|
-
lines = gitLines(['log', range, '--format=%H %s', '--', ...files]);
|
|
136
|
-
} catch (e) {
|
|
137
|
-
return gitFails(`reading downstream commits after ${String(sha).slice(0, 8)}`, e, { ...NO_DOWNSTREAM });
|
|
138
|
-
}
|
|
139
|
-
// Distinct downstream files touched
|
|
140
|
-
let distinct = 0;
|
|
141
|
-
try {
|
|
142
|
-
distinct = new Set(gitLines(['log', range, '--name-only', '--format=', '--', ...files])).size;
|
|
143
|
-
} catch (e) {
|
|
144
|
-
// Same shape as every other call site: the fallback comes FROM gitFails, so
|
|
145
|
-
// the value and the report can never drift apart.
|
|
146
|
-
distinct = gitFails(`reading downstream files after ${String(sha).slice(0, 8)}`, e, 0);
|
|
147
|
-
}
|
|
148
|
-
return {
|
|
149
|
-
commits_after_touching_files: lines.length,
|
|
150
|
-
distinct_files_touched: distinct,
|
|
151
|
-
sample_subjects: lines.slice(0, 3).map((l) => l.split(' ').slice(1).join(' ')),
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
async function fetchShipped(days) {
|
|
156
|
-
// Derive the public read host from branding (statusOrigin → publicOrigin), never a
|
|
157
|
-
// hardcoded founder host (was https://status.example.com — task 1002234). The
|
|
158
|
-
// recent-shipped feed is a public endpoint served on the status dashboard host.
|
|
159
|
-
const b = (() => { try { return require('../../src/branding').branding(); } catch (_) { return {}; } })();
|
|
160
|
-
const d = b.domains || {};
|
|
161
|
-
const statusBase = String(d.statusOrigin || d.publicOrigin || '').replace(/\/+$/, '');
|
|
162
|
-
const r = await fetch(
|
|
163
|
-
`${statusBase}/api/bongos/public/recent-shipped?days=${days}&limit=500`
|
|
164
|
-
);
|
|
165
|
-
const data = await r.json();
|
|
166
|
-
return data.tasks || [];
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
async function fetchFullTask(id) {
|
|
170
|
-
const api = await cliClient();
|
|
171
|
-
const r = await api.tasks.getTasksId({ id });
|
|
172
|
-
if (!r.ok) return null;
|
|
173
|
-
return r.data.task;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
async function fetchLearning(taskId) {
|
|
177
|
-
// /api/bongos/learnings?task=<id> — we just need body_md.
|
|
178
|
-
const api = await cliClient();
|
|
179
|
-
const r = await api.learnings.getLearnings({ query: { task: taskId } });
|
|
180
|
-
if (!r.ok) return null;
|
|
181
|
-
const rows = r.data.learnings || [];
|
|
182
|
-
return rows[0] ? { body_md: rows[0].body_md } : null;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
async function main() {
|
|
186
|
-
console.error(`Loading shipped tasks (last ${DAYS} days)...`);
|
|
187
|
-
const shipped = await fetchShipped(DAYS);
|
|
188
|
-
console.error(`Got ${shipped.length} shipped tasks. Fetching full data + diffs...`);
|
|
189
|
-
|
|
190
|
-
const results = [];
|
|
191
|
-
for (const t of shipped) {
|
|
192
|
-
const full = await fetchFullTask(t.id);
|
|
193
|
-
if (!full) {
|
|
194
|
-
console.error(` #${t.id}: could not fetch full task — skip`);
|
|
195
|
-
continue;
|
|
196
|
-
}
|
|
197
|
-
const sha = findCommitForTask(t.id);
|
|
198
|
-
const diff = gitDiffStatsForCommit(sha);
|
|
199
|
-
const files = filesTouchedByCommit(sha);
|
|
200
|
-
const downstream = downstreamImpact(sha, files);
|
|
201
|
-
const verification = {
|
|
202
|
-
scanner_clean: true, // got shipped → passed
|
|
203
|
-
smoke_clean: true,
|
|
204
|
-
diff_stats: diff || undefined,
|
|
205
|
-
};
|
|
206
|
-
const learningRow =
|
|
207
|
-
full.kind === 'learning-capture' ? await fetchLearning(full.id) : null;
|
|
208
|
-
|
|
209
|
-
// V3.R18 (#209): the runtime grade entry switched to gradeSubagent;
|
|
210
|
-
// this retroactive #204 audit keeps using the v1 deterministic scorer
|
|
211
|
-
// because the historical signal we're measuring (process audit, not
|
|
212
|
-
// subagent quality) is what's in the v1 rubric. The function was
|
|
213
|
-
// renamed from grade() to gradeDeterministic() in the same commit.
|
|
214
|
-
const g = grader.gradeDeterministic({
|
|
215
|
-
task: full,
|
|
216
|
-
verification,
|
|
217
|
-
actualMinutes: full.actual_minutes,
|
|
218
|
-
valueSummary: full.value_summary,
|
|
219
|
-
learningRow,
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
// Categorize failure mode. Only meaningful when g.passed === false.
|
|
223
|
-
// - calibration-noise: only criterion under 5 is est_actual_band
|
|
224
|
-
// - quality-concern: multiple criteria under 5
|
|
225
|
-
// - structural-risk: downstream impact high (≥10 commits or ≥10 distinct files)
|
|
226
|
-
let category = null;
|
|
227
|
-
if (!g.passed) {
|
|
228
|
-
const lowCriteria = Object.entries(g.rubric_scores)
|
|
229
|
-
.filter(([_k, v]) => v < 5)
|
|
230
|
-
.map(([k]) => k);
|
|
231
|
-
const onlyCalibrationLow = lowCriteria.length === 1 && lowCriteria[0] === 'est_actual_band';
|
|
232
|
-
const heavyDownstream =
|
|
233
|
-
downstream.commits_after_touching_files >= 10 ||
|
|
234
|
-
downstream.distinct_files_touched >= 10;
|
|
235
|
-
if (heavyDownstream) category = 'structural-risk';
|
|
236
|
-
else if (onlyCalibrationLow) category = 'calibration-noise';
|
|
237
|
-
else category = 'quality-concern';
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
results.push({
|
|
241
|
-
id: full.id,
|
|
242
|
-
title: full.title,
|
|
243
|
-
version_id: full.version_id,
|
|
244
|
-
kind: full.kind,
|
|
245
|
-
discipline: full.discipline,
|
|
246
|
-
est_minutes: full.est_minutes,
|
|
247
|
-
actual_minutes: full.actual_minutes,
|
|
248
|
-
scoping_confidence: full.scoping_confidence,
|
|
249
|
-
value_summary: full.value_summary,
|
|
250
|
-
description: full.description,
|
|
251
|
-
shipped_at: t.shipped_at,
|
|
252
|
-
diff_stats: diff,
|
|
253
|
-
files_touched: files,
|
|
254
|
-
downstream,
|
|
255
|
-
grade: g,
|
|
256
|
-
category,
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
if (process.argv.includes('--json')) {
|
|
261
|
-
console.log(JSON.stringify(results, null, 2));
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Human-readable summary.
|
|
266
|
-
const passed = results.filter((r) => r.grade.passed);
|
|
267
|
-
const failed = results.filter((r) => !r.grade.passed);
|
|
268
|
-
console.log(`\n=== Audit summary ===`);
|
|
269
|
-
console.log(`Total graded: ${results.length}`);
|
|
270
|
-
console.log(`Passed: ${passed.length}`);
|
|
271
|
-
console.log(`Failed: ${failed.length} (threshold ${grader.RUBRIC.v1.threshold}, v1 deterministic)`);
|
|
272
|
-
console.log('');
|
|
273
|
-
|
|
274
|
-
if (failed.length === 0) {
|
|
275
|
-
console.log('No failures. Nothing to redo.');
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
console.log('=== FAILURES (sorted by score asc) ===\n');
|
|
280
|
-
failed
|
|
281
|
-
.sort((a, b) => a.grade.score - b.grade.score)
|
|
282
|
-
.forEach((r) => {
|
|
283
|
-
console.log(`#${r.id} score ${r.grade.score}/10 [${r.kind}] est=${r.est_minutes}m act=${r.actual_minutes}m`);
|
|
284
|
-
console.log(` ${r.title}`);
|
|
285
|
-
const scores = r.grade.rubric_scores;
|
|
286
|
-
const parts = Object.entries(scores).map(([k, v]) => `${k}=${v}`);
|
|
287
|
-
console.log(` ${parts.join(' ')}`);
|
|
288
|
-
console.log('');
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
// Score histogram for the passed set so we see what the distribution looks like.
|
|
292
|
-
const buckets = { '5.0-5.9': 0, '6.0-6.9': 0, '7.0-7.9': 0, '8.0-8.9': 0, '9.0-10': 0 };
|
|
293
|
-
for (const r of passed) {
|
|
294
|
-
const s = r.grade.score;
|
|
295
|
-
if (s < 6) buckets['5.0-5.9']++;
|
|
296
|
-
else if (s < 7) buckets['6.0-6.9']++;
|
|
297
|
-
else if (s < 8) buckets['7.0-7.9']++;
|
|
298
|
-
else if (s < 9) buckets['8.0-8.9']++;
|
|
299
|
-
else buckets['9.0-10']++;
|
|
300
|
-
}
|
|
301
|
-
console.log('=== Passed-score histogram ===');
|
|
302
|
-
for (const [k, v] of Object.entries(buckets)) console.log(` ${k}: ${v}`);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// Run only as a CLI. Without this guard the file executes main() — and hits the
|
|
306
|
-
// API — the instant anything requires it, which is why its git helpers had never
|
|
307
|
-
// been tested: there was no way to reach them without starting an audit
|
|
308
|
-
// (task 1003223).
|
|
309
|
-
if (require.main === module) {
|
|
310
|
-
main().catch((err) => {
|
|
311
|
-
console.error(err);
|
|
312
|
-
process.exit(1);
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// Exported for tests/audit_ships_git_argv.mjs — the argv-vs-shell-string fix is a
|
|
317
|
-
// property of THESE functions, so the test drives them rather than re-typing an
|
|
318
|
-
// equivalent git call and hoping it matches.
|
|
319
|
-
module.exports = { findCommitForTask, gitDiffStatsForCommit, filesTouchedByCommit, downstreamImpact };
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
// tests/audit_ships_git_argv.mjs — audit-ships' git calls must be argv, not a
|
|
2
|
-
// shell string (task 1003223; the class task 1002761 named).
|
|
3
|
-
//
|
|
4
|
-
// THE BUG. Five calls interpolated `--format=''` into a shell string. A POSIX
|
|
5
|
-
// shell strips the quotes and git sees an empty format; the shell Node uses on
|
|
6
|
-
// Windows does not, so git receives `--format=''` literally and dies with
|
|
7
|
-
// `fatal: invalid --pretty format: ''`. A sixth built a `grep | head | awk`
|
|
8
|
-
// pipeline and pinned `shell: '/bin/bash'` to survive at all.
|
|
9
|
-
//
|
|
10
|
-
// WHY THIS NEEDS A TEST AND NOT JUST A FIX. Every one of those calls sat inside a
|
|
11
|
-
// try/catch that returned null or []. So the Windows failure did not crash the
|
|
12
|
-
// audit — it produced an audit with no diff stats and zero downstream impact for
|
|
13
|
-
// every ship, which is indistinguishable from a clean run. The originating row
|
|
14
|
-
// asked exactly this ("check whether the catch degrades to a silently empty audit
|
|
15
|
-
// — if so it is the same dangerous shape after all"). It did.
|
|
16
|
-
//
|
|
17
|
-
// The static half of this file is the real net: it is the half that stays true on
|
|
18
|
-
// Linux CI, where the shell-string form would have passed happily forever.
|
|
19
|
-
//
|
|
20
|
-
// Run: node --test tests/audit_ships_git_argv.mjs
|
|
21
|
-
|
|
22
|
-
import { test } from 'node:test';
|
|
23
|
-
import assert from 'node:assert/strict';
|
|
24
|
-
import fs from 'node:fs';
|
|
25
|
-
import path from 'node:path';
|
|
26
|
-
import { execSync, execFileSync } from 'node:child_process';
|
|
27
|
-
import { fileURLToPath } from 'node:url';
|
|
28
|
-
import { createRequire } from 'node:module';
|
|
29
|
-
|
|
30
|
-
const require = createRequire(import.meta.url);
|
|
31
|
-
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
|
-
const FILE = path.join(ROOT, 'scripts/gds/audit-ships.js');
|
|
33
|
-
const audit = require('../scripts/gds/audit-ships.js');
|
|
34
|
-
const raw = fs.readFileSync(FILE, 'utf8');
|
|
35
|
-
|
|
36
|
-
// Scan CODE, not prose. The file's header explains the bug by quoting the exact
|
|
37
|
-
// tokens that are banned ("--format=''", "grep -E", "shell: '/bin/bash'"), so a
|
|
38
|
-
// naive scan of the whole file fails on its own documentation — which is exactly
|
|
39
|
-
// what happened when this test was first written. Same comments-stripped posture
|
|
40
|
-
// as scripts/gds/category-advisory-guard.js.
|
|
41
|
-
const src = raw
|
|
42
|
-
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
|
|
43
|
-
.split('\n').map((l) => l.replace(/(^|\s)\/\/.*$/, '$1')).join('\n');
|
|
44
|
-
|
|
45
|
-
// ---- static: the shape that broke must not come back ----------------------
|
|
46
|
-
|
|
47
|
-
test('no execSync anywhere — a shell string is what mangles the quoting', () => {
|
|
48
|
-
assert.doesNotMatch(src, /\bexecSync\b/,
|
|
49
|
-
'audit-ships must spawn git through execFileSync with an argv array');
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test("no quoted --format='' — the argv spelling is --format= with no quotes", () => {
|
|
53
|
-
// Match the SHELL form precisely: two consecutive quotes after the '='. A naive
|
|
54
|
-
// /--format='/ also matches the correct argv literal '--format=', where that
|
|
55
|
-
// quote is JS string syntax, not a shell quote — it flagged the fix as the bug.
|
|
56
|
-
assert.doesNotMatch(src, /--format=''/,
|
|
57
|
-
"a quoted empty format is the exact token git rejects on Windows");
|
|
58
|
-
assert.match(src, /'--format='/, 'the argv spelling is present');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test('no shell pipeline and no pinned /bin/bash', () => {
|
|
62
|
-
assert.doesNotMatch(src, /\bshell:\s*['"]/, 'no shell is needed once calls are argv');
|
|
63
|
-
for (const tool of ['grep -E', '| head', '| awk']) {
|
|
64
|
-
assert.ok(!src.includes(tool), `${tool} is a POSIX-only dependency`);
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test('git failures are reported, not swallowed into a clean-looking audit', () => {
|
|
69
|
-
// The silent `catch (_) { return null; }` is what made the Windows breakage
|
|
70
|
-
// invisible. Every catch around a git call must go through gitFails.
|
|
71
|
-
assert.match(raw, /function gitFails\(/, 'the reporting helper exists');
|
|
72
|
-
// Scoped to catches returning an empty RESULT SET — null or [] — which is the
|
|
73
|
-
// shape that turned a git failure into a clean-looking audit. A catch returning
|
|
74
|
-
// `{}` for an unreadable branding pack is a different, legitimate default and is
|
|
75
|
-
// deliberately not swept up here.
|
|
76
|
-
const silent = src.match(/catch \(_\)\s*\{\s*return (?:null|\[\])/g) || [];
|
|
77
|
-
assert.equal(silent.length, 0,
|
|
78
|
-
`silent catch(es) returning an empty result: ${silent.join(' | ')}`);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
// ---- behavioural: the calls actually work on THIS platform ----------------
|
|
82
|
-
//
|
|
83
|
-
// These run real git against this repo, which is what makes them meaningful on
|
|
84
|
-
// Windows: before the fix, two of them threw here and returned empty.
|
|
85
|
-
|
|
86
|
-
const git = (args) => execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' }).trim();
|
|
87
|
-
// A NON-MERGE commit. `git show --name-only` prints nothing for a merge by
|
|
88
|
-
// default, so anchoring on HEAD makes the file-listing assertions depend on
|
|
89
|
-
// whatever happened to be checked out — they failed here for that reason, not
|
|
90
|
-
// because the code was wrong. --no-merges picks a commit that really touched files.
|
|
91
|
-
const HEAD = git(['rev-list', '--no-merges', '-n', '1', 'HEAD']);
|
|
92
|
-
|
|
93
|
-
test('the exact argv git rejected as a shell string succeeds here', () => {
|
|
94
|
-
const out = execFileSync('git', ['show', '--shortstat', '--format=', HEAD], { cwd: ROOT, encoding: 'utf8' });
|
|
95
|
-
assert.match(out, /\d+ files? changed/, `shortstat should parse, got: ${JSON.stringify(out.slice(0, 80))}`);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test('the shell-string form is what actually fails on this platform', () => {
|
|
99
|
-
// Not a hypothetical: assert the OLD form's behaviour so the reason for the fix
|
|
100
|
-
// is recorded in a runnable way. On POSIX it succeeds (the quotes are stripped),
|
|
101
|
-
// on Windows it throws — either way, argv is the only spelling that is correct
|
|
102
|
-
// on both, which is the whole point.
|
|
103
|
-
let threw = false;
|
|
104
|
-
try { execSync(`git show --shortstat --format='' ${HEAD}`, { cwd: ROOT, encoding: 'utf8' }); }
|
|
105
|
-
catch { threw = true; }
|
|
106
|
-
if (process.platform === 'win32') {
|
|
107
|
-
assert.equal(threw, true, 'on win32 the quoted form must be the one that fails');
|
|
108
|
-
} else {
|
|
109
|
-
assert.equal(threw, false, 'on POSIX it happens to work — which is why this hid on CI');
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test('--name-only --format= yields real paths', () => {
|
|
114
|
-
const out = execFileSync('git', ['show', '--name-only', '--format=', HEAD], { cwd: ROOT, encoding: 'utf8' });
|
|
115
|
-
const files = out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
116
|
-
assert.ok(files.length > 0, 'HEAD touched at least one file');
|
|
117
|
-
assert.ok(files.every((f) => !f.includes("'")), 'paths come back unquoted');
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
// ---- the real functions, not equivalents ---------------------------------
|
|
121
|
-
//
|
|
122
|
-
// These drive audit-ships' own helpers. Before this task the file executed main()
|
|
123
|
-
// on require — it hit the API the moment anything imported it — so its git helpers
|
|
124
|
-
// had never been reachable from a test at all. That is the deeper reason the
|
|
125
|
-
// Windows breakage went unnoticed for months.
|
|
126
|
-
|
|
127
|
-
test('gitDiffStatsForCommit returns real stats on this platform', () => {
|
|
128
|
-
const stats = audit.gitDiffStatsForCommit(HEAD);
|
|
129
|
-
assert.ok(stats, 'must not be the null the Windows failure returned');
|
|
130
|
-
assert.equal(stats.commit_sha, HEAD);
|
|
131
|
-
assert.ok(stats.files_changed > 0, `files_changed should be positive, got ${stats.files_changed}`);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test('filesTouchedByCommit returns real paths, not the empty array of a swallowed failure', () => {
|
|
135
|
-
const files = audit.filesTouchedByCommit(HEAD);
|
|
136
|
-
assert.ok(Array.isArray(files) && files.length > 0, `expected paths, got ${JSON.stringify(files)}`);
|
|
137
|
-
assert.ok(files.every((f) => typeof f === 'string' && f.length > 0));
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test('the commit list is read once per run, not once per task', () => {
|
|
141
|
-
// The shell pipeline this replaced ended in `head -1`, so git stopped at the
|
|
142
|
-
// first match. The argv form has no early exit, and findCommitForTask runs once
|
|
143
|
-
// per shipped task — up to 500 a run — so a naive port turns one bounded call
|
|
144
|
-
// into 500 full history walks. Caught by the grader on this task's first round.
|
|
145
|
-
//
|
|
146
|
-
// Measured rather than asserted structurally: the second lookup must not spend
|
|
147
|
-
// anywhere near the first's time, because it must not touch git at all.
|
|
148
|
-
const t0 = process.hrtime.bigint();
|
|
149
|
-
audit.findCommitForTask(999999901);
|
|
150
|
-
const cold = process.hrtime.bigint() - t0;
|
|
151
|
-
|
|
152
|
-
const t1 = process.hrtime.bigint();
|
|
153
|
-
audit.findCommitForTask(999999902);
|
|
154
|
-
const warm = process.hrtime.bigint() - t1;
|
|
155
|
-
|
|
156
|
-
assert.ok(warm * 5n < cold || warm < 2_000_000n,
|
|
157
|
-
`second lookup should reuse the cached list (cold ${cold / 1000000n}ms, warm ${warm / 1000000n}ms)`);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test('the helpers still answer safely for a missing sha', () => {
|
|
161
|
-
assert.equal(audit.gitDiffStatsForCommit(null), null);
|
|
162
|
-
assert.deepEqual(audit.filesTouchedByCommit(null), []);
|
|
163
|
-
assert.deepEqual(audit.downstreamImpact(null, ['x']),
|
|
164
|
-
{ commits_after_touching_files: 0, distinct_files_touched: 0, sample_subjects: [] });
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
test('a path containing a space survives argv — the hand-rolled quoting is gone', () => {
|
|
168
|
-
// git log -- <pathspec> with a space used to need shell quoting; in argv it is
|
|
169
|
-
// simply one element. An unmatched pathspec returns empty, never an error.
|
|
170
|
-
const out = execFileSync('git', ['log', '-1', '--format=%H', '--', 'no such file.md'], { cwd: ROOT, encoding: 'utf8' });
|
|
171
|
-
assert.equal(out.trim(), '', 'an unmatched pathspec is empty, not a crash');
|
|
172
|
-
});
|
|
173
|
-
|