@bongos/core 1.19.510 → 1.19.512

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.
Files changed (33) hide show
  1. package/.bongos-core.json +48 -38
  2. package/config/branding.neutral.json +3 -2
  3. package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +16 -2
  4. package/docs/adr/0238-the-join-door-is-a-provisioning-settings-policy-key.md +57 -0
  5. package/docs/adr/README.md +1 -0
  6. package/docs/copy-inventory.md +124 -118
  7. package/docs/copy-registry.json +223 -158
  8. package/docs/module-api-changelog.md +4 -0
  9. package/modules/copy-desk/proposals.js +13 -0
  10. package/modules/copy-desk/routes/copy-desk.js +9 -2
  11. package/modules/copy-desk/tests/copy_proposals.mjs +41 -0
  12. package/modules/lifecycle/conflict-resolve.js +59 -3
  13. package/modules/provisioning/provisioning.js +27 -1
  14. package/modules/provisioning/tests/provisioning.mjs +17 -14
  15. package/modules/public-landing/public/assets/cosmos.css +3 -1
  16. package/modules/public-landing/public/projects.html +125 -0
  17. package/modules/ui-design/kit/serve.js +4 -3
  18. package/package-lock.json +2 -2
  19. package/package.json +1 -1
  20. package/scripts/gds/copy-apply.js +54 -4
  21. package/scripts/gds/git-merge-regen.js +18 -0
  22. package/src/branding.js +9 -0
  23. package/src/module-api.js +1 -1
  24. package/tests/conflict_resolve.mjs +118 -4
  25. package/tests/currency_label.mjs +5 -4
  26. package/tests/git_merge_regen.mjs +128 -2
  27. package/tests/project_door_ui.mjs +267 -0
  28. package/tests/projects_hub.mjs +86 -1
  29. package/tests/projects_hub_pre_uat.mjs +4 -1
  30. package/tests/provision_settings_apply.mjs +8 -7
  31. package/tests/provisioning_settings.mjs +65 -6
  32. package/tests/provisioning_settings_apply.mjs +3 -3
  33. package/tests/provisioning_settings_env.mjs +10 -8
@@ -20,6 +20,24 @@
20
20
  // parts of nested docs; it succeeds ONLY if no conflict markers remain. A genuine
21
21
  // hand-written conflict falls through to a normal git conflict — it never invents a
22
22
  // resolution or clobbers a real edit.
23
+ //
24
+ // ⚠ THE DRIVER'S OUTPUT IS PROVISIONAL FOR ANY TREE-SCANNING ARTIFACT (task 1003546).
25
+ // Every rule below that rebuilds by SCANNING THE WORKING TREE — copy-inventory.js's
26
+ // registry pair, gen-repo-map's map + nested blocks, gen-file-map's sections — is
27
+ // invoked BY git, DURING the merge, when that tree is still the PRE-merge (ours)
28
+ // tree. So the driver writes an artifact describing the branch, git completes the
29
+ // merge underneath it, and the merge lands CLEAN (exit 0, no unmerged paths, no
30
+ // markers) carrying content that matches neither side nor the merged tree. Measured:
31
+ // two branches each shifting lines in one scanned UI file merge clean and the merge
32
+ // commit's docs/copy-registry.json is byte-identical to ours, silently dropping the
33
+ // other side's line numbers (PR #536 / task 1003544).
34
+ // It is still worth running — it clears the markers so the merge completes at all —
35
+ // but it is NOT the authority. Whoever OWNS the merge must regenerate afterwards, on
36
+ // the final tree, and commit the drift: the server does it in conflict-resolve.js
37
+ // step 7b, and a post-merge `resolveInWorktree` call (ship-preflight-steps.js) is
38
+ // correct for the same reason — by then git has written every cleanly-merged path.
39
+ // Do NOT add a rule here and assume the merge is thereby correct; a line-numbered or
40
+ // aggregate-carrying artifact cannot be resolved from the three merge sides at all.
23
41
 
24
42
  const fs = require('node:fs');
25
43
  const os = require('node:os');
package/src/branding.js CHANGED
@@ -60,6 +60,11 @@ const ENV_OVERRIDES = [
60
60
  // — which is exactly why applying a change RESTARTS the instance.
61
61
  ['PLATFORM_VISIBILITY', ['project', 'platformVisibility']],
62
62
  ['JOINABILITY', ['project', 'joinability']],
63
+ // The join door the owner set on the platform (ADR 0182 D1, task 1002324) —
64
+ // public | private | stealth. Distinct from PLATFORM_VISIBILITY above, which is
65
+ // this instance's own sign-in door; this one is how the project stands on the
66
+ // platform's map, and D6 makes it the ceiling JOINABILITY may only narrow.
67
+ ['VISIBILITY', ['project', 'visibility']],
63
68
  ];
64
69
 
65
70
  const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
@@ -215,6 +220,10 @@ function clientBranding(b = branding()) {
215
220
  project: {
216
221
  platformVisibility: b.project?.platformVisibility || 'public',
217
222
  joinability: b.project?.joinability || 'apply',
223
+ // The join door (task 1002324). Public by nature for the same reason as its
224
+ // two neighbours: it is how the project asks to be seen on the map, and it is
225
+ // the READ-BACK a manage page needs to tell a saved choice from a live one.
226
+ visibility: b.project?.visibility || 'public',
218
227
  },
219
228
  };
220
229
  }
package/src/module-api.js CHANGED
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.510'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.512'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -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
- const sha = revCount === 1 ? (scn.before ?? 'aaaaaaa') : (scn.after ?? 'bbbbbbb');
56
- return { code: 0, stdout: `${sha}\n`, stderr: '' };
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
- assert.ok(String(call.argv[0]).startsWith('/tmp/clone-xyz'), 'the generator script path is under the clone');
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);
@@ -48,10 +48,11 @@ test('R57: clientBranding exposes theme.ui (web), NOT theme.palette (server-only
48
48
  });
49
49
 
50
50
  test('clientBranding exposes project policy — the settings read-back (task 1003140), defaulting to today\'s behavior', () => {
51
- assert.deepEqual(clientBranding({}).project, { platformVisibility: 'public', joinability: 'apply' });
51
+ // `visibility` — the join door — joined the block in task 1002324 (ADR 0182 D1)
52
+ assert.deepEqual(clientBranding({}).project, { platformVisibility: 'public', joinability: 'apply', visibility: 'public' });
52
53
  assert.deepEqual(
53
- clientBranding({ project: { platformVisibility: 'gated', joinability: 'invite_only' } }).project,
54
- { platformVisibility: 'gated', joinability: 'invite_only' },
54
+ clientBranding({ project: { platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth' } }).project,
55
+ { platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth' },
55
56
  'what the instance actually resolved (env override included) is what it publishes');
56
57
  });
57
58
 
@@ -70,7 +71,7 @@ test('clientBranding is a SMALL allow-list — no server-only field leaks to the
70
71
  // `project` joined the allow-list in task 1003140: the per-project policy is public
71
72
  // by nature and is the settings read-back a manage page needs (see clientBranding).
72
73
  assert.deepEqual(Object.keys(projected).sort(), ['copy', 'currency', 'identity', 'project', 'rankLabels', 'reward', 'theme']);
73
- assert.deepEqual(Object.keys(projected.project).sort(), ['joinability', 'platformVisibility']);
74
+ assert.deepEqual(Object.keys(projected.project).sort(), ['joinability', 'platformVisibility', 'visibility']);
74
75
  for (const leak of ['domains', 'repo', 'db', 'envPrefix', 'llmProjectName']) {
75
76
  assert.equal(projected[leak], undefined, `${leak} must not reach the client`);
76
77
  }
@@ -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
- g('config', 'merge.otb-regen.driver', `node ${driver} %O %A %B %P`);
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
- g('config', 'merge.otb-regen.driver', `node ${driver} %O %A %B %P`);
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');