@bongos/core 1.19.971 → 1.19.973

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 (77) hide show
  1. package/.bongos-core.json +116 -76
  2. package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
  3. package/docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md +1 -1
  4. package/docs/adr/0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md +1 -1
  5. package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +107 -0
  6. package/docs/adr/README.md +1 -0
  7. package/docs/api/openapi.json +1 -1
  8. package/docs/api-reference.md +1 -1
  9. package/docs/architecture.md +6 -6
  10. package/docs/branding-contract.md +1 -1
  11. package/docs/canonical-permissions.md +24 -24
  12. package/docs/copy-inventory.md +193 -187
  13. package/docs/copy-registry.json +320 -276
  14. package/docs/design/projects-hub-direction-v2.md +1 -1
  15. package/docs/design/vanilla-hall-ui-redesign-scope.md +2 -2
  16. package/docs/file-map.md +7 -7
  17. package/docs/handoff-template.md +1 -1
  18. package/docs/module-api-changelog.md +4 -0
  19. package/docs/modules-contract.md +1 -1
  20. package/docs/onboarding/drachmae-and-karma.md +2 -2
  21. package/docs/packs/engineer.md +5 -5
  22. package/docs/packs/ideator.md +1 -1
  23. package/docs/recipes/README.md +1 -1
  24. package/docs/recipes/audit-replay.md +2 -2
  25. package/docs/recipes/gds-db-backup.md +3 -3
  26. package/docs/recipes/managed-settings-remote-control.md +9 -9
  27. package/docs/recipes/memory-sync-conflicts.md +2 -2
  28. package/docs/recipes/ops-gotchas.md +24 -5
  29. package/docs/recipes/overnight-builder.md +3 -3
  30. package/docs/recipes/parallel-goal-waves.md +3 -3
  31. package/docs/recipes/reranking-a-goals-board.md +2 -2
  32. package/docs/recipes/search-index-ingestion.md +1 -1
  33. package/docs/recipes/ship-and-regrade.md +2 -2
  34. package/docs/recipes/staging-environment.md +1 -1
  35. package/docs/recipes/windows-builders.md +1 -1
  36. package/modules/hall-ui/public/deploy.html +10 -5
  37. package/modules/hall-ui/public/deploy.js +199 -110
  38. package/modules/hall-ui/public/goals-page.js +6 -0
  39. package/modules/hall-ui/public/goals-render.js +16 -0
  40. package/modules/hall-ui/public/mine-lens.js +172 -0
  41. package/modules/hall-ui/public/project-settings.css +36 -0
  42. package/modules/hall-ui/public/work-summary.js +90 -0
  43. package/modules/hall-ui/public/work.css +50 -0
  44. package/modules/hall-ui/public/work.html +24 -0
  45. package/modules/hall-ui/public/work.js +38 -51
  46. package/modules/lifecycle/db-goals.js +20 -3
  47. package/modules/lifecycle/routes/goals.js +2 -2
  48. package/modules/provisioning/core-upgrade.js +61 -2
  49. package/modules/public-landing/public/contact.html +1 -1
  50. package/modules/public-landing/public/index.html +1 -1
  51. package/modules/public-landing/public/privacy.html +1 -1
  52. package/modules/public-landing/public/projects.html +1 -1
  53. package/modules/public-landing/public/terms.html +1 -1
  54. package/package-lock.json +2 -2
  55. package/package.json +1 -1
  56. package/scripts/gds/api-alias-caller-scan.js +47 -9
  57. package/scripts/gds/cloudbongos-token-isolation.conf +33 -0
  58. package/scripts/gds/fitness.js +3 -0
  59. package/scripts/gds/provision-core-upgrade.js +132 -1
  60. package/scripts/gds/run-unit-tests.js +14 -9
  61. package/scripts/gds/scope-roster-guard.js +118 -0
  62. package/scripts/hall-preview/server.js +5 -0
  63. package/src/bongos/module-scope-map.js +1 -0
  64. package/src/module-api.js +1 -1
  65. package/tests/api_alias_caller_ratchet.mjs +68 -2
  66. package/tests/core_upgrade_door.mjs +81 -0
  67. package/tests/core_upgrade_runner.mjs +132 -0
  68. package/tests/deploy_page_projects.mjs +234 -79
  69. package/tests/fitness_scope_roster.mjs +117 -0
  70. package/tests/goal_mine_lens.mjs +233 -0
  71. package/tests/goal_tier.mjs +30 -4
  72. package/tests/hall_mine_lens.mjs +381 -0
  73. package/tests/idea_spark_hall.mjs +101 -12
  74. package/tests/known_failing_ratchet.mjs +19 -9
  75. package/tests/landing_legal_pages.mjs +5 -1
  76. package/tests/projects_hub_pre_uat.mjs +12 -6
  77. package/tests/served_paths_shipped.mjs +36 -2
@@ -123,6 +123,11 @@ const FIXTURE_FALLBACKS = [
123
123
  // the profile's connections section (task 1002296) — one degree fixture serves
124
124
  // any handle, since the handle is the address and the label is the payload.
125
125
  [/^connections\/degree\/[^/]+$/, 'connections__degree'],
126
+ // the deploy door's per-project preview (task 1004171). One fixture serves any id
127
+ // because the page renders ONE project and the id is the address, not the payload —
128
+ // without it /deploy?project=… only ever rendered its "could not read" state, which
129
+ // is the trap this directory's README calls out by name.
130
+ [/^provisioning\/instances\/\d+\/core-upgrade$/, 'provisioning__instances__core-upgrade'],
126
131
  ];
127
132
 
128
133
  // A DATE THAT ROTS CANNOT DEMO AN AGE SIGNAL (task 1004041). The help-request
@@ -451,6 +451,7 @@ const MODULE_GLOBS = {
451
451
  'scripts/gds/fitness-checks-packaging.js', 'scripts/gds/fitness-checks-route-pins.js',
452
452
  'scripts/gds/fitness-checks-vocabulary.js', 'scripts/gds/fitness-checks-write-validation.js',
453
453
  'scripts/gds/fitness-migration-contraction.js', 'scripts/gds/role-pack-guard.js',
454
+ 'scripts/gds/scope-roster-guard.js',
454
455
  'scripts/gds/route-shadow-guard.js', 'scripts/gds/routine-schedule.js',
455
456
  'scripts/gds/seam-wiring-guard.js', 'scripts/gds/skill-preflight.js',
456
457
  'scripts/gds/test-path-guard.js', 'scripts/gds/version-literal-guard.js',
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.971'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.973'; // 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');
@@ -39,7 +39,7 @@ test('a collapsed enumeration is visible as scanned=0, not as a flattering total
39
39
 
40
40
  const allExcluded = scan.scanAliasCallers(fake({
41
41
  'tests/x.mjs': '/api/gds\n',
42
- 'docs/x.md': '/api/gds\n',
42
+ 'docs/adr/0001-x.md': '/api/gds\n', // frozen record — NOT `docs/x.md`, which counts since task 1004113
43
43
  }));
44
44
  assert.equal(allExcluded.total, 0);
45
45
  assert.equal(allExcluded.scanned, 0, 'an every-path-excluded run is also scanned=0 — the same signal');
@@ -67,7 +67,11 @@ test('the exclusions are real and each one is reachable', () => {
67
67
  'scripts/gds/api-alias-caller-scan.js',
68
68
  'tests/api_alias_exceptions.mjs', // 5 tests
69
69
  'modules/dev-box/app/test/thing.mjs',
70
- 'docs/architecture.md', // 6 docs
70
+ 'docs/adr/0016-permissions.md', // 6 the FROZEN RECORD under docs/ (narrowed, task 1004113)
71
+ 'docs/session-logs/2026-09-16-x.md',
72
+ 'docs/audits/2026-08-01-x.md',
73
+ 'docs/governance/authority-sweep-2026-07-24.md',
74
+ 'docs/recipes/failed-ship-recovery-proof.md',
71
75
  'migrations/033_audit_log.sql', // 7 migrations
72
76
  'scripts/hall-preview/fixtures/tasks.json', // 9 captured fixtures
73
77
  'node_modules/pkg/index.js', // vendored
@@ -81,11 +85,58 @@ test('the exclusions are real and each one is reachable', () => {
81
85
  'clients/bongos-client/index.mjs', // a .mjs that is NOT a test — a blanket \.mjs$ would blind the ratchet
82
86
  'bin/bongos.js',
83
87
  'CLAUDE.md',
88
+ // task 1004113: live instruction under docs/ is counted. If this list ever goes back to being
89
+ // excluded, the ~95 occurrences the sweep moved can silently grow back unpoliced.
90
+ 'docs/architecture.md',
91
+ 'docs/canonical-permissions.md',
92
+ 'docs/recipes/parallel-goal-waves.md',
93
+ 'docs/packs/engineer.md',
94
+ 'docs/onboarding/drachmae-and-karma.md',
84
95
  ]) {
85
96
  assert.equal(scan.isExcludedPath(p), false, `${p} must NOT be excluded`);
86
97
  }
87
98
  });
88
99
 
100
+ // ---------------------------------------------------------------------------
101
+ // task 1004113 — the docs/ split. This is the case the parent task (1004064)
102
+ // deferred, and it is proved in BOTH directions on purpose: a narrowing that
103
+ // only asserts "the frozen record is excluded" would pass identically if the
104
+ // blanket `docs/` exclusion were still in place, which is exactly the vacuous
105
+ // shape this file's header warns about.
106
+ // ---------------------------------------------------------------------------
107
+
108
+ test('docs/ is SPLIT: the frozen record stays out, live instruction is now counted', () => {
109
+ const r = scan.scanAliasCallers(fake({
110
+ 'docs/adr/0010-pms-architecture.md': 'GET /api/gds/tasks\n',
111
+ 'docs/session-logs/2026-09-16-x.md': 'GET /api/gds/tasks\n',
112
+ 'docs/audits/2026-08-01-x.md': 'GET /api/gds/tasks\n',
113
+ 'docs/governance/rank-map-2026-08-06.md': 'GET /api/gds/tasks\n',
114
+ 'docs/recipes/failed-ship-recovery-proof.md': 'POST /api/gds/claims/1/resolve\n',
115
+ 'docs/recipes/how-to-do-a-thing.md': 'node scripts/gds/api.js GET /api/gds/tasks/1\n',
116
+ 'docs/packs/engineer.md': 'create a task via POST /api/gds/tasks\n',
117
+ }));
118
+ assert.deepEqual(
119
+ Object.keys(r.byFile).sort(),
120
+ ['docs/packs/engineer.md', 'docs/recipes/how-to-do-a-thing.md'],
121
+ 'only the instructional docs count — and they MUST count, or the narrowing did nothing',
122
+ );
123
+ assert.equal(r.total, 2);
124
+ assert.equal(r.scanned, 2, 'the frozen-record files are excluded before they are read, not counted as zero');
125
+ });
126
+
127
+ test('the rename-history floors all sit INSIDE the excluded subtrees — the line is measured, not asserted', () => {
128
+ // The narrowing's load-bearing evidence: rename-history-check.js fails the build when the old
129
+ // name is ERASED from a file with a floor, so a docs sweep could have failed a DIFFERENT gate.
130
+ // It did not, because every floor is in a subtree this scanner still excludes. If a floor is ever
131
+ // added outside them, this reds and whoever added it has to decide which gate wins.
132
+ const floors = Object.keys(
133
+ JSON.parse(readFileSync(path.join(ROOT, 'config', 'rename-history-baseline.json'), 'utf8')).frozenLiteralFloors || {},
134
+ );
135
+ assert.ok(floors.length > 100, `the baseline must actually carry floors (got ${floors.length})`);
136
+ const outside = floors.filter((f) => !scan.isExcludedPath(f));
137
+ assert.deepEqual(outside, [], 'a file with a rename-history floor must never be inside the caller ratchet');
138
+ });
139
+
89
140
  test('a line ABOUT the alias is not a caller of it — and the rule is not so loose it excuses everything', () => {
90
141
  // Excluded: names the canonical prefix too, or says what it is.
91
142
  assert.equal(scan.isCountedLine('mount /api/bongos and /api/gds'), false);
@@ -100,6 +151,21 @@ test('a line ABOUT the alias is not a caller of it — and the rule is not so lo
100
151
  assert.equal(scan.isCountedLine('a line about a legacy alias, forever'), false);
101
152
  });
102
153
 
154
+ test('a PINNED auth URL is not a caller anyone may move (rule 10, task 1004113)', () => {
155
+ // These are registered in GitHub's OAuth app settings. A recipe that teaches a builder to
156
+ // register /api/bongos/auth/web/callback breaks sign-in on the instance they are standing up,
157
+ // so the ratchet must never create pressure to rewrite them.
158
+ assert.equal(scan.isCountedLine(' https://example.com/api/gds/auth/web/callback'), false);
159
+ assert.equal(scan.isCountedLine('the server sends `${publicOrigin}/api/gds/auth/web/callback`'), false);
160
+ assert.equal(scan.isCountedLine('`/api/gds/auth/web/start` = no session'), false);
161
+ // …and prose ABOUT that URL, which names the prefix without the /auth/ segment. Sweeping this
162
+ // heading left it contradicting its own body two lines down; caught in review on 1004113.
163
+ assert.equal(scan.isCountedLine('### Authorized callback URL must include the full /api/gds path'), false);
164
+ // The rule is NOT so wide that an ordinary caller in an auth-adjacent file escapes it.
165
+ assert.equal(scan.isCountedLine('node scripts/gds/api.js GET /api/gds/me'), true);
166
+ assert.equal(scan.isCountedLine('POST /api/gds/auth-free/thing'), true, 'only the /auth/ segment, not any word starting auth');
167
+ });
168
+
103
169
  test('occurrences are counted, not lines — moving ONE of two callers on a line lowers the number', () => {
104
170
  assert.equal(scan.countInText('f("/api/gds/a"); f("/api/gds/b");\n'), 2);
105
171
  assert.equal(scan.countInText('f("/api/bongos/a"); f("/api/gds/b");\n'), 0, 'the canonical name on the line is the alias-commentary rule, by design');
@@ -756,5 +756,86 @@ await t('lastCoreMove reads MOVES only — a preview refusal is not a failed dep
756
756
  assert.deepEqual(seen[0].params, [41]);
757
757
  });
758
758
 
759
+
760
+ console.log('\nthe change list is withheld when the box has moved since (task 1004173):');
761
+
762
+ const PF = (over = {}) => ({ target: '1.19.949', ok: true, lines: [], error: null,
763
+ changes: { from: '1.19.900', to: '1.19.949', entries: [{ text: 'a thing landed' }], more: 0 }, ...over });
764
+
765
+ await t('a verdict taken from where the box IS keeps its change list', () => {
766
+ const p = CU.previewFromSnapshot(ACTIVE, { ...SNAP, preflight: PF() }, null, null);
767
+ assert.equal(p.preflight.changes.entries.length, 1);
768
+ });
769
+
770
+ await t('THE LIST GOES WHEN THE BOX HAS MOVED — the verdict beside it does NOT', () => {
771
+ // Two independent staleness questions. The verdict is about a TARGET; the list is
772
+ // about a RANGE, so it also depends on where the box starts. Dropping both would throw
773
+ // away a good verdict; dropping neither would show a range starting somewhere the box
774
+ // no longer is.
775
+ const p = CU.previewFromSnapshot(ACTIVE, { ...SNAP, served_version: '1.19.930', preflight: PF() }, null, null);
776
+ assert.equal(p.preflight.changes, null, 'the range no longer starts where the box is');
777
+ assert.equal(p.preflight.ok, true, 'but the check is still a true answer about this target');
778
+ assert.equal(p.preflight.target, '1.19.949');
779
+ });
780
+
781
+ await t('a verdict about a SUPERSEDED target is withheld whole, list and all', () => {
782
+ const p = CU.previewFromSnapshot(ACTIVE, { ...SNAP, preflight: PF({ target: '1.19.920' }) }, null, null);
783
+ assert.equal(p.preflight, null, 'a stale pass reads as a guarantee — worse than no answer');
784
+ });
785
+
786
+ await t('withholding does not MUTATE the cached verdict', () => {
787
+ // previewFromSnapshot runs per request over a row the caller may reuse; blanking the
788
+ // list in place would make the second reader see a range the first one invalidated.
789
+ const snap = { ...SNAP, served_version: '1.19.930', preflight: PF() };
790
+ CU.previewFromSnapshot(ACTIVE, snap, null, null);
791
+ assert.ok(snap.preflight.changes, 'the snapshot is untouched');
792
+ });
793
+
794
+ console.log('\nis the last move still NEWS, or history the box moved past? (task 1004172):');
795
+
796
+ await t('a failure the box has already moved past is marked superseded', () => {
797
+ // The live defect, 2026-09-22: the control plane's row led with "The move to core
798
+ // 1.19.921 did not land", undated, while the box served 1.19.969 and a move to
799
+ // 1.19.972 was on offer. Two days old, a different target, and a cause fixed and
800
+ // released in between — all of it rendered as the present.
801
+ const old = { action: 'core-upgrade', state: 'error', target_version: '1.19.921', resolved_at: new Date().toISOString(), last_error: 'Postgres has no role "root"' };
802
+ const p = CU.previewFromSnapshot(ACTIVE, SNAP, null, old);
803
+ assert.equal(p.last_move.superseded, true, 'a different move is on offer now');
804
+ });
805
+
806
+ await t('a failure against the move STILL on offer is NOT superseded', () => {
807
+ const now = { action: 'core-upgrade', state: 'error', target_version: '1.19.949', resolved_at: new Date().toISOString(), last_error: 'working tree not clean' };
808
+ const p = CU.previewFromSnapshot(ACTIVE, SNAP, null, now);
809
+ assert.equal(p.last_move.superseded, false, 'this is the button in front of the owner');
810
+ });
811
+
812
+ await t('a box that reached the target by another road supersedes the failure', () => {
813
+ // The exact shape of the live case: the move failed, and the version arrived anyway.
814
+ const old = { action: 'core-upgrade', state: 'error', target_version: '1.19.949', resolved_at: new Date().toISOString(), last_error: 'x' };
815
+ const p = CU.previewFromSnapshot(ACTIVE, { ...SNAP, served_version: '1.19.949', recommended: '1.19.949' }, null, old);
816
+ assert.equal(p.last_move.superseded, true);
817
+ });
818
+
819
+ await t('AN UNREADABLE REGISTRY LEAVES A FAILURE STANDING — not knowing is not evidence of recovery', () => {
820
+ const now = { action: 'core-upgrade', state: 'error', target_version: '1.19.949', resolved_at: new Date().toISOString(), last_error: 'x' };
821
+ const p = CU.previewFromSnapshot(ACTIVE, { ...SNAP, recommended: null }, null, now);
822
+ assert.equal(p.last_move.superseded, false, 'the direction to fail in is showing an old failure, never hiding a live one');
823
+ });
824
+
825
+ await t('lastMoveSuperseded computes no version ORDERING — only identity', () => {
826
+ // The page's standing rule is that no version comparison happens outside the runner's
827
+ // channel resolution. This helper answers "is this news" without ever asking which of
828
+ // two versions is newer, and the source is held to that.
829
+ const src = read('modules', 'provisioning', 'core-upgrade.js');
830
+ const body = /function lastMoveSuperseded\([^)]*\) \{([\s\S]*?)\n\}/.exec(src);
831
+ assert.ok(body, 'the helper is there to read');
832
+ assert.doesNotMatch(body[1], /parseVersion|semver|split\(/, 'no version parsing');
833
+ assert.doesNotMatch(body[1], /[<>]=?/, 'and no ordering comparison');
834
+ // And it is genuinely reachable both ways from the same inputs.
835
+ assert.equal(CU.lastMoveSuperseded(null, '1.19.900', '1.19.949'), false, 'no target is not superseded');
836
+ assert.equal(CU.lastMoveSuperseded('1.19.949', '1.19.900', '1.19.949'), false);
837
+ assert.equal(CU.lastMoveSuperseded('1.19.921', '1.19.900', '1.19.949'), true);
838
+ });
839
+
759
840
  console.log(`\ncore_upgrade_door: ${passed} passed, ${failed} failed`);
760
841
  if (failed) process.exit(1);
@@ -836,5 +836,137 @@ await t('and a silent failure STILL refuses rather than inventing a pass', async
836
836
  assert.match(v.error, /said nothing/);
837
837
  });
838
838
 
839
+
840
+ console.log('\nwhat the move would CARRY, not just whether it is allowed (task 1004173):');
841
+
842
+ // The two merge shapes a real core history holds, as `git log --merges
843
+ // --format=%s%x1f%b%x1e` emits them. The second shape is the COMMON one: measured over
844
+ // 1.19.881→1.19.972, 98 of 114 usable summaries came from the CI auto-merge's body.
845
+ const REC = (subject, body = '') => `${subject}\x1f${body}\x1e`;
846
+ const LOG = [
847
+ REC('Merge scoperoster-1002900: The build now catches a missing registration at the moment it happens.'),
848
+ REC('Merge pull request #1108 from an-owner/task-1004167', 'task 1004167: Groundwork for letting an owner make their code repository private.\n'),
849
+ REC('Merge origin/main into scoperoster-1002900 (pre-stage freshness, task 1068)'),
850
+ REC('Merge remote-tracking branch \'origin/main\' into errshape-1003658'),
851
+ REC('Merge pull request #1107 from an-owner/task-1001621'),
852
+ REC('chore: regen repo-map after merge'),
853
+ ].join('');
854
+
855
+ await t('BOTH merge shapes are read — the CI auto-merge carries its summary in the BODY', () => {
856
+ const out = R.parseChangeRecords(LOG);
857
+ assert.equal(out.length, 2, `two usable summaries — got ${JSON.stringify(out)}`);
858
+ assert.match(out[0].text, /catches a missing registration/, 'the hand-landed shape, from the subject');
859
+ assert.equal(out[1].id, '1004167', 'and the CI shape, from the body, with its task id');
860
+ assert.match(out[1].text, /make their code repository private/);
861
+ });
862
+
863
+ await t('the mechanics of landing are DROPPED — they are not the work', () => {
864
+ // Over a real range these outnumber the summaries roughly ten to one, so listing them
865
+ // buries every sentence the owner wants.
866
+ const out = R.parseChangeRecords(LOG).map((e) => e.text).join(' | ');
867
+ assert.doesNotMatch(out, /pre-stage freshness/);
868
+ assert.doesNotMatch(out, /remote-tracking/);
869
+ assert.doesNotMatch(out, /regen repo-map/);
870
+ assert.doesNotMatch(out, /pull request/, 'a merge with no body summary contributes nothing');
871
+ });
872
+
873
+ await t('a branch merged twice says its sentence once', () => {
874
+ const dup = LOG + REC('Merge scoperoster-1002900: The build now catches a missing registration at the moment it happens.');
875
+ assert.equal(R.parseChangeRecords(dup).length, 2);
876
+ });
877
+
878
+ await t('a paragraph-long summary is bounded — it reaches a DOM', () => {
879
+ const out = R.parseChangeRecords(REC(`Merge x-1: ${'y'.repeat(5000)}`));
880
+ assert.ok(out[0].text.length <= 400, `bounded — got ${out[0].text.length}`);
881
+ assert.match(out[0].text, /…$/, 'and says it was cut');
882
+ });
883
+
884
+ // --- the range, and every way it can fail to resolve ------------------------------
885
+
886
+ const changeDeps = ({ shas = { '1.19.900': 'a'.repeat(40), '1.19.949': 'b'.repeat(40) }, log = LOG, logOk = true } = {}) => {
887
+ const ran = [];
888
+ return {
889
+ ran,
890
+ selfInstanceRoot: () => '/srv/core',
891
+ exec: (cmd) => {
892
+ ran.push(cmd);
893
+ const m = /--grep='release: core ([0-9.]+) '/.exec(cmd);
894
+ if (m) return { ok: true, stdout: `${shas[m[1]] || ''}\n` };
895
+ if (!logOk) return { ok: false, softFailed: true, stdout: '' };
896
+ return { ok: true, stdout: log };
897
+ },
898
+ };
899
+ };
900
+
901
+ await t('the range resolves through the release commits and lists what landed between', () => {
902
+ const d = changeDeps();
903
+ const r = R.changesBetween('1.19.900', '1.19.949', d);
904
+ assert.equal(r.error, undefined);
905
+ assert.equal(r.from, '1.19.900');
906
+ assert.equal(r.to, '1.19.949');
907
+ assert.equal(r.entries.length, 2);
908
+ assert.ok(d.ran.some((c) => /--merges/.test(c) && /a{40}\.\.b{40}/.test(c)), 'the log is asked for the resolved range');
909
+ });
910
+
911
+ await t('IT READS THE CONTROL PLANE’S OWN CHECKOUT, whichever instance is previewed', () => {
912
+ // A tenant's directory is a checkout of the TENANT's repo with the core vendored in
913
+ // from npm — no core history in it, and the wrong history if you squint. Under
914
+ // PROVISION_REMOTE_HOST `exec` is the remote box, so the executor matters too.
915
+ const d = changeDeps();
916
+ d.controlExec = d.exec;
917
+ d.exec = () => { throw new Error('the instance executor must not be used for core history'); };
918
+ const r = R.changesBetween('1.19.900', '1.19.949', d);
919
+ assert.equal(r.entries.length, 2);
920
+ for (const c of d.ran) assert.match(c, /git -C \/srv\/core /, 'always the control plane’s own core checkout');
921
+ });
922
+
923
+ await t('A RANGE THAT CANNOT BE RESOLVED IS AN ERROR, NEVER AN EMPTY LIST', () => {
924
+ // An empty list reads as "nothing changed", which over ninety-one releases is the one
925
+ // wrong impression this section could give — and it is the shape a missing checkout,
926
+ // a shallow clone or an unknown release all take.
927
+ const missing = R.changesBetween('1.19.900', '9.9.9', changeDeps());
928
+ assert.equal(missing.entries, undefined, 'no list at all');
929
+ assert.match(missing.error, /could not find those releases/);
930
+
931
+ const unreadable = R.changesBetween('1.19.900', '1.19.949', changeDeps({ logOk: false }));
932
+ assert.equal(unreadable.entries, undefined);
933
+ assert.match(unreadable.error, /could not read the core history/);
934
+ });
935
+
936
+ await t('a version that is not three numbers never reaches the shell', () => {
937
+ const d = changeDeps();
938
+ const r = R.changesBetween("1.19.900'; rm -rf /; echo '", '1.19.949', d);
939
+ assert.match(r.error, /could not work out which releases/);
940
+ assert.deepEqual(d.ran, [], 'and nothing was run at all');
941
+ assert.match(R.changesBetween(null, '1.19.949', changeDeps()).error, /could not work out/, 'an unknown starting point too');
942
+ });
943
+
944
+ await t('a long range is capped, and says how many it did not list', () => {
945
+ const many = Array.from({ length: R.CHANGE_CAP + 5 }, (_, i) => REC(`Merge b-${i}: summary number ${i}`)).join('');
946
+ const r = R.changesBetween('1.19.900', '1.19.949', changeDeps({ log: many }));
947
+ assert.equal(r.entries.length, R.CHANGE_CAP);
948
+ assert.equal(r.more, 5, 'the remainder is counted, not silently dropped');
949
+ });
950
+
951
+ await t('the dry run records the list beside the verdict, keyed to where the box IS', async () => {
952
+ // Both answers are about ONE move, so they are stored together — and `from` is stored
953
+ // with them because the list is only true of that starting point.
954
+ const d = makeDeps({ served: '1.19.762' });
955
+ d.queries.length = 0;
956
+ d.db = { query: async (sql, params) => {
957
+ d.queries.push({ sql, params });
958
+ if (/SELECT served_version/.test(sql)) return { rows: [{ served_version: '1.19.900' }] };
959
+ return { rows: [] };
960
+ } };
961
+ d.preflightUpgrade = () => ({ target: '1.19.949', ok: true, lines: [], error: null });
962
+ d.changesBetween = (from, to) => ({ from, to, entries: [{ text: 'a thing landed' }], more: 0 });
963
+ await R.preflightCoreUpgrade(inst(), d, intent({ target_version: '1.19.949' }));
964
+ const write = d.queries.find((q) => /INSERT INTO provisioning_core_version_snapshot/.test(q.sql));
965
+ const stored = JSON.parse(String(write.params[1]));
966
+ assert.equal(stored.changes.from, '1.19.900', 'the version the box is on right now');
967
+ assert.equal(stored.changes.to, '1.19.949');
968
+ assert.equal(stored.changes.entries[0].text, 'a thing landed');
969
+ });
970
+
839
971
  console.log(`\ncore_upgrade_runner: ${passed} passed, ${failed} failed`);
840
972
  if (failed) process.exit(1);