@bongos/core 1.19.130 → 1.19.132

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.130",
6
- "core_contract": "1.19.130",
7
- "source_commit": "9cfc165fbc5b116976ad07e249f0ed45287f3bcf",
5
+ "core_version": "1.19.132",
6
+ "core_contract": "1.19.132",
7
+ "source_commit": "450f890ee24a473026548ea2cf82cf69ac1d6d9c",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-15T03:15:08.211Z",
9
+ "built_at": "2026-08-15T03:38:15.933Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 303,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1246,
14
+ "functional_verbatim": 1248,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1570,
20
- "tree_sha256": "9c3f424caabdd8755729a2c59370b4ea6c2c0e14f591297b3769f3f18ae3da41",
19
+ "file_count": 1572,
20
+ "tree_sha256": "d0e21da3aa85bb9e774ef6c921a2cc4be514b8dab592b7a1b3fa8bb07e8be485",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -4172,12 +4172,12 @@
4172
4172
  {
4173
4173
  "path": "package-lock.json",
4174
4174
  "mode": "0000644",
4175
- "sha256": "d530560bb0e2b69c73bba650529a2d45f3bb4c09728c54e012987f4340681c93"
4175
+ "sha256": "26a0143ba944f1edf0c163992ecd6f447fa911d16d7b8161578c7b1bc6725a48"
4176
4176
  },
4177
4177
  {
4178
4178
  "path": "package.json",
4179
4179
  "mode": "0000644",
4180
- "sha256": "1c4c6038789240a0e60015e8501ad5aa572b1c630d0754ee65147acb5a10bb5b"
4180
+ "sha256": "763b31a597012a53b4f7b6c0c0ce9a30e10ba13206468820d16dcc5d04d972ec"
4181
4181
  },
4182
4182
  {
4183
4183
  "path": "public-docs/index.html",
@@ -4767,7 +4767,7 @@
4767
4767
  {
4768
4768
  "path": "scripts/gds/docs-entropy.js",
4769
4769
  "mode": "0000644",
4770
- "sha256": "afeea5e6c75a8cb7ac9bb8ee3eefc828102866dc3ae6836b0dc28946c56cb330"
4770
+ "sha256": "1a7cbe47158e4de63c1b788d6bb4f682584723a45c1824ab2ae3fbf92a54fbce"
4771
4771
  },
4772
4772
  {
4773
4773
  "path": "scripts/gds/doctor.js",
@@ -5157,7 +5157,7 @@
5157
5157
  {
5158
5158
  "path": "scripts/gds/run-unit-tests.js",
5159
5159
  "mode": "0000644",
5160
- "sha256": "5ff111416adc80a4513bc91488185f5f47800f16c1fe8b746e0982ee9b4ffeb9"
5160
+ "sha256": "4c3bef6907b4807d93426e754ff20c75e56b498e4302c1cd47e4b3953bd8b4d0"
5161
5161
  },
5162
5162
  {
5163
5163
  "path": "scripts/gds/sandbox-stage.js",
@@ -5322,7 +5322,7 @@
5322
5322
  {
5323
5323
  "path": "scripts/gds/ship.js",
5324
5324
  "mode": "0000644",
5325
- "sha256": "7ae14021dbae94ffc5a70cf05081e645ca2dcf9685ccf135658538cb44e79dde"
5325
+ "sha256": "681de343f8491a2f22165246f21ac371a8f76411b395991c784635e8e9646ed7"
5326
5326
  },
5327
5327
  {
5328
5328
  "path": "scripts/gds/smoke-batch-claim.sh",
@@ -5762,7 +5762,7 @@
5762
5762
  {
5763
5763
  "path": "src/module-api.js",
5764
5764
  "mode": "0000644",
5765
- "sha256": "d7733ec4964e2aff924d904ee73784843f81ea72c3e2eeb6f3c7ea816c62ceb6"
5765
+ "sha256": "9754ad61248f7a9ccf1b4361bf7de1895318872a4994f74f53668151c60f8aeb"
5766
5766
  },
5767
5767
  {
5768
5768
  "path": "src/module-loader/catalog.js",
@@ -6242,7 +6242,7 @@
6242
6242
  {
6243
6243
  "path": "tests/cloud_bongos_pack.mjs",
6244
6244
  "mode": "0000644",
6245
- "sha256": "715fecde91ee9dff56dfca9b9da46a47b49322b0dd50aad1bd606491f551fe06"
6245
+ "sha256": "087d0379a559ebb858e8e42458754cfb93720a5222e5f85fb81995851ce811be"
6246
6246
  },
6247
6247
  {
6248
6248
  "path": "tests/conductor.mjs",
@@ -6484,6 +6484,11 @@
6484
6484
  "mode": "0000644",
6485
6485
  "sha256": "d9fd04cc3f2fcb3b4a9945c1e743c44445a216a10648fa619f74716b381468f3"
6486
6486
  },
6487
+ {
6488
+ "path": "tests/docs_entropy.mjs",
6489
+ "mode": "0000644",
6490
+ "sha256": "4d64164d45d6b81d706074b8394da4f3d0f7f8c5263a37a287a163d6a746a96b"
6491
+ },
6487
6492
  {
6488
6493
  "path": "tests/docs_openapi_route.mjs",
6489
6494
  "mode": "0000644",
@@ -7654,6 +7659,11 @@
7654
7659
  "mode": "0000644",
7655
7660
  "sha256": "23731936e26feb6406333f484c86210be2f2eaee1b4060ba34c9e03f8fd09685"
7656
7661
  },
7662
+ {
7663
+ "path": "tests/ship_publish_error.mjs",
7664
+ "mode": "0000644",
7665
+ "sha256": "eb8c52362355479a50c83ba84968d605b93f98e93513cf8436dd096e6514f6e8"
7666
+ },
7657
7667
  {
7658
7668
  "path": "tests/ship_resume.mjs",
7659
7669
  "mode": "0000644",
@@ -7767,7 +7777,7 @@
7767
7777
  {
7768
7778
  "path": "tests/subdomain_auth.mjs",
7769
7779
  "mode": "0000644",
7770
- "sha256": "e28409f9063a36482482bcb295de5c629430dd28c53a03e1b3c979d5566306dd"
7780
+ "sha256": "24257ca4ad1a7bcefb735ee1b02609469035730e0723645f71e10eb71525bb72"
7771
7781
  },
7772
7782
  {
7773
7783
  "path": "tests/task_classifier_vocab.mjs",
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.130",
3
+ "version": "1.19.132",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.130",
9
+ "version": "1.19.132",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.130",
3
+ "version": "1.19.132",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -16,37 +16,50 @@
16
16
  //
17
17
  // Exit codes:
18
18
  // 0 scan complete
19
- // 1 fatal error
19
+ // 1 fatal error (including a scan defect: zero markdown files enumerated)
20
20
 
21
21
  'use strict';
22
22
 
23
23
  const fs = require('node:fs');
24
24
  const path = require('node:path');
25
- const { execSync } = require('node:child_process');
26
- const { hasFlag, cliClient, requireSession } = require('./cli-lib');
25
+ const { execFileSync } = require('node:child_process');
26
+ const { hasFlag, cliClient, requireSession, cliExit } = require('./cli-lib');
27
27
 
28
28
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
29
29
  const MIN_DUPE_CHARS = 120; // minimum paragraph length to flag as duplicate
30
30
  const MAX_DUPES_REPORTED = 20; // cap to avoid flooding the report
31
31
 
32
- // Shell helper — returns stdout or null on failure.
33
- function sh(cmd) {
32
+ // ---------- file collection ----------
33
+
34
+ // argv array, never a shell string: `git ls-files '*.md'` as a string runs
35
+ // through cmd.exe on Windows, where the quotes are literal, so git matched
36
+ // nothing and the whole scan reported on zero files (task 1002526).
37
+ function gitLsFiles(args) {
34
38
  try {
35
- return execSync(cmd, {
39
+ return execFileSync('git', args, {
36
40
  cwd: REPO_ROOT,
37
41
  encoding: 'utf8',
38
- stdio: ['ignore', 'pipe', 'ignore'],
39
- }).trim();
40
- } catch (_) {
41
- return null;
42
+ stdio: ['ignore', 'pipe', 'pipe'],
43
+ });
44
+ } catch (err) {
45
+ const detail = String(err.stderr || err.message || '').trim();
46
+ throw new Error(`docs-entropy: \`git ${args.join(' ')}\` failed in ${REPO_ROOT}: ${detail}`);
42
47
  }
43
48
  }
44
49
 
45
- // ---------- file collection ----------
46
-
47
- function collectMarkdownFiles() {
48
- const out = sh("git ls-files '*.md'");
49
- return out ? out.split('\n').map(f => f.trim()).filter(Boolean) : [];
50
+ // Throws rather than returning [] — an empty enumeration in a repo that tracks
51
+ // hundreds of .md files is a broken scan, and a lint that silently reports on
52
+ // nothing is worse than one that fails.
53
+ function collectMarkdownFiles({ run = gitLsFiles } = {}) {
54
+ const out = run(['ls-files', '*.md']);
55
+ const files = String(out || '').split('\n').map(f => f.trim()).filter(Boolean);
56
+ if (!files.length) {
57
+ throw new Error(
58
+ `docs-entropy: scan defect — enumerated 0 tracked markdown files under ${REPO_ROOT}. ` +
59
+ 'This repo tracks hundreds, so this is a broken scan (bad git invocation or wrong cwd), not a clean result.'
60
+ );
61
+ }
62
+ return files;
50
63
  }
51
64
 
52
65
  // ---------- parsing ----------
@@ -326,10 +339,12 @@ async function docsEntropyMain() {
326
339
  const fileIdeas = hasFlag('--file-ideas', args);
327
340
  const verbose = hasFlag('--verbose', args);
328
341
 
329
- const relFiles = collectMarkdownFiles();
330
- if (!relFiles.length) {
331
- console.error('docs-entropy: no tracked markdown files found');
332
- process.exit(1);
342
+ let relFiles;
343
+ try {
344
+ relFiles = collectMarkdownFiles();
345
+ } catch (err) {
346
+ console.error(err.message);
347
+ return cliExit(1);
333
348
  }
334
349
 
335
350
  const linkFindings = scanStaleLinks(relFiles);
@@ -350,9 +365,12 @@ async function docsEntropyMain() {
350
365
  };
351
366
 
352
367
  if (asJson) {
368
+ // cliExit, not process.exit: the JSON here can run well past the 64KB pipe
369
+ // buffer, and process.exit() drops unflushed stdout — CI saw the payload
370
+ // truncated mid-string (PR 148's flaky unit run). Natural exit flushes.
353
371
  console.log(JSON.stringify(result, null, 2));
354
372
  if (fileIdeas) await fileToInbox(staleLinks, brokenAnchors, dupes);
355
- process.exit(0);
373
+ return cliExit(0);
356
374
  }
357
375
 
358
376
  console.log('Docs entropy scan');
@@ -390,13 +408,13 @@ async function docsEntropyMain() {
390
408
  await fileToInbox(staleLinks, brokenAnchors, dupes);
391
409
  }
392
410
 
393
- process.exit(0);
411
+ cliExit(0);
394
412
  }
395
413
 
396
414
  if (require.main === module) {
397
415
  docsEntropyMain().catch((err) => {
398
416
  console.error('fatal:', err);
399
- process.exit(1);
417
+ cliExit(1);
400
418
  });
401
419
  }
402
420
 
@@ -77,7 +77,7 @@ const INTEGRATION = new Set([
77
77
  'governance_fold_bench', 'governance_live_proof', 'linkify_refs', 'memory_isolation',
78
78
  'memory_pull', 'needs_rebase', 'parallel-load', 'reward_mode_cost_plus_only',
79
79
  'session_records', 'setup_smoke',
80
- 'status-probe', 'subdomain_auth',
80
+ 'status-probe',
81
81
  ]);
82
82
 
83
83
  // Excluded so this gate is trustworthy in a CLEAN checkout (the CI condition):
@@ -1213,6 +1213,34 @@ async function ciLand(taskId, branch, valueSummary) {
1213
1213
  return await ciLandLocal(taskId, branch, valueSummary);
1214
1214
  }
1215
1215
 
1216
+ // task 1001916: render a FAILED POST /tasks/:id/publish-branch for the builder.
1217
+ // The server has carried { step, reason } — which git step failed + the
1218
+ // credential-scrubbed stderr — since task 1547, but the bail site printed only
1219
+ // `data.message || data.error`: "[object Object]" on the ADR 0116 envelope, a
1220
+ // bare `publish_failed` on the legacy flat shape. That hid a non-fast-forward
1221
+ // rejection (the server had already freshened the branch itself) across several
1222
+ // blind retry laps, so that case also gets its specific remedy. Pure + exported
1223
+ // for tests/ship_publish_error.mjs (no git, no network).
1224
+ const NON_FAST_FORWARD_RE = /fetch first|non-fast-forward/i;
1225
+
1226
+ function formatPublishFailure(data, status, branch) {
1227
+ const { code, message } = normalizeApiError(data);
1228
+ const envelopeDetails = (data && data.error && typeof data.error === 'object' && data.error.details) || null;
1229
+ const details = envelopeDetails || data || {};
1230
+ const label = message || code || (data && data.message) || `HTTP ${status}`;
1231
+ // git stderr is multi-line; this is embedded mid-sentence in one log line.
1232
+ const reason = details.reason ? String(details.reason).replace(/\s+/g, ' ').trim() : '';
1233
+ const parts = [];
1234
+ if (details.step) parts.push(`step: ${details.step}`);
1235
+ if (reason) parts.push(`reason: ${reason}`);
1236
+ let out = parts.length ? `${label} (${parts.join('; ')})` : label;
1237
+ if (NON_FAST_FORWARD_RE.test(reason)) {
1238
+ out += ` — the server already moved ${branch}: fetch origin, reset onto origin/${branch}, `
1239
+ + 're-run the generators, and re-ship so the push fast-forwards.';
1240
+ }
1241
+ return out;
1242
+ }
1243
+
1216
1244
  // ciLandServer — the dev-box path: no GitHub push credential on this machine, so
1217
1245
  // hand the branch to the GDS, which pushes it + opens the PR + auto-merges with
1218
1246
  // the server-side credential. Requires a RE-AUTHED (non-box) cli session — a
@@ -1249,7 +1277,7 @@ async function ciLandServer(taskId, branch, valueSummary) {
1249
1277
  return landBailed('server-mediated push is not configured (operator action)', strandCommand(taskId, 'server'));
1250
1278
  }
1251
1279
  if (!r.ok) {
1252
- const e = (r.data && (r.data.message || r.data.error)) || `HTTP ${r.status}`;
1280
+ const e = formatPublishFailure(r.data, r.status, branch);
1253
1281
  console.log(` ci-land: server publish failed — ${e}. Task stays at confirmed. ${strandNextStep(taskId, 'server')}`);
1254
1282
  return landBailed(`server publish failed — ${e}`, strandCommand(taskId, 'server'));
1255
1283
  }
@@ -4472,6 +4500,10 @@ module.exports = {
4472
4500
  // object, so a consumer MUST test `.ok`; the old `if (!merged)` would sail past
4473
4501
  // a bail. Also pins pending (a poll timeout) as distinct from bailed.
4474
4502
  landed, landPending, landBailed, strandCommand,
4503
+ // task 1001916: the publish-branch failure formatter — exported pure so both
4504
+ // wire shapes (envelope + legacy flat), the step/reason surfacing, and the
4505
+ // non-fast-forward remedy hint are pinned without a server.
4506
+ formatPublishFailure,
4475
4507
  // task 1719: pure post-land status reconciler — a task that reached 'shipped'
4476
4508
  // in-session shows the SHIPPED banner + writes no breadcrumb; otherwise keep the
4477
4509
  // pre-merge status (confirmed → next-turn breadcrumb fallback). Exported for tests.
package/src/module-api.js CHANGED
@@ -687,8 +687,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
687
687
  // landed since 1.19.128 with no explicit bump. run 31858158416. (task 1002620)
688
688
  // 1.19.130 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
689
689
  // landed since 1.19.129 with no explicit bump. run 31861289412. (task 1002620)
690
+ // 1.19.131 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
691
+ // landed since 1.19.130 with no explicit bump. run 31861682626. (task 1002620)
692
+ // 1.19.132 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
693
+ // landed since 1.19.131 with no explicit bump. run 31862317758. (task 1002620)
690
694
  // ---------------------------------------------------------------------------
691
- const CORE_VERSION = '1.19.130'; // CI auto-patch carrier (ADR 0161); see changelog above
695
+ const CORE_VERSION = '1.19.132'; // CI auto-patch carrier (ADR 0161); see changelog above
692
696
 
693
697
  // A namespaced logger so a module's log lines are attributable + consistent.
694
698
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -71,14 +71,16 @@ test('R74: themeMarkup paints the backdrop only when the pack supplies one', ()
71
71
  assert.match(themeMarkup(clientBranding(vanilla()).theme), /body\{background:.+\}/);
72
72
  });
73
73
 
74
- test('customer-0: GDS_BRANDING_FILE selects the cloudbongos.com instance pack (task 1374)', () => {
74
+ test('customer-0: GDS_BRANDING_FILE selects the cloudbongos.com instance pack (task 1374)', (t) => {
75
75
  // The live dogfood instance runs from the SAME checkout as OTB; the env knob
76
76
  // points this process at its own pack (deltas over neutral) instead of OTB's
77
77
  // config/branding.json. Proves the brand-switch + the cloudbongos pack content.
78
78
  // Both packs are INSTANCE content — the neutral core ships neither, so this
79
79
  // asserts one deployment's config (task 1002470). The selector MECHANISM is
80
80
  // proven unconditionally by the fixture test below.
81
- if (!existsSync(join(ROOT, 'config/branding.cloudbongos.json'))) return;
81
+ // Report the absence as a SKIP, not a silent pass — a case that asserts nothing
82
+ // must not read as green coverage (task 1002526).
83
+ if (!existsSync(join(ROOT, 'config/branding.cloudbongos.json'))) return t.skip('no config/branding.cloudbongos.json on this checkout');
82
84
  const b = loadBranding({ env: { GDS_BRANDING_FILE: 'config/branding.cloudbongos.json' } });
83
85
  assert.equal(b.identity.productName, 'Cloud Bongos'); // from neutral
84
86
  assert.equal(b.currency.label, 'credits'); // from neutral, not "drachmae"
@@ -91,11 +93,11 @@ test('customer-0: GDS_BRANDING_FILE selects the cloudbongos.com instance pack (t
91
93
  assert.equal(b.firstAdmin, 'lars589');
92
94
  });
93
95
 
94
- test('customer-0: no GDS_BRANDING_FILE still resolves the instance pack (default unchanged)', () => {
96
+ test('customer-0: no GDS_BRANDING_FILE still resolves the instance pack (default unchanged)', (t) => {
95
97
  // The OTB process (server.js) sets no GDS_BRANDING_FILE → config/branding.json.
96
98
  // Same instance-content caveat as above: with no instance pack the correct default
97
99
  // IS the neutral starter, which is what the vanilla tests in this file already pin.
98
- if (!existsSync(INSTANCE_PATH)) return;
100
+ if (!existsSync(INSTANCE_PATH)) return t.skip('no config/branding.json (instance pack) on this checkout');
99
101
  const b = loadBranding({ env: {} });
100
102
  assert.notDeepEqual(b.identity, vanilla().identity, 'the instance pack must win over neutral by default');
101
103
  });
@@ -0,0 +1,55 @@
1
+ // tests/docs_entropy.mjs — the markdown entropy lint actually scans (task 1002526).
2
+ //
3
+ // The bug this pins: collectMarkdownFiles() shelled out to the STRING
4
+ // `git ls-files '*.md'`. On Windows that runs through cmd.exe, where the single
5
+ // quotes are literal, so git matched nothing — docs-entropy reported on zero
6
+ // files on every Windows checkout, a lint that proved nothing. Two guarantees:
7
+ // 1. enumeration goes through an argv ARRAY (no shell quoting to get wrong),
8
+ // 2. zero tracked markdown files is treated as a SCAN DEFECT and errors
9
+ // loudly — this repo tracks hundreds, so an empty result is never real.
10
+ import assert from 'node:assert/strict';
11
+ import { test } from 'node:test';
12
+ import { execFileSync } from 'node:child_process';
13
+ import { createRequire } from 'node:module';
14
+ import { fileURLToPath } from 'node:url';
15
+ import path from 'node:path';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+ const SCANNER = path.join(ROOT, 'scripts', 'gds', 'docs-entropy.js');
20
+ const { collectMarkdownFiles } = require(SCANNER); // require.main-guarded → no scan on import
21
+
22
+ test('collectMarkdownFiles passes the glob as an argv array, never a shell string', () => {
23
+ let seen = null;
24
+ const files = collectMarkdownFiles({ run: (args) => { seen = args; return 'a.md\nb.md\n'; } });
25
+ assert.ok(Array.isArray(seen), 'git args must be an array — a shell string is what broke Windows');
26
+ assert.deepEqual(seen, ['ls-files', '*.md'], 'the glob reaches git unquoted');
27
+ assert.deepEqual(files, ['a.md', 'b.md']);
28
+ });
29
+
30
+ test('zero tracked markdown files is a loud scan defect, not a clean empty result', () => {
31
+ assert.throws(
32
+ () => collectMarkdownFiles({ run: () => '' }),
33
+ /scan defect/i,
34
+ 'an empty enumeration must name itself a defect so the lint cannot silently prove nothing',
35
+ );
36
+ });
37
+
38
+ test('a failing git invocation propagates instead of collapsing to zero files', () => {
39
+ // The old sh() helper caught every failure and returned null → [] → a scan of
40
+ // nothing. The enumeration must never swallow its own failure.
41
+ assert.throws(
42
+ () => collectMarkdownFiles({ run: () => { throw new Error('not a git repository'); } }),
43
+ /not a git repository/,
44
+ );
45
+ });
46
+
47
+ test('the real scan enumerates the repo (the regression: it reported on 0 files)', () => {
48
+ const stdout = execFileSync(process.execPath, [SCANNER, '--json'], {
49
+ cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
50
+ });
51
+ const result = JSON.parse(stdout);
52
+ assert.ok(result.scanned_files > 50, `expected hundreds of markdown files, scanned ${result.scanned_files}`);
53
+ assert.ok(Number.isFinite(result.stale_links));
54
+ assert.ok(Number.isFinite(result.broken_anchors));
55
+ });
@@ -0,0 +1,119 @@
1
+ // tests/ship_publish_error.mjs
2
+ //
3
+ // Unit tests for formatPublishFailure (scripts/gds/ship.js, task 1001916) — the
4
+ // display string ciLandServer prints when POST /tasks/:id/publish-branch fails.
5
+ //
6
+ // Why this exists: the server has carried { step, reason } (the credential-
7
+ // scrubbed git stderr) since task 1547, but ciLandServer printed only
8
+ // `r.data.message || r.data.error`. On the ADR 0116 envelope that renders
9
+ // "[object Object]", and on the legacy flat shape a bare "publish_failed" — so a
10
+ // non-fast-forward rejection (the server had already freshened the branch) was
11
+ // invisible and the builder retried blind for several laps. What we prove here:
12
+ // 1. Both wire shapes resolve to the CODE, never "[object Object]".
13
+ // 2. step + reason appear when the server sent them.
14
+ // 3. A non-fast-forward reason carries the specific remedy hint (reset onto
15
+ // origin/<branch>, re-run the generators, re-ship) — and only then.
16
+ // Pure: no network, no git, no fs.
17
+ //
18
+ // Run: node tests/ship_publish_error.mjs
19
+
20
+ import { strict as assert } from 'node:assert';
21
+ import { createRequire } from 'node:module';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const ship = require('../scripts/gds/ship.js');
25
+
26
+ let passed = 0;
27
+ let failed = 0;
28
+ function test(name, fn) {
29
+ try {
30
+ fn();
31
+ passed++;
32
+ console.log(` ok ${name}`);
33
+ } catch (err) {
34
+ failed++;
35
+ console.log(` FAIL ${name}`);
36
+ console.log(` ${err.message}`);
37
+ }
38
+ }
39
+
40
+ const BRANCH = 'claude/task-1001916';
41
+ const fmt = (data, status = 500, branch = BRANCH) => ship.formatPublishFailure(data, status, branch);
42
+
43
+ // ---------- 1. bare error, no step/reason ----------
44
+
45
+ test('legacy flat { error: code } with no details reads as the bare code', () => {
46
+ const out = fmt({ error: 'publish_failed' });
47
+ assert.match(out, /publish_failed/);
48
+ assert.ok(!/step:/.test(out), 'no step was sent, so none is invented');
49
+ assert.ok(!/reason:/.test(out), 'no reason was sent, so none is invented');
50
+ });
51
+
52
+ test('ADR 0116 envelope with no details resolves to the code, never [object Object]', () => {
53
+ const out = fmt({ error: { code: 'publish_failed', message: 'publish_failed' } });
54
+ assert.match(out, /publish_failed/);
55
+ assert.ok(!/\[object Object\]/.test(out), 'the envelope object must never be stringified raw');
56
+ });
57
+
58
+ test('no body at all falls back to the HTTP status', () => {
59
+ assert.match(fmt(null, 502), /HTTP 502/);
60
+ });
61
+
62
+ // ---------- 2. error + step + reason ----------
63
+
64
+ test('envelope details carry step + reason into the message', () => {
65
+ const out = fmt({
66
+ error: {
67
+ code: 'publish_failed',
68
+ message: 'publish_failed',
69
+ details: { step: 'push', reason: 'remote: Permission to org/repo.git denied' },
70
+ },
71
+ });
72
+ assert.match(out, /publish_failed/);
73
+ assert.match(out, /step: push/);
74
+ assert.match(out, /Permission to org\/repo\.git denied/);
75
+ });
76
+
77
+ test('legacy flat top-level step + reason are surfaced the same way', () => {
78
+ const out = fmt({ error: 'publish_failed', step: 'fetch', reason: 'could not read Username' });
79
+ assert.match(out, /step: fetch/);
80
+ assert.match(out, /could not read Username/);
81
+ });
82
+
83
+ test('a non-git failure reason gets NO fast-forward remedy hint', () => {
84
+ const out = fmt({ error: 'publish_failed', step: 'push', reason: 'Permission denied (publickey)' });
85
+ assert.ok(!/fast-forward/i.test(out), 'the remedy hint is for a stale-branch push only');
86
+ assert.ok(!/reset/i.test(out), 'nor the reset instruction');
87
+ });
88
+
89
+ // ---------- 3. the non-fast-forward remedy hint ----------
90
+
91
+ const NON_FF_STDERR =
92
+ '! [rejected] claude/task-1001916 -> claude/task-1001916 (fetch first)\n' +
93
+ "error: failed to push some refs to 'github.com/org/repo.git'";
94
+
95
+ test('a "fetch first" rejection names the branch and the reset-onto-origin remedy', () => {
96
+ const out = fmt({ error: 'publish_failed', step: 'push', reason: NON_FF_STDERR });
97
+ assert.match(out, /step: push/);
98
+ assert.match(out, new RegExp(`origin/${BRANCH}`), 'points at the ref the server already moved');
99
+ assert.match(out, /reset/i, 'names the reset');
100
+ assert.match(out, /generator/i, 'and the regenerate step before re-shipping');
101
+ assert.match(out, /re-ship|fast-forward/i, 'and why: so the push fast-forwards');
102
+ assert.ok(!/\n/.test(out), 'multi-line git stderr is collapsed — this goes mid-sentence into one log line');
103
+ });
104
+
105
+ test('the "non-fast-forward" wording triggers the same hint through the envelope', () => {
106
+ const out = fmt({
107
+ error: {
108
+ code: 'publish_failed',
109
+ message: 'publish_failed',
110
+ details: { step: 'push', reason: 'Updates were rejected: non-fast-forward' },
111
+ },
112
+ });
113
+ assert.match(out, new RegExp(`origin/${BRANCH}`));
114
+ assert.match(out, /reset/i);
115
+ });
116
+
117
+ console.log('');
118
+ console.log(`ship_publish_error: ${passed} passed, ${failed} failed`);
119
+ process.exit(failed === 0 ? 0 : 1);
@@ -1,14 +1,46 @@
1
1
  // tests/subdomain_auth.mjs — the builders-subdomain auth helpers (#726).
2
2
  //
3
3
  // Locks down the two security-sensitive pure helpers added for the dedicated
4
- // builders.amazonprimea.com subdomain:
4
+ // builders.<apex> subdomain:
5
5
  // - safeReturnTo: the open-redirect guard on the post-sign-in return target.
6
6
  // - sessionCookieDomainOpts: which hosts get an apex-scoped session cookie
7
7
  // (so one sign-in spans the subdomains) vs a host-only one (localhost,
8
8
  // staging — which must NOT bleed a token across environments).
9
9
  // Pure functions, no DB — requiring the route module does not open a pool.
10
+ //
11
+ // Both helpers derive their allowed hosts from the instance branding pack's
12
+ // domains.cookieDomain, so the fixtures inject a SYNTHETIC pack (the
13
+ // GDS_BRANDING_FILE seam, as tests/single_host_hall.mjs does) instead of
14
+ // reading whatever config/branding.json happens to be on disk. That keeps the
15
+ // assertions identical on every instance — the neutral core (cookieDomain "")
16
+ // and a branded host alike. envPrefix is overridden too so an ambient
17
+ // <PREFIX>_COOKIE_DOMAIN in the shell cannot reach the resolver.
18
+ //
19
+ // Run: node tests/subdomain_auth.mjs
10
20
 
11
21
  import { createRequire } from 'node:module';
22
+ import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
23
+ import { tmpdir } from 'node:os';
24
+ import { join } from 'node:path';
25
+
26
+ // The apex every fixture below is built from. RFC 2606 reserved TLD — clearly
27
+ // synthetic, never routable, and no host brand is baked into this file.
28
+ const APEX = 'bongos-test.example';
29
+
30
+ const brandDir = mkdtempSync(join(tmpdir(), 'cb-subdomain-auth-'));
31
+ const brandFile = join(brandDir, 'branding.json');
32
+ writeFileSync(brandFile, JSON.stringify({
33
+ envPrefix: 'SUBDOMAIN_AUTH_FIXTURE',
34
+ // Leading dot mirrors the real packs; configuredCookieApex() strips it.
35
+ domains: { cookieDomain: `.${APEX}`, publicOrigin: `https://${APEX}` },
36
+ }));
37
+ process.env.GDS_BRANDING_FILE = brandFile;
38
+ // The pack is only read once (auth.js resolves COOKIE_APEX at module load);
39
+ // remove the temp dir on every exit path so runs don't accumulate in tmpdir.
40
+ process.on('exit', () => { try { rmSync(brandDir, { recursive: true, force: true }); } catch { /* best-effort */ } });
41
+
42
+ // Required AFTER the pack is in place — auth.js resolves COOKIE_APEX at module
43
+ // load, and branding() memoizes on its first call.
12
44
  const require = createRequire(import.meta.url);
13
45
  const authRoute = require('../src/bongos/routes/auth.js');
14
46
 
@@ -24,15 +56,15 @@ console.log('\nsafeReturnTo — accepts safe same-city targets:');
24
56
  ok(safeReturnTo('/builders') === '/builders', 'relative /builders');
25
57
  ok(safeReturnTo('/watch') === '/watch', 'relative /watch');
26
58
  ok(safeReturnTo('/builders/settings?x=1') === '/builders/settings?x=1', 'relative path with query');
27
- ok(safeReturnTo('https://builders.amazonprimea.com/watch') === 'https://builders.amazonprimea.com/watch', 'absolute builders subdomain');
28
- ok(safeReturnTo('https://amazonprimea.com/builders') === 'https://amazonprimea.com/builders', 'absolute apex');
29
- ok(safeReturnTo('https://status.amazonprimea.com/') === 'https://status.amazonprimea.com/', 'absolute status subdomain');
59
+ ok(safeReturnTo(`https://builders.${APEX}/watch`) === `https://builders.${APEX}/watch`, 'absolute builders subdomain');
60
+ ok(safeReturnTo(`https://${APEX}/builders`) === `https://${APEX}/builders`, 'absolute apex');
61
+ ok(safeReturnTo(`https://status.${APEX}/`) === `https://status.${APEX}/`, 'absolute status subdomain');
30
62
 
31
63
  console.log('\nsafeReturnTo — rejects everything else (→ null, caller falls back):');
32
64
  ok(safeReturnTo('//evil.com') === null, 'protocol-relative //evil.com');
33
65
  ok(safeReturnTo('https://evil.com/builders') === null, 'foreign https origin');
34
- ok(safeReturnTo('https://amazonprimea.com.evil.com/') === null, 'lookalike suffix host');
35
- ok(safeReturnTo('http://amazonprimea.com/builders') === null, 'http (non-https) apex');
66
+ ok(safeReturnTo(`https://${APEX}.evil.com/`) === null, 'lookalike suffix host');
67
+ ok(safeReturnTo(`http://${APEX}/builders`) === null, 'http (non-https) apex');
36
68
  ok(safeReturnTo('javascript:alert(1)') === null, 'javascript: scheme');
37
69
  ok(safeReturnTo('/path\\with\\backslash') === null, 'backslash path');
38
70
  ok(safeReturnTo('') === null, 'empty');
@@ -43,15 +75,15 @@ ok(safeReturnTo('builders') === null, 'bare word (no leading slash, not a URL)')
43
75
  // ---- sessionCookieDomainOpts: cross-subdomain scoping ----------------------
44
76
  const reqWith = (host) => ({ headers: { host } });
45
77
  console.log('\nsessionCookieDomainOpts — apex-scoped on production hosts:');
46
- ok(sessionCookieDomainOpts(reqWith('amazonprimea.com')).domain === '.amazonprimea.com', 'apex → .amazonprimea.com');
47
- ok(sessionCookieDomainOpts(reqWith('www.amazonprimea.com')).domain === '.amazonprimea.com', 'www → .amazonprimea.com');
48
- ok(sessionCookieDomainOpts(reqWith('builders.amazonprimea.com')).domain === '.amazonprimea.com', 'builders → .amazonprimea.com');
49
- ok(sessionCookieDomainOpts(reqWith('status.amazonprimea.com')).domain === '.amazonprimea.com', 'status → .amazonprimea.com');
50
- ok(sessionCookieDomainOpts(reqWith('amazonprimea.com:3000')).domain === '.amazonprimea.com', 'apex with port');
78
+ ok(sessionCookieDomainOpts(reqWith(APEX)).domain === `.${APEX}`, `apex → .${APEX}`);
79
+ ok(sessionCookieDomainOpts(reqWith(`www.${APEX}`)).domain === `.${APEX}`, `www → .${APEX}`);
80
+ ok(sessionCookieDomainOpts(reqWith(`builders.${APEX}`)).domain === `.${APEX}`, `builders → .${APEX}`);
81
+ ok(sessionCookieDomainOpts(reqWith(`status.${APEX}`)).domain === `.${APEX}`, `status → .${APEX}`);
82
+ ok(sessionCookieDomainOpts(reqWith(`${APEX}:3000`)).domain === `.${APEX}`, 'apex with port');
51
83
 
52
84
  console.log('\nsessionCookieDomainOpts — host-only elsewhere (no domain key):');
53
85
  ok(sessionCookieDomainOpts(reqWith('localhost:3000')).domain === undefined, 'localhost → host-only');
54
- ok(sessionCookieDomainOpts(reqWith('staging.amazonprimea.com')).domain === undefined, 'staging → host-only (no cross-env bleed)');
86
+ ok(sessionCookieDomainOpts(reqWith(`staging.${APEX}`)).domain === undefined, 'staging → host-only (no cross-env bleed)');
55
87
  ok(sessionCookieDomainOpts(reqWith('127.0.0.1')).domain === undefined, 'raw IP → host-only');
56
88
 
57
89
  console.log(`\nSubdomain-auth tests: ${pass} passed, ${fail} failed`);