@bongos/core 1.19.645 → 1.19.647
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 +36 -31
- package/.claude/skills/grade-recover/SKILL.md +4 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +137 -3
- package/docs/api-reference.md +4 -2
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_237_box_widen_paths.sql +40 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/dev-box/box-access.js +128 -4
- package/modules/dev-box/boxes.js +21 -0
- package/modules/dev-box/routes/box.js +108 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box-sync.js +84 -1
- package/scripts/gds/claude-materialize.js +14 -1
- package/scripts/gds/ship-flow.js +27 -1
- package/scripts/gds/ship-honesty.js +53 -0
- package/src/module-api.js +1 -1
- package/tests/box_access.mjs +155 -0
- package/tests/box_sync_scope_report.mjs +45 -0
- package/tests/claude_materialize.mjs +30 -0
- package/tests/ship_cannot_lie.mjs +103 -1
package/tests/box_access.mjs
CHANGED
|
@@ -414,5 +414,160 @@ t('1003227 config/ is a cone-mode DIRECTORY entry (a file path aborts the whole
|
|
|
414
414
|
}
|
|
415
415
|
});
|
|
416
416
|
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
// task 1003087 / idea 1000793 — builder widening that SURVIVES the */10 tick
|
|
419
|
+
//
|
|
420
|
+
// The bug: the cron re-applies the server-computed sparse set, so a builder's
|
|
421
|
+
// own `git sparse-checkout add` is deleted — sometimes mid-command (a live suite
|
|
422
|
+
// died with MODULE_NOT_FOUND that read exactly like a code bug). The widen set is
|
|
423
|
+
// therefore recorded server-side and unioned in HERE, on the same path the cron
|
|
424
|
+
// asks every tick. These pin the two halves that matter: it actually widens, and
|
|
425
|
+
// it cannot be used to climb past the rank scope.
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
console.log('\nnormalizeWidenPath — a repo-relative directory, or nothing:');
|
|
429
|
+
|
|
430
|
+
t('accepts a plain repo-relative dir and strips the noise around it', () => {
|
|
431
|
+
assert.equal(access.normalizeWidenPath('docs'), 'docs');
|
|
432
|
+
assert.equal(access.normalizeWidenPath(' docs '), 'docs');
|
|
433
|
+
assert.equal(access.normalizeWidenPath('./docs/'), 'docs');
|
|
434
|
+
assert.equal(access.normalizeWidenPath('docs//adr'), 'docs/adr');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
t('folds a Windows separator rather than judging the path on it', () => {
|
|
438
|
+
const BS = String.fromCharCode(92);
|
|
439
|
+
assert.equal(access.normalizeWidenPath(`modules${BS}game`), 'modules/game');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
t('REFUSES traversal, absolute paths and drive letters outright', () => {
|
|
443
|
+
// Refused, never "cleaned up into something that still works" — this string
|
|
444
|
+
// reaches `git sparse-checkout set` on a real box.
|
|
445
|
+
for (const bad of ['../etc', 'a/../../b', '/etc/passwd', 'C:/Windows', '..', '.', '']) {
|
|
446
|
+
assert.equal(access.normalizeWidenPath(bad), null, `${JSON.stringify(bad)} must be refused`);
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
t('refuses a non-string, an over-long path, and odd characters', () => {
|
|
451
|
+
assert.equal(access.normalizeWidenPath(null), null);
|
|
452
|
+
assert.equal(access.normalizeWidenPath(42), null);
|
|
453
|
+
assert.equal(access.normalizeWidenPath({}), null);
|
|
454
|
+
assert.equal(access.normalizeWidenPath('a'.repeat(500)), null);
|
|
455
|
+
assert.equal(access.normalizeWidenPath('modules/ga me'), null);
|
|
456
|
+
assert.equal(access.normalizeWidenPath('modules/rm -rf'), null);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
console.log('\nadmissibleWidenPaths — rank is the ceiling, and it is the whole point:');
|
|
460
|
+
|
|
461
|
+
t('a Metic (full rank scope) may widen anywhere', () => {
|
|
462
|
+
assert.deepEqual(access.admissibleWidenPaths(['modules/game'], 'metic'), ['modules/game']);
|
|
463
|
+
assert.deepEqual(access.admissibleWidenPaths(['migrations'], 'archon'), ['migrations']);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
t('a Xenos may NOT widen past the starter surface — that would be rank escalation', () => {
|
|
467
|
+
// The security property of this feature stated as a test: if an arbitrary widen
|
|
468
|
+
// were honored, a convenience flag would hand a starter-scoped builder the whole
|
|
469
|
+
// repo, which is the rank gate defeated rather than a checkout preference.
|
|
470
|
+
assert.deepEqual(access.admissibleWidenPaths(['modules/game'], 'xenos'), []);
|
|
471
|
+
assert.deepEqual(access.admissibleWidenPaths(['migrations'], 'xenos'), []);
|
|
472
|
+
assert.deepEqual(access.admissibleWidenPaths(['modules/lifecycle'], 'thetes'), []);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
t('a Xenos MAY widen INSIDE starter — a task box is narrower than their rank allows', () => {
|
|
476
|
+
// Not a null grant: task scope is BASE union modules, which is narrower than
|
|
477
|
+
// starter, so this is the room to climb back to what rank already permitted.
|
|
478
|
+
assert.deepEqual(access.admissibleWidenPaths(['docs/adr'], 'xenos'), ['docs/adr']);
|
|
479
|
+
assert.deepEqual(access.admissibleWidenPaths(['src/world'], 'xenos'), ['src/world']);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
t('normalizes, dedupes and sorts, and drops the invalid without failing the rest', () => {
|
|
483
|
+
const got = access.admissibleWidenPaths(['./docs/', 'docs', '../etc', 'art'], 'metic');
|
|
484
|
+
assert.deepEqual(got, ['art', 'docs'], 'one invalid entry must not discard the valid ones');
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
t('is capped, so a widen set can never become a second scope system', () => {
|
|
488
|
+
const many = Array.from({ length: access.MAX_WIDEN_PATHS + 20 }, (_, i) => `docs/d${i}`);
|
|
489
|
+
assert.equal(access.admissibleWidenPaths(many, 'metic').length, access.MAX_WIDEN_PATHS);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
t('a missing / non-array widen set is simply no widening', () => {
|
|
493
|
+
for (const v of [undefined, null, 'docs', 42, {}]) {
|
|
494
|
+
assert.deepEqual(access.admissibleWidenPaths(v, 'metic'), []);
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
console.log('\nwithWidenPaths — union into the computed set, without redundancy:');
|
|
499
|
+
|
|
500
|
+
t('adds a dir the set does not already cover', () => {
|
|
501
|
+
assert.deepEqual(access.withWidenPaths(['src/bongos'], ['docs']), ['src/bongos', 'docs']);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
t('skips a dir an ANCESTOR already covers — cone mode pulls the whole subtree', () => {
|
|
505
|
+
assert.deepEqual(access.withWidenPaths(['scripts'], ['scripts/gds']), ['scripts'],
|
|
506
|
+
'adding scripts/gds under scripts is noise in the git command and in the scope readout');
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
t('passes a null sparse set (the full clone) straight through', () => {
|
|
510
|
+
assert.equal(access.withWidenPaths(null, ['docs']), null,
|
|
511
|
+
'a full clone already holds everything — there is nothing to widen INTO');
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
t('an empty widen set returns the computed set untouched', () => {
|
|
515
|
+
const base = ['src/bongos', 'scripts'];
|
|
516
|
+
assert.deepEqual(access.withWidenPaths(base, []), base);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
console.log('\ndecideSourceAccess — the widen reaches the spec the box actually fetches:');
|
|
520
|
+
|
|
521
|
+
t('a task-scoped box gets its widen unioned into the sparse set', () => {
|
|
522
|
+
const d = access.decideSourceAccess({
|
|
523
|
+
rank: 'metic', status: 'active', scopeKeys: [], hasActiveClaims: false,
|
|
524
|
+
moduleScopeMap: {}, widenPaths: ['modules/game'],
|
|
525
|
+
});
|
|
526
|
+
assert.equal(d.allowed, true);
|
|
527
|
+
assert.ok(d.spec.sparsePaths.includes('modules/game'),
|
|
528
|
+
'the widen must reach the SPEC — this is the value the cron writes into sparse-checkout');
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
t('an out-of-scope widen never reaches the spec for a starter-scoped builder', () => {
|
|
532
|
+
const d = access.decideSourceAccess({
|
|
533
|
+
rank: 'xenos', status: 'active', scopeKeys: [], hasActiveClaims: false,
|
|
534
|
+
moduleScopeMap: {}, widenPaths: ['modules/game'],
|
|
535
|
+
});
|
|
536
|
+
assert.ok(!d.spec.sparsePaths.includes('modules/game'));
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
t('no widenPaths leaves every scope byte-identical to before the feature', () => {
|
|
540
|
+
const withArg = access.decideSourceAccess({
|
|
541
|
+
rank: 'metic', status: 'active', scopeKeys: [], hasActiveClaims: false, moduleScopeMap: {}, widenPaths: [],
|
|
542
|
+
});
|
|
543
|
+
const without = access.decideSourceAccess({
|
|
544
|
+
rank: 'metic', status: 'active', scopeKeys: [], hasActiveClaims: false, moduleScopeMap: {},
|
|
545
|
+
});
|
|
546
|
+
assert.deepEqual(withArg.spec, without.spec, 'a box that asked for nothing must fetch exactly what it always did');
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
t('a full-clone builder is unaffected — sparsePaths stays null', () => {
|
|
550
|
+
const d = access.decideSourceAccess({ rank: 'metic', status: 'active', widenPaths: ['docs'] });
|
|
551
|
+
assert.equal(d.spec.sparsePaths, null);
|
|
552
|
+
assert.equal(d.spec.mode, 'full');
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
t('widening cannot resurrect access for an inactive or below-floor builder', () => {
|
|
556
|
+
// The widen set is read from the box row, so it must not become a way around
|
|
557
|
+
// the ALLOW/DENY gate that runs before any scope is computed.
|
|
558
|
+
const inactive = access.decideSourceAccess({ rank: 'metic', status: 'inactive', widenPaths: ['docs'] });
|
|
559
|
+
assert.equal(inactive.allowed, false);
|
|
560
|
+
assert.equal(inactive.reason, 'BUILDER_INACTIVE');
|
|
561
|
+
assert.equal(inactive.spec, undefined);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
t('every admitted widen path is still a DIRECTORY — cone mode aborts on a file', () => {
|
|
565
|
+
for (const p of access.admissibleWidenPaths(['docs/adr', 'src/world'], 'metic')) {
|
|
566
|
+
assert.ok(!p.endsWith('/'), `${p} must not carry a trailing slash`);
|
|
567
|
+
const last = p.split('/').pop();
|
|
568
|
+
assert.ok(!/\.[a-z0-9]+$/i.test(last), `${p} looks like a FILE — cone mode would abort the set`);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
417
572
|
console.log(`\nbox_access.mjs: ${passed} passed, ${failed} failed`);
|
|
418
573
|
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -18,6 +18,8 @@ import { createRequire } from 'node:module';
|
|
|
18
18
|
|
|
19
19
|
const require = createRequire(import.meta.url);
|
|
20
20
|
const { formatScopeReport } = require('../scripts/gds/box-sync.js');
|
|
21
|
+
// task 1003087 — the widen read-out lives in the same module.
|
|
22
|
+
const sync = require('../scripts/gds/box-sync.js');
|
|
21
23
|
|
|
22
24
|
let passed = 0;
|
|
23
25
|
let failed = 0;
|
|
@@ -77,5 +79,48 @@ t('an unknown scope value is reported verbatim rather than swallowed', () => {
|
|
|
77
79
|
assert.match(joined({ scope: 'something-new', sparse_paths: [] }), /something-new/);
|
|
78
80
|
});
|
|
79
81
|
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// task 1003087 — the --widen read-out
|
|
84
|
+
//
|
|
85
|
+
// The whole feature exists because a widen that silently stops applying is
|
|
86
|
+
// indistinguishable from one that works. So the report must say when the server
|
|
87
|
+
// stored a path it will NOT apply (outside the caller's rank scope) — otherwise
|
|
88
|
+
// this fix reintroduces its own bug one layer up.
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
const widenLines = (body) => sync.formatWidenReport(body).join('\n');
|
|
92
|
+
|
|
93
|
+
t('an empty widen set says so plainly rather than printing nothing', () => {
|
|
94
|
+
assert.match(widenLines({ widen_paths: [], ignored: [] }), /nothing kept/i);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
t('a kept set names the paths and the count', () => {
|
|
98
|
+
const out = widenLines({ widen_paths: ['docs', 'modules/game'], ignored: [] });
|
|
99
|
+
assert.match(out, /2 path/);
|
|
100
|
+
assert.match(out, /docs/);
|
|
101
|
+
assert.match(out, /modules\/game/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
t('an IGNORED path is called out loudly, not folded into the kept list', () => {
|
|
105
|
+
const out = widenLines({ widen_paths: ['docs', 'modules/game'], ignored: ['modules/game'] });
|
|
106
|
+
assert.match(out, /IGNORED/);
|
|
107
|
+
assert.match(out, /rank scope/i,
|
|
108
|
+
'the reason must be named — "stored but not applied" with no why is the silent failure again');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
t('a garbage / missing body degrades to one honest line rather than throwing', () => {
|
|
112
|
+
for (const bad of [null, undefined, 'nope', 42, []]) {
|
|
113
|
+
assert.match(widenLines(bad), /could not read/i);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
t('valueAfter reads the argument after a flag, and refuses the next flag as a value', () => {
|
|
118
|
+
assert.equal(sync.valueAfter(['--widen', 'docs'], '--widen'), 'docs');
|
|
119
|
+
assert.equal(sync.valueAfter(['--widen', '--dry-run'], '--widen'), null,
|
|
120
|
+
'consuming the next FLAG as a path would silently widen to something nobody typed');
|
|
121
|
+
assert.equal(sync.valueAfter(['--widen'], '--widen'), null);
|
|
122
|
+
assert.equal(sync.valueAfter([], '--widen'), null);
|
|
123
|
+
});
|
|
124
|
+
|
|
80
125
|
console.log(`\nbox_sync_scope_report.mjs: ${passed} passed, ${failed} failed`);
|
|
81
126
|
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -318,6 +318,36 @@ t('materializeClaude: a skill the module STOPPED declaring is withdrawn too', ()
|
|
|
318
318
|
for (const d of [core, inst]) rmSync(d, { recursive: true, force: true });
|
|
319
319
|
});
|
|
320
320
|
|
|
321
|
+
t('materializeClaude: a CORE skill that took over a module-owned name is NOT withdrawn — and a disabled module\'s core-shipped name still is (task 1003818)', () => {
|
|
322
|
+
// The regression this pins: step 1 copies the core's dir in, and 1c deleted it again in
|
|
323
|
+
// the SAME run, because no enabled module declared the name any more. The core-checkout
|
|
324
|
+
// twin has always guarded this via tracked() ("a core skill can take over a name a
|
|
325
|
+
// module once owned"); the instance side shipped without it in task 1003632.
|
|
326
|
+
const { core, inst, modDir } = withdrawFixture();
|
|
327
|
+
m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false }); // alpha lands, recorded
|
|
328
|
+
|
|
329
|
+
// the module stops declaring alpha; the core now ships its own
|
|
330
|
+
writeFileSync(join(modDir, 'module.json'), JSON.stringify({ key: 'foo', default: true, contributes: { skills: ['beta'] } }));
|
|
331
|
+
mkdirSync(join(core, '.claude', 'skills', 'alpha'), { recursive: true });
|
|
332
|
+
writeFileSync(join(core, '.claude', 'skills', 'alpha', 'SKILL.md'), '---\nname: alpha\n---\nCORE OWNS THIS NOW\n');
|
|
333
|
+
const kept = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
334
|
+
assert.deepEqual(kept.withdrawnSkills, [], 'a name the core ships now is the core\'s, whatever the manifest said');
|
|
335
|
+
assert.match(readFileSync(join(inst, '.claude', 'skills', 'alpha', 'SKILL.md'), 'utf8'), /CORE OWNS THIS NOW/, 'and it is the core copy that survives');
|
|
336
|
+
|
|
337
|
+
// THE OTHER HALF, which the guard must not break: a DISABLED module declaring a
|
|
338
|
+
// core-shipped name means step 1 skips the copy, so the instance dir IS stale.
|
|
339
|
+
const inst2 = mkdtempSync(join(tmpdir(), 'wd-inst-'));
|
|
340
|
+
writeFileSync(join(modDir, 'module.json'), JSON.stringify({ key: 'foo', default: true, contributes: { skills: ['alpha', 'beta'] } }));
|
|
341
|
+
m.materializeClaude({ coreRoot: core, instanceDir: inst2, dryRun: false });
|
|
342
|
+
assert.ok(existsSync(join(inst2, '.claude', 'skills', 'alpha')), 'landed while enabled');
|
|
343
|
+
mkdirSync(join(inst2, 'config'), { recursive: true });
|
|
344
|
+
writeFileSync(join(inst2, 'config', 'modules.json'), JSON.stringify({ modules: { foo: false } }));
|
|
345
|
+
const gone = m.materializeClaude({ coreRoot: core, instanceDir: inst2, dryRun: false });
|
|
346
|
+
assert.ok(gone.withdrawnSkills.includes('alpha'), 'a disabled module\'s core-shipped name is still withdrawn — step 1 skipped it, so the dir is stale');
|
|
347
|
+
assert.ok(!existsSync(join(inst2, '.claude', 'skills', 'alpha')));
|
|
348
|
+
for (const d of [core, inst, inst2]) rmSync(d, { recursive: true, force: true });
|
|
349
|
+
});
|
|
350
|
+
|
|
321
351
|
t('materializeClaude: a HAND-WRITTEN skill is never withdrawn, even when a disabled module declares its name', () => {
|
|
322
352
|
const { core, inst } = withdrawFixture();
|
|
323
353
|
// The owner wrote .claude/skills/alpha themselves; no materialize ever landed it,
|
|
@@ -14,13 +14,20 @@
|
|
|
14
14
|
// lands the task at 'completed' before the grade runs, and 'completed' is
|
|
15
15
|
// ALSO what a grade-failed task looks like — so a bare `fatal: <stack>`
|
|
16
16
|
// left nobody able to tell how far it got.
|
|
17
|
+
//
|
|
18
|
+
// (3) --regrade must not swallow --notes. Same shape as (1): prose handed to
|
|
19
|
+
// ship.js that reaches nothing, silently (task 1003702).
|
|
17
20
|
import { strict as assert } from 'node:assert';
|
|
18
21
|
import { test } from 'node:test';
|
|
19
22
|
import { createRequire } from 'node:module';
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
20
24
|
|
|
21
25
|
const require = createRequire(import.meta.url);
|
|
22
26
|
const { argText } = require('../scripts/gds/cli-lib.js');
|
|
23
|
-
const {
|
|
27
|
+
const {
|
|
28
|
+
fatalRecoveryLines, markShipProgress, _peekForTest,
|
|
29
|
+
proseFlagPresent, regradeNotesRefusal,
|
|
30
|
+
} = require('../scripts/gds/ship-honesty.js');
|
|
24
31
|
|
|
25
32
|
// Injected IO so no case touches the filesystem.
|
|
26
33
|
const io = {
|
|
@@ -166,3 +173,98 @@ test('markShipProgress is the single writer, and ship.js re-exports the reader',
|
|
|
166
173
|
assert.equal(typeof shipCli.fatalRecoveryLines, 'function', 'ship.js still exposes it');
|
|
167
174
|
assert.equal(shipCli.fatalRecoveryLines, fatalRecoveryLines, 'same function, not a copy');
|
|
168
175
|
});
|
|
176
|
+
|
|
177
|
+
// ── (3) --regrade refuses --notes instead of swallowing it (task 1003702) ────
|
|
178
|
+
//
|
|
179
|
+
// The third way to lose the builder's prose, and the same incident shape as (1):
|
|
180
|
+
// prose handed to ship.js that reaches nothing, with no error. --regrade
|
|
181
|
+
// resolved only summary + response, so --notes parsed as an unknown flag and
|
|
182
|
+
// vanished; a builder answering a "no evidence in the notes" blocker passed it
|
|
183
|
+
// again there and got the identical finding back marked unanswered.
|
|
184
|
+
|
|
185
|
+
test('every spelling of --notes is caught, and nothing else is', () => {
|
|
186
|
+
// All four forms argText accepts. Catching only the bare flag would leave
|
|
187
|
+
// --notes-file — the form the ship docs actively recommend for prose — silent,
|
|
188
|
+
// which is the exact defect wearing different clothes.
|
|
189
|
+
for (const argv of [
|
|
190
|
+
['1', '--regrade', '--notes', 'x'],
|
|
191
|
+
['1', '--regrade', '--notes=x'],
|
|
192
|
+
['1', '--regrade', '--notes-file', 'n.md'],
|
|
193
|
+
['1', '--regrade', '--notes-file=n.md'],
|
|
194
|
+
]) {
|
|
195
|
+
assert.equal(proseFlagPresent(argv, 'notes'), true, `caught: ${argv.join(' ')}`);
|
|
196
|
+
}
|
|
197
|
+
// Not over-eager: the flags a re-grade legitimately takes must still pass, and
|
|
198
|
+
// a flag that merely CONTAINS the word is a different flag.
|
|
199
|
+
for (const argv of [
|
|
200
|
+
['1', '--regrade'],
|
|
201
|
+
['1', '--regrade', '--response-file', 'r.md'],
|
|
202
|
+
['1', '--regrade', '--summary', 'x'],
|
|
203
|
+
['1', '--regrade', '--no-notes-please'],
|
|
204
|
+
['1', '--regrade', '--response', 'my --notes were wrong'],
|
|
205
|
+
]) {
|
|
206
|
+
assert.equal(proseFlagPresent(argv, 'notes'), false, `not caught: ${argv.join(' ')}`);
|
|
207
|
+
}
|
|
208
|
+
// Generic over the flag name — it is not a --notes special case.
|
|
209
|
+
assert.equal(proseFlagPresent(['--response-file', 'r.md'], 'response'), true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('THE ACCEPTANCE: the refusal names where the evidence must go instead', () => {
|
|
213
|
+
// A non-zero exit that does not say what to do is the same dead end with an
|
|
214
|
+
// error code on it. The refusal has to carry the working channel, or a builder
|
|
215
|
+
// reads "not supported" and goes back to putting evidence nowhere.
|
|
216
|
+
const text = regradeNotesRefusal(1003698).join('\n');
|
|
217
|
+
assert.match(text, /--response/, 'names the channel that reaches the panel');
|
|
218
|
+
assert.match(text, /--response-file/, 'and its file form, the safe one for prose');
|
|
219
|
+
assert.match(text, /diff/i, 'and the diff, which is what the panel actually reads');
|
|
220
|
+
assert.match(text, /ship\.js 1003698 --regrade/, 'a runnable command with the real task id');
|
|
221
|
+
// It must not be reassuring about the thing that did not happen.
|
|
222
|
+
assert.match(text, /NOT been saved/, 'says plainly that the notes went nowhere');
|
|
223
|
+
// And it must not claim the notes were merely "not supported yet" — the
|
|
224
|
+
// substantive fact is that the grader never reads ship notes at all, so a
|
|
225
|
+
// builder does not go looking for a flag that would make them count.
|
|
226
|
+
assert.match(text, /never reads ship notes/, 'explains WHY, not just that it refused');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('the refusal is wired into the regrade path, before any grade spend', () => {
|
|
230
|
+
// Wiring proof (the (1) lesson: a helper nothing calls is not a fix). Also
|
|
231
|
+
// pins the ORDER — a refusal that costs a 4-worker panel run first is barely
|
|
232
|
+
// better than the silence, so the guard must precede the preflight.
|
|
233
|
+
const src = readFileSync(new URL('../scripts/gds/ship-flow.js', import.meta.url), 'utf8');
|
|
234
|
+
const regrade = src.slice(src.indexOf('async function regradeMain'));
|
|
235
|
+
const guard = regrade.indexOf("proseFlagPresent(args, 'notes')");
|
|
236
|
+
assert.ok(guard !== -1, 'regradeMain consults the guard');
|
|
237
|
+
const preflight = regrade.indexOf('preflight(claimLike');
|
|
238
|
+
assert.ok(preflight !== -1 && guard < preflight, 'it refuses before the preflight and the panel');
|
|
239
|
+
// The usage line must not advertise a flag the path refuses.
|
|
240
|
+
const usage = regrade.slice(0, regrade.indexOf('resolveProse'));
|
|
241
|
+
assert.ok(!/--notes/.test(usage.replace(/proseFlagPresent[^\n]*/g, '')
|
|
242
|
+
.replace(/\/\/[^\n]*/g, '')), 'usage does not offer --notes');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('ACCEPTANCE 3: --summary reaches the GRADER on a re-grade but never the task row', () => {
|
|
246
|
+
// Task 1003702's third acceptance asked whether --summary reaches the stored
|
|
247
|
+
// value_summary on a re-grade. It does NOT, and that is a separate defect
|
|
248
|
+
// filed as task 1003817 (it needs a server route change; this task's fix is
|
|
249
|
+
// CLI-only). Pinned here rather than left in prose for the reason this whole
|
|
250
|
+
// file exists: the panel reads the DIFF, so an answer that lives only in ship
|
|
251
|
+
// notes is an answer nobody can check — which is the very bug task 1003702 is.
|
|
252
|
+
const src = readFileSync(new URL('../scripts/gds/ship-flow.js', import.meta.url), 'utf8');
|
|
253
|
+
const regrade = src.slice(src.indexOf('async function regradeMain'));
|
|
254
|
+
const code = regrade.replace(/\/\/[^\n]*/g, ''); // comments describe, they do not write
|
|
255
|
+
|
|
256
|
+
// Half one — the threading is intact, so nothing regressed: --summary still
|
|
257
|
+
// falls back to the stored value and still reaches the grader and the card.
|
|
258
|
+
assert.match(code, /const summary = summaryArg \|\| task\.value_summary/,
|
|
259
|
+
'the stored summary is still the fallback when --summary is omitted');
|
|
260
|
+
assert.match(code, /gradeAndRecord\(\{[\s\S]{0,200}?summary,/,
|
|
261
|
+
'the summary still reaches the grader prompt');
|
|
262
|
+
assert.match(code, /valueSummary: summary/, 'and the completion card');
|
|
263
|
+
|
|
264
|
+
// Half two — and there is no writer. A `value_summary:` KEY would be a request
|
|
265
|
+
// payload; the legitimate read is `task.value_summary`, dot-access. If someone
|
|
266
|
+
// later adds the write (task 1003817), this assertion is what tells them to
|
|
267
|
+
// update the comment above `const summary` rather than leave it lying.
|
|
268
|
+
assert.ok(!/[^.]\bvalue_summary\s*:/.test(code),
|
|
269
|
+
'regradeMain writes value_summary nowhere — see task 1003817');
|
|
270
|
+
});
|