@bongos/core 1.19.383 → 1.19.385
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 +29 -19
- package/docs/adr/0195-adr-numbers-are-checked-like-migration-numbers.md +46 -0
- package/docs/adr/README.md +34 -23
- package/modules/grading/grader-workers/index.js +46 -3
- package/modules/grading/grader.js +25 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/adr-namespace.js +273 -0
- package/scripts/gds/fitness.js +7 -0
- package/scripts/gds/ship-grade-steps.js +43 -2
- package/src/module-api.js +5 -1
- package/tests/fitness.mjs +118 -0
- package/tests/grade_unavailable_display.mjs +48 -0
- package/tests/grader_panel.mjs +84 -0
package/tests/fitness.mjs
CHANGED
|
@@ -354,6 +354,124 @@ test('migration-namespacing actually CATCHES a cross-source stem collision (the
|
|
|
354
354
|
assert.equal(collisions[0].stem, '188_x');
|
|
355
355
|
});
|
|
356
356
|
|
|
357
|
+
// --- Check 31: ADR numbers unique + index agrees with the tree (task 1003164 / ADR 0195) ---
|
|
358
|
+
//
|
|
359
|
+
// The ADR half of Check 11b's guard. The fixture is the REAL collision: on 2026-08-22 two
|
|
360
|
+
// branches each landed an ADR numbered 0181 under different slugs, so git saw no path
|
|
361
|
+
// collision, README.md auto-merged into two rows, and every gate passed.
|
|
362
|
+
|
|
363
|
+
const adrNs = require(path.join(ROOT, 'scripts', 'gds', 'adr-namespace.js'));
|
|
364
|
+
|
|
365
|
+
test('ADR numbering: the live tree is clean (all 20 known duplicates grandfathered)', () => {
|
|
366
|
+
const r = fitness.checkAdrNumbering();
|
|
367
|
+
assert.equal(r.hardFail, false, r.violations.join('\n'));
|
|
368
|
+
assert.match(r.note, /ADR file\(s\)/, 'note reports the file + row counts');
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('ADR numbering actually CATCHES two ADRs sharing a number (the 0181 pair, not a no-op gate)', () => {
|
|
372
|
+
const collisions = adrNs.numberCollisions([
|
|
373
|
+
'0180-dependency-intake-and-drift-policy.md',
|
|
374
|
+
'0181-ship-requires-an-assurance.md',
|
|
375
|
+
'0181-teardown-state-gate-and-address-retention.md',
|
|
376
|
+
'0182-something-else.md',
|
|
377
|
+
]);
|
|
378
|
+
assert.equal(collisions.length, 1, 'the shared number must be reported exactly once');
|
|
379
|
+
assert.equal(collisions[0].number, '0181');
|
|
380
|
+
assert.deepEqual(collisions[0].files, [
|
|
381
|
+
'0181-ship-requires-an-assurance.md',
|
|
382
|
+
'0181-teardown-state-gate-and-address-retention.md',
|
|
383
|
+
], 'BOTH paths are named — the report has to say which two files to look at');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('ADR numbering: a grandfathered pair passes, but a THIRD file on that number does not', () => {
|
|
387
|
+
const pair = adrNs.LEGACY_DUPLICATE_ADRS['0008'];
|
|
388
|
+
assert.equal(pair.length, 2, 'fixture: 0008 is a known legacy pair');
|
|
389
|
+
|
|
390
|
+
// the exact frozen pair is exempt
|
|
391
|
+
assert.deepEqual(adrNs.numberCollisions([...pair]), [], 'the frozen legacy pair must pass');
|
|
392
|
+
|
|
393
|
+
// a third file joining the number is NOT — the exemption is set-equality, not "number 0008 is free"
|
|
394
|
+
const third = adrNs.numberCollisions([...pair, '0008-a-brand-new-adr.md']);
|
|
395
|
+
assert.equal(third.length, 1, 'a third file on a grandfathered number must still be flagged');
|
|
396
|
+
assert.equal(third[0].files.length, 3);
|
|
397
|
+
|
|
398
|
+
// so is a RENAME of either half — schema-migrations-style, the frozen names are the exemption
|
|
399
|
+
const renamed = adrNs.numberCollisions([pair[0].replace('.md', '-v2.md'), pair[1]]);
|
|
400
|
+
assert.equal(renamed.length, 1, 'renaming half of a grandfathered pair un-grandfathers it');
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('ADR numbering: the index must have one row per file, pointing at a file that exists', () => {
|
|
404
|
+
const files = ['0001-a.md', '0002-b.md'];
|
|
405
|
+
const row = (n, slug) => `| ${n} | [Title](${n}-${slug}.md) | topic |`;
|
|
406
|
+
|
|
407
|
+
// clean: one row per file, every row resolves
|
|
408
|
+
const clean = adrNs.indexViolations({
|
|
409
|
+
basenames: files,
|
|
410
|
+
rows: adrNs.parseIndexRows([row('0001', 'a'), row('0002', 'b')].join('\n')),
|
|
411
|
+
});
|
|
412
|
+
assert.deepEqual(clean, [], 'a matching index must pass: ' + clean.join('; '));
|
|
413
|
+
|
|
414
|
+
// DANGLING row — a row for a number with no file (the other half of the collision problem:
|
|
415
|
+
// the 2026-08-22 renumber had to reposition a row by hand)
|
|
416
|
+
const dangling = adrNs.indexViolations({
|
|
417
|
+
basenames: files,
|
|
418
|
+
rows: adrNs.parseIndexRows([row('0001', 'a'), row('0002', 'b'), row('0003', 'ghost')].join('\n')),
|
|
419
|
+
});
|
|
420
|
+
assert.equal(dangling.length, 1, 'exactly one violation for the dangling row');
|
|
421
|
+
assert.match(dangling[0], /does not exist/, 'the message names the missing file');
|
|
422
|
+
assert.match(dangling[0], /0003-ghost\.md/);
|
|
423
|
+
|
|
424
|
+
// DUPLICATED row — what an auto-merged index leaves behind (the live 0116/0117 case)
|
|
425
|
+
const dupe = adrNs.indexViolations({
|
|
426
|
+
basenames: files,
|
|
427
|
+
rows: adrNs.parseIndexRows([row('0001', 'a'), row('0002', 'b'), row('0002', 'b')].join('\n')),
|
|
428
|
+
});
|
|
429
|
+
assert.equal(dupe.length, 1);
|
|
430
|
+
assert.match(dupe[0], /has 2 index rows/, 'the message counts the rows and names their lines');
|
|
431
|
+
|
|
432
|
+
// UNINDEXED file — how a collision hides: the second file of a colliding pair is the one
|
|
433
|
+
// that never gets a row, so `ls docs/adr/` and the index quietly disagree
|
|
434
|
+
const missing = adrNs.indexViolations({
|
|
435
|
+
basenames: [...files, '0003-unindexed.md'],
|
|
436
|
+
rows: adrNs.parseIndexRows([row('0001', 'a'), row('0002', 'b')].join('\n')),
|
|
437
|
+
});
|
|
438
|
+
assert.equal(missing.length, 1);
|
|
439
|
+
assert.match(missing[0], /has no row in docs\/adr\/README\.md/);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test('ADR numbering: a row is read by its OWN-number link, so citing other ADRs in the prose is fine', () => {
|
|
443
|
+
// Index rows routinely cite sibling ADRs inside the title cell. Only the link whose
|
|
444
|
+
// filename starts with the row's own number is the row's subject.
|
|
445
|
+
const rows = adrNs.parseIndexRows(
|
|
446
|
+
'| 0100 | [Two-repo model, superseding [0099](0099-old.md) and see [0101](0101-next.md)](0100-two-repo.md) | topic |',
|
|
447
|
+
);
|
|
448
|
+
assert.equal(rows.length, 1);
|
|
449
|
+
assert.equal(rows[0].target, '0100-two-repo.md', 'the own-number link is the row subject');
|
|
450
|
+
assert.deepEqual(
|
|
451
|
+
adrNs.indexViolations({ basenames: ['0100-two-repo.md'], rows }),
|
|
452
|
+
[], 'citing other ADRs in a row must not be read as extra rows',
|
|
453
|
+
);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test('ADR numbering: out-of-order rows WARN and never hard-fail (inherited drift, not a PR author\'s doing)', () => {
|
|
457
|
+
const row = (n) => `| ${n} | [T](${n}-s.md) | topic |`;
|
|
458
|
+
assert.deepEqual(adrNs.indexOrderWarnings(adrNs.parseIndexRows([row('0001'), row('0002')].join('\n'))), []);
|
|
459
|
+
|
|
460
|
+
const warns = adrNs.indexOrderWarnings(adrNs.parseIndexRows([row('0001'), row('0003'), row('0002')].join('\n')));
|
|
461
|
+
assert.equal(warns.length, 1, 'the backwards row is reported');
|
|
462
|
+
assert.match(warns[0], /ascending order/);
|
|
463
|
+
|
|
464
|
+
// and ordering contributes to `warnings`, never to `violations`
|
|
465
|
+
const live = fitness.checkAdrNumbering();
|
|
466
|
+
assert.equal(live.hardFail, false, 'the live index is out of order in places and must still pass');
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test('ADR numbering: nextAdrNumber allocates above the highest number on disk', () => {
|
|
470
|
+
assert.equal(adrNs.nextAdrNumber(['0001-a.md', '0181-b.md', '0181-c.md']), 182, 'max + 1, duplicates included');
|
|
471
|
+
assert.equal(adrNs.nextAdrNumber([]), 1, 'an empty tree starts at 1');
|
|
472
|
+
assert.equal(adrNs.nextAdrNumber(['README.md', 'notes.txt']), 1, 'non-ADR files do not count');
|
|
473
|
+
});
|
|
474
|
+
|
|
357
475
|
// --- goal scope map imports PROTECTED_GLOBS (BV1.R54 / task 1508, ADR 0086 §7) ---
|
|
358
476
|
|
|
359
477
|
test('scope map import check: the live module-scope-map.js passes', () => {
|
|
@@ -35,6 +35,54 @@ test('grader-unavailable grade → "GRADER UNAVAILABLE", NOT a 0/10 FAIL or "fix
|
|
|
35
35
|
assert.doesNotMatch(out, /blended .*\/10/, 'must NOT print a 0/10 quality score for a non-grade');
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
// ---------- task 1003313: the outage card says WHY ----------
|
|
39
|
+
//
|
|
40
|
+
// Before this, a total outage printed "GRADER UNAVAILABLE" and stopped — the
|
|
41
|
+
// builder had no way to tell a logged-out CLI from a Windows alias stub from a
|
|
42
|
+
// real API outage, and two builders stayed dark for days. The diagnostic and the
|
|
43
|
+
// local `claude` CLI verdict are report-only: they never change the outcome.
|
|
44
|
+
|
|
45
|
+
const OUTAGE = {
|
|
46
|
+
passed: false, score: 0, threshold: 6, notes_md: 'GRADER UNAVAILABLE — all 4/4 gating worker(s) failed to run',
|
|
47
|
+
rubric_scores: {}, issues: [],
|
|
48
|
+
signals: {
|
|
49
|
+
panel_outcome: 'unavailable',
|
|
50
|
+
workers: ['narc', 'quality', 'hacker', 'efficiency'].map((kind) => ({
|
|
51
|
+
kind, error: 'SUBAGENT_NONZERO_EXIT', exit_code: 1, stderr_tail: 'Invalid API key · Run /login',
|
|
52
|
+
})),
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
test('an outage card prints each dead worker with its exit code + CLI error (task 1003313)', () => {
|
|
57
|
+
const out = formatGradeOutcome(OUTAGE, 1003313);
|
|
58
|
+
assert.match(out, /GRADER UNAVAILABLE/);
|
|
59
|
+
assert.match(out, /What the grader subprocess actually did/);
|
|
60
|
+
assert.match(out, /narc\s+exit 1 — Invalid API key/, 'names the worker, its exit code, and what the CLI said');
|
|
61
|
+
assert.doesNotMatch(out, /Fix the issues above/, 'still not a quality rejection');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('an outage card relays the injected claude-CLI preflight finding + its fix (task 1003313)', () => {
|
|
65
|
+
const out = formatGradeOutcome(OUTAGE, 1003313, {
|
|
66
|
+
level: 'warn', title: 'claude CLI',
|
|
67
|
+
detail: 'launches (2.1.0) but is not logged in',
|
|
68
|
+
fix: 'Run `claude login`',
|
|
69
|
+
});
|
|
70
|
+
assert.match(out, /Local `claude` CLI: launches \(2\.1\.0\) but is not logged in/);
|
|
71
|
+
assert.match(out, /Fix: Run `claude login`/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a HEALTHY claude-CLI finding adds no noise to the outage card (task 1003313)', () => {
|
|
75
|
+
const out = formatGradeOutcome(OUTAGE, 1003313, { level: 'ok', title: 'claude CLI', detail: 'logged in' });
|
|
76
|
+
assert.doesNotMatch(out, /Local `claude` CLI/, 'an ok finding is not worth a line');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('an outage from a pre-propagation server prints no empty diagnostic block (task 1003313)', () => {
|
|
80
|
+
const g = { ...OUTAGE, signals: { panel_outcome: 'unavailable', workers: [{ kind: 'narc', error: 'SUBAGENT_NONZERO_EXIT' }] } };
|
|
81
|
+
const out = formatGradeOutcome(g, 1003313);
|
|
82
|
+
assert.match(out, /GRADER UNAVAILABLE/);
|
|
83
|
+
assert.doesNotMatch(out, /What the grader subprocess actually did/);
|
|
84
|
+
});
|
|
85
|
+
|
|
38
86
|
test('a real FAIL still prints the FAIL block + fix-the-issues guidance (#943 regression guard)', () => {
|
|
39
87
|
const g = {
|
|
40
88
|
passed: false, score: 4, threshold: 6,
|
package/tests/grader_panel.mjs
CHANGED
|
@@ -246,6 +246,90 @@ await atest('graderUnavailableResult: server-compat shape (null axes, fallthroug
|
|
|
246
246
|
assert.equal(r.passed, false);
|
|
247
247
|
});
|
|
248
248
|
|
|
249
|
+
// ---------- task 1003313: a dark panel names its own cause ----------
|
|
250
|
+
//
|
|
251
|
+
// The regression these pin: runWorker used to keep only err.code and throw away
|
|
252
|
+
// err.exit_code / err.stderr, so a total outage stored four identical
|
|
253
|
+
// SUBAGENT_NONZERO_EXIT rows and two builders sat dark for days with a 0/10 card
|
|
254
|
+
// that could not say whether the CLI was logged out, aliased, or the API down.
|
|
255
|
+
|
|
256
|
+
const workersMod = require('../modules/grading/grader-workers/index.js');
|
|
257
|
+
const { runWorker, redactStderrTail, STDERR_TAIL_MAX } = workersMod;
|
|
258
|
+
|
|
259
|
+
// A worker module stub: buildPrompt/extractJson/computeVerdict, no spawning.
|
|
260
|
+
const STUB_WORKER = {
|
|
261
|
+
KIND: 'quality', NAME: 'Quality', SPECIALTY: 'stub',
|
|
262
|
+
buildPrompt: () => 'prompt',
|
|
263
|
+
extractJson: (s) => JSON.parse(s),
|
|
264
|
+
computeVerdict: (parsed, meta) => (meta.subprocess_error
|
|
265
|
+
? { verdict: 'fail', scores: {}, issues: [], notes_md: '', confidence: 0, error: meta.subprocess_error }
|
|
266
|
+
: { verdict: 'pass', scores: { code_quality: 9 }, issues: [], notes_md: '', confidence: 0.9, error: null }),
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// The exact error runSubagent throws on a nonzero exit.
|
|
270
|
+
const nonzeroExit = ({ exitCode = 1, stderr = '', stdout = '' } = {}) =>
|
|
271
|
+
Object.assign(new Error(`subagent exited with code ${exitCode}`), {
|
|
272
|
+
code: 'SUBAGENT_NONZERO_EXIT', exit_code: exitCode, run_ms: 120, stdout, stderr,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
await atest('runWorker: a dead subprocess carries exit_code + stderr_tail onto the worker row (task 1003313)', async () => {
|
|
276
|
+
const r = await runWorker(STUB_WORKER, {
|
|
277
|
+
task: {}, diff: '', changedFiles: [], diffStats: {}, model: 'm', runOpts: {},
|
|
278
|
+
runSubagentFn: async () => { throw nonzeroExit({ exitCode: 1, stderr: 'Invalid API key · Run /login' }); },
|
|
279
|
+
});
|
|
280
|
+
assert.equal(r.error, 'SUBAGENT_NONZERO_EXIT');
|
|
281
|
+
assert.equal(r.exit_code, 1, 'the exit code must survive the catch');
|
|
282
|
+
assert.match(r.stderr_tail, /Invalid API key/, "the CLI's own error line must survive the catch");
|
|
283
|
+
assert.equal(r.retried, true, 'a transient error is still retried once (#465)');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await atest('runWorker: a clean run adds neither exit_code nor stderr_tail (task 1003313)', async () => {
|
|
287
|
+
const r = await runWorker(STUB_WORKER, {
|
|
288
|
+
task: {}, diff: '', changedFiles: [], diffStats: {}, model: 'm', runOpts: {},
|
|
289
|
+
runSubagentFn: async () => ({ stdout: '{"code_quality":9}', run_ms: 10, cost_usd: 0.5 }),
|
|
290
|
+
});
|
|
291
|
+
assert.equal(r.verdict, 'pass');
|
|
292
|
+
assert.equal('exit_code' in r, false, 'a healthy grade row keeps its existing shape');
|
|
293
|
+
assert.equal('stderr_tail' in r, false);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
await atest('runWorker: stdout is the fallback when the CLI explained itself there (task 1003313)', async () => {
|
|
297
|
+
const r = await runWorker(STUB_WORKER, {
|
|
298
|
+
task: {}, diff: '', changedFiles: [], diffStats: {}, model: 'm', runOpts: {},
|
|
299
|
+
runSubagentFn: async () => { throw nonzeroExit({ exitCode: 1, stderr: '', stdout: 'Not logged in.' }); },
|
|
300
|
+
});
|
|
301
|
+
assert.match(r.stderr_tail, /Not logged in/);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('redactStderrTail: strips secrets, strips ANSI, bounds the length (task 1003313)', () => {
|
|
305
|
+
assert.match(redactStderrTail('auth failed for sk-ant-api03-AbCdEf_12345'), /\[redacted\]/);
|
|
306
|
+
assert.doesNotMatch(redactStderrTail('token ghp_abcdefghijklmnopqrstuvwxyz0123'), /ghp_/);
|
|
307
|
+
assert.doesNotMatch(redactStderrTail('header Bearer eyJhbGciOiJIUzI1NiJ9.abc'), /eyJhbGci/);
|
|
308
|
+
assert.equal(redactStderrTail('\u001B[31mred error\u001B[0m'), 'red error', 'ANSI colour codes are stripped');
|
|
309
|
+
assert.equal(redactStderrTail('x'.repeat(STDERR_TAIL_MAX + 200)).length, STDERR_TAIL_MAX);
|
|
310
|
+
assert.equal(redactStderrTail(' '), null, 'an empty tail is null, not an empty string');
|
|
311
|
+
assert.equal(redactStderrTail(undefined), null);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('aggregatePanel: the GRADER UNAVAILABLE verdict names exit code + CLI error (task 1003313)', () => {
|
|
315
|
+
const dead = (kind) => ({
|
|
316
|
+
kind, verdict: 'fail', error: 'SUBAGENT_NONZERO_EXIT', scores: {}, issues: [], confidence: 0,
|
|
317
|
+
exit_code: 1, stderr_tail: 'Invalid API key · Run /login',
|
|
318
|
+
});
|
|
319
|
+
const r = aggregatePanel([dead('narc'), dead('quality'), dead('hacker'), dead('efficiency')], {});
|
|
320
|
+
assert.equal(r.signals.panel_outcome, 'unavailable');
|
|
321
|
+
assert.match(r.notes_md, /GRADER UNAVAILABLE/);
|
|
322
|
+
assert.match(r.notes_md, /exit 1/, 'the verdict text must carry the exit code');
|
|
323
|
+
assert.match(r.notes_md, /Invalid API key/, 'the verdict text must carry the CLI error line');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test('aggregatePanel: no diagnostic appended when the workers carry none (pre-propagation grades)', () => {
|
|
327
|
+
const dead = (kind) => ({ kind, verdict: 'fail', error: 'SUBAGENT_NONZERO_EXIT', scores: {}, issues: [], confidence: 0 });
|
|
328
|
+
const r = aggregatePanel([dead('narc'), dead('quality'), dead('hacker'), dead('efficiency')], {});
|
|
329
|
+
assert.equal(r.signals.panel_outcome, 'unavailable');
|
|
330
|
+
assert.doesNotMatch(r.notes_md, /Diagnostic —/);
|
|
331
|
+
});
|
|
332
|
+
|
|
249
333
|
console.log(`${asyncPassed} passed, ${asyncFailed} failed (async)`);
|
|
250
334
|
const totalFailed = failed + asyncFailed;
|
|
251
335
|
console.log(`\n${passed + asyncPassed} passed, ${totalFailed} failed`);
|