@bongos/core 1.19.372 → 1.19.374

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 (32) hide show
  1. package/.bongos-core.json +54 -34
  2. package/.claude/skills/goal-create/SKILL.md +7 -4
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -0
  5. package/clients/bongos-client/index.cjs +4 -0
  6. package/clients/bongos-client/index.d.ts +5 -1
  7. package/clients/bongos-client/index.mjs +4 -0
  8. package/docs/adr/0161-publish-on-merge.md +1 -1
  9. package/docs/adr/0180-dependency-intake-and-drift-policy.md +6 -0
  10. package/docs/api/openapi.json +79 -4
  11. package/docs/api-reference.md +5 -3
  12. package/docs/copy-inventory.md +6 -3
  13. package/docs/copy-registry.json +32 -5
  14. package/modules/builder-settings/profile-nudge-prefs.js +125 -0
  15. package/modules/builder-settings/routes/settings.js +48 -0
  16. package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
  17. package/modules/hall-ui/public/goal-inbox.js +10 -0
  18. package/modules/hall-ui/public/index.html +4 -0
  19. package/modules/hall-ui/public/profile-nudge.js +122 -0
  20. package/modules/lifecycle/goal-category.js +28 -0
  21. package/modules/lifecycle/routes/goals.js +10 -1
  22. package/package-lock.json +2 -2
  23. package/package.json +1 -1
  24. package/scripts/gds/bump-version.js +41 -1
  25. package/scripts/gds/fitness-checks-write-validation.js +1 -0
  26. package/scripts/gds/ship-land.js +94 -3
  27. package/src/module-api.js +5 -1
  28. package/tests/bump_version.mjs +77 -1
  29. package/tests/goal_category.mjs +28 -0
  30. package/tests/profile_nudge_prefs.mjs +97 -0
  31. package/tests/ship_push_diagnosis.mjs +78 -0
  32. package/tests/sound_player.mjs +38 -0
@@ -406,6 +406,80 @@ function rerunGateReviewIfStale(branch, headSha) {
406
406
  return ghArgvOk(['run', 'rerun', String(id)]);
407
407
  }
408
408
 
409
+ // ---------- why the push failed, and whether retrying can ever help ----------
410
+ //
411
+ // A failed `git push` already parks the task at `confirmed` and opens no PR,
412
+ // which is correct. What was wrong (task 1003293) was the ADVICE: "Fix the push
413
+ // and re-ship" is useless for the most common cause, because re-shipping fails
414
+ // identically every time and the reconciler cannot heal a branch that was never
415
+ // pushed. The task then sits at `confirmed` with its code on one laptop, looking
416
+ // like an ordinary pending land. That happened twice in one session — both times
417
+ // on a commit touching .github/workflows/, both times invisible until someone
418
+ // checked git instead of the log.
419
+ //
420
+ // So the distinction that matters is TERMINAL vs transient. A terminal failure
421
+ // must name the actual remedy; a transient one may honestly say "re-ship".
422
+ // `argvOk` runs the push with stdio:'inherit', so git's own message reaches the
423
+ // log but nothing ever reads it — these patterns read it.
424
+ const PUSH_DIAGNOSES = [
425
+ {
426
+ id: 'workflow_scope',
427
+ // GitHub's wording: "refusing to allow an OAuth App to create or update
428
+ // workflow `.github/workflows/x.yml` without `workflow` scope".
429
+ match: /without .{0,3}workflow.{0,3} scope|refusing to allow (?:an?|the) \S+ (?:App|integration)/i,
430
+ terminal: true,
431
+ why: 'this branch changes a file under .github/workflows/, and the credential git used has no `workflow` scope — GitHub refuses that push regardless of how often it is retried',
432
+ fix: 'push over SSH once, then re-run the ship:\n git push git@github.com:<owner>/<repo>.git <branch>\n (or re-authenticate the CLI with the `workflow` scope: gh auth refresh -h github.com -s workflow)',
433
+ },
434
+ {
435
+ id: 'protected_branch',
436
+ match: /GH006|protected branch|required status check/i,
437
+ terminal: true,
438
+ why: 'branch protection refused the push outright',
439
+ fix: 'land through a pull request instead of pushing directly, or have an admin adjust the rule',
440
+ },
441
+ {
442
+ id: 'auth',
443
+ match: /Authentication failed|could not read Username|Permission denied \(publickey\)|remote: Invalid username or password|403 Forbidden/i,
444
+ terminal: true,
445
+ why: 'git could not authenticate to the remote at all',
446
+ fix: 'restore the credential (gh auth login, or an SSH key the remote accepts), then re-run the ship',
447
+ },
448
+ {
449
+ id: 'stale_branch',
450
+ match: /non-fast-forward|fetch first|behind its remote counterpart|Updates were rejected/i,
451
+ terminal: false,
452
+ why: 'the remote branch has commits this checkout does not',
453
+ fix: 'git fetch && git rebase origin/<branch>, then re-run the ship',
454
+ },
455
+ ];
456
+
457
+ // Pure: classify a failed push from git's stderr. Exported + tested.
458
+ // An unrecognised failure is reported as NON-terminal on purpose — "try again"
459
+ // is the safe default when we do not know, and a wrong "this can never work"
460
+ // would send someone hunting a problem that does not exist.
461
+ function pushDiagnosis(stderr) {
462
+ const text = String(stderr == null ? '' : stderr);
463
+ for (const d of PUSH_DIAGNOSES) {
464
+ if (d.match.test(text)) return { id: d.id, terminal: d.terminal, why: d.why, fix: d.fix };
465
+ }
466
+ return { id: 'unknown', terminal: false, why: 'the push failed for a reason this lane does not recognise', fix: 'read git\'s message above, resolve it, then re-run the ship' };
467
+ }
468
+
469
+ // Push, capturing stderr so it can be diagnosed — and echoing it so the log still
470
+ // shows exactly what git said, which stdio:'inherit' gave us for free.
471
+ function pushCapturing(branch, deps = {}) {
472
+ const exec = deps.execFileSync || require('node:child_process').execFileSync;
473
+ try {
474
+ exec('git', ['push', 'origin', branch], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
475
+ return { ok: true, stderr: '' };
476
+ } catch (e) {
477
+ const stderr = [e && e.stderr, e && e.stdout].filter(Boolean).join('\n');
478
+ if (stderr) process.stderr.write(stderr.endsWith('\n') ? stderr : `${stderr}\n`);
479
+ return { ok: false, stderr };
480
+ }
481
+ }
482
+
409
483
  // ciLandLocal — today's local path: this machine pushes the branch + opens the PR
410
484
  // + enables auto-merge via git + gh. async since task 1169 — it asks the server to
411
485
  // post the gate-author-trust attestation between the push and the PR open.
@@ -413,9 +487,24 @@ async function ciLandLocal(taskId, branch, valueSummary) {
413
487
  const headSha = gitOk('git rev-parse HEAD');
414
488
 
415
489
  console.log(` pushing ${branch} to origin...`);
416
- if (!gitArgvOk(['push', 'origin', branch])) {
417
- console.log(' ci-land: push failed; task stays at confirmed. Fix the push and re-ship.');
418
- return landBailed(`could not push ${branch} to origin`, `node scripts/gds/ship.js ${taskId}`);
490
+ const pushed = pushCapturing(branch);
491
+ if (!pushed.ok) {
492
+ const d = pushDiagnosis(pushed.stderr);
493
+ console.log(` ci-land: push FAILED — ${d.why}.`);
494
+ console.log(` ci-land: nothing was pushed and no PR exists; task stays at confirmed.`);
495
+ if (d.terminal) {
496
+ // The important line. Re-shipping cannot fix this, and the reconciler
497
+ // cannot heal a branch that never reached the remote — so do not say
498
+ // "re-ship" and leave the work stranded on one machine.
499
+ console.log(' ci-land: RE-SHIPPING WILL FAIL THE SAME WAY. Do this instead:');
500
+ console.log(` ${d.fix}`);
501
+ } else {
502
+ console.log(` ci-land: to retry — ${d.fix}`);
503
+ }
504
+ return landBailed(
505
+ `could not push ${branch} to origin (${d.id})`,
506
+ d.terminal ? d.fix.split('\n')[0] : `node scripts/gds/ship.js ${taskId}`,
507
+ );
419
508
  }
420
509
 
421
510
  // task 1169 (ADR 0058): attest the author's trust tier on the head SHA BEFORE
@@ -545,6 +634,8 @@ function pollCiDeploy(taskId, branch, headSha) {
545
634
 
546
635
  module.exports = {
547
636
  localHistoryRewritten, // task 1003221 — the append-only discriminator
637
+ pushDiagnosis, // task 1003293 — a terminal push failure must not advise a retry
638
+ PUSH_DIAGNOSES,
548
639
  ciLand,
549
640
  classifyLandOutcome,
550
641
  formatPublishFailure,
package/src/module-api.js CHANGED
@@ -1174,8 +1174,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1174
1174
  // landed since 1.19.370 with no explicit bump. run 32904582252. (task 1002620)
1175
1175
  // 1.19.372 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1176
1176
  // landed since 1.19.371 with no explicit bump. run 32905166786. (task 1002620)
1177
+ // 1.19.373 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1178
+ // landed since 1.19.372 with no explicit bump. run 32906118616. (task 1002620)
1179
+ // 1.19.374 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1180
+ // landed since 1.19.373 with no explicit bump. run 32906477349. (task 1002620)
1177
1181
  // ---------------------------------------------------------------------------
1178
- const CORE_VERSION = '1.19.372'; // CI auto-patch carrier (ADR 0161); see changelog above
1182
+ const CORE_VERSION = '1.19.374'; // CI auto-patch carrier (ADR 0161); see changelog above
1179
1183
 
1180
1184
  // A namespaced logger so a module's log lines are attributable + consistent.
1181
1185
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -16,7 +16,10 @@ import { createRequire } from 'node:module';
16
16
 
17
17
  const require = createRequire(import.meta.url);
18
18
  const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
19
- const { bumpPatch, CONST_RE } = require(path.join(ROOT, 'scripts', 'gds', 'bump-version.js'));
19
+ const bump = require(path.join(ROOT, 'scripts', 'gds', 'bump-version.js'));
20
+ const { bumpPatch, CONST_RE } = bump;
21
+ // The LIVE test below reads this repo's real release history (task 1003202).
22
+ const { execFileSync } = require('node:child_process');
20
23
 
21
24
  const SAMPLE = [
22
25
  '// prologue',
@@ -111,3 +114,76 @@ test('publish.yml wires the terminator: guard step present, every armed step gat
111
114
  const gates = (PUBLISH_YML.match(/steps\.relhead\.outputs\.release_head == 'false'/g) || []).length;
112
115
  assert.ok(gates >= 7, `every post-guard armed step must carry the gate (setup-node, install, resolve, bump, pack, publish, tag) — found ${gates}`);
113
116
  });
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // laneReleasedVersions — the token-free "is this version already published?"
120
+ // (task 1003202 / blocker 1000115, the OIDC trusted-publishing migration).
121
+ //
122
+ // publish.yml used to ask the REGISTRY, which needs a credential: @bongos/core is
123
+ // private, so an unauthenticated `npm view` is a flat E404 rather than a "no".
124
+ // OIDC authenticates `npm publish` and nothing else, so keeping that check would
125
+ // have reported "not taken" every run, skipped the auto-bump, and collided on the
126
+ // published version — working for exactly one release before breaking. git holds
127
+ // the same answer, needs no auth, and is the source the cascade terminator above
128
+ // already trusts.
129
+ // ---------------------------------------------------------------------------
130
+
131
+ test('laneReleasedVersions reads the versions this lane announced', () => {
132
+ const subjects = [
133
+ 'release: core 1.19.368 — CI auto-patch carrier (publish-on-merge, ADR 0161)',
134
+ 'Merge pull request #385 from lars589/some-branch',
135
+ 'release: core 1.19.367 — CI auto-patch carrier (publish-on-merge, ADR 0161)',
136
+ 'feat(security): something unrelated',
137
+ ];
138
+ const v = bump.laneReleasedVersions(subjects);
139
+ assert.deepEqual([...v].sort(), ['1.19.367', '1.19.368']);
140
+ assert.equal(v.has('1.19.369'), false, 'a version never announced must read as free');
141
+ });
142
+
143
+ test('a HUMAN release commit is NOT counted — that version still needs publishing', () => {
144
+ // The same distinction RELEASE_COMMIT_RE was written to make for the cascade
145
+ // terminator. Counting it here would skip the publish of a hand-cut release.
146
+ const v = bump.laneReleasedVersions([
147
+ 'release: core 1.19.2 — carrier for the init-blocker fixes',
148
+ 'release: core 1.20.0 — hand-cut minor',
149
+ ]);
150
+ assert.equal(v.size, 0);
151
+ });
152
+
153
+ test('malformed and empty input yields an empty set, never a throw', () => {
154
+ // This runs inside the publish lane. A crash here would take down releases;
155
+ // an empty set degrades to "nothing is taken", which the publish itself
156
+ // catches with EPUBLISHCONFLICT — loud, and nothing wrong ships.
157
+ assert.equal(bump.laneReleasedVersions([]).size, 0);
158
+ assert.equal(bump.laneReleasedVersions().size, 0);
159
+ assert.equal(bump.laneReleasedVersions([null, undefined, '', 42]).size, 0);
160
+ });
161
+
162
+ test('laneReleasedVersions and isReleaseCommit agree on what a lane release looks like', () => {
163
+ // They share RELEASE_COMMIT_RE on purpose: if one ever recognises a subject the
164
+ // other does not, the cascade terminator and the version check disagree about
165
+ // the same commit, and the lane either double-publishes or silently no-ops.
166
+ const subjects = [
167
+ 'release: core 1.19.368 — CI auto-patch carrier (publish-on-merge, ADR 0161)',
168
+ 'release: core 1.19.2 — carrier for the init-blocker fixes',
169
+ 'chore: not a release at all',
170
+ ];
171
+ for (const s of subjects) {
172
+ const counted = bump.laneReleasedVersions([s]).size === 1;
173
+ assert.equal(counted, bump.isReleaseCommit(s), `disagreement on: ${s}`);
174
+ }
175
+ });
176
+
177
+ test('LIVE: the version HEAD declares is one this lane already released', () => {
178
+ // The steady state the lane depends on: HEAD carries the version set by the
179
+ // previous release commit, so `taken` is true and the auto-bump fires. If this
180
+ // ever reads false on a normal main checkout, the lane would try to republish
181
+ // an existing version.
182
+ const headVersion = /^const CORE_VERSION = '([0-9.]+)';/m
183
+ .exec(fs.readFileSync(path.join(ROOT, 'src', 'module-api.js'), 'utf8'))[1];
184
+ const subjects = execFileSync('git', ['log', '--format=%s', 'HEAD'], { encoding: 'utf8', cwd: ROOT }).split('\n');
185
+ const released = bump.laneReleasedVersions(subjects);
186
+ assert.ok(released.size > 100, `expected a long release history, saw ${released.size}`);
187
+ assert.equal(released.has(headVersion), true,
188
+ `HEAD declares ${headVersion}, which this lane has no release commit for — the auto-bump would not fire`);
189
+ });
@@ -145,6 +145,34 @@ test('an unknown category is a 404, not a silently-created one', async () => {
145
145
  assert.equal(res.statusCode, 404);
146
146
  });
147
147
 
148
+ // ---- the advisory (task 1003277) --------------------------------------------
149
+
150
+ test('an uncategorised goal earns an advisory that carries the live set and the create path', () => {
151
+ const adv = gc.adviseCategory({ categoryId: null, categories: [
152
+ { id: 3, slug: 'security', label: 'Security' },
153
+ { id: 6, slug: 'economics', label: 'Economics' },
154
+ ] });
155
+ assert.equal(adv.reason, 'uncategorised');
156
+ assert.deepEqual(adv.categories.map((c) => c.slug), ['security', 'economics'],
157
+ 'the live set rides along so a client offers real choices, never hard-coded labels');
158
+ assert.match(adv.message, /POST \/work-categories/, 'the prompt has somewhere to write into');
159
+ assert.match(adv.message, /designed prompt, not an error/, 'the criterion\'s own framing travels with the advice');
160
+ assert.match(adv.message, /gates nothing/, 'and so does the rule that outranks the feature');
161
+ });
162
+
163
+ test('a categorised goal gets NO advisory — silence, the fail-open posture', () => {
164
+ assert.equal(gc.adviseCategory({ categoryId: 3, categories: [] }), null);
165
+ });
166
+
167
+ test('the advisory is advice on a 201, never a refusal', () => {
168
+ // Pinned in source: POST /goals attaches category_advisory to its SUCCESS body
169
+ // and never fails the create over it (best-effort, wrapped in try).
170
+ const routes = read('modules', 'lifecycle', 'routes', 'goals.js');
171
+ assert.match(routes, /category_advisory = goalCategory\.adviseCategory/);
172
+ assert.match(routes, /res\.status\(201\)\.json\(\{ ok: true, goal, succeeds_goal_id: succeedsId, category_advisory \}\)/,
173
+ 'it rides the 201 — advice appended to a request that already succeeded');
174
+ });
175
+
148
176
  // ---- decoration ------------------------------------------------------------
149
177
 
150
178
  test('a goal carries a readable category, and an uncategorised one carries null', () => {
@@ -0,0 +1,97 @@
1
+ // tests/profile_nudge_prefs.mjs — the "Complete your profile" nudge shaper
2
+ // (task 1002802 / P06, goal 1000045).
3
+ //
4
+ // The nudge replaced the profile-setup wizard spec Decision 2 rejected: a
5
+ // DISMISSIBLE line item in the hall's needs-attention inbox, shown when bio or
6
+ // preferred_disciplines are empty. These pin the pure decision layer — what
7
+ // counts as missing, when the line shows, and that a dismissal is durable and
8
+ // reversible without disturbing the sibling knobs sharing behavior_prefs.
9
+ //
10
+ // Run: node --test tests/profile_nudge_prefs.mjs
11
+
12
+ import assert from 'node:assert/strict';
13
+ import { test } from 'node:test';
14
+ import { createRequire } from 'node:module';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const p = require('../modules/builder-settings/profile-nudge-prefs.js');
18
+
19
+ test('missingProfileFields: empty, whitespace, and absent all count as missing', () => {
20
+ assert.deepEqual(p.missingProfileFields({ bio: '', preferredDisciplines: [] }), ['bio', 'disciplines']);
21
+ assert.deepEqual(p.missingProfileFields({ bio: ' \n\t ', preferredDisciplines: [] }), ['bio', 'disciplines']);
22
+ assert.deepEqual(p.missingProfileFields({}), ['bio', 'disciplines']);
23
+ assert.deepEqual(p.missingProfileFields({ bio: null, preferredDisciplines: null }), ['bio', 'disciplines']);
24
+ });
25
+
26
+ test('missingProfileFields: a real value on either side drops only that entry', () => {
27
+ assert.deepEqual(p.missingProfileFields({ bio: 'I build things', preferredDisciplines: [] }), ['disciplines']);
28
+ assert.deepEqual(p.missingProfileFields({ bio: '', preferredDisciplines: ['engineer'] }), ['bio']);
29
+ assert.deepEqual(p.missingProfileFields({ bio: 'x', preferredDisciplines: ['engineer'] }), []);
30
+ });
31
+
32
+ test('missingProfileFields: a non-array disciplines value is missing, not a crash', () => {
33
+ assert.deepEqual(p.missingProfileFields({ bio: 'x', preferredDisciplines: 'engineer' }), ['disciplines']);
34
+ assert.deepEqual(p.missingProfileFields({ bio: 'x', preferredDisciplines: {} }), ['disciplines']);
35
+ });
36
+
37
+ test('nudgeDismissed: defaults to NOT dismissed; only a real boolean overrides', () => {
38
+ assert.equal(p.nudgeDismissed({}), false);
39
+ assert.equal(p.nudgeDismissed(null), false);
40
+ assert.equal(p.nudgeDismissed({ profile_nudge_dismissed: true }), true);
41
+ assert.equal(p.nudgeDismissed({ profile_nudge_dismissed: false }), false);
42
+ // Garbage in the blob must not read as dismissed — that would silence the
43
+ // nudge for a builder who never dismissed it.
44
+ assert.equal(p.nudgeDismissed({ profile_nudge_dismissed: 'yes' }), false);
45
+ assert.equal(p.nudgeDismissed({ profile_nudge_dismissed: 1 }), false);
46
+ });
47
+
48
+ test('shouldNudge: shows only when something is missing AND not dismissed', () => {
49
+ const missing = { bio: '', preferredDisciplines: [] };
50
+ const complete = { bio: 'x', preferredDisciplines: ['engineer'] };
51
+ assert.equal(p.shouldNudge({ behaviorPrefs: {}, ...missing }), true);
52
+ assert.equal(p.shouldNudge({ behaviorPrefs: { profile_nudge_dismissed: true }, ...missing }), false);
53
+ assert.equal(p.shouldNudge({ behaviorPrefs: {}, ...complete }), false);
54
+ assert.equal(p.shouldNudge({ behaviorPrefs: { profile_nudge_dismissed: true }, ...complete }), false);
55
+ });
56
+
57
+ test('describeForApi: reports what is missing even while dismissed (show is the gate)', () => {
58
+ const d = p.describeForApi({ profile_nudge_dismissed: true }, { bio: '', preferredDisciplines: [] });
59
+ assert.equal(d.dismissed, true);
60
+ assert.equal(d.show, false, 'a dismissed nudge never shows');
61
+ assert.deepEqual(d.missing, ['bio', 'disciplines'], 'but the API still says what is empty');
62
+ assert.equal(d.override, true);
63
+ assert.equal(d.default_dismissed, false);
64
+ });
65
+
66
+ test('validatePatch: accepts dismissed true/false/null, refuses everything else', () => {
67
+ assert.deepEqual(p.validatePatch({ dismissed: true }), { ok: true, patch: { profile_nudge_dismissed: true } });
68
+ assert.deepEqual(p.validatePatch({ dismissed: false }), { ok: true, patch: { profile_nudge_dismissed: false } });
69
+ assert.deepEqual(p.validatePatch({ dismissed: null }), { ok: true, patch: { profile_nudge_dismissed: null } });
70
+ assert.deepEqual(p.validatePatch({}), { ok: true, patch: {} }, 'empty body is a valid no-op');
71
+ assert.equal(p.validatePatch({ dismissed: 'yes' }).ok, false);
72
+ assert.equal(p.validatePatch({ nope: true }).ok, false);
73
+ assert.equal(p.validatePatch(null).ok, false);
74
+ assert.equal(p.validatePatch([]).ok, false);
75
+ });
76
+
77
+ test('applyPatch: null clears the key; sibling behavior knobs are never disturbed', () => {
78
+ const existing = { wandering: 'focused', widgets: false, interaction: { technical_level: 'engineer' } };
79
+ const set = p.applyPatch(existing, { profile_nudge_dismissed: true });
80
+ assert.equal(set.profile_nudge_dismissed, true);
81
+ assert.equal(set.wandering, 'focused');
82
+ assert.equal(set.widgets, false);
83
+ assert.deepEqual(set.interaction, { technical_level: 'engineer' });
84
+ // Clearing removes the key entirely (back to the sparse default) and still
85
+ // leaves the siblings alone — the undismiss path.
86
+ const cleared = p.applyPatch(set, { profile_nudge_dismissed: null });
87
+ assert.equal('profile_nudge_dismissed' in cleared, false);
88
+ assert.equal(cleared.wandering, 'focused');
89
+ assert.equal(p.nudgeDismissed(cleared), false, 'cleared → the nudge can return');
90
+ // Pure: the input blob is not mutated.
91
+ assert.equal('profile_nudge_dismissed' in existing, false);
92
+ });
93
+
94
+ test('the reserved key is the documented one (behavior_prefs is shared)', () => {
95
+ assert.equal(p.RESERVED_KEY, 'profile_nudge_dismissed');
96
+ assert.equal(p.DEFAULT_DISMISSED, false);
97
+ });
@@ -0,0 +1,78 @@
1
+ // tests/ship_push_diagnosis.mjs — a terminal push failure must not advise a retry
2
+ // (task 1003293).
3
+ //
4
+ // A failed `git push` in ciLandLocal already parks the task at `confirmed` and
5
+ // opens no PR, which is correct. The ADVICE was the defect: "Fix the push and
6
+ // re-ship" cannot work for the most common cause — a commit touching
7
+ // .github/workflows/ pushed with a credential lacking GitHub's `workflow` scope.
8
+ // Re-shipping fails identically, and the reconciler cannot heal a branch that
9
+ // never reached the remote, so the task sits at `confirmed` looking like an
10
+ // ordinary pending land while its code exists on exactly one machine. That
11
+ // happened twice in one session.
12
+ //
13
+ // These tests pin the distinction the fix turns on: TERMINAL (retrying is
14
+ // pointless — say what actually resolves it) vs transient (retrying is the
15
+ // answer).
16
+ import assert from 'node:assert/strict';
17
+ import { test } from 'node:test';
18
+ import { createRequire } from 'node:module';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24
+ const { pushDiagnosis, PUSH_DIAGNOSES } = require(path.join(ROOT, 'scripts', 'gds', 'ship-land.js'));
25
+
26
+ test('THE case that stranded two tasks: the workflow scope rejection', () => {
27
+ // GitHub's real wording, verbatim from the run that stranded task 1003283.
28
+ const d = pushDiagnosis(
29
+ 'remote: error: refusing to allow an OAuth App to create or update workflow '
30
+ + '`.github/workflows/publish.yml` without `workflow` scope\n'
31
+ + 'To https://github.com/owner/repo.git\n ! [remote rejected] branch -> branch');
32
+ assert.equal(d.id, 'workflow_scope');
33
+ assert.equal(d.terminal, true, 'retrying this can never succeed — saying "re-ship" is what stranded the work');
34
+ assert.match(d.fix, /ssh|workflow/i, 'the advice must name the remedy that actually works');
35
+ });
36
+
37
+ test('branch protection and a dead credential are terminal too', () => {
38
+ assert.equal(pushDiagnosis('remote: error: GH006: Protected branch update failed').terminal, true);
39
+ assert.equal(pushDiagnosis('fatal: Authentication failed for https://github.com/o/r.git/').terminal, true);
40
+ assert.equal(pushDiagnosis('git@github.com: Permission denied (publickey).').terminal, true);
41
+ });
42
+
43
+ test('a stale branch IS retryable — the honest "re-ship" case', () => {
44
+ const d = pushDiagnosis('error: failed to push some refs\nUpdates were rejected because the remote contains work that you do not have locally');
45
+ assert.equal(d.id, 'stale_branch');
46
+ assert.equal(d.terminal, false);
47
+ });
48
+
49
+ test('an unrecognised failure defaults to RETRYABLE, never to terminal', () => {
50
+ // The safe direction. A wrong "this can never work" sends someone hunting a
51
+ // problem that does not exist; a wrong "try again" costs one command.
52
+ const d = pushDiagnosis('fatal: the remote end hung up unexpectedly');
53
+ assert.equal(d.id, 'unknown');
54
+ assert.equal(d.terminal, false);
55
+ assert.equal(pushDiagnosis('').terminal, false);
56
+ assert.equal(pushDiagnosis(null).terminal, false);
57
+ assert.equal(pushDiagnosis(undefined).terminal, false);
58
+ });
59
+
60
+ test('every diagnosis carries both a cause and a remedy', () => {
61
+ // A classification with no `fix` is just a nicer way of saying "it broke",
62
+ // which is the state this replaced.
63
+ for (const d of PUSH_DIAGNOSES) {
64
+ assert.ok(d.id && d.why && d.fix, `incomplete diagnosis: ${JSON.stringify(d)}`);
65
+ assert.ok(d.why.length > 20, `${d.id}: "why" must explain, not label`);
66
+ assert.equal(typeof d.terminal, 'boolean');
67
+ }
68
+ const fallback = pushDiagnosis('anything at all');
69
+ assert.ok(fallback.why && fallback.fix);
70
+ });
71
+
72
+ test('the patterns do not collide — the first match is the RIGHT match', () => {
73
+ // A workflow-scope rejection also contains "error:" and often "403"; if the
74
+ // auth pattern matched it first, the advice would send someone to re-login
75
+ // instead of to SSH, and the push would fail again.
76
+ const workflowMsg = 'remote: error: refusing to allow an OAuth App to create or update workflow `.github/workflows/x.yml` without `workflow` scope';
77
+ assert.equal(pushDiagnosis(workflowMsg).id, 'workflow_scope');
78
+ });
@@ -343,3 +343,41 @@ test('playerFor never throws and returns null or a [cmd, args] pair', () => {
343
343
  assert.ok(Array.isArray(got[1]));
344
344
  }
345
345
  });
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // readPrefs honours the path it was GIVEN (task 1003293).
349
+ //
350
+ // The legacy-path fallback used to fire for any unreadable path, so a caller
351
+ // naming an explicit file silently received a different one — the user's real
352
+ // prefs. That made the two fail-open tests above pass in CI (no prefs on a
353
+ // runner) and fail on any machine where the builder had saved sound settings:
354
+ // green where nobody looks, red where everybody does. The fallback is still
355
+ // right for the DEFAULT call; it is wrong the moment a caller names a file.
356
+ // ---------------------------------------------------------------------------
357
+
358
+ test('an explicit missing path fails open — it never substitutes another file', () => {
359
+ // The regression: write a legacy-shaped prefs file at the pre-rename location
360
+ // and prove an explicit path still ignores it. Skipped if the real file exists,
361
+ // because this test must never clobber a builder's own settings.
362
+ const legacy = player.LEGACY_PREFS_PATH;
363
+ if (!legacy || fs.existsSync(legacy)) {
364
+ // Nothing to plant, but the invariant is still checkable: an absent explicit
365
+ // path must yield the fail-open default, whatever the legacy file holds.
366
+ const p = player.readPrefs(path.join(os.tmpdir(), `otb-absent-${process.pid}.json`));
367
+ assert.equal(p.volume, 100);
368
+ assert.equal(p.enabled('ship'), true);
369
+ return;
370
+ }
371
+ fs.mkdirSync(path.dirname(legacy), { recursive: true });
372
+ fs.writeFileSync(legacy, JSON.stringify({ volume: 11, ship: false }));
373
+ try {
374
+ const p = player.readPrefs(path.join(os.tmpdir(), `otb-absent-${process.pid}.json`));
375
+ assert.equal(p.volume, 100, 'an explicit path must not inherit the legacy file volume');
376
+ assert.equal(p.enabled('ship'), true, 'an explicit path must not inherit legacy mutes');
377
+ // …while the DEFAULT call still honours it — the fallback is not removed.
378
+ const d = player.readPrefs();
379
+ assert.equal(d.volume, 11, 'the default path must still fall back to the legacy file');
380
+ } finally {
381
+ fs.unlinkSync(legacy);
382
+ }
383
+ });