@bongos/core 1.19.761 → 1.19.762
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 +18 -13
- package/docs/module-api-changelog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness-ratchets.js +26 -5
- package/src/module-api.js +1 -1
- package/tests/fitness_ratchets.mjs +153 -39
- package/tests/github_push_stderr_truncation.mjs +139 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.762",
|
|
6
|
+
"core_contract": "1.19.762",
|
|
7
|
+
"source_commit": "4235a1ef680b4e7b79d8e61dc88b68069bc48d5f",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-16T03:
|
|
9
|
+
"built_at": "2026-09-16T03:56:12.759Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 483,
|
|
13
13
|
"agent_docs_stubbed": 25,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2116,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2624,
|
|
20
|
+
"tree_sha256": "7561f06e166d4cb1fa763b4771e03e57bba45299206df6dc706609de095557a3",
|
|
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": "
|
|
2515
|
+
"sha256": "6eafab6b4df93ff7ed5abfb7c2d7ed203d4a277cb9533fa262653e6ada53a4de"
|
|
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": "
|
|
7570
|
+
"sha256": "b20ee554299dfd5a29d5877d1eb9df41f460d30f1db26526485b4588228ef2e3"
|
|
7571
7571
|
},
|
|
7572
7572
|
{
|
|
7573
7573
|
"path": "package.json",
|
|
7574
7574
|
"mode": "0000644",
|
|
7575
|
-
"sha256": "
|
|
7575
|
+
"sha256": "222eb11d6d265b2235b1a7455707bc9bc9aa2210bcec0deb34dcbfdc12de784d"
|
|
7576
7576
|
},
|
|
7577
7577
|
{
|
|
7578
7578
|
"path": "public-docs/index.html",
|
|
@@ -8062,7 +8062,7 @@
|
|
|
8062
8062
|
{
|
|
8063
8063
|
"path": "scripts/gds/fitness-ratchets.js",
|
|
8064
8064
|
"mode": "0000644",
|
|
8065
|
-
"sha256": "
|
|
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": "
|
|
9385
|
+
"sha256": "9fca9034c480450daf57672bfecc9c7d09f8105fddd88d81ef8e46d025903cac"
|
|
9386
9386
|
},
|
|
9387
9387
|
{
|
|
9388
9388
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10532,7 +10532,7 @@
|
|
|
10532
10532
|
{
|
|
10533
10533
|
"path": "tests/fitness_ratchets.mjs",
|
|
10534
10534
|
"mode": "0000644",
|
|
10535
|
-
"sha256": "
|
|
10535
|
+
"sha256": "949526efc49c317a7be7e33beea0643b29748f3fd4e914d3a3670ce9ce6aadeb"
|
|
10536
10536
|
},
|
|
10537
10537
|
{
|
|
10538
10538
|
"path": "tests/fitness_two_root.mjs",
|
|
@@ -10589,6 +10589,11 @@
|
|
|
10589
10589
|
"mode": "0000644",
|
|
10590
10590
|
"sha256": "850113ed91babc9eb54dc22231f194d283fd3f94cee9deef1e6e0bf6f3a969c2"
|
|
10591
10591
|
},
|
|
10592
|
+
{
|
|
10593
|
+
"path": "tests/github_push_stderr_truncation.mjs",
|
|
10594
|
+
"mode": "0000644",
|
|
10595
|
+
"sha256": "d400c1830c9255851682c1653841a616ab9088f6facb2a5893f0ec41a72626eb"
|
|
10596
|
+
},
|
|
10592
10597
|
{
|
|
10593
10598
|
"path": "tests/github_repo_visibility.mjs",
|
|
10594
10599
|
"mode": "0000644",
|
|
@@ -1996,5 +1996,7 @@ 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)
|
|
1999
2001
|
---------------------------------------------------------------------------
|
|
2000
2002
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.762",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.762",
|
|
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.
|
|
3
|
+
"version": "1.19.762",
|
|
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",
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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.
|
|
74
|
+
const CORE_VERSION = '1.19.762'; // 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');
|
|
@@ -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
|
-
|
|
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
|
|
109
|
-
const
|
|
110
|
-
const
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
+
});
|