@bongos/core 1.19.509 → 1.19.511
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 +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +16 -2
- package/docs/adr/0237-a-starter-bundle-is-a-preset-over-the-always-on-core-keyed-by-declared-type.md +137 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +42 -3
- package/docs/api-reference.md +3 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/copy-desk/proposals.js +13 -0
- package/modules/copy-desk/routes/copy-desk.js +9 -2
- package/modules/copy-desk/tests/copy_proposals.mjs +41 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/lifecycle/conflict-resolve.js +59 -3
- package/modules/provisioning/routes/provisioning.js +19 -0
- package/modules/provisioning/starter-bundles.js +159 -0
- package/modules/public-landing/public/assets/cosmos.css +3 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/copy-apply.js +54 -4
- package/scripts/gds/git-merge-regen.js +18 -0
- package/src/module-api.js +1 -1
- package/tests/conflict_resolve.mjs +118 -4
- package/tests/git_merge_regen.mjs +128 -2
- package/tests/projects_hub.mjs +13 -0
- package/tests/provisioning_starter_bundles.mjs +268 -0
|
@@ -52,9 +52,21 @@ function mkExec(scn = {}) {
|
|
|
52
52
|
if (cmd === 'checkout') return ret(scn.checkout ?? 0);
|
|
53
53
|
if (cmd === 'rev-parse') {
|
|
54
54
|
revCount += 1;
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
// 1st = the pre-merge tip, 2nd = the merge commit, 3rd = the post-merge
|
|
56
|
+
// regeneration commit (task 1003546 step 7b), when there was drift to commit.
|
|
57
|
+
if (revCount === 1) return { code: 0, stdout: `${scn.before ?? 'aaaaaaa'}\n`, stderr: '' };
|
|
58
|
+
if (revCount === 2) return { code: 0, stdout: `${scn.after ?? 'bbbbbbb'}\n`, stderr: '' };
|
|
59
|
+
return { code: 0, stdout: `${scn.regenSha ?? scn.after ?? 'bbbbbbb'}\n`, stderr: '' };
|
|
57
60
|
}
|
|
61
|
+
if (cmd === 'status') {
|
|
62
|
+
// --porcelain after step 7b's regeneration: a non-empty body = the merged tree
|
|
63
|
+
// disagreed with the generated artifacts the merge produced. Default OFF so the
|
|
64
|
+
// pre-1003546 cases keep asserting the plain merge-and-push path.
|
|
65
|
+
const drift = scn.drift ?? false;
|
|
66
|
+
return { code: 0, stdout: drift ? ' M docs/copy-registry.json\n' : '', stderr: '' };
|
|
67
|
+
}
|
|
68
|
+
if (cmd === 'add') return ret(0);
|
|
69
|
+
if (cmd === 'commit') return ret(scn.commit ?? 0);
|
|
58
70
|
if (cmd === 'merge' && a[1] === '--abort') return ret(0);
|
|
59
71
|
if (cmd === 'merge') {
|
|
60
72
|
if (scn.merge === 'conflict') { mergeOutcome = 'conflict'; return { code: 1, stdout: 'CONFLICT (content)', stderr: '' }; }
|
|
@@ -79,8 +91,13 @@ function mkExec(scn = {}) {
|
|
|
79
91
|
function mkDeps(scn = {}, over = {}) {
|
|
80
92
|
const reg = [];
|
|
81
93
|
const exec = mkExec(scn);
|
|
94
|
+
const genCalls = [];
|
|
82
95
|
return {
|
|
83
96
|
exec,
|
|
97
|
+
// task 1003546: resolveBranchConflicts regenerates on the MERGED tree before it
|
|
98
|
+
// pushes, so it takes the same runGenerators seam refreshStaleGenerated does.
|
|
99
|
+
// Stubbed for every case — the real one would spawn node against a fake clone.
|
|
100
|
+
runGenerators: over.runGenerators || (async (opts) => { genCalls.push(opts); return { code: scn.genCode ?? 0 }; }),
|
|
84
101
|
githubPush: {
|
|
85
102
|
isConfigured: () => (over.configured === undefined ? true : over.configured),
|
|
86
103
|
isSafeBranch: (b) => (over.safeBranch === undefined ? (typeof b === 'string' && b.length > 0 && !b.startsWith('-')) : over.safeBranch),
|
|
@@ -94,6 +111,7 @@ function mkDeps(scn = {}, over = {}) {
|
|
|
94
111
|
rmtmp: () => {},
|
|
95
112
|
_exec: exec,
|
|
96
113
|
_reg: reg,
|
|
114
|
+
_gen: genCalls,
|
|
97
115
|
};
|
|
98
116
|
}
|
|
99
117
|
|
|
@@ -377,7 +395,11 @@ test('1915 defaultRunGenerators strips *_INSTANCE_ROOT from the child env and pi
|
|
|
377
395
|
const leaked = Object.keys(call.opts.env || {}).filter((k) => /_INSTANCE_ROOT$/.test(k));
|
|
378
396
|
assert.deepEqual(leaked, [], 'no *_INSTANCE_ROOT key survives into the generator child env');
|
|
379
397
|
assert.ok(call.opts.env.PATH, 'benign env (PATH) is preserved so node still resolves');
|
|
380
|
-
|
|
398
|
+
// Separator-normalised: defaultRunGenerators builds this with path.join, which
|
|
399
|
+
// emits backslashes on win32 — the bare startsWith made this assertion fail on
|
|
400
|
+
// every Windows builder for a reason unrelated to what it tests (task 1003546).
|
|
401
|
+
assert.ok(String(call.argv[0]).replace(/\\/g, '/').startsWith('/tmp/clone-xyz'),
|
|
402
|
+
'the generator script path is under the clone');
|
|
381
403
|
}
|
|
382
404
|
} finally {
|
|
383
405
|
if (prev.g === undefined) delete process.env.GDS_INSTANCE_ROOT; else process.env.GDS_INSTANCE_ROOT = prev.g;
|
|
@@ -455,8 +477,100 @@ test('1915 defaultRunGenerators reports the worst child exit code (0 all-clean;
|
|
|
455
477
|
assert.equal(badGen.code, 2, 'a single failing generator surfaces its non-zero code');
|
|
456
478
|
});
|
|
457
479
|
|
|
480
|
+
// ── task 1003546: the merged tree is regenerated BEFORE the push ────────────────
|
|
481
|
+
// The `otb-regen` driver runs DURING the merge and rebuilds by scanning the working
|
|
482
|
+
// tree, which at that moment is still the PRE-merge tree. So a merge can come out
|
|
483
|
+
// clean — exit 0, no unmerged paths, no markers — carrying a generated artifact that
|
|
484
|
+
// describes neither side nor the merged tree, and nothing downstream can see it.
|
|
485
|
+
// resolveBranchConflicts must therefore regenerate on the FINAL tree and commit the
|
|
486
|
+
// drift before pushing (PR #536 / task 1003544: the registry kept the branch's line
|
|
487
|
+
// numbers and `unit` stayed red through the heal).
|
|
488
|
+
|
|
489
|
+
test('1003546 clean merge → generators run on the merged tree, drift committed, head_sha is the regen commit', async () => {
|
|
490
|
+
const deps = mkDeps({ merge: 'clean', before: 'oldsha1', after: 'mergesha', drift: true, regenSha: 'regensha' });
|
|
491
|
+
const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-cr1' }, deps);
|
|
492
|
+
assert.equal(r.resolved, true);
|
|
493
|
+
assert.equal(deps._gen.length, 1, 'generators ran exactly once against the merged tree');
|
|
494
|
+
assert.ok(ran(deps._exec, 'commit'), 'post-merge generator drift is committed');
|
|
495
|
+
assert.equal(r.regenerated, true, 'the result reports that a regeneration was needed');
|
|
496
|
+
assert.equal(r.head_sha, 'regensha', 'head_sha is the regen commit, not the bare merge commit');
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('1003546 the regeneration happens AFTER the merge and its commit BEFORE the push (ordering is the whole point)', async () => {
|
|
500
|
+
const order = [];
|
|
501
|
+
const deps = mkDeps({ merge: 'clean', drift: true });
|
|
502
|
+
const baseExec = deps.exec;
|
|
503
|
+
deps.exec = async (args) => {
|
|
504
|
+
const a = args[0] === '-C' ? args.slice(2) : args;
|
|
505
|
+
if (['merge', 'commit', 'push'].includes(a[0])) order.push(a[0]);
|
|
506
|
+
return baseExec(args);
|
|
507
|
+
};
|
|
508
|
+
deps.runGenerators = async () => { order.push('__generators__'); return { code: 0 }; };
|
|
509
|
+
const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-cr2' }, deps);
|
|
510
|
+
assert.equal(r.resolved, true);
|
|
511
|
+
assert.deepEqual(order, ['merge', '__generators__', 'commit', 'push'],
|
|
512
|
+
'a pre-merge regeneration (or a push before the regen commit) is exactly the bug');
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test('1003546 no post-merge drift → the merge alone is pushed, nothing extra committed', async () => {
|
|
516
|
+
const deps = mkDeps({ merge: 'clean', after: 'mergesha', drift: false });
|
|
517
|
+
const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-cr3' }, deps);
|
|
518
|
+
assert.equal(r.resolved, true);
|
|
519
|
+
assert.equal(r.regenerated, false);
|
|
520
|
+
assert.equal(r.head_sha, 'mergesha', 'the merge commit is the head when regen is a no-op');
|
|
521
|
+
assert.ok(!ran(deps._exec, 'commit'), 'nothing is committed when the merge was already correct');
|
|
522
|
+
assert.ok(ran(deps._exec, 'push'), 'the resolved branch still lands');
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test('1003546 a failing generator never pushes an uncertifiable merged tree', async () => {
|
|
526
|
+
const deps = mkDeps({ merge: 'clean', drift: true, genCode: 2 });
|
|
527
|
+
const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-cr4' }, deps);
|
|
528
|
+
assert.equal(r.resolved, false);
|
|
529
|
+
assert.equal(r.skipped, 'generator_failed');
|
|
530
|
+
assert.ok(!ran(deps._exec, 'push'), 'a tree we cannot certify is left for a human, never pushed');
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test('1003546 a failing post-merge commit is reported, not pushed', async () => {
|
|
534
|
+
const deps = mkDeps({ merge: 'clean', drift: true, commit: 1 });
|
|
535
|
+
const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-cr5' }, deps);
|
|
536
|
+
assert.equal(r.resolved, false);
|
|
537
|
+
assert.equal(r.skipped, 'commit_failed');
|
|
538
|
+
assert.ok(!ran(deps._exec, 'push'), 'no push without the regeneration commit');
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// THE INVARIANT THAT WOULD HAVE CAUGHT #536 ON DAY ONE. Two hand-maintained lists
|
|
542
|
+
// name the same artifacts: REGENERABLE (what the merge driver claims to heal) and
|
|
543
|
+
// GENERATOR_SCRIPTS (what the server actually re-runs). copy-inventory.js was added
|
|
544
|
+
// to the first and not the second, so the copy registry was "covered" by the driver
|
|
545
|
+
// and regenerated by nobody. Pin them as one set, via each artifact's PRODUCER.
|
|
546
|
+
test('1003546 every REGENERABLE artifact has a producer, and every producer is in GENERATOR_SCRIPTS', async () => {
|
|
547
|
+
const regen = require('../scripts/gds/git-merge-regen.js');
|
|
548
|
+
const G = require('../scripts/gds/gen-repo-map.js');
|
|
549
|
+
const CI = require('../scripts/gds/copy-inventory.js');
|
|
550
|
+
|
|
551
|
+
// Derived from each generator's own exports where it publishes them, so this map
|
|
552
|
+
// cannot silently drift from the generator it names.
|
|
553
|
+
const PRODUCED_BY = new Map([
|
|
554
|
+
['docs/repo-map.md', 'scripts/gds/gen-repo-map.js'],
|
|
555
|
+
['docs/file-map.md', 'scripts/gds/gen-file-map.js'],
|
|
556
|
+
['docs/session-log-index.md', 'scripts/gds/gen-session-index.js'],
|
|
557
|
+
[CI.REGISTRY_REL, 'scripts/gds/copy-inventory.js'],
|
|
558
|
+
[CI.REPORT_REL, 'scripts/gds/copy-inventory.js'],
|
|
559
|
+
...G.NESTED_DIRS.map((d) => [`${d}/CLAUDE.md`, 'scripts/gds/gen-repo-map.js']),
|
|
560
|
+
]);
|
|
561
|
+
|
|
562
|
+
for (const p of regen.REGENERABLE) {
|
|
563
|
+
const producer = PRODUCED_BY.get(p);
|
|
564
|
+
assert.ok(producer, `REGENERABLE knows ${p} but this test has no producer for it — name the generator that writes it`);
|
|
565
|
+
assert.ok(
|
|
566
|
+
cr.GENERATOR_SCRIPTS.includes(producer),
|
|
567
|
+
`${p} is healed by the merge driver but ${producer} is not in GENERATOR_SCRIPTS, so the server never re-runs it on the merged tree (the task 1003544 strand)`
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
458
572
|
// Drain the microtask queue, then report + exit with the right code.
|
|
459
573
|
setTimeout(() => {
|
|
460
|
-
console.log(`\nconflict-resolve (task 1385 + 1915) tests: ${passed} passed, ${failed} failed`);
|
|
574
|
+
console.log(`\nconflict-resolve (task 1385 + 1915 + 1003546) tests: ${passed} passed, ${failed} failed`);
|
|
461
575
|
process.exit(failed > 0 ? 1 : 0);
|
|
462
576
|
}, 100);
|
|
@@ -22,6 +22,12 @@
|
|
|
22
22
|
// 9. END-TO-END for docs/copy-registry.json: two branches that each regenerate
|
|
23
23
|
// it differently merge without conflict markers via the real .gitattributes +
|
|
24
24
|
// git-config wiring, and the result equals a fresh regeneration.
|
|
25
|
+
// 10. REGRESSION (task 1003546) — the driver runs MID-merge, so a tree-scanning
|
|
26
|
+
// artifact heals to the PRE-merge tree: two branches shifting line numbers in
|
|
27
|
+
// the same scanned UI file merge CLEAN and leave the registry STALE. Pins why
|
|
28
|
+
// the merge's OWNER must regenerate afterwards (conflict-resolve.js step 7b).
|
|
29
|
+
// Needs a real clone (the driver's __dirname must BE the merging tree), so it
|
|
30
|
+
// skips where the environment cannot host that.
|
|
25
31
|
//
|
|
26
32
|
// Run: node tests/git_merge_regen.mjs
|
|
27
33
|
|
|
@@ -135,7 +141,15 @@ function withBlockBody(src, body) {
|
|
|
135
141
|
try {
|
|
136
142
|
g('init', '-q');
|
|
137
143
|
g('config', 'user.email', 't@t'); g('config', 'user.name', 't');
|
|
138
|
-
|
|
144
|
+
// The fixture repo carries none of the repo's eol rules, so on a Windows box
|
|
145
|
+
// with the usual global core.autocrlf=true git would check the merge result out
|
|
146
|
+
// as CRLF while the generators render LF — a byte comparison below would fail on
|
|
147
|
+
// line endings alone. Pin it so the fixture is platform-neutral (task 1003546).
|
|
148
|
+
g('config', 'core.autocrlf', 'false');
|
|
149
|
+
// Forward slashes: `git config` strips backslashes from the value, so a win32
|
|
150
|
+
// path.join() driver path reaches node with its separators gone and the driver
|
|
151
|
+
// never loads — this fixture was silently dead on Windows (task 1003546).
|
|
152
|
+
g('config', 'merge.otb-regen.driver', `node ${driver.replace(/\\/g, '/')} %O %A %B %P`);
|
|
139
153
|
fs.writeFileSync(path.join(tmp, '.gitattributes'), 'docs/repo-map.md merge=otb-regen\n');
|
|
140
154
|
fs.mkdirSync(path.join(tmp, 'docs'));
|
|
141
155
|
writeMap('base\n'); g('add', '-A'); g('commit', '-qm', 'base');
|
|
@@ -213,7 +227,15 @@ function withBlockBody(src, body) {
|
|
|
213
227
|
try {
|
|
214
228
|
g('init', '-q');
|
|
215
229
|
g('config', 'user.email', 't@t'); g('config', 'user.name', 't');
|
|
216
|
-
|
|
230
|
+
// The fixture repo carries none of the repo's eol rules, so on a Windows box
|
|
231
|
+
// with the usual global core.autocrlf=true git would check the merge result out
|
|
232
|
+
// as CRLF while the generators render LF — a byte comparison below would fail on
|
|
233
|
+
// line endings alone. Pin it so the fixture is platform-neutral (task 1003546).
|
|
234
|
+
g('config', 'core.autocrlf', 'false');
|
|
235
|
+
// Forward slashes: `git config` strips backslashes from the value, so a win32
|
|
236
|
+
// path.join() driver path reaches node with its separators gone and the driver
|
|
237
|
+
// never loads — this fixture was silently dead on Windows (task 1003546).
|
|
238
|
+
g('config', 'merge.otb-regen.driver', `node ${driver.replace(/\\/g, '/')} %O %A %B %P`);
|
|
217
239
|
fs.writeFileSync(path.join(tmp, '.gitattributes'), 'docs/copy-registry.json merge=otb-regen\n');
|
|
218
240
|
fs.mkdirSync(path.join(tmp, 'docs'));
|
|
219
241
|
writeReg('{"schema":0}\n'); g('add', '-A'); g('commit', '-qm', 'base');
|
|
@@ -235,4 +257,108 @@ function withBlockBody(src, body) {
|
|
|
235
257
|
}
|
|
236
258
|
}
|
|
237
259
|
|
|
260
|
+
// ── 10) REGRESSION (task 1003546): the driver runs MID-merge, so a tree-scanning ──
|
|
261
|
+
// artifact heals to the PRE-merge tree and the merge lands clean but STALE.
|
|
262
|
+
//
|
|
263
|
+
// Cases 8 + 9 prove the copy-registry rule regenerates and that a real merge comes
|
|
264
|
+
// out marker-free — but their fixture repos contain no scanned SOURCE, so the scan
|
|
265
|
+
// returns the same bytes whichever tree it reads and they are blind to WHEN it runs.
|
|
266
|
+
// This case supplies the missing half: two branches that each shift line numbers in
|
|
267
|
+
// the SAME scanned UI file, in a real clone where the driver's own `__dirname` is
|
|
268
|
+
// the merging tree. The merge exits 0 with no unmerged paths and no markers — and
|
|
269
|
+
// keeps OUR registry verbatim, silently discarding the other side's line numbers.
|
|
270
|
+
// Measured before the fix: `"line": 464` where the merged tree says 466; that is the
|
|
271
|
+
// PR #536 / task 1003544 strand, and the reason conflict-resolve.js regenerates
|
|
272
|
+
// AFTER the merge (step 7b) instead of trusting the driver.
|
|
273
|
+
//
|
|
274
|
+
// It SKIPS rather than fails where the environment cannot host the fixture (no local
|
|
275
|
+
// clone, the scanned file moved, or a base whose registry is already stale) — a
|
|
276
|
+
// characterization test must never red for a reason it cannot attribute.
|
|
277
|
+
{
|
|
278
|
+
const SCANNED = 'modules/hall-ui/public/task-detail.js';
|
|
279
|
+
const REGEN_PATHS = ['docs/copy-registry.json', 'docs/copy-inventory.md'];
|
|
280
|
+
// Short clone path: a deep temp dir blows Windows' MAX_PATH on the clone's
|
|
281
|
+
// .git/objects/info/commit-graphs entries.
|
|
282
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-regen-e2e-'));
|
|
283
|
+
const work = path.join(root, 'w');
|
|
284
|
+
const g = (...a) => spawnSync('git', a, { cwd: work, encoding: 'utf8' });
|
|
285
|
+
const gen = (...a) => spawnSync('node', [path.join(work, 'scripts/gds/copy-inventory.js'), ...a],
|
|
286
|
+
{ cwd: work, encoding: 'utf8' });
|
|
287
|
+
// "Does regeneration change tracked CONTENT?" — `git diff` applies .gitattributes
|
|
288
|
+
// (`*.json text eol=lf`), so it ignores the worktree line endings that make a raw
|
|
289
|
+
// byte comparison stale on a Windows checkout.
|
|
290
|
+
const regenDirty = () => { gen(); return g('diff', '--quiet', '--', ...REGEN_PATHS).status !== 0; };
|
|
291
|
+
const scannedFile = () => path.join(work, SCANNED);
|
|
292
|
+
|
|
293
|
+
let skip = null;
|
|
294
|
+
try {
|
|
295
|
+
if (spawnSync('git', ['clone', '--local', '-q', repo, work], { encoding: 'utf8' }).status !== 0) {
|
|
296
|
+
skip = 'the repo could not be locally cloned';
|
|
297
|
+
}
|
|
298
|
+
if (!skip) {
|
|
299
|
+
g('config', 'user.email', 't@t');
|
|
300
|
+
g('config', 'user.name', 't');
|
|
301
|
+
g('config', 'core.autocrlf', 'false');
|
|
302
|
+
g('config', 'merge.otb-regen.name', 'regen');
|
|
303
|
+
g('config', 'merge.otb-regen.driver',
|
|
304
|
+
`node ${path.join(work, 'scripts/gds/git-merge-regen.js').replace(/\\/g, '/')} %O %A %B %P`);
|
|
305
|
+
|
|
306
|
+
if (!fs.existsSync(scannedFile())) skip = `${SCANNED} is gone — repoint this fixture at another scanned surface`;
|
|
307
|
+
else if (fs.readFileSync(scannedFile(), 'utf8').split('\n').length < 420) skip = `${SCANNED} is too short for two distant edits`;
|
|
308
|
+
}
|
|
309
|
+
if (!skip && regenDirty()) {
|
|
310
|
+
skip = 'the committed copy registry is already stale at this base — nothing could be attributed to the merge';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!skip) {
|
|
314
|
+
g('checkout', '--', ...REGEN_PATHS);
|
|
315
|
+
const pad = (atLine, tag) => {
|
|
316
|
+
const lines = fs.readFileSync(scannedFile(), 'utf8').split('\n');
|
|
317
|
+
lines.splice(atLine, 0, `// pad ${tag}`, `// pad ${tag}`);
|
|
318
|
+
fs.writeFileSync(scannedFile(), lines.join('\n'));
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// Base is HEAD itself — never a branch NAME, which does not exist in a CI
|
|
322
|
+
// detached checkout.
|
|
323
|
+
const base = g('rev-parse', 'HEAD').stdout.trim();
|
|
324
|
+
g('checkout', '-q', '-B', 'fixture-ours', base);
|
|
325
|
+
pad(10, 'OURS');
|
|
326
|
+
gen();
|
|
327
|
+
g('add', '-A'); g('commit', '-qm', 'ours: shift copy lines near the top + regen');
|
|
328
|
+
|
|
329
|
+
g('checkout', '-q', '-B', 'fixture-theirs', base);
|
|
330
|
+
pad(400, 'THEIRS');
|
|
331
|
+
gen();
|
|
332
|
+
g('add', '-A'); g('commit', '-qm', 'theirs: shift copy lines far below + regen');
|
|
333
|
+
|
|
334
|
+
g('checkout', '-q', 'fixture-ours');
|
|
335
|
+
const m = g('merge', '--no-edit', 'fixture-theirs');
|
|
336
|
+
|
|
337
|
+
// 1. The merge lands CLEAN — the driver did clear the textual conflict.
|
|
338
|
+
assert.equal(m.status, 0, `the driver must resolve the registry conflict; got ${m.status} :: ${m.stdout}${m.stderr}`);
|
|
339
|
+
assert.equal(g('diff', '--name-only', '--diff-filter=U').stdout.trim(), '', 'no path may be left unmerged');
|
|
340
|
+
const healed = fs.readFileSync(path.join(work, 'docs', 'copy-registry.json'), 'utf8');
|
|
341
|
+
assert.ok(!MARKERS.test(healed), 'the merged registry must carry no conflict markers');
|
|
342
|
+
|
|
343
|
+
// 2. THE HAZARD: it kept OURS verbatim, because the scan ran before the merge.
|
|
344
|
+
assert.equal(
|
|
345
|
+
g('diff', '--quiet', 'HEAD^1', 'HEAD', '--', 'docs/copy-registry.json').status, 0,
|
|
346
|
+
'expected the merge to keep ours byte-for-byte (the mid-merge scan)'
|
|
347
|
+
);
|
|
348
|
+
assert.equal(regenDirty(), true,
|
|
349
|
+
'expected the merged tree to be STALE — the driver cannot see the merged tree, which is WHY '
|
|
350
|
+
+ 'conflict-resolve.js regenerates after the merge (step 7b). If this assertion now fails, git has '
|
|
351
|
+
+ 'begun running merge drivers against the merged tree: re-read step 7b, it may be simplifiable.');
|
|
352
|
+
|
|
353
|
+
// 3. THE CURE step 7b relies on: a post-merge regeneration is sufficient.
|
|
354
|
+
g('add', '-A'); g('commit', '-qm', 'regenerate from the merged tree');
|
|
355
|
+
assert.equal(regenDirty(), false, 'a post-merge regeneration must leave the registry fresh');
|
|
356
|
+
} else {
|
|
357
|
+
console.log(`git_merge_regen: case 10 SKIPPED — ${skip}`);
|
|
358
|
+
}
|
|
359
|
+
} finally {
|
|
360
|
+
try { fs.rmSync(root, { recursive: true, force: true }); } catch (_) { /* best effort */ }
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
238
364
|
console.log('git_merge_regen: all assertions passed');
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -1995,6 +1995,19 @@ test('the wizard takes the world by scope: no card ground, the six-step strip as
|
|
|
1995
1995
|
assert.doesNotMatch(block, /#[0-9a-f]{3,8}\b/i, 'every colour is a token');
|
|
1996
1996
|
});
|
|
1997
1997
|
|
|
1998
|
+
/* ── one fold for the whole wizard (task 1003538, a task 1003346 follow-up). The rail folded
|
|
1999
|
+
at the sheet's 900 while the wizard's fields, choice bands and review ledger folded at the
|
|
2000
|
+
page's 840 (cosmos's createSplit collapse), so between the two the rail was already three-up
|
|
2001
|
+
beside full-width bands. Both sheets now name the same width. ── */
|
|
2002
|
+
test('the wizard folds at one width: the rail\'s media rule sits at 840px with the bands, and no 900px rule is left in the #view-new block (task 1003538)', () => {
|
|
2003
|
+
const at = COSMOS.indexOf('THE CREATE WIZARD, IN THE WORLD');
|
|
2004
|
+
assert.ok(at > -1, 'the #view-new block exists in cosmos.css');
|
|
2005
|
+
const block = COSMOS.slice(at);
|
|
2006
|
+
assert.match(block, /@media \(max-width:840px\)\{\s*#view-new \.steps\{grid-template-columns:repeat\(3,1fr\);/, 'the rail folds to three columns at 840, the width the bands fold at');
|
|
2007
|
+
assert.doesNotMatch(block, /@media \(max-width:900px\)/, 'no 900px rule is left in the wizard block');
|
|
2008
|
+
assert.match(STYLE, /@media \(max-width:840px\)\{[\s\S]*?\.review\{grid-template-columns:1fr;/, 'the page\'s own wizard block still folds its bands and ledger at that same 840');
|
|
2009
|
+
});
|
|
2010
|
+
|
|
1998
2011
|
test('the wizard\'s own structures wear the world: hairline fields, cells of one band, the review as a ledger, the human steps as a numbered ledger, the standup strip as the rail (task 1003346)', () => {
|
|
1999
2012
|
const at = STYLE.indexOf('the create wizard (task 1002763');
|
|
2000
2013
|
assert.ok(at > -1, 'the wizard block is still the page\'s own');
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// tests/provisioning_starter_bundles.mjs — the curated starter bundles (BV1.R19,
|
|
2
|
+
// task 1002338; goal 1000046 criterion `proj-module-select`).
|
|
3
|
+
//
|
|
4
|
+
// Three things are pinned here, and the middle one is the reason this file exists.
|
|
5
|
+
//
|
|
6
|
+
// 1. The resolver — bundleForProjectType(type) for each declared type, and the
|
|
7
|
+
// core-only fallback for a type nobody declared. A creation form must never
|
|
8
|
+
// be handed a guess.
|
|
9
|
+
// 2. Bundle <-> catalog integrity — a bundle is a list of module KEYS, declared
|
|
10
|
+
// in a pure file that may not import the live catalog (the one-way rule, ADR
|
|
11
|
+
// 0083). This test IS the link: it reads every modules/*/module.json off disk
|
|
12
|
+
// and fails the build the moment a bundle names a module that does not exist,
|
|
13
|
+
// names an always-on one (a preselect that toggles nothing), or the declared
|
|
14
|
+
// always-on list drifts from the manifests' `default: true` set.
|
|
15
|
+
// 3. The read surface — GET /provisioning/starter-bundles is public and returns
|
|
16
|
+
// exactly what the resolver returns, so R20's picker and R17's rule engine
|
|
17
|
+
// cannot read a different answer than a script would.
|
|
18
|
+
//
|
|
19
|
+
// DB-free: the route is a pure read, so nothing here touches Postgres or the
|
|
20
|
+
// network beyond a loopback listener on port 0.
|
|
21
|
+
//
|
|
22
|
+
// Run: node --test --test-reporter=tap tests/provisioning_starter_bundles.mjs
|
|
23
|
+
|
|
24
|
+
import { test, before, after } from 'node:test';
|
|
25
|
+
import { strict as assert } from 'node:assert';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import http from 'node:http';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
|
|
32
|
+
// ESM named imports off the CJS module on purpose: knip cannot trace a
|
|
33
|
+
// createRequire() import, so a test-only export would read as dead code and move
|
|
34
|
+
// the knip ratchet (see the repo's knip learning).
|
|
35
|
+
import {
|
|
36
|
+
ALWAYS_ON_CORE,
|
|
37
|
+
STARTER_BUNDLES,
|
|
38
|
+
HUB_ONLY_MODULES,
|
|
39
|
+
bundleForProjectType,
|
|
40
|
+
allStarterBundles,
|
|
41
|
+
} from '../modules/provisioning/starter-bundles.js';
|
|
42
|
+
|
|
43
|
+
const require = createRequire(import.meta.url);
|
|
44
|
+
|
|
45
|
+
// Stub the doorway in require.cache BEFORE the route file loads (the
|
|
46
|
+
// access_requests_invite pattern): the route destructures `pool` at require time,
|
|
47
|
+
// and a real pg pool would hold the event loop open long after the assertions
|
|
48
|
+
// pass. Nothing here needs a database — the route under test is a pure read.
|
|
49
|
+
const apiPath = require.resolve('../src/module-api.js');
|
|
50
|
+
const realApi = require(apiPath);
|
|
51
|
+
require.cache[apiPath].exports = {
|
|
52
|
+
...realApi,
|
|
53
|
+
pool: { async query() { throw new Error('the starter-bundles route must not touch the database'); } },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
57
|
+
|
|
58
|
+
const MODULES_DIR = fileURLToPath(new URL('../modules', import.meta.url));
|
|
59
|
+
|
|
60
|
+
// Every module key on disk, with the manifest's `default` — the live catalog as
|
|
61
|
+
// the loader discovers it (src/module-loader/loader.js scans exactly this).
|
|
62
|
+
function manifestsOnDisk() {
|
|
63
|
+
const out = new Map();
|
|
64
|
+
for (const ent of fs.readdirSync(MODULES_DIR, { withFileTypes: true })) {
|
|
65
|
+
if (!ent.isDirectory()) continue;
|
|
66
|
+
const manifestPath = path.join(MODULES_DIR, ent.name, 'module.json');
|
|
67
|
+
if (!fs.existsSync(manifestPath)) continue;
|
|
68
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
69
|
+
out.set(manifest.key, manifest);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── the resolver ────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
test('bundleForProjectType returns the always-on core plus the type\'s opt-in modules', () => {
|
|
77
|
+
// Arrange
|
|
78
|
+
const type = 'game';
|
|
79
|
+
|
|
80
|
+
// Act
|
|
81
|
+
const resolved = bundleForProjectType(type);
|
|
82
|
+
|
|
83
|
+
// Assert
|
|
84
|
+
assert.equal(resolved.type, 'game');
|
|
85
|
+
assert.deepEqual(resolved.core, [...ALWAYS_ON_CORE]);
|
|
86
|
+
assert.deepEqual(resolved.bundle, [...STARTER_BUNDLES.game.modules]);
|
|
87
|
+
assert.deepEqual(resolved.modules, [...ALWAYS_ON_CORE, ...STARTER_BUNDLES.game.modules]);
|
|
88
|
+
assert.ok(resolved.label && resolved.summary, 'carries owner-facing copy for the picker to render');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('every declared project type resolves to a bundle', () => {
|
|
92
|
+
// Arrange — the shipped vocabulary (task 1002772, owner decision 2026-08-13).
|
|
93
|
+
const declared = provisioning.PROJECT_TYPES;
|
|
94
|
+
|
|
95
|
+
// Act + Assert
|
|
96
|
+
for (const type of declared) {
|
|
97
|
+
const resolved = bundleForProjectType(type);
|
|
98
|
+
assert.equal(resolved.type, type, `${type} resolves to itself, not the fallback`);
|
|
99
|
+
}
|
|
100
|
+
assert.deepEqual(
|
|
101
|
+
Object.keys(STARTER_BUNDLES).sort(),
|
|
102
|
+
[...declared].sort(),
|
|
103
|
+
'the bundle table covers the declared type vocabulary exactly — no missing type, no invented one',
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('an undeclared or unknown project type falls back to the core, and never guesses a type', () => {
|
|
108
|
+
// A project may have no type at all: the column is nullable because every row
|
|
109
|
+
// predates the field and must not be back-filled with a guess (migration
|
|
110
|
+
// provisioning_007). The fallback must be honest about that.
|
|
111
|
+
for (const type of [null, undefined, '', 'saas-tool', 'GAME', 42, {}]) {
|
|
112
|
+
// Act
|
|
113
|
+
const resolved = bundleForProjectType(type);
|
|
114
|
+
|
|
115
|
+
// Assert
|
|
116
|
+
assert.equal(resolved.type, null, `${JSON.stringify(type)} → no declared type`);
|
|
117
|
+
assert.deepEqual(resolved.bundle, [], 'nothing is preselected on a guess');
|
|
118
|
+
assert.deepEqual(resolved.modules, [...ALWAYS_ON_CORE]);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('"not-sure" is a real answer that resolves to the core only, distinct from an undeclared type', () => {
|
|
123
|
+
// Arrange + Act
|
|
124
|
+
const notSure = bundleForProjectType('not-sure');
|
|
125
|
+
const undeclared = bundleForProjectType(null);
|
|
126
|
+
|
|
127
|
+
// Assert — same modules, different provenance: one owner said "not sure", the
|
|
128
|
+
// other said nothing at all, and the picker should be able to tell them apart.
|
|
129
|
+
assert.deepEqual(notSure.modules, undeclared.modules);
|
|
130
|
+
assert.equal(notSure.type, 'not-sure');
|
|
131
|
+
assert.equal(undeclared.type, null);
|
|
132
|
+
assert.deepEqual(notSure.bundle, [], 'the escape hatch adds nothing — nothing depends on it');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('a caller mutating a resolved bundle cannot corrupt the declaration', () => {
|
|
136
|
+
// Arrange
|
|
137
|
+
const first = bundleForProjectType('game');
|
|
138
|
+
|
|
139
|
+
// Act
|
|
140
|
+
first.modules.push('not-a-module');
|
|
141
|
+
first.bundle.length = 0;
|
|
142
|
+
first.core.length = 0;
|
|
143
|
+
|
|
144
|
+
// Assert
|
|
145
|
+
const second = bundleForProjectType('game');
|
|
146
|
+
assert.deepEqual(second.core, [...ALWAYS_ON_CORE]);
|
|
147
|
+
assert.deepEqual(second.bundle, [...STARTER_BUNDLES.game.modules]);
|
|
148
|
+
assert.ok(!second.modules.includes('not-a-module'));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('allStarterBundles returns one resolved bundle per declared type', () => {
|
|
152
|
+
// Act
|
|
153
|
+
const all = allStarterBundles();
|
|
154
|
+
|
|
155
|
+
// Assert
|
|
156
|
+
assert.deepEqual(all.map((b) => b.type), Object.keys(STARTER_BUNDLES));
|
|
157
|
+
for (const row of all) assert.deepEqual(row, bundleForProjectType(row.type));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ── bundle <-> catalog integrity ────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
test('every module a bundle names exists in the module catalog', () => {
|
|
163
|
+
// Arrange
|
|
164
|
+
const onDisk = manifestsOnDisk();
|
|
165
|
+
assert.ok(onDisk.size > 0, 'read the manifests off disk');
|
|
166
|
+
|
|
167
|
+
// Act + Assert
|
|
168
|
+
for (const [type, bundle] of Object.entries(STARTER_BUNDLES)) {
|
|
169
|
+
for (const key of bundle.modules) {
|
|
170
|
+
assert.ok(onDisk.has(key), `bundle "${type}" names "${key}", which is not a module on disk`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('every always-on module is a real module whose manifest says default: true', () => {
|
|
176
|
+
// Arrange
|
|
177
|
+
const onDisk = manifestsOnDisk();
|
|
178
|
+
|
|
179
|
+
// Act + Assert
|
|
180
|
+
for (const key of ALWAYS_ON_CORE) {
|
|
181
|
+
assert.ok(onDisk.has(key), `ALWAYS_ON_CORE names "${key}", which is not a module on disk`);
|
|
182
|
+
assert.equal(onDisk.get(key).default, true, `"${key}" is listed as always-on but its manifest is not default: true`);
|
|
183
|
+
}
|
|
184
|
+
const defaultsOnDisk = [...onDisk.values()].filter((m) => m.default === true).map((m) => m.key).sort();
|
|
185
|
+
assert.deepEqual(
|
|
186
|
+
[...ALWAYS_ON_CORE].sort(),
|
|
187
|
+
defaultsOnDisk,
|
|
188
|
+
'the declared always-on list has drifted from the manifests — a module was added, removed, or flipped its default',
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('a bundle may only add opt-in modules — never one that is already always-on', () => {
|
|
193
|
+
// A preselect that names an always-on module toggles nothing and reads to the
|
|
194
|
+
// owner as a choice they made; that is a lie the picker would render.
|
|
195
|
+
for (const [type, bundle] of Object.entries(STARTER_BUNDLES)) {
|
|
196
|
+
for (const key of bundle.modules) {
|
|
197
|
+
assert.ok(!ALWAYS_ON_CORE.includes(key), `bundle "${type}" names always-on module "${key}"`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('no bundle names a hub-only module', () => {
|
|
203
|
+
// platform-identity and provisioning are the control plane's own modules — the
|
|
204
|
+
// hub runs them, a project created THROUGH the hub never should.
|
|
205
|
+
for (const [type, bundle] of Object.entries(STARTER_BUNDLES)) {
|
|
206
|
+
for (const key of bundle.modules) {
|
|
207
|
+
assert.ok(!HUB_ONLY_MODULES.includes(key), `bundle "${type}" names hub-only module "${key}"`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('a bundle lists each module once', () => {
|
|
213
|
+
for (const [type, bundle] of Object.entries(STARTER_BUNDLES)) {
|
|
214
|
+
assert.equal(new Set(bundle.modules).size, bundle.modules.length, `bundle "${type}" repeats a module`);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ── the read surface ────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
// Mount the real router. The route under test is a pure read, so it needs no
|
|
221
|
+
// session gate and no res.fail helper — which is itself the point of the
|
|
222
|
+
// assertion below.
|
|
223
|
+
const express = require('express');
|
|
224
|
+
const buildProvisioningRouter = require('../modules/provisioning/routes/provisioning.js');
|
|
225
|
+
const app = express();
|
|
226
|
+
app.use('/api/bongos', buildProvisioningRouter());
|
|
227
|
+
|
|
228
|
+
let server;
|
|
229
|
+
let port;
|
|
230
|
+
before(async () => {
|
|
231
|
+
server = http.createServer(app);
|
|
232
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
233
|
+
port = server.address().port;
|
|
234
|
+
});
|
|
235
|
+
after(async () => { await new Promise((resolve) => server.close(resolve)); });
|
|
236
|
+
|
|
237
|
+
function get(pathname) {
|
|
238
|
+
return new Promise((resolve, reject) => {
|
|
239
|
+
http.get({ host: '127.0.0.1', port, path: pathname }, (res) => {
|
|
240
|
+
let body = '';
|
|
241
|
+
res.on('data', (c) => { body += c; });
|
|
242
|
+
res.on('end', () => {
|
|
243
|
+
let parsed = null;
|
|
244
|
+
try { parsed = JSON.parse(body); } catch { /* non-JSON */ }
|
|
245
|
+
resolve({ status: res.statusCode, body: parsed, raw: body });
|
|
246
|
+
});
|
|
247
|
+
}).on('error', reject);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
test('GET /provisioning/starter-bundles is public and serves the resolved bundles', async () => {
|
|
252
|
+
// Act — no session: `rank: public`, the same gate (none) the signed-out create
|
|
253
|
+
// wizard reads GET /provisioning/onboard-plan with.
|
|
254
|
+
const res = await get('/api/bongos/provisioning/starter-bundles');
|
|
255
|
+
|
|
256
|
+
// Assert
|
|
257
|
+
assert.equal(res.status, 200, `readable with no session (got ${res.status} ${res.raw})`);
|
|
258
|
+
assert.deepEqual(res.body.core, [...ALWAYS_ON_CORE]);
|
|
259
|
+
assert.deepEqual(res.body.bundles, allStarterBundles());
|
|
260
|
+
assert.equal(res.body.bundles.length, provisioning.PROJECT_TYPES.length);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('the route never reaches the database — the bundles are a declaration, not a query', async () => {
|
|
264
|
+
// The stubbed pool throws on any query, so a 200 here IS the assertion: an
|
|
265
|
+
// owner picking a type in the wizard must not cost a round trip to Postgres.
|
|
266
|
+
const res = await get('/api/bongos/provisioning/starter-bundles');
|
|
267
|
+
assert.equal(res.status, 200);
|
|
268
|
+
});
|