@bongos/core 1.19.932 → 1.19.934

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.
@@ -356,12 +356,74 @@ test('every commitPin call site in the lane decides about push', () => {
356
356
  }
357
357
  });
358
358
 
359
- test('the direct-upgrade fallback withholds --commit-pin unless the mode is push', () => {
359
+ test('the direct-upgrade fallback asks pinFlagsFor rather than deciding inline', () => {
360
360
  const src = readFileSync(path.join(ROOT, '.claude', 'scheduled-tasks', 'core-update-subscription', 'subscribe.js'), 'utf8');
361
- // --commit-pin makes upgrade.js commit AND push, so the fallback has to gate it too — the
362
- // policy must not depend on which engine the instance's core happened to support.
363
- assert.match(src, /if \(canCommitPin && pinMode === 'push'\) upArgs\.push\('--commit-pin'\)/,
364
- 'the fallback path would push a co-tenant pin through upgrade.js instead of the lane');
361
+ // The fourth version of this assertion. It used to pin the inline `if` verbatim, which froze
362
+ // the bug of task 1004122 in place: the line was correct about NOT pushing a co-tenant pin and
363
+ // wrong about how, and a test that quotes an expression can only ever confirm it is still there.
364
+ assert.match(src, /upArgs\.push\(\.\.\.pinFlagsFor\(\{ pinMode, canCommitPin, canPinNoPush \}\)\)/,
365
+ 'the fallback decides its pin flags inline again, where no test can reach them');
366
+ });
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // 8b. pinFlagsFor — the fallback's pin policy, and the cross-file contract (task 1004122)
370
+ //
371
+ // go-live.js is the PREFERRED engine and the fallback must not apply a weaker policy just
372
+ // because an instance's core could not support it. It did: the fallback withheld --commit-pin
373
+ // on `local-commit`, so upgrade.js left the `.claude/` it had just rewritten uncommitted, the
374
+ // lane's own net can stage nothing but PIN_FILES, and the next sweep halted on those files
375
+ // forever. The instance took exactly one upgrade and then needed a human.
376
+ // ---------------------------------------------------------------------------
377
+
378
+ test('pinFlagsFor: local-commit commits the bump WITHOUT pushing — the fix for the one-upgrade wedge', () => {
379
+ assert.deepEqual(
380
+ S.pinFlagsFor({ pinMode: 'local-commit', canCommitPin: true, canPinNoPush: true }),
381
+ ['--commit-pin', '--pin-no-push'],
382
+ 'upgrade.js commits the pin AND the .claude/ it materialized, and pushes nothing');
383
+ });
384
+
385
+ test('pinFlagsFor: push keeps --commit-pin alone, leave-dirty still commits nothing', () => {
386
+ assert.deepEqual(S.pinFlagsFor({ pinMode: 'push', canCommitPin: true, canPinNoPush: true }), ['--commit-pin']);
387
+ // --pin-no-push WITHOUT --commit-pin: nothing is committed, and upgrade.js's pull-deploy alarm
388
+ // drops to a note, because on this target no reset is coming to revert the bump.
389
+ assert.deepEqual(S.pinFlagsFor({ pinMode: 'leave-dirty', canCommitPin: true, canPinNoPush: true }), ['--pin-no-push']);
390
+ });
391
+
392
+ test('pinFlagsFor: an old core that lacks --pin-no-push gets NOTHING, never the lone --commit-pin', () => {
393
+ // parseArgs silently ignores an unknown flag, so the pair would degrade to a bare --commit-pin
394
+ // and push the bump into the CUSTOMER's repo. Fail closed: keep today's behaviour instead.
395
+ assert.deepEqual(S.pinFlagsFor({ pinMode: 'local-commit', canCommitPin: true, canPinNoPush: false }), []);
396
+ assert.deepEqual(S.pinFlagsFor({ pinMode: 'leave-dirty', canCommitPin: true, canPinNoPush: false }), []);
397
+ // …and a core with no --commit-pin at all is never asked to commit.
398
+ for (const mode of ['push', 'local-commit', 'leave-dirty']) {
399
+ assert.ok(!S.pinFlagsFor({ pinMode: mode, canCommitPin: false, canPinNoPush: true }).includes('--commit-pin'), `mode ${mode}`);
400
+ }
401
+ });
402
+
403
+ test('pinFlagsFor mirrors go-live.js upgradeCommand() mode for mode', () => {
404
+ // The cross-file contract, pinned at the source in the same way GO_LIVE_REQUIRED is: go-live
405
+ // builds a command STRING on a box, so the two can only be compared through its source.
406
+ const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'go-live.js'), 'utf8');
407
+ const body = src.slice(src.indexOf('function upgradeCommand('));
408
+ const commitPinModes = /\.\.\.\(mode === 'leave-dirty' \? \[\] : \['--commit-pin'\]\)/.test(body);
409
+ const noPushModes = /\.\.\.\(mode === 'push' \? \[\] : \['--pin-no-push'\]\)/.test(body);
410
+ assert.ok(commitPinModes && noPushModes, 'go-live.js upgradeCommand() moved — re-derive pinFlagsFor from it');
411
+ for (const mode of ['push', 'local-commit', 'leave-dirty']) {
412
+ const goLive = [
413
+ ...(mode === 'leave-dirty' ? [] : ['--commit-pin']),
414
+ ...(mode === 'push' ? [] : ['--pin-no-push']),
415
+ ];
416
+ assert.deepEqual(
417
+ S.pinFlagsFor({ pinMode: mode, canCommitPin: true, canPinNoPush: true }), goLive,
418
+ `mode ${mode}: the fallback applies a different pin policy from the engine it stands in for`);
419
+ }
420
+ });
421
+
422
+ test('supportsPinNoPush probes the file, and is fail-closed on anything it cannot read', () => {
423
+ assert.equal(S.supportsPinNoPush('/x/upgrade.js', () => 'usage: --commit-pin --pin-no-push'), true);
424
+ assert.equal(S.supportsPinNoPush('/x/upgrade.js', () => 'usage: --commit-pin'), false, 'a core that predates the flag');
425
+ assert.equal(S.supportsPinNoPush(null, () => '--pin-no-push'), false);
426
+ assert.equal(S.supportsPinNoPush('/x/upgrade.js', () => { throw new Error('ENOENT'); }), false);
365
427
  });
366
428
 
367
429
  // ---------------------------------------------------------------------------
package/tests/upgrade.mjs CHANGED
@@ -171,6 +171,58 @@ t('guard: dirty tree refuses (unless --force)', async () => {
171
171
  assert.match(res.error, /not clean/);
172
172
  });
173
173
 
174
+ // task 1004122 — the pre-flight must not refuse on what step 5 is about to overwrite anyway.
175
+ // An instance upgraded before task 1003957 carries ~40 modified `.claude/skills/*` nobody
176
+ // edited, and provision-core-upgrade.js (the Deploy page) passes --commit-pin but NOT --force,
177
+ // so the button stayed dead forever. The waiver is scoped to what a dry-run materialize on THIS
178
+ // instance says it rewrites; anything else still halts.
179
+ t('guard: a dirty tree that is ONLY this upgrade\'s own .claude/ materialization proceeds', async () => {
180
+ const src = mkdtempSync(join(tmpdir(), 'src-'));
181
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
182
+ const dir = scratchConsumer();
183
+ const dryRuns = [];
184
+ const res = await u.runUpgrade(
185
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, healthCheck: false, skipMigrate: true },
186
+ {
187
+ log: () => {}, err: () => {},
188
+ run: fakeRun([], { dirty: ' M .claude/skills/builder-end/SKILL.md\n M .claude/settings.json', installTo: '1.15.0', dir }),
189
+ materialize: (a) => { if (a.dryRun) dryRuns.push(a); return { skills: 2, writtenPaths: ['.claude/skills/builder-end/SKILL.md', '.claude/settings.json'] }; },
190
+ regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
191
+ databaseUrl: '', pgDatabase: '',
192
+ }
193
+ );
194
+ assert.equal(res.ok, true, 'the bump runs, and step 5 rewrites those same files');
195
+ assert.equal(dryRuns.length, 1, 'the pre-flight previewed the materialization exactly once');
196
+ assert.equal(dryRuns[0].instanceDir, dir);
197
+ assert.ok(dryRuns[0].coreRoot.endsWith(join('node_modules', '@bongos', 'core')), 'against the core the instance has NOW — the target is not installed yet');
198
+ });
199
+
200
+ t('guard: a dirty tree with anything ELSE in it still refuses', async () => {
201
+ const dir = scratchConsumer();
202
+ const res = await u.runUpgrade({ to: '1.15.0', instance: dir }, {
203
+ log: () => {}, err: () => {},
204
+ run: fakeRun([], { dirty: ' M .claude/settings.json\n M migrations/core_253_wip.sql' }),
205
+ materialize: () => ({ skills: 1, writtenPaths: ['.claude/settings.json'] }),
206
+ });
207
+ assert.equal(res.ok, false);
208
+ assert.match(res.error, /not clean/);
209
+ assert.match(res.error, /migrations\/core_253_wip\.sql/, 'and it names the path that is genuinely a human\'s');
210
+ assert.ok(!res.error.includes('.claude/settings.json'), 'without re-listing the half it waived');
211
+ });
212
+
213
+ t('guard: --skip-materialize waives nothing — step 5 rewrites nothing, so every dirty path is real', async () => {
214
+ const dir = scratchConsumer();
215
+ let asked = 0;
216
+ const res = await u.runUpgrade({ to: '1.15.0', instance: dir, skipMaterialize: true }, {
217
+ log: () => {}, err: () => {},
218
+ run: fakeRun([], { dirty: ' M .claude/settings.json' }),
219
+ materialize: () => { asked += 1; return { skills: 1, writtenPaths: ['.claude/settings.json'] }; },
220
+ });
221
+ assert.equal(res.ok, false);
222
+ assert.match(res.error, /not clean/);
223
+ assert.equal(asked, 0, 'and materialize was never even asked');
224
+ });
225
+
174
226
  t('guard: already on the target refuses (unless --force)', async () => {
175
227
  const dir = scratchConsumer({ installed: '1.15.0' });
176
228
  const res = await u.runUpgrade({ to: '1.15.0', instance: dir }, { log: () => {}, err: () => {}, run: fakeRun([], { dirty: '' }) });
@@ -365,11 +365,10 @@ test('real git: an owner edit outside the generated files is never swept into th
365
365
  // when anything but the pin is dirty, which is simultaneously the `failed=1` on a successful
366
366
  // upgrade and the next sweep's permanent halt.
367
367
 
368
- const { materializeClaude } = require('../scripts/gds/claude-materialize.js');
368
+ const { materializeClaude, materializeWillRewrite } = require('../scripts/gds/claude-materialize.js');
369
369
 
370
370
  // A minimal core package: just the .claude/ tree materialize copies out of it.
371
- function scratchCore(version) {
372
- const dir = mkdtempSync(join(tmpdir(), 'upgrade-core-'));
371
+ function coreAt(dir, version) {
373
372
  put(dir, 'package.json', JSON.stringify({ name: '@bongos/core', version }));
374
373
  put(dir, 'config/branding.json', JSON.stringify({ identity: {} }));
375
374
  put(dir, '.claude/skills/builder-end/SKILL.md', `# builder-end (core ${version})\n`);
@@ -378,6 +377,13 @@ function scratchCore(version) {
378
377
  put(dir, '.claude/hooks/stop.js', `// hook for ${version}\n`);
379
378
  return dir;
380
379
  }
380
+ function scratchCore(version) {
381
+ return coreAt(mkdtempSync(join(tmpdir(), 'upgrade-core-')), version);
382
+ }
383
+ // The same tree where an instance really carries it — what materializeWillRewrite probes.
384
+ function installCore(instanceDir, version) {
385
+ return coreAt(join(instanceDir, 'node_modules', '@bongos', 'core'), version);
386
+ }
381
387
 
382
388
  // A scaffolded co-tenant that already TRACKS the .claude/ a previous materialize landed.
383
389
  function scratchCoTenant(version = '1.19.737') {
@@ -390,6 +396,7 @@ function scratchCoTenant(version = '1.19.737') {
390
396
  put(dir, 'package-lock.json', `lock ${version}\n`);
391
397
  put(dir, 'config/branding.json', JSON.stringify({ identity: {} }));
392
398
  put(dir, 'CLAUDE.md', 'the instance owns this\n');
399
+ put(dir, '.gitignore', 'node_modules/\n');
393
400
  materializeClaude({ coreRoot: scratchCore(version), instanceDir: dir, log: quiet });
394
401
  git(dir, 'add', '-A');
395
402
  git(dir, 'commit', '-q', '-m', 'scaffold');
@@ -477,3 +484,90 @@ test('adopt mode records nothing, so `init --adopt` never commits over what it r
477
484
  assert.ok(!sum.writtenPaths.includes('.claude/settings.json'));
478
485
  assert.equal(readFileSync(join(dir, '.claude/skills/builder-end/SKILL.md'), 'utf8'), 'the adopting project already had this\n');
479
486
  });
487
+
488
+ // ── the fleet that was ALREADY wedged (task 1004122) ────────────────────────────
489
+ //
490
+ // Task 1003957 above stops an upgrade CREATING the dirt. It does nothing for the instances
491
+ // that were upgraded before it shipped: every one of them still carries ~40 modified
492
+ // `.claude/skills/*` files nobody edited, and the clean-tree pre-flight refuses on them. So
493
+ // `test4` (instance 11) failed its Deploy-page upgrade on 2026-09-20 — provision-core-upgrade.js
494
+ // passes --commit-pin but NOT --force, so the button stays dead until someone commits by hand.
495
+ //
496
+ // The fix is that the pre-flight stops counting a path THIS upgrade is about to overwrite as
497
+ // evidence of anything. The set is not an allow-list: it is what a dry-run materialize against
498
+ // the core actually installed here says it would write.
499
+
500
+ test('gitTreeClean: an ignored path stops counting, and is reported rather than hidden', () => {
501
+ const dir = scratchCoTenant();
502
+ writeFileSync(join(dir, '.claude/skills/builder-end/SKILL.md'), 'rewritten by an earlier upgrade\n');
503
+ assert.equal(gitTreeClean(dir).ok, false, 'by default it is dirt like any other');
504
+ const t = gitTreeClean(dir, spawnSync, ['.claude/skills/builder-end/SKILL.md']);
505
+ assert.equal(t.ok, true);
506
+ assert.equal(t.ignored, 1, 'and the caller can say how many it waived');
507
+ assert.equal(t.dirty, '');
508
+ });
509
+
510
+ test('gitTreeClean: ignoring the materialized paths never waives anything else', () => {
511
+ const dir = scratchCoTenant();
512
+ writeFileSync(join(dir, '.claude/skills/builder-end/SKILL.md'), 'rewritten by an earlier upgrade\n');
513
+ put(dir, 'config/branding.json', 'the owner was here\n');
514
+ const t = gitTreeClean(dir, spawnSync, ['.claude/skills/builder-end/SKILL.md']);
515
+ assert.equal(t.ok, false, 'the owner edit still halts the bump');
516
+ assert.match(t.dirty, /config\/branding\.json/);
517
+ assert.ok(!t.dirty.includes('builder-end'), 'and only the materialized half was waived');
518
+ assert.equal(t.ignored, 1);
519
+ });
520
+
521
+ test('materializeWillRewrite: the set comes from the core the instance actually has', () => {
522
+ const dir = scratchCoTenant();
523
+ installCore(dir, '1.19.785');
524
+ const paths = materializeWillRewrite({ instanceDir: dir, materialize: materializeClaude }, quiet);
525
+ assert.ok(paths.includes('.claude/skills/builder-end/SKILL.md'));
526
+ assert.ok(paths.includes('.claude/settings.json'));
527
+ assert.ok(paths.includes('.claude/hooks/stop.js'));
528
+ assert.ok(paths.includes('.claude/skills/.instance-skills.json'), 'the provenance manifest is rewritten every run too');
529
+ assert.ok(!paths.includes('CLAUDE.md'), 'a file materialize writes only when ABSENT is not rewritten — the instance owns it');
530
+ assert.equal(git(dir, 'status', '--porcelain').stdout, '', 'and previewing it changed nothing on disk');
531
+ });
532
+
533
+ test('materializeWillRewrite: no core installed → no skill is waived, and a throw waives nothing at all', () => {
534
+ const dir = scratchCoTenant();
535
+ // Nothing to copy out of: materialize reports only the provenance record it rewrites anyway.
536
+ const paths = materializeWillRewrite({ instanceDir: dir, materialize: materializeClaude }, quiet);
537
+ assert.ok(!paths.some((p) => p.startsWith('.claude/skills/builder-')), 'no skill file is waived on the strength of a core that is not there');
538
+
539
+ // And an outright failure is fail-SOFT in the strict direction — an empty set plus a note.
540
+ const notes = [];
541
+ const boom = () => { throw new Error('unreadable core tree'); };
542
+ assert.deepEqual(materializeWillRewrite({ instanceDir: dir, materialize: boom }, (m) => notes.push(m)), []);
543
+ assert.equal(notes.length, 1, 'it says so rather than failing silently');
544
+ assert.match(notes[0], /unreadable core tree/);
545
+ });
546
+
547
+ test('real git: an instance wedged by a pre-1003957 upgrade unwedges itself on the next bump', () => {
548
+ const dir = scratchCoTenant();
549
+ sweep(dir, '1.19.737', '1.19.785', { artifactPaths: [] }); // the upgrade that left the mess
550
+ installCore(dir, '1.19.785'); // …and the core it installed
551
+ assert.equal(gitTreeClean(dir).ok, false, 'today: the next `bongos upgrade` refuses here');
552
+
553
+ // What runUpgrade's pre-flight now does.
554
+ const rewritable = materializeWillRewrite({ instanceDir: dir, materialize: materializeClaude }, quiet);
555
+ assert.equal(gitTreeClean(dir, spawnSync, rewritable).ok, true, 'the bump is allowed to proceed');
556
+
557
+ // …and proceeding is what cleans it up: step 5 rewrites those same paths and persistPin commits them.
558
+ const { pin } = sweep(dir, '1.19.785', '1.19.786');
559
+ assert.equal(pin.committed, true);
560
+ assert.equal(git(dir, 'status', '--porcelain').stdout, '', 'one bump, no human hands, tree clean');
561
+ });
562
+
563
+ test('real git: a wedged instance with an owner edit beside the mess still refuses', () => {
564
+ const dir = scratchCoTenant();
565
+ sweep(dir, '1.19.737', '1.19.785', { artifactPaths: [] });
566
+ installCore(dir, '1.19.785');
567
+ put(dir, 'core_999_half_written.sql', '-- not finished\n');
568
+ const rewritable = materializeWillRewrite({ instanceDir: dir, materialize: materializeClaude }, quiet);
569
+ const t = gitTreeClean(dir, spawnSync, rewritable);
570
+ assert.equal(t.ok, false, 'a human really does need to look');
571
+ assert.match(t.dirty, /core_999_half_written\.sql/);
572
+ assert.ok(!t.dirty.includes('.claude/skills/builder-end'), 'the materialization half is still waived');
573
+ });