@bongos/core 1.19.646 → 1.19.648
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 +48 -33
- 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 +8 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +142 -3
- package/docs/api-reference.md +5 -3
- 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/modules/lifecycle/db-tasks.js +13 -1
- package/modules/lifecycle/db.js +40 -64
- package/modules/lifecycle/routes/task-write-routes.js +28 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box-sync.js +84 -1
- package/scripts/gds/ship-flow.js +69 -10
- 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/home_bounded_reads.mjs +14 -5
- package/tests/lifecycle_facade_surface.mjs +112 -0
- package/tests/migration_allocation.mjs +28 -0
- package/tests/patch_gate_ordering.mjs +4 -0
- package/tests/patch_value_summary_e2e.mjs +142 -0
- package/tests/ship_cannot_lie.mjs +42 -13
package/scripts/gds/box-sync.js
CHANGED
|
@@ -15,6 +15,18 @@
|
|
|
15
15
|
//
|
|
16
16
|
// Usage: node scripts/gds/box-sync.js # re-fetch now
|
|
17
17
|
// node scripts/gds/box-sync.js --dry-run # print what it WOULD run
|
|
18
|
+
// node scripts/gds/box-sync.js --widen <dir> # keep <dir> across ticks
|
|
19
|
+
// node scripts/gds/box-sync.js --unwiden <dir> # stop keeping it
|
|
20
|
+
// node scripts/gds/box-sync.js --widen-list # what is kept, and what is ignored
|
|
21
|
+
//
|
|
22
|
+
// WHY --widen EXISTS (task 1003087 / idea 1000793). `git sparse-checkout add`
|
|
23
|
+
// does not survive: the */10 cron re-applies the SERVER-computed set and deletes
|
|
24
|
+
// the addition, sometimes MID-COMMAND — a live test suite died with
|
|
25
|
+
// MODULE_NOT_FOUND that read exactly like a code bug. --widen records the
|
|
26
|
+
// directory server-side instead, so every fetch (the cron included) returns it
|
|
27
|
+
// in the set and it stops being deleted. It is capped, and it can never widen
|
|
28
|
+
// past what your RANK already reaches — a request outside it is stored but
|
|
29
|
+
// reported as ignored rather than silently applied.
|
|
18
30
|
|
|
19
31
|
const fs = require('node:fs');
|
|
20
32
|
const path = require('node:path');
|
|
@@ -36,7 +48,78 @@ function resolveFetchScript(opts = {}) {
|
|
|
36
48
|
try { return fs.existsSync(p) ? p : null; } catch { return null; }
|
|
37
49
|
}
|
|
38
50
|
|
|
51
|
+
// valueAfter(argv, flag) — the argument following a flag, or null. Kept tiny and
|
|
52
|
+
// pure so the arg handling is testable without spawning anything.
|
|
53
|
+
function valueAfter(argv, flag) {
|
|
54
|
+
const i = argv.indexOf(flag);
|
|
55
|
+
if (i < 0) return null;
|
|
56
|
+
const v = argv[i + 1];
|
|
57
|
+
return typeof v === 'string' && v && !v.startsWith('--') ? v : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// formatWidenReport — PURE. The lines to print for a /box/widen body. Reports
|
|
61
|
+
// IGNORED entries loudly: a widen the server accepted into storage but will not
|
|
62
|
+
// apply (because it is outside your rank scope) looks identical to a working one
|
|
63
|
+
// unless it is said out loud, and a silently-inert widen is exactly the class of
|
|
64
|
+
// bug this whole task is about.
|
|
65
|
+
function formatWidenReport(body) {
|
|
66
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
67
|
+
return ['widen: could not read your widen set.'];
|
|
68
|
+
}
|
|
69
|
+
const kept = Array.isArray(body.widen_paths) ? body.widen_paths : [];
|
|
70
|
+
const ignored = Array.isArray(body.ignored) ? body.ignored : [];
|
|
71
|
+
const out = [];
|
|
72
|
+
out.push(kept.length
|
|
73
|
+
? `widen: keeping ${kept.length} path(s) across ticks: ${kept.join(', ')}`
|
|
74
|
+
: 'widen: nothing kept — the fetch set is whatever your claim scope says.');
|
|
75
|
+
if (ignored.length) {
|
|
76
|
+
out.push(`widen: IGNORED (outside your rank scope, stored but not applied): ${ignored.join(', ')}`);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The --widen family. Talks to the control plane, so it works off a box too —
|
|
82
|
+
// unlike the fetch itself, the widen set is server state you can inspect and
|
|
83
|
+
// edit from anywhere.
|
|
84
|
+
async function runWiden(argv) {
|
|
85
|
+
const { apiCall } = require('./cli-lib');
|
|
86
|
+
const add = valueAfter(argv, '--widen');
|
|
87
|
+
const remove = valueAfter(argv, '--unwiden');
|
|
88
|
+
const clear = argv.includes('--widen-clear');
|
|
89
|
+
const listOnly = !add && !remove && !clear;
|
|
90
|
+
|
|
91
|
+
const r = listOnly
|
|
92
|
+
? await apiCall('GET', '/api/gds/box/widen')
|
|
93
|
+
: await apiCall('POST', '/api/gds/box/widen', {
|
|
94
|
+
...(add ? { add: [add] } : {}),
|
|
95
|
+
...(remove ? { remove: [remove] } : {}),
|
|
96
|
+
...(clear ? { clear: true } : {}),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (!r || (r.status !== 200 && r.status !== 201)) {
|
|
100
|
+
const code = r && r.data && r.data.error ? r.data.error : `HTTP ${r ? r.status : '?'}`;
|
|
101
|
+
if (code === 'no_box') {
|
|
102
|
+
console.error('widen: you have no dev box — there is nothing to widen. Provision one first.');
|
|
103
|
+
} else if (code === 'bad_widen_path') {
|
|
104
|
+
const bad = r.data && r.data.rejected ? r.data.rejected.join(', ') : '';
|
|
105
|
+
console.error(`widen: refused ${bad} — give a repo-relative DIRECTORY (no leading /, no "..").`);
|
|
106
|
+
} else {
|
|
107
|
+
console.error(`widen: failed (${code}).`);
|
|
108
|
+
}
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
for (const line of formatWidenReport(r.data)) console.log(line);
|
|
112
|
+
if (!listOnly) console.log('widen: run `box-sync` (no flags) to apply it to the checkout now.');
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
39
116
|
function main(argv = process.argv.slice(2)) {
|
|
117
|
+
// The widen family is server state, not a fetch — handled before the on-a-box
|
|
118
|
+
// check, so it works from a laptop too.
|
|
119
|
+
if (argv.some((a) => a === '--widen' || a === '--unwiden' || a === '--widen-list' || a === '--widen-clear')) {
|
|
120
|
+
runWiden(argv).then((code) => process.exit(code), () => process.exit(1));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
40
123
|
const dryRun = argv.includes('--dry-run') || argv.includes('-n');
|
|
41
124
|
const script = resolveFetchScript();
|
|
42
125
|
if (!script) {
|
|
@@ -130,4 +213,4 @@ async function reportScope() {
|
|
|
130
213
|
|
|
131
214
|
if (require.main === module) main();
|
|
132
215
|
|
|
133
|
-
module.exports = { resolveFetchScript, FETCH_SCRIPT, formatScopeReport };
|
|
216
|
+
module.exports = { resolveFetchScript, FETCH_SCRIPT, formatScopeReport, formatWidenReport, valueAfter };
|
package/scripts/gds/ship-flow.js
CHANGED
|
@@ -659,6 +659,58 @@ async function shipMain() {
|
|
|
659
659
|
});
|
|
660
660
|
}
|
|
661
661
|
|
|
662
|
+
// persistRegradeSummary — store a re-grade's corrected --summary on the task row
|
|
663
|
+
// (task 1003817). A re-grade has no claim, so it cannot reach claim-resolve, the
|
|
664
|
+
// only writer of tasks.value_summary before this; PATCH /tasks/:id is the route
|
|
665
|
+
// it uses instead.
|
|
666
|
+
//
|
|
667
|
+
// WHY PATCH AND NOT publish-branch, which already RECEIVES value_summary and
|
|
668
|
+
// could have persisted it with a one-line change: publish-branch is only called
|
|
669
|
+
// when pushVia() === 'server'. A laptop land goes through ciLandLocal (gh), and
|
|
670
|
+
// --no-merge / --db-only / --allow-empty never land at all — so persisting there
|
|
671
|
+
// would have fixed one path of four and left the defect standing on the rest. It
|
|
672
|
+
// would also have made publish-branch a SECOND writer racing claim-resolve on
|
|
673
|
+
// the normal ship path, which sends the same field. Writing from here keeps one
|
|
674
|
+
// writer per ship: the claim on a first ship, this on a re-grade.
|
|
675
|
+
//
|
|
676
|
+
// Three deliberate postures:
|
|
677
|
+
// - OMITTED --summary writes NOTHING. The stored value is the fallback the
|
|
678
|
+
// grader was already handed, so a re-grade that omits the flag must leave
|
|
679
|
+
// the row exactly as it was — the COALESCE/NULLIF posture every other
|
|
680
|
+
// value_summary write in db-ship.js keeps. This is acceptance 2.
|
|
681
|
+
// - UNCHANGED text writes nothing either, so a no-op edit mints no audit_log row.
|
|
682
|
+
// - A FAILURE is LOUD BUT NON-BLOCKING. The grade is the expensive step and it
|
|
683
|
+
// has not run yet; discarding a re-grade over a summary that did not persist
|
|
684
|
+
// would trade the cheap problem for the costly one. The builder is told
|
|
685
|
+
// plainly that the row still holds the old sentence, so nothing is silent.
|
|
686
|
+
// An older pinned core that has not learned the field is named as exactly
|
|
687
|
+
// that rather than reported as a generic error — the same forward-compat
|
|
688
|
+
// posture the resolve call in shipMain documents at length (task 1003767),
|
|
689
|
+
// because @cloudbongos/cli ships separately from the core and an instance
|
|
690
|
+
// pins its own core version.
|
|
691
|
+
async function persistRegradeSummary({ taskId, summaryArg, task }) {
|
|
692
|
+
const next = (summaryArg || '').trim();
|
|
693
|
+
if (!next) return { written: false, reason: 'omitted' };
|
|
694
|
+
if (next === (task.value_summary || '').trim()) return { written: false, reason: 'unchanged' };
|
|
695
|
+
let r;
|
|
696
|
+
try {
|
|
697
|
+
r = await (await cliClient()).tasks.patchTasksId({ id: taskId, body: { value_summary: next } });
|
|
698
|
+
} catch (err) {
|
|
699
|
+
console.error(` ⚠ value_summary NOT stored (${err && err.message ? err.message : 'request failed'}) — task #${taskId} keeps its previous summary. The grade below is unaffected.`);
|
|
700
|
+
return { written: false, reason: 'error' };
|
|
701
|
+
}
|
|
702
|
+
if (!r.ok) {
|
|
703
|
+
if (isUnknownFieldRejection(r.status, r.data, 'value_summary')) {
|
|
704
|
+
console.error(` ⚠ value_summary NOT stored: this instance's core predates PATCH /tasks/:id accepting the field. Upgrade the core, or edit the summary in the hall. The grade below is unaffected.`);
|
|
705
|
+
} else {
|
|
706
|
+
console.error(` ⚠ value_summary NOT stored (${apiErrorLine(r.data, r.status)}) — task #${taskId} keeps its previous summary. The grade below is unaffected.`);
|
|
707
|
+
}
|
|
708
|
+
return { written: false, reason: 'refused' };
|
|
709
|
+
}
|
|
710
|
+
console.log(` value_summary updated on task #${taskId} — the hall, /status and the ship broadcast now read the corrected sentence.`);
|
|
711
|
+
return { written: true };
|
|
712
|
+
}
|
|
713
|
+
|
|
662
714
|
// ---------- Re-grade flow (#678) ----------
|
|
663
715
|
//
|
|
664
716
|
// `node scripts/gds/ship.js <id> --regrade` closes the task-595-regrade-gap
|
|
@@ -676,6 +728,7 @@ async function shipMain() {
|
|
|
676
728
|
// - grade: gradeAndRecord → POST /tasks/:id/grade (rank-open; applyGrade
|
|
677
729
|
// promotes completed→confirmed + awards credits on pass, idempotent)
|
|
678
730
|
// - on pass: the normal confirmed→shipped merge + sync tail runs.
|
|
731
|
+
|
|
679
732
|
async function regradeMain() {
|
|
680
733
|
await requireSession();
|
|
681
734
|
const args = process.argv.slice(2);
|
|
@@ -764,16 +817,14 @@ async function regradeMain() {
|
|
|
764
817
|
// task's stored value_summary so the merge commit + Discord #ship-news post
|
|
765
818
|
// carry meaningful text even when --summary is omitted.
|
|
766
819
|
//
|
|
767
|
-
// AND IT
|
|
768
|
-
//
|
|
769
|
-
//
|
|
770
|
-
//
|
|
771
|
-
//
|
|
772
|
-
//
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
// a server route change, so it is deliberately NOT folded into this CLI fix.
|
|
776
|
-
// tests/ship_cannot_lie.mjs pins both halves of that so the answer cannot rot.
|
|
820
|
+
// AND IT NOW REACHES THE TASK ROW TOO — task 1003817. It did not before: this
|
|
821
|
+
// `summary` fed the grader prompt, the merge commit message and the PR
|
|
822
|
+
// title/body and stopped there, so a builder who re-graded with a CORRECTED
|
|
823
|
+
// --summary saw it applied everywhere except the place that is the record —
|
|
824
|
+
// the hall, /status and the Discord #ship-news line all kept the first ship's
|
|
825
|
+
// sentence forever. persistRegradeSummary (below the preflight) is the write;
|
|
826
|
+
// PATCH /tasks/:id is the route it uses, and `value_summary` was added to that
|
|
827
|
+
// schema by this same task. tests/ship_cannot_lie.mjs pins the wiring.
|
|
777
828
|
const summary = summaryArg || task.value_summary || '';
|
|
778
829
|
|
|
779
830
|
// The grader helpers want a claim-like object for touches[] (preflight's
|
|
@@ -804,6 +855,14 @@ async function regradeMain() {
|
|
|
804
855
|
process.exit(1);
|
|
805
856
|
}
|
|
806
857
|
|
|
858
|
+
// ---- Persist a corrected --summary to the task row (task 1003817) ----
|
|
859
|
+
// Placed HERE deliberately: after the guards that can still abort the re-grade
|
|
860
|
+
// (so a refused run writes nothing), and BEFORE the grade — which is the order
|
|
861
|
+
// the normal ship already has, where claim-resolve stores value_summary and
|
|
862
|
+
// the panel runs afterwards. The row, the grader prompt, the merge commit and
|
|
863
|
+
// the PR then all carry the same sentence.
|
|
864
|
+
await persistRegradeSummary({ taskId, summaryArg, task });
|
|
865
|
+
|
|
807
866
|
// ---- Delta-aware prior-round context (task 1002662) ----
|
|
808
867
|
// The prior grade's findings + the builder's response + the interim fix diff
|
|
809
868
|
// ride to the panel so an already-answered finding can't re-veto on
|
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.648'; // 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');
|
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);
|
|
@@ -99,16 +99,25 @@ await test('the limit is OPT-IN — the Work Board and /builder-start are untouc
|
|
|
99
99
|
await test('every db.* the claimable handler calls actually exists', () => {
|
|
100
100
|
// The bug this catches, caught the hard way: an earlier cut pushed the limit into the
|
|
101
101
|
// db layer, was reverted, and left the route calling `db.listClaimableTasksPaged` — a
|
|
102
|
-
// function that no longer existed. Every assertion in this file is source text,
|
|
103
|
-
// source text cannot see a missing symbol; a neighbouring suite found it instead.
|
|
102
|
+
// function that no longer existed. Every OTHER assertion in this file is source text,
|
|
103
|
+
// and source text cannot see a missing symbol; a neighbouring suite found it instead.
|
|
104
|
+
//
|
|
105
|
+
// So this one is not source text: it REQUIRES the facade and reads the real key. It
|
|
106
|
+
// used to grep db.js's module.exports block for each name, which was a proxy for
|
|
107
|
+
// exactly this and stopped being a valid one at task 1003817 — db.js now SPREADS
|
|
108
|
+
// db-tasks.js's exports instead of re-listing them, so `listClaimableTasks` is a live
|
|
109
|
+
// key that appears nowhere in the block's text. Requiring is safe here: the lifecycle
|
|
110
|
+
// db family is pool-lazy (tests/lifecycle_facade_surface.mjs relies on the same thing).
|
|
104
111
|
const from = ROUTE.indexOf("router.get('/tasks/claimable'");
|
|
105
112
|
const body = ROUTE.slice(from, ROUTE.indexOf('res.json({ claimable', from));
|
|
106
113
|
const called = [...new Set([...body.matchAll(/\bdb\.([A-Za-z0-9_]+)\s*\(/g)].map((m) => m[1]))];
|
|
107
114
|
assert.ok(called.length > 0, 'the handler calls the db layer');
|
|
108
|
-
const
|
|
109
|
-
const block = dbExports.slice(dbExports.lastIndexOf('module.exports'));
|
|
115
|
+
const db = require(path.join(ROOT, 'modules', 'lifecycle', 'db.js'));
|
|
110
116
|
for (const fn of called) {
|
|
111
|
-
assert.ok(
|
|
117
|
+
assert.ok(
|
|
118
|
+
typeof db[fn] === 'function',
|
|
119
|
+
`db.js must export ${fn} — the route calls it, and this is the runtime check, not a text match`
|
|
120
|
+
);
|
|
112
121
|
}
|
|
113
122
|
});
|
|
114
123
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// tests/lifecycle_facade_surface.mjs
|
|
2
|
+
//
|
|
3
|
+
// The lifecycle facade's TASK surface, pinned (task 1003817).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS FILE EXISTS. modules/lifecycle/db.js used to re-export db-tasks.js by
|
|
6
|
+
// hand: 31 names destructured at the top purely so module.exports could list
|
|
7
|
+
// them again, with no internal caller for a single one. Two hand-maintained
|
|
8
|
+
// copies of one list is real duplication, and the `duplicate_window_count`
|
|
9
|
+
// ratchet counted it — the two runs were 29 identical normalized lines, one
|
|
10
|
+
// under the 30-line window, so task 1003750 adding `updateTaskPriority` to both
|
|
11
|
+
// tipped it over and cost an owner-decided baseline raise (59 -> 60). The reason
|
|
12
|
+
// recorded in config/fitness-baselines.json told whoever added the NEXT
|
|
13
|
+
// db-tasks function to fix the facade instead of raising it again. Task 1003817
|
|
14
|
+
// added `updateTaskValueSummary`, so this is that fix: db.js now spreads
|
|
15
|
+
// db-tasks.js's exports minus an explicit omit list.
|
|
16
|
+
//
|
|
17
|
+
// A SPREAD IS CHEAPER TO MAINTAIN AND EASIER TO GET WRONG. Nothing stops it from
|
|
18
|
+
// silently WIDENING the module's public surface (publishing an internal that was
|
|
19
|
+
// deliberately withheld) or NARROWING it (a rename in db-tasks.js quietly
|
|
20
|
+
// dropping a name every route calls, which is a runtime `undefined is not a
|
|
21
|
+
// function` in an async Express handler — ADR 0091 §2 records exactly that
|
|
22
|
+
// outage for `LIVE_RANK_LADDER`, where it hung the request until Cloudflare
|
|
23
|
+
// 524'd). The old twin list at least failed loudly at require time. So the
|
|
24
|
+
// safety the list used to provide is re-established here, as an assertion rather
|
|
25
|
+
// than as boilerplate: db.js's task surface must be db-tasks.js's exports minus
|
|
26
|
+
// TASK_FACADE_OMIT, exactly — no more, no fewer.
|
|
27
|
+
//
|
|
28
|
+
// No live DB: requiring the lifecycle db family is pool-lazy.
|
|
29
|
+
//
|
|
30
|
+
// Run: node tests/lifecycle_facade_surface.mjs
|
|
31
|
+
|
|
32
|
+
import { strict as assert } from 'node:assert';
|
|
33
|
+
import { readFileSync } from 'node:fs';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
import { dirname, join } from 'node:path';
|
|
36
|
+
import { createRequire } from 'node:module';
|
|
37
|
+
|
|
38
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
const repoRoot = join(__dirname, '..');
|
|
40
|
+
const require = createRequire(import.meta.url);
|
|
41
|
+
|
|
42
|
+
let passed = 0;
|
|
43
|
+
let failed = 0;
|
|
44
|
+
async function test(name, fn) {
|
|
45
|
+
try {
|
|
46
|
+
await fn();
|
|
47
|
+
console.log(` ok ${name}`);
|
|
48
|
+
passed++;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.error(` FAIL ${name}`);
|
|
51
|
+
console.error(' ', err.message);
|
|
52
|
+
failed++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const db = require(join(repoRoot, 'modules', 'lifecycle', 'db.js'));
|
|
57
|
+
const dbTasks = require(join(repoRoot, 'modules', 'lifecycle', 'db-tasks.js'));
|
|
58
|
+
const dbSrc = readFileSync(join(repoRoot, 'modules', 'lifecycle', 'db.js'), 'utf8');
|
|
59
|
+
|
|
60
|
+
// The omit list is read from the SOURCE, not re-typed here — a test that hardcodes
|
|
61
|
+
// the same two names cannot notice a third being added without a reason.
|
|
62
|
+
function omitListFromSource(src) {
|
|
63
|
+
const block = src.match(/const TASK_FACADE_OMIT = new Set\(\[([\s\S]*?)\]\)/);
|
|
64
|
+
assert.ok(block, 'db.js must declare TASK_FACADE_OMIT so the withheld names are a named decision');
|
|
65
|
+
return block[1]
|
|
66
|
+
.split('\n')
|
|
67
|
+
.map((l) => l.replace(/\/\/.*/, '').trim())
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.map((l) => l.replace(/^'|',?$/g, ''));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await test('db.js re-exports every db-tasks export except the named omissions', () => {
|
|
73
|
+
const omit = new Set(omitListFromSource(dbSrc));
|
|
74
|
+
const expected = Object.keys(dbTasks).filter((k) => !omit.has(k)).sort();
|
|
75
|
+
const actual = expected.filter((k) => Object.prototype.hasOwnProperty.call(db, k));
|
|
76
|
+
const missing = expected.filter((k) => !Object.prototype.hasOwnProperty.call(db, k));
|
|
77
|
+
assert.deepEqual(missing, [], `db.js dropped task surface: ${missing.join(', ')} — a route calling one of these gets undefined at runtime, not an error at require`);
|
|
78
|
+
assert.equal(actual.length, expected.length);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await test('and publishes NONE of the omitted internals', () => {
|
|
82
|
+
const omit = omitListFromSource(dbSrc);
|
|
83
|
+
assert.ok(omit.length > 0, 'the omit list is not empty — a bare spread would widen the surface');
|
|
84
|
+
for (const name of omit) {
|
|
85
|
+
assert.ok(
|
|
86
|
+
Object.prototype.hasOwnProperty.call(dbTasks, name),
|
|
87
|
+
`${name} is omitted from a surface it is not on — stale entry, remove it`
|
|
88
|
+
);
|
|
89
|
+
assert.ok(
|
|
90
|
+
!Object.prototype.hasOwnProperty.call(db, name),
|
|
91
|
+
`${name} is on the omit list but reachable through db.js — the spread widened the facade`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await test('the spread is what wires it, and the twin name list is gone', () => {
|
|
97
|
+
assert.match(dbSrc, /\.\.\.dbTasksFacade,/, 'module.exports spreads the filtered db-tasks surface');
|
|
98
|
+
assert.match(
|
|
99
|
+
dbSrc,
|
|
100
|
+
/const dbTasksFacade = Object\.fromEntries\(\s*Object\.entries\(dbTasks\)\.filter\(\(\[name\]\) => !TASK_FACADE_OMIT\.has\(name\)\)/,
|
|
101
|
+
'the filter is the omit list, not an inline literal'
|
|
102
|
+
);
|
|
103
|
+
// The regression this replaces: a `const { … } = require('./db-tasks.js')`
|
|
104
|
+
// destructure whose only purpose was to feed module.exports.
|
|
105
|
+
assert.ok(
|
|
106
|
+
!/const \{[\s\S]*?\} = require\('\.\/db-tasks\.js'\)/.test(dbSrc),
|
|
107
|
+
'db-tasks.js must not be hand-destructured again — that is the duplication this task removed'
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
console.log(`\nlifecycle_facade_surface: ${passed} passed, ${failed} failed`);
|
|
112
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -205,6 +205,7 @@ function shouldReserveMigrationNumber(task) {
|
|
|
205
205
|
[() => db.updateTaskDescription(1, 'edited body'), 'description'], // task 1002647
|
|
206
206
|
[() => db.updateTaskTitle(1, 'corrected title'), 'title'], // task 1002792
|
|
207
207
|
[() => db.updateTaskPriority(1, 2), 'priority'], // task 1003750
|
|
208
|
+
[() => db.updateTaskValueSummary(1, 'corrected summary'), 'value_summary'], // task 1003817
|
|
208
209
|
];
|
|
209
210
|
for (const [call, column] of setters) {
|
|
210
211
|
const before = seen.length;
|
|
@@ -255,6 +256,33 @@ function shouldReserveMigrationNumber(task) {
|
|
|
255
256
|
assert.ok(guardAt > 0 && firstWriteAt > 0 && guardAt < firstWriteAt, 'the priority bounds check must run before the first write, not after');
|
|
256
257
|
});
|
|
257
258
|
|
|
259
|
+
// task 1003817: value_summary was the FOURTH write-once field, and the one
|
|
260
|
+
// that is the public record — the hall, /status and the Discord #ship-news
|
|
261
|
+
// line all read it. It had exactly one writer, the claim-resolve route, so
|
|
262
|
+
// `ship.js --regrade --summary` (no claim, by definition) could correct it in
|
|
263
|
+
// the PR and the merge commit and leave the record holding the first ship's
|
|
264
|
+
// sentence forever.
|
|
265
|
+
await test('PATCH /tasks/:id accepts value_summary so a re-graded summary reaches the record (task 1003817)', () => {
|
|
266
|
+
assert.ok(
|
|
267
|
+
/value_summary: \{ type: 'string', minLength: 1, maxLength: LIMITS\.VALUE_SUMMARY \}/.test(routesSrc),
|
|
268
|
+
'the PATCH schema must declare value_summary with the shared limit — rewritable, never blankable'
|
|
269
|
+
);
|
|
270
|
+
assert.ok(/const hasValueSummary = Object\.prototype\.hasOwnProperty\.call\(body, 'value_summary'\)/.test(routesSrc), 'value_summary is gated like every other field');
|
|
271
|
+
assert.ok(/if \(!hasParent &&[^)]*!hasValueSummary\b/.test(routesSrc), 'a value_summary-only PATCH is a supported request');
|
|
272
|
+
assert.ok(/db\.updateTaskValueSummary\(id, body\.value_summary\.trim\(\)\)/.test(routesSrc), 'the write goes through the allowlisted setter');
|
|
273
|
+
assert.ok(
|
|
274
|
+
/async function updateTaskValueSummary/.test(dbSrc) && /updateTaskValueSummary,/.test(dbSrc),
|
|
275
|
+
'the lifecycle db family must define it and re-export it (the ADR 0093 facade contract)'
|
|
276
|
+
);
|
|
277
|
+
// minLength 1 refuses "" but not " ", and the write trims — so the
|
|
278
|
+
// whitespace guard is what actually keeps the record un-blankable. It must
|
|
279
|
+
// sit above the first write, like the priority bounds check above.
|
|
280
|
+
const blankGuardAt = routesSrc.indexOf("res.fail('bad_value_summary'");
|
|
281
|
+
const firstWriteAt2 = routesSrc.indexOf('await db.updateTaskKind(');
|
|
282
|
+
assert.ok(blankGuardAt > 0, 'a whitespace-only value_summary is refused, not trimmed into a blank record');
|
|
283
|
+
assert.ok(blankGuardAt < firstWriteAt2, 'and that refusal runs before the first write');
|
|
284
|
+
});
|
|
285
|
+
|
|
258
286
|
await test('PATCH /tasks/:id accepts needs_migration so a wrong flag is correctable', () => {
|
|
259
287
|
assert.ok(
|
|
260
288
|
/needs_migration: \{ type: 'boolean' \}/.test(routesSrc),
|
|
@@ -49,6 +49,10 @@ const REFUSALS = [
|
|
|
49
49
|
// task 1003750: priority joined the PATCH surface; its bounds check is a
|
|
50
50
|
// validation refusal like the rest, so it must settle before the first write.
|
|
51
51
|
"'bad_priority'",
|
|
52
|
+
// task 1003817: value_summary joined the PATCH surface. Its refusal is the
|
|
53
|
+
// blank guard — minLength 1 stops "" but not " ", and the write trims — so
|
|
54
|
+
// it must settle before the first write like every other validation.
|
|
55
|
+
"'bad_value_summary'",
|
|
52
56
|
'badKind(res)',
|
|
53
57
|
"'bad_discipline'",
|
|
54
58
|
];
|