@bongos/core 1.19.761 → 1.19.763

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 CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.761",
6
- "core_contract": "1.19.761",
7
- "source_commit": "3412fa5f1d7bb5d383191d64806396f61b136ae5",
5
+ "core_version": "1.19.763",
6
+ "core_contract": "1.19.763",
7
+ "source_commit": "d56c0dce70d9d91b47d04673af4ed036f6456415",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-16T03:36:29.589Z",
9
+ "built_at": "2026-09-16T04:43:46.371Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 483,
13
13
  "agent_docs_stubbed": 25,
14
- "functional_verbatim": 2115,
14
+ "functional_verbatim": 2117,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2623,
20
- "tree_sha256": "e9ee270455be1db1f07c7c206e5f9ba6a408a2f231634a18330a70ac78a0ed59",
19
+ "file_count": 2625,
20
+ "tree_sha256": "d1d3bb6923feb8c9a3191045a69e641c553b0ac0d9ca4b973e8f0f63c48b67d9",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2512,7 +2512,7 @@
2512
2512
  {
2513
2513
  "path": "docs/module-api-changelog.md",
2514
2514
  "mode": "0000644",
2515
- "sha256": "94b5d8c1844e775937a9acec9a78b5c2a789ed6e8e194835c02cd0d2634a4d29"
2515
+ "sha256": "ef6513a9bb60c6532ad6b981c24e7e867aaeb75a85d874b7d6e67842de9cda5a"
2516
2516
  },
2517
2517
  {
2518
2518
  "path": "docs/modules-contract.md",
@@ -7567,12 +7567,12 @@
7567
7567
  {
7568
7568
  "path": "package-lock.json",
7569
7569
  "mode": "0000644",
7570
- "sha256": "172d01adc5b82fa70d7079ec41e84a7a99b89b84c40c58d8d383b45c7a18f584"
7570
+ "sha256": "3331982a91d6104fac63e458b3f10d7fb6e1388ba91a8ebf2245019e5d056720"
7571
7571
  },
7572
7572
  {
7573
7573
  "path": "package.json",
7574
7574
  "mode": "0000644",
7575
- "sha256": "7f3d59f7939972bb1a32c493f4203e0c22286a3296ae69d9a11a06ed2c5d62a9"
7575
+ "sha256": "da63833600ca5e2e1d01eb5e6382bd53896d586f0f5b4e899b8616bd854f3009"
7576
7576
  },
7577
7577
  {
7578
7578
  "path": "public-docs/index.html",
@@ -7707,7 +7707,7 @@
7707
7707
  {
7708
7708
  "path": "scripts/gds/audit-ships.js",
7709
7709
  "mode": "0000644",
7710
- "sha256": "8dbba68d9b8c0635263f3dfb4a223edb14621fb715aa9ab603ea8220313f9dfa"
7710
+ "sha256": "ffcb9068753855fcb0c1ea5c69fddbe97c4c5cb72f225ec38ca4c599d5aca24a"
7711
7711
  },
7712
7712
  {
7713
7713
  "path": "scripts/gds/autonomy-gate.js",
@@ -8062,7 +8062,7 @@
8062
8062
  {
8063
8063
  "path": "scripts/gds/fitness-ratchets.js",
8064
8064
  "mode": "0000644",
8065
- "sha256": "7a0828405f5069ef97dedfa0874c156507f3ec10651c0e45cfd62d31315275d7"
8065
+ "sha256": "b3f4092fffd30e50cb145b524beeed5d483132f52e77e37a7ab9c18bbf300bc7"
8066
8066
  },
8067
8067
  {
8068
8068
  "path": "scripts/gds/fitness.js",
@@ -9382,7 +9382,7 @@
9382
9382
  {
9383
9383
  "path": "src/module-api.js",
9384
9384
  "mode": "0000644",
9385
- "sha256": "3429cc6b1beee6916f92a274dd2e85be55cb7e10236d8d70c4a4a8cf7ac6387c"
9385
+ "sha256": "d57ef047cc14ac346694d7a0507103b2b2b85583553786a3c21ba6aacac329c2"
9386
9386
  },
9387
9387
  {
9388
9388
  "path": "src/module-loader/catalog.js",
@@ -9654,6 +9654,11 @@
9654
9654
  "mode": "0000644",
9655
9655
  "sha256": "4973872431cb0e5e7aedb978f4d5b28f804fe33aa8288f16d2656a28c85befbe"
9656
9656
  },
9657
+ {
9658
+ "path": "tests/audit_ships_git_argv.mjs",
9659
+ "mode": "0000644",
9660
+ "sha256": "bb7f06a866ccea00a88d2f373c70f7e4bc0a3aa5e658c61346046b83637baa35"
9661
+ },
9657
9662
  {
9658
9663
  "path": "tests/auth_admission_gate.mjs",
9659
9664
  "mode": "0000644",
@@ -10532,7 +10537,7 @@
10532
10537
  {
10533
10538
  "path": "tests/fitness_ratchets.mjs",
10534
10539
  "mode": "0000644",
10535
- "sha256": "e33edfa4925694d824c042f1fd06a977f1ac14b5c1cb24b5326014ca9f86ece0"
10540
+ "sha256": "949526efc49c317a7be7e33beea0643b29748f3fd4e914d3a3670ce9ce6aadeb"
10536
10541
  },
10537
10542
  {
10538
10543
  "path": "tests/fitness_two_root.mjs",
@@ -10589,6 +10594,11 @@
10589
10594
  "mode": "0000644",
10590
10595
  "sha256": "850113ed91babc9eb54dc22231f194d283fd3f94cee9deef1e6e0bf6f3a969c2"
10591
10596
  },
10597
+ {
10598
+ "path": "tests/github_push_stderr_truncation.mjs",
10599
+ "mode": "0000644",
10600
+ "sha256": "d400c1830c9255851682c1653841a616ab9088f6facb2a5893f0ec41a72626eb"
10601
+ },
10592
10602
  {
10593
10603
  "path": "tests/github_repo_visibility.mjs",
10594
10604
  "mode": "0000644",
@@ -1996,5 +1996,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1996
1996
  landed since 1.19.759 with no explicit bump. run 35052040282. (task 1002620)
1997
1997
  1.19.761 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1998
1998
  landed since 1.19.760 with no explicit bump. run 35052410219. (task 1002620)
1999
+ 1.19.762 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2000
+ landed since 1.19.761 with no explicit bump. run 35053643269. (task 1002620)
2001
+ 1.19.763 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2002
+ landed since 1.19.762 with no explicit bump. run 35056690992. (task 1002620)
1999
2003
  ---------------------------------------------------------------------------
2000
2004
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.761",
3
+ "version": "1.19.763",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.761",
9
+ "version": "1.19.763",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.761",
3
+ "version": "1.19.763",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -9,35 +9,92 @@
9
9
  // Audit-only — does NOT write to task_grades. Pre-grader ships keep clean
10
10
  // history.
11
11
 
12
- const { execSync } = require('node:child_process');
12
+ const { execFileSync } = require('node:child_process');
13
13
  const grader = require('../../modules/grading/grader');
14
14
  const { cliClient } = require('./cli-lib');
15
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
+
16
53
  const DAYS = Number(process.env.AUDIT_DAYS || 2);
17
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
+
18
71
  function findCommitForTask(taskId) {
19
- let sha;
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;
20
82
  try {
21
- sha = execSync(
22
- // Matches both subject conventions: the legacy ' #NNN:' prefix and the
23
- // de-#-ed 'task NNN:' form ship.js writes since task 916 (a bare "#NNN"
24
- // in commit subjects autolinked to dead GitHub issue numbers).
25
- `git log --all --format='%H %s' | grep -E ' (#|task )${taskId}:' | head -1 | awk '{print $1}'`,
26
- { encoding: 'utf8', shell: '/bin/bash' }
27
- ).trim();
28
- } catch (_) {
29
- return null;
83
+ lines = commitSubjects();
84
+ } catch (e) {
85
+ return gitFails(`finding the commit for task ${taskId}`, e, null);
30
86
  }
31
- return sha || null;
87
+ const hit = lines.find((l) => re.test(l));
88
+ return hit ? (hit.split(' ')[0] || null) : null;
32
89
  }
33
90
 
34
91
  function gitDiffStatsForCommit(sha) {
35
92
  if (!sha) return null;
36
93
  let raw;
37
94
  try {
38
- raw = execSync(`git show --shortstat --format='' ${sha}`, { encoding: 'utf8' });
39
- } catch (_) {
40
- return null;
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);
41
98
  }
42
99
  // Example shortstat line: " 15 files changed, 1171 insertions(+), 65 deletions(-)"
43
100
  const m = raw.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/);
@@ -53,10 +110,9 @@ function gitDiffStatsForCommit(sha) {
53
110
  function filesTouchedByCommit(sha) {
54
111
  if (!sha) return [];
55
112
  try {
56
- const out = execSync(`git show --name-only --format='' ${sha}`, { encoding: 'utf8' });
57
- return out.split('\n').map((s) => s.trim()).filter(Boolean);
58
- } catch (_) {
59
- return [];
113
+ return gitLines(['show', '--name-only', '--format=', sha]);
114
+ } catch (e) {
115
+ return gitFails(`listing files touched by ${String(sha).slice(0, 8)}`, e, []);
60
116
  }
61
117
  }
62
118
 
@@ -64,35 +120,30 @@ function filesTouchedByCommit(sha) {
64
120
  // from origin/main that landed AFTER `sha` and touched any of `files`. High
65
121
  // count = subsequent work has built on top of this change, so a "redo"
66
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
+
67
127
  function downstreamImpact(sha, files) {
68
- if (!sha || files.length === 0) {
69
- return { commits_after_touching_files: 0, distinct_files_touched: 0, sample_subjects: [] };
70
- }
71
- // -- shell-quote each file path so spaces/specials don't break the cmd.
72
- const fileArgs = files.map((f) => `'${f.replace(/'/g, "'\\''")}'`).join(' ');
73
- let out;
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;
74
134
  try {
75
- out = execSync(
76
- `git log ${sha}..origin/main --format='%H %s' -- ${fileArgs}`,
77
- { encoding: 'utf8' }
78
- );
79
- } catch (_) {
80
- return { commits_after_touching_files: 0, distinct_files_touched: 0, sample_subjects: [] };
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 });
81
138
  }
82
- const lines = out.split('\n').map((s) => s.trim()).filter(Boolean);
83
139
  // Distinct downstream files touched
84
140
  let distinct = 0;
85
141
  try {
86
- const namesOut = execSync(
87
- `git log ${sha}..origin/main --name-only --format='' -- ${fileArgs}`,
88
- { encoding: 'utf8' }
89
- );
90
- const set = new Set(
91
- namesOut.split('\n').map((s) => s.trim()).filter(Boolean)
92
- );
93
- distinct = set.size;
94
- } catch (_) {
95
- distinct = 0;
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);
96
147
  }
97
148
  return {
98
149
  commits_after_touching_files: lines.length,
@@ -251,7 +302,18 @@ async function main() {
251
302
  for (const [k, v] of Object.entries(buckets)) console.log(` ${k}: ${v}`);
252
303
  }
253
304
 
254
- main().catch((err) => {
255
- console.error(err);
256
- process.exit(1);
257
- });
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 };
@@ -243,10 +243,20 @@ function knipIssueCount() {
243
243
  //
244
244
  // The file's own header says raising a baseline to pass CI is out of policy;
245
245
  // deleting the file must not be the loophole that raising it is not.
246
- function readBaselines() {
246
+ //
247
+ // `baselinePath` is injectable for TESTS ONLY (task 1003351) and defaults to the
248
+ // real file, so every production caller is unchanged. The three task-1003227
249
+ // cases used to prove the three unreadable shapes by renaming and overwriting the
250
+ // repo's OWN config/fitness-baselines.json, restoring in a finally — and
251
+ // run-unit-tests.js runs each file under a 90s execFileSync timeout, whose
252
+ // SIGTERM skips the finally and leaves the last payload on disk. That is how a
253
+ // real checkout ended up with {"_comment":"no metrics key"} committed-adjacent
254
+ // and the sibling case dying on `Cannot read properties of undefined`. A test
255
+ // must not have to survive being killed to leave the repo intact.
256
+ function readBaselines(baselinePath = BASELINE_PATH) {
247
257
  let raw;
248
258
  try {
249
- raw = fs.readFileSync(BASELINE_PATH, 'utf8');
259
+ raw = fs.readFileSync(baselinePath, 'utf8');
250
260
  } catch (e) {
251
261
  const missing = e && e.code === 'ENOENT';
252
262
  return {
@@ -355,9 +365,17 @@ function computeMetrics({ withDocs = true } = {}) {
355
365
  }
356
366
 
357
367
  // The fitness.js check (in-suite, hermetic — everything except knip).
358
- function checkQualityRatchets() {
359
- const metrics = computeMetrics();
360
- const { metrics: baselines, error: baselineError } = readBaselines();
368
+ //
369
+ // Both options are injectable for TESTS ONLY (task 1003351); fitness.js calls
370
+ // this through `CHECKS.map((fn) => fn())`, with no arguments, so the defaults are
371
+ // what production runs. `metrics` matters as much as `baselinePath` here: a test
372
+ // that only wanted to prove an unreadable baseline file still paid for a full
373
+ // computeMetrics() sweep — git ls-files plus every tracked .js plus the
374
+ // docs-entropy scan, measured at 23.7s + 2.5s + 2.8s, i.e. ~29s of this file's
375
+ // 29s runtime, which is what pushed it into the 90s timeout in the first place.
376
+ function checkQualityRatchets({ baselinePath = BASELINE_PATH, metrics: injectedMetrics = null } = {}) {
377
+ const metrics = injectedMetrics === null ? computeMetrics() : injectedMetrics;
378
+ const { metrics: baselines, error: baselineError } = readBaselines(baselinePath);
361
379
  const { violations, warnings } = compareToBaselines(metrics, baselines);
362
380
  // task 1003227: an unreadable baseline file is a hard failure, not silence. Put it
363
381
  // FIRST so it is the headline rather than buried under per-metric noise.
@@ -434,6 +452,9 @@ if (require.main === module) main();
434
452
 
435
453
  module.exports = {
436
454
  checkQualityRatchets,
455
+ // Exported for tests/fitness_ratchets.mjs (task 1003351), which proves the
456
+ // injected baselinePath is honoured rather than silently falling back.
457
+ readBaselines,
437
458
  computeMetrics,
438
459
  compareToBaselines,
439
460
  duplicateWindowCount,
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.761'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.763'; // 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,173 @@
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
+
@@ -10,6 +10,71 @@ import { fileURLToPath } from 'node:url';
10
10
 
11
11
  const require = createRequire(import.meta.url);
12
12
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
13
+
14
+ // Task 1003351 — the tripwire, installed BEFORE fitness-ratchets.js is required.
15
+ // Fixing today's three cases only fixes today's three cases; the hazard is the
16
+ // SHAPE, and it is an easy one to reach for again ("just back it up and restore
17
+ // in a finally"). A finally is not a guarantee — run-unit-tests.js kills this
18
+ // file on a 90s timeout and SIGTERM skips it — so the rule is stronger than
19
+ // "restore afterwards": a test in this file may not write under <ROOT>/config at
20
+ // all. Failing at the write names the offending path, which is far easier to act
21
+ // on than the delayed symptom it used to produce (a dirty tree, and a sibling
22
+ // case dying on a mangled baseline one run later).
23
+ //
24
+ // WHICH ARGUMENT IS THE WRITE. This is the whole subtlety, and getting it wrong
25
+ // makes the guard worse than none — it would read as comprehensive while missing
26
+ // the exact call the original bug used. fs.copyFileSync(src, DEST) and
27
+ // fs.renameSync(old, NEW) write to their SECOND argument, so a restore-direction
28
+ // `fs.copyFileSync(backup, real)` — precisely what the old `finally` blocks ran —
29
+ // passes a harmless temp path in position 0. Every method is therefore listed
30
+ // with the argument indices that are WRITE positions, not assumed to be [0].
31
+ // renameSync guards BOTH: position 1 writes into config/, and position 0 moves a
32
+ // file OUT of it, which is how the ABSENT-baseline case used to work.
33
+ const CONFIG_WRITE_ARGS = {
34
+ writeFileSync: [0],
35
+ appendFileSync: [0],
36
+ unlinkSync: [0],
37
+ rmSync: [0],
38
+ truncateSync: [0],
39
+ mkdirSync: [0],
40
+ renameSync: [0, 1],
41
+ copyFileSync: [1],
42
+ cpSync: [1],
43
+ linkSync: [1],
44
+ symlinkSync: [1],
45
+ };
46
+
47
+ {
48
+ const fs = require('node:fs');
49
+ const CONFIG_DIR = path.join(ROOT, 'config');
50
+ const underConfig = (value) => {
51
+ // Only string paths are resolvable; an fd or a URL is not a path here and
52
+ // passes through to the real call untouched.
53
+ if (typeof value !== 'string') return null;
54
+ const resolved = path.resolve(value);
55
+ const inside = resolved === CONFIG_DIR || resolved.startsWith(CONFIG_DIR + path.sep);
56
+ return inside ? resolved : null;
57
+ };
58
+ for (const [name, writeArgs] of Object.entries(CONFIG_WRITE_ARGS)) {
59
+ const original = fs[name];
60
+ if (typeof original !== 'function') continue;
61
+ fs[name] = function guarded(...args) {
62
+ for (const i of writeArgs) {
63
+ const hit = underConfig(args[i]);
64
+ if (hit) {
65
+ throw new Error(
66
+ `tests/fitness_ratchets.mjs may not fs.${name} under <ROOT>/config — got ${hit} `
67
+ + `(argument ${i}). Task 1003351: a timeout SIGTERM skips your finally and the repo `
68
+ + 'keeps the payload. Pass a temp path via checkQualityRatchets({ baselinePath }) / '
69
+ + 'readBaselines(path) instead.',
70
+ );
71
+ }
72
+ }
73
+ return original.apply(this, args);
74
+ };
75
+ }
76
+ }
77
+
13
78
  const rat = require(path.join(ROOT, 'scripts', 'gds', 'fitness-ratchets.js'));
14
79
 
15
80
  test('ratchet compare: worse fails, equal passes, better nudges, unknown warns', () => {
@@ -103,53 +168,102 @@ test('the committed baseline file parses and covers every hermetic metric', () =
103
168
  // (pure) for the metric-level rules and exercise the file-level rule by pointing at a
104
169
  // baseline that does not exist.
105
170
 
106
- test('1003227 an ABSENT baseline file makes the gate hard-fail, not pass', async () => {
171
+ // Task 1003351: these three used to prove their point by renaming/overwriting the
172
+ // repo's own config/fitness-baselines.json and restoring in a `finally`. That is
173
+ // only safe if the process is allowed to finish, and it is not: run-unit-tests.js
174
+ // runs each test file under execFileSync with a 90s timeout, and the SIGTERM that
175
+ // enforces it skips `finally` entirely. Since each case also called the real
176
+ // checkQualityRatchets() — a ~29s full-repo sweep — this file sat right at the
177
+ // timeout, so the kill was the normal outcome rather than the rare one. It left
178
+ // {"_comment":"no metrics key"} as the committed file's contents, which then made
179
+ // the sibling case die on `Cannot read properties of undefined (reading
180
+ // 'oversized_file_count')` and the working tree dirty. Observed on Windows
181
+ // 2026-08-29; standalone `node tests/fitness_ratchets.mjs` never reproduced it,
182
+ // because nothing imposes the timeout there.
183
+ //
184
+ // Now each case owns a baseline file in its own temp dir and injects synthetic
185
+ // metrics, so there is no sweep, no shared file, and nothing to restore. A
186
+ // killed process leaves the repo exactly as it found it.
187
+ const tmpBaseline = (contents) => {
107
188
  const fs = require('node:fs');
108
- const real = rat.BASELINE_PATH;
109
- const backup = `${real}.1003227-test-backup`;
110
- const had = fs.existsSync(real);
111
- if (had) fs.renameSync(real, backup);
112
- try {
113
- const r = rat.checkQualityRatchets();
114
- assert.equal(r.hardFail, true, 'a gate that cannot read its floors must not report a pass');
115
- assert.ok(r.violations.length > 0, 'and it must say so as a VIOLATION, not a warning');
116
- assert.match(r.violations[0], /MISSING/, 'the first violation names the real cause');
117
- assert.match(r.violations[0], /fails CLOSED/i, 'and states the posture explicitly');
118
- } finally {
119
- if (had) fs.renameSync(backup, real);
120
- }
189
+ const os = require('node:os');
190
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ratchet-baseline-'));
191
+ const file = path.join(dir, 'fitness-baselines.json');
192
+ if (contents !== null) fs.writeFileSync(file, contents);
193
+ return file;
194
+ };
195
+ // Any non-empty metric map exercises compareToBaselines without a repo sweep. An
196
+ // unknown metric is a WARNING, never a violation, so violations[0] is the
197
+ // baseline error itself — which is the whole point of each assertion below.
198
+ const SYNTHETIC_METRICS = { excess_lines_total: 1 };
199
+
200
+ test('1003227 an ABSENT baseline file makes the gate hard-fail, not pass', async () => {
201
+ const r = rat.checkQualityRatchets({ baselinePath: tmpBaseline(null), metrics: SYNTHETIC_METRICS });
202
+ assert.equal(r.hardFail, true, 'a gate that cannot read its floors must not report a pass');
203
+ assert.ok(r.violations.length > 0, 'and it must say so as a VIOLATION, not a warning');
204
+ assert.match(r.violations[0], /MISSING/, 'the first violation names the real cause');
205
+ assert.match(r.violations[0], /fails CLOSED/i, 'and states the posture explicitly');
121
206
  });
122
207
 
123
208
  test('1003227 a MALFORMED baseline file also hard-fails', async () => {
124
- const fs = require('node:fs');
125
- const real = rat.BASELINE_PATH;
126
- const backup = `${real}.1003227-test-backup2`;
127
- const had = fs.existsSync(real);
128
- if (had) fs.copyFileSync(real, backup);
129
- try {
130
- fs.writeFileSync(real, '{ this is not json');
131
- const r = rat.checkQualityRatchets();
132
- assert.equal(r.hardFail, true, 'unparseable is no more verifiable than absent');
133
- assert.match(r.violations[0], /not valid JSON/);
134
- } finally {
135
- if (had) { fs.copyFileSync(backup, real); fs.unlinkSync(backup); }
136
- }
209
+ const r = rat.checkQualityRatchets({
210
+ baselinePath: tmpBaseline('{ this is not json'), metrics: SYNTHETIC_METRICS,
211
+ });
212
+ assert.equal(r.hardFail, true, 'unparseable is no more verifiable than absent');
213
+ assert.match(r.violations[0], /not valid JSON/);
137
214
  });
138
215
 
139
216
  test('1003227 a baseline file with no "metrics" object hard-fails', async () => {
217
+ const r = rat.checkQualityRatchets({
218
+ baselinePath: tmpBaseline(JSON.stringify({ _comment: 'no metrics key' })),
219
+ metrics: SYNTHETIC_METRICS,
220
+ });
221
+ assert.equal(r.hardFail, true);
222
+ assert.match(r.violations[0], /no "metrics" object/);
223
+ });
224
+
225
+ test('1003351 the config/ write-guard actually fires — a tripwire nobody has tripped is not a tripwire', () => {
140
226
  const fs = require('node:fs');
141
- const real = rat.BASELINE_PATH;
142
- const backup = `${real}.1003227-test-backup3`;
143
- const had = fs.existsSync(real);
144
- if (had) fs.copyFileSync(real, backup);
145
- try {
146
- fs.writeFileSync(real, JSON.stringify({ _comment: 'no metrics key' }));
147
- const r = rat.checkQualityRatchets();
148
- assert.equal(r.hardFail, true);
149
- assert.match(r.violations[0], /no "metrics" object/);
150
- } finally {
151
- if (had) { fs.copyFileSync(backup, real); fs.unlinkSync(backup); }
152
- }
227
+ // Every probe is chosen so an UNGUARDED call cannot damage anything: each one
228
+ // names a source or a parent directory that does not exist, so without the
229
+ // guard it raises ENOENT instead of moving a real file into config/. That is
230
+ // what makes the MESSAGE match load-bearing rather than decorative — a bare
231
+ // assert.throws() would pass on the very failure it exists to catch.
232
+ const guarded = /may not fs\.\w+ under <ROOT>\/config/;
233
+ const real = path.join(ROOT, 'config', 'fitness-baselines.json');
234
+ const absent = path.join(ROOT, 'config', '.1003351-absent-dir', 'probe.json');
235
+ const noSuchSource = path.join(ROOT, 'config', '..', '.1003351-no-such-source');
236
+
237
+ assert.throws(() => fs.unlinkSync(real), guarded, 'unlinkSync on the real baseline');
238
+ assert.throws(() => fs.writeFileSync(absent, 'x'), guarded,
239
+ 'a NESTED write under config/ — the guard is on the prefix, not one filename');
240
+
241
+ // The destination-argument cases. These are the ones that matter most: they are
242
+ // the restore direction the original buggy finally blocks used, and a guard that
243
+ // only inspected argument 0 would wave every one of them through.
244
+ assert.throws(() => fs.copyFileSync(noSuchSource, real), guarded,
245
+ 'copyFileSync writes to argument 1 — the restore call the old tests ran');
246
+ assert.throws(() => fs.renameSync(noSuchSource, real), guarded,
247
+ 'renameSync writes to argument 1');
248
+ assert.throws(() => fs.renameSync(real, noSuchSource), guarded,
249
+ 'renameSync argument 0 moves the baseline OUT of config/ — the ABSENT case');
250
+
251
+ // And it is a guard, not a blanket ban on writing: temp paths still work, and a
252
+ // copy whose DESTINATION is outside config/ is legitimate even when it reads
253
+ // the real baseline.
254
+ const tmpDest = tmpBaseline('{"metrics":{}}');
255
+ assert.ok(fs.existsSync(tmpDest), 'writes outside config/ are untouched');
256
+ fs.copyFileSync(real, tmpDest);
257
+ assert.ok(fs.readFileSync(tmpDest, 'utf8').includes('metrics'), 'reading the real baseline is allowed');
258
+ });
259
+
260
+ test('1003351 the real baseline path still reads cleanly — the injected path is a test seam, not a bypass', () => {
261
+ const real = rat.readBaselines();
262
+ assert.equal(real.error, null, 'the committed baseline file parses');
263
+ assert.ok(real.metrics && typeof real.metrics === 'object', 'and yields a metrics map');
264
+ const injected = rat.readBaselines(tmpBaseline(JSON.stringify({ metrics: { only_me: 1 } })));
265
+ assert.deepEqual(injected.metrics, { only_me: 1 }, 'an injected path is actually honoured');
266
+ assert.notDeepEqual(injected.metrics, real.metrics, 'and does not fall back to the real file');
153
267
  });
154
268
 
155
269
  test('1003227 a missing KEY for a NEW metric stays a warning (adding a metric must not brick CI)', () => {
@@ -0,0 +1,139 @@
1
+ // tests/github_push_stderr_truncation.mjs — the builder-facing git stderr must
2
+ // keep the line that names the CAUSE, even when the cap bites (task 1003199).
3
+ //
4
+ // THE INCIDENT. The server-mediated publish rejected three pushes with
5
+ // `remote: fatal error in commit_refs`. The server log kept that line; the
6
+ // builder's client did not. stderr was head-sliced at 500 chars (and again at 200
7
+ // on the way out), so what reached the person who had to act was clipped mid-word
8
+ // at `error: failed` — naming neither cause nor remedy. They correctly stopped and
9
+ // filed blocker 1000117; diagnosing it took ten minutes in a separate session, the
10
+ // round trip took a day, and two finished tasks sat outside main throughout.
11
+ //
12
+ // Task 1003222 fixed the truncation: the cap went 500 → 8000 and became head+tail
13
+ // with a loud marker instead of a blind head-slice. It exported truncateLoudly
14
+ // "so its behaviour is testable, not just its effect" — and then no test was
15
+ // written. This is that test.
16
+ //
17
+ // WHY THE SHAPE OF THE FIXTURES MATTERS. git puts the useful line LAST: the
18
+ // `remote:` lines follow the progress chatter, and a non-fast-forward failure pads
19
+ // itself with a long `hint:` block. A head-only truncation therefore throws away
20
+ // precisely the causal line, on precisely the errors that need it. Both fixtures
21
+ // below are built to EXCEED the cap — the originating row's own warning was that a
22
+ // synthetic string under 500 chars cannot fail this test, and under the 8000-char
23
+ // cap that trap is even easier to fall into.
24
+ //
25
+ // Run: node --test tests/github_push_stderr_truncation.mjs
26
+
27
+ import { test } from 'node:test';
28
+ import assert from 'node:assert/strict';
29
+ import { createRequire } from 'node:module';
30
+
31
+ const require = createRequire(import.meta.url);
32
+ const { truncateLoudly, STDERR_MAX, scrubCredential } = require('../modules/lifecycle/github-push.js');
33
+
34
+ // Progress chatter git emits before it ever reaches the verdict. Padded past the
35
+ // cap so the truncation genuinely bites.
36
+ const chatter = (n) => Array.from({ length: n },
37
+ (_, i) => `Enumerating objects: ${i}, done.\nCounting objects: ${i}% (${i}/${n}), done.`).join('\n');
38
+
39
+ // CLASS 1 — the blocker-1000117 shape: a server-side rejection whose cause is the
40
+ // LAST line of the stream.
41
+ const COMMIT_REFS = [
42
+ 'Enumerating objects: 41, done.',
43
+ chatter(400),
44
+ 'Writing objects: 100% (23/23), 4.21 KiB | 1.05 MiB/s, done.',
45
+ 'Total 23 (delta 17), reused 0 (delta 0), pack-reused 0',
46
+ 'remote: fatal error in commit_refs',
47
+ 'To https://github.com/example/repo.git',
48
+ ' ! [remote rejected] task-1003199 -> task-1003199 (failure)',
49
+ "error: failed to push some refs to 'https://github.com/example/repo.git'",
50
+ ].join('\n');
51
+
52
+ // CLASS 2 — non-fast-forward. Real shape: progress chatter, then the verdict, then
53
+ // a long `hint:` block. The hints are the padding a head-slice would preserve at
54
+ // the cause's expense, and they sit AFTER the verdict, which is why keeping a tail
55
+ // is what saves it.
56
+ const NON_FAST_FORWARD = [
57
+ chatter(600),
58
+ 'To https://github.com/example/repo.git',
59
+ ' ! [rejected] task-1003199 -> task-1003199 (fetch first)',
60
+ "error: failed to push some refs to 'https://github.com/example/repo.git'",
61
+ 'hint: Updates were rejected because the remote contains work that you do not',
62
+ 'hint: have locally. This is usually caused by another repository pushing to',
63
+ 'hint: the same ref. If you want to integrate the remote changes, use',
64
+ "hint: 'git pull' before pushing again.",
65
+ 'hint: See the "Note about fast-forwards" in "git push --help" for details.',
66
+ ].join('\n');
67
+
68
+ test('precondition: both fixtures exceed the cap, or this whole file proves nothing', () => {
69
+ assert.ok(COMMIT_REFS.length > STDERR_MAX, `commit_refs fixture is ${COMMIT_REFS.length}, cap ${STDERR_MAX}`);
70
+ assert.ok(NON_FAST_FORWARD.length > STDERR_MAX, `nff fixture is ${NON_FAST_FORWARD.length}, cap ${STDERR_MAX}`);
71
+ });
72
+
73
+ test('a commit_refs failure keeps the line that names the cause', () => {
74
+ const out = truncateLoudly(COMMIT_REFS);
75
+ assert.ok(out.includes('remote: fatal error in commit_refs'),
76
+ 'the causal line is the whole reason stderr is attached at all');
77
+ assert.ok(out.includes('! [remote rejected]'), 'and the rejection line beside it');
78
+ });
79
+
80
+ test('a non-fast-forward failure keeps ! [rejected] and the error line', () => {
81
+ const out = truncateLoudly(NON_FAST_FORWARD);
82
+ assert.ok(out.includes('! [rejected]'), 'the verdict line');
83
+ assert.ok(out.includes('error: failed to push some refs'), 'the error line');
84
+ });
85
+
86
+ test('the truncation SAYS it truncated — never a message that just stops', () => {
87
+ const out = truncateLoudly(COMMIT_REFS);
88
+ assert.match(out, /characters omitted/,
89
+ 'a silently clipped message reads like the whole message; that is what cost the day');
90
+ assert.match(out, /full text in the server log/, 'and it names where the rest lives');
91
+ });
92
+
93
+ test('the output stays bounded — the cap is still a real payload guard', () => {
94
+ const out = truncateLoudly(COMMIT_REFS);
95
+ // cap + the marker itself; the marker is deliberately outside the budget so the
96
+ // kept head and tail are each a full share.
97
+ assert.ok(out.length < STDERR_MAX + 200, `bounded, got ${out.length}`);
98
+ });
99
+
100
+ test('text within the cap is returned untouched — no marker on a short error', () => {
101
+ const short = 'remote: fatal error in commit_refs\nerror: failed to push some refs';
102
+ assert.equal(truncateLoudly(short), short);
103
+ });
104
+
105
+ test('both ends survive: a head-only slice would drop the tail, a tail-only the head', () => {
106
+ const out = truncateLoudly(NON_FAST_FORWARD);
107
+ assert.ok(out.startsWith('Enumerating objects'), 'the head is kept — the run did start');
108
+ assert.ok(out.trimEnd().endsWith('"git push --help" for details.'),
109
+ 'and the tail, which is where git puts the verdict and its remedy');
110
+ });
111
+
112
+ // The shape of the guarantee, stated rather than assumed. head+tail keeps the two
113
+ // ENDS, so a verdict in the exact MIDDLE of a stream far larger than the cap is
114
+ // lost. That is not a defect for the two classes this task is about — git puts the
115
+ // verdict at the end of both — but it is the boundary of what the current
116
+ // truncation promises, and a future reader should not have to rediscover it. If a
117
+ // failure class ever turns up whose cause sits mid-stream, THIS is the test that
118
+ // says the fix must become line-aware (prefer remote:/error:/fatal: over hint:)
119
+ // rather than merely bigger.
120
+ test('KNOWN LIMIT: a cause buried mid-stream is not preserved — head+tail keeps the ends', () => {
121
+ const buried = [chatter(400), 'remote: fatal error in commit_refs', chatter(400)].join('\n');
122
+ assert.ok(buried.length > STDERR_MAX * 2, 'the fixture must dwarf the cap for the middle to be dropped');
123
+ const out = truncateLoudly(buried);
124
+ assert.ok(!out.includes('remote: fatal error in commit_refs'),
125
+ 'documenting the boundary: if this starts passing, the truncation became smarter and this test should be rewritten, not deleted');
126
+ });
127
+
128
+ test('empty and non-string input do not throw', () => {
129
+ for (const v of ['', null, undefined, 0]) assert.equal(truncateLoudly(v), '');
130
+ });
131
+
132
+ test('truncation never resurrects a scrubbed credential', () => {
133
+ // scrubCredential runs BEFORE truncateLoudly on both call sites. Pin the order's
134
+ // consequence: a token in the tail half must not reappear in the kept tail.
135
+ const withToken = [chatter(400), 'remote: https://x-access-token:ghp_AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHH@github.com/e/r.git', 'error: failed'].join('\n');
136
+ const out = truncateLoudly(scrubCredential(withToken));
137
+ assert.ok(!out.includes('ghp_AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHH'), 'the token must not survive');
138
+ assert.ok(out.includes('error: failed'), 'while the causal tail still does');
139
+ });