@bongos/core 1.19.129 → 1.19.131

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.129",
6
- "core_contract": "1.19.129",
7
- "source_commit": "4f6f1fe2d419e1f7a6b2877ce6d3b2f793308257",
5
+ "core_version": "1.19.131",
6
+ "core_contract": "1.19.131",
7
+ "source_commit": "7fc91a6722c91cd40c09ce632c2a765377952e49",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-15T02:03:44.422Z",
9
+ "built_at": "2026-08-15T03:23:58.787Z",
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": 1247,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1570,
20
- "tree_sha256": "15adcb143cdb095a549345805c5491c08b64429b4ef6730ef4878344c15d2ea9",
19
+ "file_count": 1571,
20
+ "tree_sha256": "81215c25cbaed99553dba15247df2e2f9daf37245d8144df0a5e32b507b4c0b9",
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": "113fa76500c41f49120cb6a76558c693e3862d5f7dbd754c6b6f9e604bd0d45e"
4175
+ "sha256": "ad651e607ce3937ca8522552d21ecfbaa03108734f9c7987c79a2e8ef8a7c7f3"
4176
4176
  },
4177
4177
  {
4178
4178
  "path": "package.json",
4179
4179
  "mode": "0000644",
4180
- "sha256": "88877c77760c0d9d44361b8522fa2e871a8a62f85a500454acc9af972db8b00e"
4180
+ "sha256": "178ddf3d5958c428d370e3381cdca505455abe6006b9f920245de0062bc2d5aa"
4181
4181
  },
4182
4182
  {
4183
4183
  "path": "public-docs/index.html",
@@ -4992,7 +4992,7 @@
4992
4992
  {
4993
4993
  "path": "scripts/gds/mirror-lineage-check.js",
4994
4994
  "mode": "0000644",
4995
- "sha256": "ec187c4a4ad91a3758ff4635baa5ad32dcad7fbf0b61e4f8d3fbc81784254833"
4995
+ "sha256": "31296eda794e640ce5f7191d166d08710b35290be67878826a2c7209f0216e54"
4996
4996
  },
4997
4997
  {
4998
4998
  "path": "scripts/gds/mirror-redact.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": "0d17e220c27abd9a9064c461404fa8fdf2f352f404e10673b76ff8a34ccd04c2"
5765
+ "sha256": "fd76c822f4e795248c2c010052dab9cce8cc237a5f501fbf90e66c413c58eac6"
5766
5766
  },
5767
5767
  {
5768
5768
  "path": "src/module-loader/catalog.js",
@@ -7052,12 +7052,12 @@
7052
7052
  {
7053
7053
  "path": "tests/mirror_lineage_fulltree.mjs",
7054
7054
  "mode": "0000644",
7055
- "sha256": "ce44c9b95988aa587164f2bfbd002046a188be34f14d968635b5d88bb8e98c56"
7055
+ "sha256": "0482cd900a7c45a18ece314deb977eef852060b50d56b814d58ac631bc568426"
7056
7056
  },
7057
7057
  {
7058
7058
  "path": "tests/mirror_lineage_proof.mjs",
7059
7059
  "mode": "0000644",
7060
- "sha256": "5c9ec605b7e34b046f6df9b1d9f6efa532d394d1134e69990542dde2e86e4ffe"
7060
+ "sha256": "74301070c93c5cb5b4f31d32e508336c96ba199064db9fdf810f07f8d431a98d"
7061
7061
  },
7062
7062
  {
7063
7063
  "path": "tests/mirror_redact.mjs",
@@ -7654,6 +7654,11 @@
7654
7654
  "mode": "0000644",
7655
7655
  "sha256": "23731936e26feb6406333f484c86210be2f2eaee1b4060ba34c9e03f8fd09685"
7656
7656
  },
7657
+ {
7658
+ "path": "tests/ship_publish_error.mjs",
7659
+ "mode": "0000644",
7660
+ "sha256": "eb8c52362355479a50c83ba84968d605b93f98e93513cf8436dd096e6514f6e8"
7661
+ },
7657
7662
  {
7658
7663
  "path": "tests/ship_resume.mjs",
7659
7664
  "mode": "0000644",
@@ -7767,7 +7772,7 @@
7767
7772
  {
7768
7773
  "path": "tests/subdomain_auth.mjs",
7769
7774
  "mode": "0000644",
7770
- "sha256": "e28409f9063a36482482bcb295de5c629430dd28c53a03e1b3c979d5566306dd"
7775
+ "sha256": "24257ca4ad1a7bcefb735ee1b02609469035730e0723645f71e10eb71525bb72"
7771
7776
  },
7772
7777
  {
7773
7778
  "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.129",
3
+ "version": "1.19.131",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.129",
9
+ "version": "1.19.131",
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.129",
3
+ "version": "1.19.131",
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",
@@ -13,10 +13,11 @@
13
13
  // export does (mirror-redact + secret-scrub, via the SHARED redactBlob) and
14
14
  // assert ZERO residual instance literals, public IPs, or secrets survive.
15
15
  // Same detector the export gates on — "one detector, two consumers."
16
- // 2. MIGRATION NUMBERING — the set of core migrations/NNN_*.sql numbers that
17
- // reach the mirror is IDENTICAL to the private set (redaction never renames
18
- // a file), so the claim-time next-number allocator computes the SAME next
19
- // migration on both sides. A dropped/renumbered migration = divergence = fail.
16
+ // 2. MIGRATION NUMBERING — the set of core migration numbers (bare NNN_*.sql and
17
+ // namespaced core_NNN_*.sql alike) that reach the mirror is IDENTICAL to the
18
+ // private set (redaction never renames a file), so the claim-time next-number
19
+ // allocator computes the SAME next migration on both sides. A dropped or
20
+ // renumbered migration = divergence = fail.
20
21
  // 3. ENUM VALUES — for every publishable migration, the CHECK (... IN (...))
21
22
  // value-lists are byte-identical before and after redaction. Redaction may
22
23
  // touch a domain in a comment; it must NEVER alter a schema enum value.
@@ -46,6 +47,7 @@ const {
46
47
  const { isBinary } = require(path.join(REPO_ROOT, 'scripts/public-mirror-export.js'));
47
48
  const { scrubSecrets, collectEnvSecrets, SECRET_PATTERNS } = require(path.join(REPO_ROOT, 'modules/security/secret-scrub.js'));
48
49
  const { nextFree } = require(path.join(REPO_ROOT, 'scripts/gds/next-number.js'));
50
+ const { migrationNumberOf } = require(path.join(REPO_ROOT, 'scripts/gds/migration-namespace.js'));
49
51
 
50
52
  // Known secret SHAPES as NON-GLOBAL clones. We scan each file with .test(), and a
51
53
  // `/g` regex carries lastIndex state across .test() calls — reused in a loop over
@@ -55,30 +57,41 @@ const { nextFree } = require(path.join(REPO_ROOT, 'scripts/gds/next-number.js'))
55
57
  // deterministic regardless of file order.
56
58
  const KNOWN_SECRET_SHAPES = SECRET_PATTERNS.map((re) => new RegExp(re.source, re.flags.replace('g', '')));
57
59
 
58
- // Core migrations are migrations/NNN_slug.sql (3+ digit prefix) DIRECTLY under
59
- // migrations/. Module migrations live under modules/<key>/migrations/ and instance
60
- // SEEDS under migrations/instance/ (ADR 0105) — both numbered independently and
61
- // excluded from the publishable mirror; the claim-time allocator only reads the
62
- // top-level core dir, so only core numbering can diverge.
63
- const MIGRATION_BASENAME_RE = /^(\d{3,})_.*\.sql$/;
64
-
65
60
  // ---- pure helpers (exported for tests) ------------------------------------
66
61
 
62
+ // The CORE migration number for a repo-relative path, or null if it is not one.
63
+ //
64
+ // Core migrations sit DIRECTLY under migrations/ as either the legacy bare NNN_slug.sql
65
+ // or the namespaced core_NNN_slug.sql every NEW core migration must use (ADR 0108 §5).
66
+ // The prefix namespaces the FILENAME (so a core stem can't collide with an instance/
67
+ // module one across repos), NOT the number line — core_188 continues the bare 001–187
68
+ // sequence as ONE sequence. Hence the shared migrationNumberOf, the SAME parser the
69
+ // claim-time allocator (next-number.js) counts from: this proof and the allocator can
70
+ // never disagree about which numbers are taken. (Task 1002527 — the bare-regex version
71
+ // was blind to core_188+, leaving BOTH halves equally blind and the gate falsely green.)
72
+ //
73
+ // Module migrations (modules/<key>/migrations/) and instance SEEDS
74
+ // (migrations/instance/, ADR 0105) are numbered independently and excluded from the
75
+ // publishable mirror; the allocator only reads the top-level core dir, so only core
76
+ // numbering can diverge.
77
+ function coreMigrationNumber(repoRelPath) {
78
+ const s = String(repoRelPath);
79
+ if (!s.startsWith('migrations/')) return null; // the migrations dir …
80
+ // … and ONLY its top level. Counting migrations/instance/NNN_*.sql put every instance
81
+ // seed in the PRIVATE set but not the MIRROR set (they are unpublishable, task 2140) —
82
+ // a false private⇄mirror divergence that red-lined the unit gate (task 2152). A path
83
+ // with a further slash is a subdir, never a core migration.
84
+ const rest = s.slice('migrations/'.length);
85
+ if (rest.includes('/')) return null;
86
+ return migrationNumberOf(rest);
87
+ }
88
+
67
89
  // The set of CORE migration numbers among a list of repo-relative paths.
68
90
  function migrationNumbersFromPaths(paths) {
69
91
  const out = new Set();
70
92
  for (const p of paths || []) {
71
- const s = String(p);
72
- if (!s.startsWith('migrations/')) continue; // the migrations dir …
73
- // … and ONLY its top level. migrations/instance/NNN_*.sql are this instance's host
74
- // seeds (ADR 0105), excluded from the publishable mirror (task 2140) — counting them
75
- // here put every instance seed in the PRIVATE set but not the MIRROR set, a false
76
- // private⇄mirror divergence that red-lined the unit gate (task 2152). A path with a
77
- // further slash is a subdir, never a core migration.
78
- const rest = s.slice('migrations/'.length);
79
- if (rest.includes('/')) continue;
80
- const m = MIGRATION_BASENAME_RE.exec(rest);
81
- if (m) out.add(parseInt(m[1], 10));
93
+ const n = coreMigrationNumber(p);
94
+ if (n != null) out.add(n);
82
95
  }
83
96
  return out;
84
97
  }
@@ -178,10 +191,12 @@ function proveLineage({ publishableFiles, privateMigrationNumbers, ctx }) {
178
191
  for (const f of publishableFiles) {
179
192
  const { redactedText, leaks: fileLeaks } = scanFile(f.buf, ctx);
180
193
  for (const l of fileLeaks) leaks.push({ path: f.path, label: l.label, sample: l.sample });
181
- if (f.path.startsWith('migrations/') && MIGRATION_BASENAME_RE.test(f.path.split('/').pop())) {
194
+ // Same predicate as the private half (migrationNumbersFromPaths) — the two sets are
195
+ // only comparable if both are built by the identical parser.
196
+ const migNum = coreMigrationNumber(f.path);
197
+ if (migNum != null) {
182
198
  migFiles.push({ path: f.path, original: f.buf.toString('utf8'), redacted: redactedText != null ? redactedText : f.buf.toString('utf8') });
183
- const mm = MIGRATION_BASENAME_RE.exec(f.path.split('/').pop());
184
- mirrorNums.add(parseInt(mm[1], 10));
199
+ mirrorNums.add(migNum);
185
200
  }
186
201
  }
187
202
 
@@ -317,7 +332,7 @@ function main() {
317
332
  if (require.main === module) main();
318
333
 
319
334
  module.exports = {
320
- MIGRATION_BASENAME_RE,
335
+ coreMigrationNumber,
321
336
  migrationNumbersFromPaths,
322
337
  parseEnumChecks,
323
338
  numberingDivergence,
@@ -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
@@ -685,8 +685,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
685
685
  // landed since 1.19.127 with no explicit bump. run 31857842473. (task 1002620)
686
686
  // 1.19.129 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
687
687
  // landed since 1.19.128 with no explicit bump. run 31858158416. (task 1002620)
688
+ // 1.19.130 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
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)
688
692
  // ---------------------------------------------------------------------------
689
- const CORE_VERSION = '1.19.129'; // CI auto-patch carrier (ADR 0161); see changelog above
693
+ const CORE_VERSION = '1.19.131'; // CI auto-patch carrier (ADR 0161); see changelog above
690
694
 
691
695
  // A namespaced logger so a module's log lines are attributable + consistent.
692
696
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -12,11 +12,18 @@
12
12
  // and asserts zero residual leaks + private⇄public migration-numbering/enum stability.
13
13
  // Filesystem + git (git ls-files) are the only dependencies — no DB, no network.
14
14
  import assert from 'node:assert/strict';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
15
18
  import { test } from 'node:test';
16
19
  import { createRequire } from 'node:module';
17
20
 
18
21
  const require = createRequire(import.meta.url);
19
22
  const { runSimulation } = require('../scripts/gds/mirror-lineage-check.js');
23
+ const { migrationNumberOf } = require('../scripts/gds/migration-namespace.js');
24
+ const { nextFree } = require('../scripts/gds/next-number.js');
25
+
26
+ const MIGRATIONS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations');
20
27
 
21
28
  test('C9 full-tree simulation: the redacted publishable mirror leaks nothing', () => {
22
29
  const report = runSimulation();
@@ -43,3 +50,24 @@ test('C9 full-tree simulation: the redacted publishable mirror leaks nothing', (
43
50
  `redaction altered enum value-list(s) in ${report.drift.length} migration(s): ${report.drift.map((d) => d.path).join(', ')}`
44
51
  );
45
52
  });
53
+
54
+ // The proof is only worth its green tick if it actually SEES every core migration.
55
+ // Before task 1002527 it parsed the bare `NNN_` form only, so every `core_NNN_` file
56
+ // (ADR 0108 §5) was invisible to BOTH halves — equally blind, therefore silently
57
+ // "consistent". This asserts the simulation's coverage against the real tree so the
58
+ // gate can't quietly stop covering new core migrations again.
59
+ test('C9 full-tree simulation covers namespaced core_NNN_ migrations too (task 1002527)', () => {
60
+ const basenames = fs.readdirSync(MIGRATIONS_DIR).filter((n) => migrationNumberOf(n) != null);
61
+ const namespaced = basenames.filter((n) => !/^\d/.test(n));
62
+ assert.ok(namespaced.length > 0, 'expected at least one core_NNN_ migration on disk — update this test if the namespace discipline changed');
63
+
64
+ const report = runSimulation();
65
+ assert.equal(
66
+ report.counts.migrations,
67
+ basenames.length,
68
+ `the proof saw ${report.counts.migrations} core migrations but migrations/ holds ${basenames.length} — a naming form is not being parsed`
69
+ );
70
+ // Bare and namespaced core files share ONE number line, so the proof's next-free must
71
+ // match what next-number.js hands a claim; if they disagree a number gets re-used.
72
+ assert.equal(report.nextPrivate, nextFree(new Set(basenames.map(migrationNumberOf))));
73
+ });
@@ -8,7 +8,9 @@ import { createRequire } from 'node:module';
8
8
  const require = createRequire(import.meta.url);
9
9
  const {
10
10
  migrationNumbersFromPaths, parseEnumChecks, numberingDivergence, enumDrift,
11
+ proveLineage, buildRedactionContext,
11
12
  } = require('../scripts/gds/mirror-lineage-check.js');
13
+ const { nextFree } = require('../scripts/gds/next-number.js');
12
14
 
13
15
  test('migrationNumbersFromPaths picks CORE migrations only (not module/ADR numbers)', () => {
14
16
  const set = migrationNumbersFromPaths([
@@ -31,9 +33,61 @@ test('migrationNumbersFromPaths excludes migrations/instance/ seeds (task 2152)'
31
33
  'migrations/080_core_thing.sql', // bare core → counted
32
34
  'migrations/instance/080_gds_v4_planning_seed.sql', // instance seed → NOT core
33
35
  'migrations/instance/186_governance_seed.sql', // instance seed → NOT core
34
- 'migrations/core_192_namespaced.sql', // core_ prefix ≠ bare NNN_ form → not counted either
36
+ 'migrations/core_192_namespaced.sql', // namespaced core (ADR 0108 §5) → counted
35
37
  ]);
36
- assert.deepEqual([...set].sort((a, b) => a - b), [80]);
38
+ assert.deepEqual([...set].sort((a, b) => a - b), [80, 192]);
39
+ });
40
+
41
+ // ---- namespaced core migrations (ADR 0108 §5 / task 1002527) ---------------
42
+ // Core migrations above LEGACY_BARE_CORE_MAX are `core_NNN_`-prefixed so their stem
43
+ // can't collide with an instance/module migration across repos. The prefix namespaces
44
+ // the FILENAME, not the NUMBER LINE: core_188 continues the bare 001–187 sequence as
45
+ // ONE sequence (migration-namespace.js pins the cutoff so no bare core file can ever
46
+ // exceed it, and next-number.js unions both forms into a single max+1). The C9 proof
47
+ // must parse both forms or it silently stops covering every new core migration.
48
+
49
+ test('migrationNumbersFromPaths parses namespaced core_NNN_ names as ONE sequence with the bare ones (task 1002527)', () => {
50
+ const set = migrationNumbersFromPaths([
51
+ 'migrations/003_pms.sql', // legacy bare core
52
+ 'migrations/187_goal_sort_order.sql', // the last bare core number
53
+ 'migrations/core_188_goal_visibility.sql',
54
+ 'migrations/core_219_grade_attempts_idx.sql',
55
+ 'modules/game/migrations/game_001_world.sql', // module migration — still excluded
56
+ ]);
57
+ assert.deepEqual([...set].sort((a, b) => a - b), [3, 187, 188, 219]);
58
+ // ONE sequence, so the next free core number continues past the namespaced files —
59
+ // the same number next-number.js hands a claim (188 here would re-use core_188).
60
+ assert.equal(nextFree(set), 220);
61
+ });
62
+
63
+ test('proveLineage counts namespaced core_NNN_ migrations in the MIRROR set (task 1002527)', () => {
64
+ const ctx = buildRedactionContext();
65
+ const file = (p, sql) => ({ path: p, buf: Buffer.from(sql, 'utf8') });
66
+ const publishableFiles = [
67
+ file('migrations/003_pms.sql', "CREATE TABLE a (s TEXT CHECK (s IN ('x', 'y')));\n"),
68
+ file('migrations/core_188_goal_visibility.sql', "CREATE TABLE b (s TEXT CHECK (s IN ('p', 'q')));\n"),
69
+ file('src/bongos/db.js', 'module.exports = {};\n'), // not a migration
70
+ ];
71
+ const report = proveLineage({ publishableFiles, privateMigrationNumbers: new Set([3, 188]), ctx });
72
+
73
+ // Both halves must agree: the private set (migrationNumbersFromPaths) and the mirror
74
+ // set (proveLineage) parse the SAME names, so a namespaced file is never a false
75
+ // "missing" divergence — the failure mode that would red-line the C9 gate.
76
+ assert.equal(report.counts.migrations, 2);
77
+ assert.deepEqual(report.numbering, { missing: [], extra: [], consistent: true });
78
+ assert.equal(report.nextMirror, 189);
79
+ assert.equal(report.nextPrivate, 189);
80
+ });
81
+
82
+ test('proveLineage still CATCHES a genuinely dropped namespaced core migration (task 1002527)', () => {
83
+ const ctx = buildRedactionContext();
84
+ const publishableFiles = [
85
+ { path: 'migrations/003_pms.sql', buf: Buffer.from('SELECT 1;\n', 'utf8') },
86
+ ];
87
+ // core_188 exists privately but never reached the mirror → a real divergence.
88
+ const report = proveLineage({ publishableFiles, privateMigrationNumbers: new Set([3, 188]), ctx });
89
+ assert.equal(report.numbering.consistent, false);
90
+ assert.deepEqual(report.numbering.missing, [188]);
37
91
  });
38
92
 
39
93
  test('parseEnumChecks extracts the quoted value-lists from CHECK (... IN (...))', () => {
@@ -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`);