@bongos/core 1.19.382 → 1.19.384

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/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.382",
3
+ "version": "1.19.384",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.382",
9
+ "version": "1.19.384",
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.382",
3
+ "version": "1.19.384",
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",
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // adr-namespace.js — ADR numbering discipline (task 1003164). The ADR half of the guard
4
+ // `migration-namespace.js` already gives migrations. Pure helpers over file lists + index
5
+ // text, plus the fitness check that reads the tree.
6
+ //
7
+ // PROBLEM. An ADR number is a CITATION TARGET. ADRs cite each other by number, prose and
8
+ // task notes cite them by number, and `docs/adr/README.md` is the index every agent is
9
+ // told to consult. Two files can carry the same number and NOTHING notices: the filenames
10
+ // differ (`0181-ship-requires-an-assurance.md` vs
11
+ // `0181-teardown-state-gate-and-address-retention.md`), so there is no path collision, no
12
+ // merge conflict, and git adds both cleanly. README.md auto-merges too, ending up with two
13
+ // `| 0181 |` rows. Unit, fitness, docs-entropy and the grader all passed; it was found on
14
+ // 2026-08-22 only because a human read `ls docs/adr/`. By then task 1000900's
15
+ // close-rationale had already cited "ADR 0180", which the collision had made point at an
16
+ // unrelated document — a citation that is wrong forever.
17
+ //
18
+ // THE ASYMMETRY THIS CLOSES. Migrations have exactly this protection already: a re-used
19
+ // migration number silently SKIPS a migration, so `stemCollisions()` hard-fails CI. ADRs
20
+ // got `next-number.js` — which computes the next free number at claim time, on a
21
+ // possibly-stale tree, best-effort — and then nothing enforced it afterwards. Same shared,
22
+ // monotonic, human-allocated number line; one guarded, one not. A stale claim card, two
23
+ // builders claiming the same day, or a branch that sits long enough all reproduce it.
24
+ //
25
+ // WHY GRANDFATHER RATHER THAN RENUMBER. When this landed the live tree already held TWENTY
26
+ // colliding numbers, not the one the bug report found. Renumbering nineteen pairs would
27
+ // break every existing citation to them — which is the very harm this check exists to
28
+ // prevent — and README.md states the rule outright: a number, once assigned, is never
29
+ // reused. So the existing pairs are frozen into LEGACY_DUPLICATE_ADRS by their EXACT
30
+ // filenames (the shape migration-namespace.js uses for its own grandfathering, and for
31
+ // RENAMED_MODULE_PREFIXES). Frozen by exact filename, not by number, is the load-bearing
32
+ // part: a THIRD file joining a legacy number still fails, and so does a rename of either
33
+ // half. The list can only shrink. See ADR 0195.
34
+ //
35
+ // The index half is checked by LINK TARGET, not by number, because a number rule alone
36
+ // cannot express the grandfathered pairs: the invariant is that every ADR file has exactly
37
+ // one row and every row points at a file that exists. That catches the dangling row AND
38
+ // the duplicated row AND the file nobody indexed — the last of which is how a collision
39
+ // actually hides, since the second file of a colliding pair is the one that never gets a
40
+ // row.
41
+
42
+ const fs = require('node:fs');
43
+ const path = require('node:path');
44
+ const { CORE_ROOT } = require('./fitness-lib.js');
45
+
46
+ // ---- parsing ---------------------------------------------------------------
47
+
48
+ // `NNNN-kebab-title.md` — the naming rule in docs/adr/README.md §File naming.
49
+ const ADR_RE = /^(\d{4})-(.+)\.md$/;
50
+
51
+ // Parse an ADR basename into { number, num, slug, ok }. `number` is the 4-digit STRING
52
+ // (the citation form, zero-padded); `num` is the integer for ordering/allocation. A
53
+ // non-ADR name (README.md, a draft) returns { ok: false }.
54
+ function parseAdrName(basename) {
55
+ const m = ADR_RE.exec(String(basename == null ? '' : basename));
56
+ if (!m) return { number: null, num: null, slug: null, ok: false };
57
+ return { number: m[1], num: parseInt(m[1], 10), slug: m[2], ok: true };
58
+ }
59
+
60
+ // The 4-digit number string of an ADR basename, or null if it is not an ADR.
61
+ function adrNumberOf(basename) {
62
+ const p = parseAdrName(basename);
63
+ return p.ok ? p.number : null;
64
+ }
65
+
66
+ // ---- grandfathered collisions ---------------------------------------------
67
+
68
+ // Numbers already shared by two files when this check landed (task 1003164, 2026-08-27).
69
+ // Frozen by EXACT filename: a third file on one of these numbers, or a rename of either
70
+ // half, is still a hard failure. This list may shrink (if a pair is ever consolidated) and
71
+ // must never grow — a new collision is the bug the check exists to catch.
72
+ const LEGACY_DUPLICATE_ADRS = Object.freeze({
73
+ '0008': Object.freeze(['0008-family-based-tile-generation.md', '0008-google-chat-oauth-user-auth.md']),
74
+ '0024': Object.freeze(['0024-cloneable-repo-local-first-memory.md', '0024-multi-agent-system-architecture.md']),
75
+ '0025': Object.freeze(['0025-offsite-backup-vendor-digitalocean-spaces.md', '0025-structured-criterion-task-link.md']),
76
+ '0044': Object.freeze(['0044-mediterranean-palette-replacement.md', '0044-per-box-live-game-preview.md']),
77
+ '0057': Object.freeze(['0057-container-cost-ledger.md', '0057-discord-archon-approval-channels.md']),
78
+ '0072': Object.freeze(['0072-bongos-app-mac-signed-first-windows-deferred.md', '0072-dev-box-code-staleness-visibility.md']),
79
+ '0073': Object.freeze(['0073-builder-needs-signal-and-byok-gemini-key.md', '0073-secrets-scan-exclude-uri-detector.md']),
80
+ '0082': Object.freeze(['0082-productized-key-storage-provisioning.md', '0082-server-side-merge-conflict-auto-resolution.md']),
81
+ '0087': Object.freeze(['0087-bongos-app-architecture-and-handoff-contract.md', '0087-compete-on-governance-not-tooling-cursor.md']),
82
+ '0095': Object.freeze(['0095-borrowed-memory-and-retrieval-concepts.md', '0095-cross-agent-context-management.md']),
83
+ '0097': Object.freeze(['0097-one-active-claim-per-session-and-worktree-binding.md', '0097-retroactive-reward-backfill.md']),
84
+ '0103': Object.freeze(['0103-core-first-extraction-cloud-bongos-trunk.md', '0103-gdsv4-bongos-consolidation.md']),
85
+ '0144': Object.freeze(['0144-devbox-rehome-onto-cloudbongos-plane.md', '0144-federated-single-logout-backchannel.md']),
86
+ '0145': Object.freeze(['0145-devbox-app-branding-driven-module.md', '0145-free-hosted-project-tier-isolation-and-domain-separation.md']),
87
+ '0152': Object.freeze(['0152-landing-gate-served-by-the-core-app.md', '0152-metic-task-abandonment.md']),
88
+ '0167': Object.freeze(['0167-gate-trust-link-agpl-default.md', '0167-module-catalog-source-vs-provenance.md']),
89
+ '0172': Object.freeze(['0172-editable-rank-roles-substrate-axis.md', '0172-per-craft-compensation-ideator-credit-lane.md']),
90
+ '0184': Object.freeze(['0184-a-task-visual-inherits-its-summary-audience.md', '0184-ship-requires-an-assurance.md']),
91
+ '0187': Object.freeze(['0187-collab-four-decisions.md', '0187-oauth-handshake-cookie-lifetime.md']),
92
+ '0192': Object.freeze(['0192-a-category-orients-and-authorises-nothing.md', '0192-platform-visibility-member-door.md']),
93
+ });
94
+
95
+ // Is this exact set of files the frozen legacy pair for that number? Set-equality, so
96
+ // adding a third file or renaming either half stops being grandfathered.
97
+ function isGrandfathered(number, files) {
98
+ const legacy = LEGACY_DUPLICATE_ADRS[number];
99
+ if (!legacy) return false;
100
+ if (files.length !== legacy.length) return false;
101
+ const a = [...files].sort();
102
+ const b = [...legacy].sort();
103
+ return a.every((f, i) => f === b[i]);
104
+ }
105
+
106
+ // ---- collision detector ----------------------------------------------------
107
+
108
+ // Given ADR basenames, return [{ number, files[] }] for every number claimed by more than
109
+ // one file, EXCLUDING the frozen legacy pairs. The direct analogue of
110
+ // migration-namespace.js stemCollisions().
111
+ function numberCollisions(basenames) {
112
+ const byNumber = new Map();
113
+ for (const name of basenames || []) {
114
+ const p = parseAdrName(name);
115
+ if (!p.ok) continue;
116
+ if (!byNumber.has(p.number)) byNumber.set(p.number, new Set());
117
+ byNumber.get(p.number).add(name);
118
+ }
119
+ const out = [];
120
+ for (const [number, set] of byNumber) {
121
+ const files = [...set].sort();
122
+ if (files.length > 1 && !isGrandfathered(number, files)) out.push({ number, files });
123
+ }
124
+ return out.sort((a, b) => a.number.localeCompare(b.number));
125
+ }
126
+
127
+ // The next free ADR number (integer): max + 1, 1 on an empty tree. Mirrors next-number.js
128
+ // nextFree so a caller can allocate without reaching for git.
129
+ function nextAdrNumber(basenames) {
130
+ let max = 0;
131
+ for (const name of basenames || []) {
132
+ const p = parseAdrName(name);
133
+ if (p.ok && p.num > max) max = p.num;
134
+ }
135
+ return max + 1;
136
+ }
137
+
138
+ // ---- index (docs/adr/README.md) --------------------------------------------
139
+
140
+ // An index row: `| NNNN | [Title](NNNN-slug.md) | topic |`. The leading cell may carry a
141
+ // disambiguating suffix (`0008-a`) for a grandfathered pair, so the number is read from
142
+ // the leading cell and the row's OWN file from the link target that starts with it — row
143
+ // prose cites other ADRs by link too, and only the own-number target is the row's subject.
144
+ const INDEX_ROW_RE = /^\|\s*(\d{4})(?:-[a-z])?\s*\|/;
145
+ const INDEX_LINK_RE = /\]\((\d{4}-[^)]*\.md)\)/g;
146
+
147
+ // Parse README text into [{ number, target, targets, lineNo, ok }]. `target` is the row's
148
+ // own ADR filename, or null when the row names none (or names more than one of its own
149
+ // number, which is ambiguous and reported as a violation).
150
+ function parseIndexRows(readmeText) {
151
+ const rows = [];
152
+ const lines = String(readmeText == null ? '' : readmeText).split('\n');
153
+ lines.forEach((line, i) => {
154
+ const m = INDEX_ROW_RE.exec(line);
155
+ if (!m) return;
156
+ const number = m[1];
157
+ INDEX_LINK_RE.lastIndex = 0;
158
+ const own = [...line.matchAll(INDEX_LINK_RE)].map((x) => x[1]).filter((t) => t.startsWith(number + '-'));
159
+ rows.push({ number, target: own.length === 1 ? own[0] : null, targets: own, lineNo: i + 1, ok: own.length === 1 });
160
+ });
161
+ return rows;
162
+ }
163
+
164
+ // The index invariant, checked by LINK TARGET: every ADR file has exactly one row, and
165
+ // every row points at a file that exists. Returns violation strings.
166
+ function indexViolations({ basenames = [], rows = [] } = {}) {
167
+ const violations = [];
168
+ const files = new Set((basenames || []).filter((n) => parseAdrName(n).ok));
169
+
170
+ const rowsByTarget = new Map();
171
+ for (const r of rows) {
172
+ if (!r.ok) {
173
+ violations.push(
174
+ `docs/adr/README.md:${r.lineNo}: the index row for ${r.number} names ${r.targets.length} link(s) to its own number `
175
+ + `(${r.targets.join(', ') || 'none'}) — a row must link its own ADR file exactly once so the index can be checked against the tree.`);
176
+ continue;
177
+ }
178
+ if (!rowsByTarget.has(r.target)) rowsByTarget.set(r.target, []);
179
+ rowsByTarget.get(r.target).push(r.lineNo);
180
+ }
181
+
182
+ for (const [target, lineNos] of [...rowsByTarget.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
183
+ if (!files.has(target)) {
184
+ violations.push(
185
+ `docs/adr/README.md:${lineNos[0]}: index row points at docs/adr/${target}, which does not exist — a dangling index row `
186
+ + `(the other half of the collision problem: a merge can leave a row behind after the file it named moved or was renumbered).`);
187
+ }
188
+ if (lineNos.length > 1) {
189
+ violations.push(
190
+ `docs/adr/README.md: ${target} has ${lineNos.length} index rows (lines ${lineNos.join(', ')}) — exactly one row per ADR; `
191
+ + 'a duplicated row is what an auto-merged index leaves behind.');
192
+ }
193
+ }
194
+
195
+ for (const f of [...files].sort()) {
196
+ if (!rowsByTarget.has(f)) {
197
+ violations.push(
198
+ `docs/adr/${f} has no row in docs/adr/README.md — every ADR is indexed. (This is how a number collision hides: the `
199
+ + 'second file of a colliding pair is the one that never gets indexed, so the tree and the index quietly disagree.)');
200
+ }
201
+ }
202
+
203
+ return violations;
204
+ }
205
+
206
+ // Rows should read in ascending number order. WARNING only, never a hard failure: the live
207
+ // index carries legacy rows an auto-merge placed out of order, and blocking on history
208
+ // would fail every PR for a mess nobody's diff created (the ADR 0189 split — declaration
209
+ // rot hard-fails, inherited drift warns).
210
+ function indexOrderWarnings(rows) {
211
+ const warnings = [];
212
+ let prev = null;
213
+ for (const r of rows) {
214
+ if (prev !== null && r.number < prev) {
215
+ warnings.push(`docs/adr/README.md:${r.lineNo}: row ${r.number} sits after ${prev} — the index reads in ascending order.`);
216
+ }
217
+ if (prev === null || r.number > prev) prev = r.number;
218
+ }
219
+ return warnings;
220
+ }
221
+
222
+ // ---- the fitness check -----------------------------------------------------
223
+
224
+ const ADR_DIR = path.join(CORE_ROOT, 'docs', 'adr');
225
+
226
+ function adrFilesIn(absDir) {
227
+ try { return fs.readdirSync(absDir).filter((f) => parseAdrName(f).ok).sort(); }
228
+ catch { return []; } // no docs/adr in a vanilla instance → nothing to check
229
+ }
230
+
231
+ // Check 31 — two ADRs may not share a number, and the index must agree with the tree.
232
+ function checkAdrNumbering() {
233
+ const files = adrFilesIn(ADR_DIR);
234
+ const violations = [];
235
+
236
+ for (const c of numberCollisions(files)) {
237
+ violations.push(
238
+ `ADR number ${c.number} is claimed by ${c.files.length} files (${c.files.map((f) => `docs/adr/${f}`).join(', ')}) — `
239
+ + 'an ADR number is a citation target, so a duplicate makes every citation to it ambiguous forever. Renumber the NEWER '
240
+ + 'file to the next free number (node scripts/gds/next-number.js) and move its README row with it.');
241
+ }
242
+
243
+ let readme = null;
244
+ try { readme = fs.readFileSync(path.join(ADR_DIR, 'README.md'), 'utf8'); } catch { /* no index → nothing to reconcile */ }
245
+ const rows = readme === null ? [] : parseIndexRows(readme);
246
+ if (readme !== null) violations.push(...indexViolations({ basenames: files, rows }));
247
+ const warnings = readme === null ? [] : indexOrderWarnings(rows);
248
+
249
+ return {
250
+ name: 'ADR numbers unique + index agrees with the tree (task 1003164)',
251
+ ok: violations.length === 0,
252
+ hardFail: violations.length > 0,
253
+ violations,
254
+ warnings,
255
+ note: `${files.length} ADR file(s), ${rows.length} index row(s), `
256
+ + `${Object.keys(LEGACY_DUPLICATE_ADRS).length} grandfathered duplicate number(s) (frozen by exact filename — the list may only shrink). `
257
+ + `Next free number: ${String(nextAdrNumber(files)).padStart(4, '0')}.`,
258
+ };
259
+ }
260
+
261
+ module.exports = {
262
+ ADR_RE,
263
+ LEGACY_DUPLICATE_ADRS,
264
+ parseAdrName,
265
+ adrNumberOf,
266
+ isGrandfathered,
267
+ numberCollisions,
268
+ nextAdrNumber,
269
+ parseIndexRows,
270
+ indexViolations,
271
+ indexOrderWarnings,
272
+ checkAdrNumbering,
273
+ };
@@ -1319,6 +1319,12 @@ const CHECKS = [
1319
1319
  // doc-comment gate that blocks a ship buys filler prose, not understanding.
1320
1320
  // Making it blocking is an owner decision and needs its own ADR.
1321
1321
  require('./doc-comment-coverage.js').checkDocCommentCoverage,
1322
+ // Check 31 — task 1003164: two ADRs may not share a number, and docs/adr/README.md
1323
+ // must agree with the tree. The ADR half of the guard Check 11b already gives
1324
+ // migrations: same shared, monotonic, human-allocated number line, and until this
1325
+ // landed only one of the two was enforced. An ADR number is a citation target, so a
1326
+ // duplicate is wrong forever (ADR 0195).
1327
+ require('./adr-namespace.js').checkAdrNumbering,
1322
1328
  ];
1323
1329
 
1324
1330
  function runAll() {
@@ -1364,6 +1370,7 @@ module.exports = {
1364
1370
  checkKernelImportsNoDomain,
1365
1371
  checkModuleMigrationsAdditive,
1366
1372
  checkMigrationNamespacing, // Check 11b — ADR 0108 §5 cross-repo migration filenames
1373
+ checkAdrNumbering: require('./adr-namespace.js').checkAdrNumbering, // Check 31 — task 1003164 / ADR 0195: ADR numbers unique + index agrees with the tree
1367
1374
  checkDesignContract,
1368
1375
  checkKernelPromptsNoHardcodedIdentity, // BV1.R76 / task 1705
1369
1376
  scanPromptIdentity, // BV1.R76 — negative-test hook
package/src/module-api.js CHANGED
@@ -1204,8 +1204,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1204
1204
  // landed since 1.19.380 with no explicit bump. run 33088290961. (task 1002620)
1205
1205
  // 1.19.382 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1206
1206
  // landed since 1.19.381 with no explicit bump. run 33094545251. (task 1002620)
1207
+ // 1.19.383 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1208
+ // landed since 1.19.382 with no explicit bump. run 33101389971. (task 1002620)
1209
+ // 1.19.384 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1210
+ // landed since 1.19.383 with no explicit bump. run 33116184189. (task 1002620)
1207
1211
  // ---------------------------------------------------------------------------
1208
- const CORE_VERSION = '1.19.382'; // CI auto-patch carrier (ADR 0161); see changelog above
1212
+ const CORE_VERSION = '1.19.384'; // CI auto-patch carrier (ADR 0161); see changelog above
1209
1213
 
1210
1214
  // A namespaced logger so a module's log lines are attributable + consistent.
1211
1215
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
package/tests/fitness.mjs CHANGED
@@ -354,6 +354,124 @@ test('migration-namespacing actually CATCHES a cross-source stem collision (the
354
354
  assert.equal(collisions[0].stem, '188_x');
355
355
  });
356
356
 
357
+ // --- Check 31: ADR numbers unique + index agrees with the tree (task 1003164 / ADR 0195) ---
358
+ //
359
+ // The ADR half of Check 11b's guard. The fixture is the REAL collision: on 2026-08-22 two
360
+ // branches each landed an ADR numbered 0181 under different slugs, so git saw no path
361
+ // collision, README.md auto-merged into two rows, and every gate passed.
362
+
363
+ const adrNs = require(path.join(ROOT, 'scripts', 'gds', 'adr-namespace.js'));
364
+
365
+ test('ADR numbering: the live tree is clean (all 20 known duplicates grandfathered)', () => {
366
+ const r = fitness.checkAdrNumbering();
367
+ assert.equal(r.hardFail, false, r.violations.join('\n'));
368
+ assert.match(r.note, /ADR file\(s\)/, 'note reports the file + row counts');
369
+ });
370
+
371
+ test('ADR numbering actually CATCHES two ADRs sharing a number (the 0181 pair, not a no-op gate)', () => {
372
+ const collisions = adrNs.numberCollisions([
373
+ '0180-dependency-intake-and-drift-policy.md',
374
+ '0181-ship-requires-an-assurance.md',
375
+ '0181-teardown-state-gate-and-address-retention.md',
376
+ '0182-something-else.md',
377
+ ]);
378
+ assert.equal(collisions.length, 1, 'the shared number must be reported exactly once');
379
+ assert.equal(collisions[0].number, '0181');
380
+ assert.deepEqual(collisions[0].files, [
381
+ '0181-ship-requires-an-assurance.md',
382
+ '0181-teardown-state-gate-and-address-retention.md',
383
+ ], 'BOTH paths are named — the report has to say which two files to look at');
384
+ });
385
+
386
+ test('ADR numbering: a grandfathered pair passes, but a THIRD file on that number does not', () => {
387
+ const pair = adrNs.LEGACY_DUPLICATE_ADRS['0008'];
388
+ assert.equal(pair.length, 2, 'fixture: 0008 is a known legacy pair');
389
+
390
+ // the exact frozen pair is exempt
391
+ assert.deepEqual(adrNs.numberCollisions([...pair]), [], 'the frozen legacy pair must pass');
392
+
393
+ // a third file joining the number is NOT — the exemption is set-equality, not "number 0008 is free"
394
+ const third = adrNs.numberCollisions([...pair, '0008-a-brand-new-adr.md']);
395
+ assert.equal(third.length, 1, 'a third file on a grandfathered number must still be flagged');
396
+ assert.equal(third[0].files.length, 3);
397
+
398
+ // so is a RENAME of either half — schema-migrations-style, the frozen names are the exemption
399
+ const renamed = adrNs.numberCollisions([pair[0].replace('.md', '-v2.md'), pair[1]]);
400
+ assert.equal(renamed.length, 1, 'renaming half of a grandfathered pair un-grandfathers it');
401
+ });
402
+
403
+ test('ADR numbering: the index must have one row per file, pointing at a file that exists', () => {
404
+ const files = ['0001-a.md', '0002-b.md'];
405
+ const row = (n, slug) => `| ${n} | [Title](${n}-${slug}.md) | topic |`;
406
+
407
+ // clean: one row per file, every row resolves
408
+ const clean = adrNs.indexViolations({
409
+ basenames: files,
410
+ rows: adrNs.parseIndexRows([row('0001', 'a'), row('0002', 'b')].join('\n')),
411
+ });
412
+ assert.deepEqual(clean, [], 'a matching index must pass: ' + clean.join('; '));
413
+
414
+ // DANGLING row — a row for a number with no file (the other half of the collision problem:
415
+ // the 2026-08-22 renumber had to reposition a row by hand)
416
+ const dangling = adrNs.indexViolations({
417
+ basenames: files,
418
+ rows: adrNs.parseIndexRows([row('0001', 'a'), row('0002', 'b'), row('0003', 'ghost')].join('\n')),
419
+ });
420
+ assert.equal(dangling.length, 1, 'exactly one violation for the dangling row');
421
+ assert.match(dangling[0], /does not exist/, 'the message names the missing file');
422
+ assert.match(dangling[0], /0003-ghost\.md/);
423
+
424
+ // DUPLICATED row — what an auto-merged index leaves behind (the live 0116/0117 case)
425
+ const dupe = adrNs.indexViolations({
426
+ basenames: files,
427
+ rows: adrNs.parseIndexRows([row('0001', 'a'), row('0002', 'b'), row('0002', 'b')].join('\n')),
428
+ });
429
+ assert.equal(dupe.length, 1);
430
+ assert.match(dupe[0], /has 2 index rows/, 'the message counts the rows and names their lines');
431
+
432
+ // UNINDEXED file — how a collision hides: the second file of a colliding pair is the one
433
+ // that never gets a row, so `ls docs/adr/` and the index quietly disagree
434
+ const missing = adrNs.indexViolations({
435
+ basenames: [...files, '0003-unindexed.md'],
436
+ rows: adrNs.parseIndexRows([row('0001', 'a'), row('0002', 'b')].join('\n')),
437
+ });
438
+ assert.equal(missing.length, 1);
439
+ assert.match(missing[0], /has no row in docs\/adr\/README\.md/);
440
+ });
441
+
442
+ test('ADR numbering: a row is read by its OWN-number link, so citing other ADRs in the prose is fine', () => {
443
+ // Index rows routinely cite sibling ADRs inside the title cell. Only the link whose
444
+ // filename starts with the row's own number is the row's subject.
445
+ const rows = adrNs.parseIndexRows(
446
+ '| 0100 | [Two-repo model, superseding [0099](0099-old.md) and see [0101](0101-next.md)](0100-two-repo.md) | topic |',
447
+ );
448
+ assert.equal(rows.length, 1);
449
+ assert.equal(rows[0].target, '0100-two-repo.md', 'the own-number link is the row subject');
450
+ assert.deepEqual(
451
+ adrNs.indexViolations({ basenames: ['0100-two-repo.md'], rows }),
452
+ [], 'citing other ADRs in a row must not be read as extra rows',
453
+ );
454
+ });
455
+
456
+ test('ADR numbering: out-of-order rows WARN and never hard-fail (inherited drift, not a PR author\'s doing)', () => {
457
+ const row = (n) => `| ${n} | [T](${n}-s.md) | topic |`;
458
+ assert.deepEqual(adrNs.indexOrderWarnings(adrNs.parseIndexRows([row('0001'), row('0002')].join('\n'))), []);
459
+
460
+ const warns = adrNs.indexOrderWarnings(adrNs.parseIndexRows([row('0001'), row('0003'), row('0002')].join('\n')));
461
+ assert.equal(warns.length, 1, 'the backwards row is reported');
462
+ assert.match(warns[0], /ascending order/);
463
+
464
+ // and ordering contributes to `warnings`, never to `violations`
465
+ const live = fitness.checkAdrNumbering();
466
+ assert.equal(live.hardFail, false, 'the live index is out of order in places and must still pass');
467
+ });
468
+
469
+ test('ADR numbering: nextAdrNumber allocates above the highest number on disk', () => {
470
+ assert.equal(adrNs.nextAdrNumber(['0001-a.md', '0181-b.md', '0181-c.md']), 182, 'max + 1, duplicates included');
471
+ assert.equal(adrNs.nextAdrNumber([]), 1, 'an empty tree starts at 1');
472
+ assert.equal(adrNs.nextAdrNumber(['README.md', 'notes.txt']), 1, 'non-ADR files do not count');
473
+ });
474
+
357
475
  // --- goal scope map imports PROTECTED_GLOBS (BV1.R54 / task 1508, ADR 0086 §7) ---
358
476
 
359
477
  test('scope map import check: the live module-scope-map.js passes', () => {