@bongos/core 1.19.910 → 1.19.911

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.
@@ -52,6 +52,10 @@ const { completenessScore, weakestSectionPayload, scoreFieldValues } = require('
52
52
  // The trusted tier, spelled once (task 1003197 (D)) — routing.js owns METIC_PLUS
53
53
  // and already names 'criterion-proposal' in NEVER_ROUTE_KINDS.
54
54
  const { mayProposeCriterion, METIC_PLUS } = require('../routing');
55
+ // "Gravity" — the landing verdict read BEFORE the write (task 1004083, BV2.R02).
56
+ // Read-only and never-throwing; it calls the same matrix the capture calls so the
57
+ // composer can state the outcome instead of implying one. See ../landing-preview.js.
58
+ const { previewLanding } = require('../landing-preview');
55
59
  // task 1003208: structured logging (pino via the doorway) — was console.*.
56
60
  const log = api.logger('ideas');
57
61
  const { validateOrRespond, LIMITS, parseId } = api;
@@ -514,6 +518,49 @@ module.exports = function buildInboxRouter() {
514
518
  const { score, fields } = scoreFieldValues(req.body.field_values, templateKey ? { templateKey } : undefined);
515
519
  return res.json({ score, fields, template_key: templateKey || null });
516
520
  }, { errorCode: 'score_failed' }));
521
+ // POST /inbox/preview-landing — GRAVITY, MADE VISIBLE (task 1004083, BV2.R02).
522
+ //
523
+ // What pressing "File it" would do, without doing it. The composer draws one
524
+ // line from this so a filer can see which goal will catch the thought, change
525
+ // it, or deliberately let it float — the criterion's done-when, read literally.
526
+ //
527
+ // A DRY RUN IN THE SHAPE OF /inbox/score DIRECTLY ABOVE: same POST-with-a-body
528
+ // reason (the draft is the input, not an id), same "a half-formed draft is a
529
+ // legal state" contract. It differs in one way worth naming, because the file
530
+ // header above claims /score is pure: this one READS (the goal row, the
531
+ // building version). It still WRITES NOTHING — see ../landing-preview.js, which
532
+ // calls `currentBuildingVersionId` and deliberately never `ensureMaintenanceGoal`,
533
+ // so previewing a homeless bug cannot mint a goal as a side effect of typing.
534
+ //
535
+ // THE RANK AND THE VECTOR ARE NOT READ FROM THE BODY, exactly as POST /inbox
536
+ // refuses to read them: the rank comes off the authenticated builder and the
537
+ // vector is hardcoded to 'api'. A preview is only worth having if it is the
538
+ // same question the capture will be asked — one that answered a rank the filer
539
+ // does not hold would teach them a landing they cannot have.
540
+ //
541
+ // NEVER A 4xx FOR A DRAFT. Only the input SHAPE is validated; an unknown kind,
542
+ // an absent title, a goal that vanished all come back as `outcome: null`
543
+ // ("nothing to say yet"), because a composer redrawing on every keystroke must
544
+ // degrade to silence rather than to a red error under a half-typed thought.
545
+ //
546
+ // rank: any-builder — it answers only about the caller's own prospective
547
+ // filing, and filing itself is open to every rank.
548
+ router.post('/inbox/preview-landing', api.requireBuilder, api.asyncHandler('POST /inbox/preview-landing', async (req, res) => {
549
+ if (validateOrRespond(req, res, {
550
+ goal_id: { type: 'integer', min: 1 },
551
+ kind: { type: 'string', maxLength: 64 },
552
+ grade: { type: 'string', maxLength: 16 },
553
+ })) return;
554
+ const body = req.body || {};
555
+ const verdict = await previewLanding({
556
+ goalId: body.goal_id ?? null,
557
+ kind: body.kind ?? null,
558
+ grade: body.grade ?? null,
559
+ filerRank: req.builder?.rank ?? null,
560
+ vector: 'api',
561
+ });
562
+ return res.json(verdict);
563
+ }, { errorCode: 'preview_landing_failed' }));
517
564
  // GET /inbox/sparks — THE SPARK QUEUE (task 1002921 / BV1.R145, goal 1000062).
518
565
  //
519
566
  // Big Idea Sparks awaiting development: the ideator craft's pickup surface,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.910",
3
+ "version": "1.19.911",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.910",
9
+ "version": "1.19.911",
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.910",
3
+ "version": "1.19.911",
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,213 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // api-alias-caller-scan.js — the detector behind the lowercase `/api/gds` CALLER ratchet
4
+ // (task 1004064, from idea 1001030; the sibling of gds-literal-scan.js).
5
+ //
6
+ // WHY THIS EXISTS. Task 1003707 (criterion C3e of goal 1000073) moved 665 in-repo callers off the
7
+ // permanent `/api/gds` alias onto the canonical `/api/bongos`. The ratchet that already guards the
8
+ // rename, `legacy_gds_literals`, is CASE-SENSITIVE on the shouting acronym — deliberately, because
9
+ // `GDS` is the word a newcomer trips over. So that metric read 1135 before the sweep and 1135
10
+ // after it, and it was right to: the alias is spelled in lowercase.
11
+ //
12
+ // The consequence is the gap this file closes. The lowercase spelling is invisible to CI in BOTH
13
+ // directions: the 665-caller sweep earned no credit, and — the half that matters — a caller written
14
+ // against `/api/gds` tomorrow moves no number and reds no build. 1003707's own session log says it
15
+ // plainly: "the 665 will quietly grow back". `tests/api_alias_exceptions.mjs` pins the callers that
16
+ // were deliberately LEFT behind and why, but it polices the exceptions, not new offenders. That is
17
+ // a ratchet's job.
18
+ //
19
+ // WHY A COUNT AND NOT ZERO. Every remaining occurrence has a reason, and most of them are reasons
20
+ // that will never expire — the OAuth callbacks are registered in GitHub's and Discord's app
21
+ // settings, the uptime poller is the one production caller proving the alias still answers, and
22
+ // tests are what keep it exercised in CI. A zero-rule would demand deleting things that must not be
23
+ // deleted. So this holds the line the same way its sibling does: the number may fall, never rise.
24
+ //
25
+ // WHAT IS NEVER COUNTED, and why each one. The exclusion list is not invented here — it is
26
+ // 1003707's own worked-out list, transcribed from docs/session-logs/2026-09-16-task-1003707-api-gds-callers.md
27
+ // so that a future move has to argue with a stated reason rather than with a leftover:
28
+ //
29
+ // 1. THE ALIAS CONTRACT (path-excluded). api-prefix.js and api-path-404.js ARE the alias; they
30
+ // cannot name it less often than they do.
31
+ // 2. THE PINNED OAUTH CALLBACKS (path-excluded). routes/auth.js, provisioning.js and the scripts
32
+ // + skills that register the same callback for a new instance. These paths live in the GitHub
33
+ // and Discord app settings; a repo-only change silently breaks sign-in.
34
+ // 3. THE LIVE PROOF (path-excluded). uptime-poller.js and smoke-gds.js exercise the alias on
35
+ // purpose — if nothing calls it, its eventual breakage is found by a user rather than by us.
36
+ // Note fitness-ratchets.js belongs here too: it is the GATE, and its builder-facing hint has
37
+ // to spell the alias out to tell anyone what they tripped over.
38
+ // 4. THE RENAME GUARDRAILS (path-excluded). rename-history-check.js, rename-ledger-prose.js,
39
+ // gds-literal-scan.js, codemod-rename-src-gds.js, fitness-ratchets.js and THIS FILE name the
40
+ // old path because that is what they are for. Same principle as GDS_RULE_FILES next door: a
41
+ // gate that cannot state its own rule cannot explain itself.
42
+ // 5. tests/ (path-excluded). Tests are what keeps the alias exercised in CI. Moving them off it
43
+ // would retire the alias by accident.
44
+ // 6. docs/ (path-excluded) — a DELIBERATE SCOPE CALL, not an oversight. It is the largest
45
+ // remaining bucket (~320) and it is where rename-history-check's per-file floors live, so a
46
+ // docs pass has to separate the record of what we used to call it from instructions that
47
+ // still teach the old spelling. 1004064's own brief says to treat docs as out of scope and
48
+ // file it rather than widen the task; filed as the follow-up named in that task's session log.
49
+ // 7. MIGRATIONS + THE FROZEN RECORD (path-excluded). Same rule as the sibling detector: an
50
+ // applied migration renamed is an applied migration re-run, and a rewritten ADR destroys the
51
+ // evidence that the words changed.
52
+ // 8. A LINE THAT IS ABOUT THE ALIAS (line-excluded). Any line that ALSO names `/api/bongos`, or
53
+ // carries the words alias / legacy / permanent / forever, is commentary on the alias rather
54
+ // than a caller of it. This is the same rule 1003707's codemod used to decide what to leave,
55
+ // so the ratchet and the sweep agree by construction.
56
+ // 9. CAPTURED FIXTURES (path-excluded). scripts/hall-preview/fixtures/*.json are snapshots of
57
+ // real API responses; rewriting one would make the fixture misreport what the server returned.
58
+ //
59
+ // Usage:
60
+ // node scripts/gds/api-alias-caller-scan.js # count + the top offending files
61
+ // node scripts/gds/api-alias-caller-scan.js --files # every file with its count
62
+ // node scripts/gds/api-alias-caller-scan.js --json
63
+
64
+ const fs = require('node:fs');
65
+ const path = require('node:path');
66
+ const { execFileSync } = require('node:child_process');
67
+
68
+ const ROOT = path.resolve(__dirname, '..', '..');
69
+
70
+ // The alias, lowercase — the whole point of this detector, since its sibling is case-sensitive on
71
+ // the shouting form and therefore blind to exactly this spelling.
72
+ const ALIAS = '/api/gds';
73
+ const ALIAS_RE = /\/api\/gds/g;
74
+
75
+ // The canonical prefix. A line naming BOTH is commentary on the alias, not a caller of it.
76
+ const CANONICAL = '/api/bongos';
77
+
78
+ // Path exclusions, matched against the repo-relative path (forward slashes), at any depth.
79
+ // Every entry maps to a numbered reason in the header above — keep them in step.
80
+ const ALIAS_EXCLUDED_PATHS = [
81
+ // 1. the alias contract
82
+ /^src\/bongos\/api-prefix\.js$/,
83
+ /^src\/bongos\/api-path-404\.js$/,
84
+ // 2. the pinned OAuth callbacks + everything that registers them for a new instance
85
+ /^src\/bongos\/routes\/auth\.js$/,
86
+ /^modules\/provisioning\/provisioning\.js$/,
87
+ /^scripts\/gds\/(onboard|provision-net|dev|login)\.js$/,
88
+ /^modules\/lifecycle\/kickoff-checklist\.js$/,
89
+ /^modules\/public-landing\/public\/projects\.html$/,
90
+ /^\.claude\/skills\/new-project\/SKILL\.md$/,
91
+ // 3. the live proof that the alias still answers
92
+ /^src\/bongos\/uptime-poller\.js$/,
93
+ /^scripts\/gds\/smoke-gds\.js$/,
94
+ // 4. the rename guardrails — they name the old path on purpose
95
+ /^scripts\/gds\/(rename-history-check|rename-ledger-prose|gds-literal-scan|codemod-rename-src-gds|api-alias-caller-scan|fitness-ratchets)\.js$/,
96
+ /^config\/(fitness-baselines|rename-history-baseline)\.json$/,
97
+ // 5. tests keep the alias exercised in CI — the repo's own two spellings, `tests/` at the root
98
+ // and a module's `tests/` or `test/` dir. Deliberately NOT a blanket `\.mjs$`: three .mjs
99
+ // files are not tests (clients/bongos-client/index.mjs and its example, scripts/status-probe.mjs)
100
+ // and the client is exactly the kind of caller this ratchet exists to police.
101
+ /(^|\/)tests?\//,
102
+ // 6. docs are a separate pass (scope call, see the header)
103
+ /(^|\/)docs\//,
104
+ // 7. migrations + the frozen record
105
+ /(^|\/)migrations\//,
106
+ /(^|\/)limitations\//,
107
+ // 9. captured API-response fixtures
108
+ /(^|\/)fixtures\//,
109
+ ];
110
+
111
+ // Vendored / generated / third-party trees — the same exclusion the sibling detector applies.
112
+ const ALIAS_EXCLUDED_TREES = /(^|\/)(node_modules|public-docs)\/|\.min\.js$|(^|\/)vendor\//;
113
+
114
+ // A line that is ABOUT the alias rather than a caller of it. Rule 8 above.
115
+ const ALIAS_COMMENTARY_RE = /\balias\b|\blegacy\b|\bpermanent\b|\bforever\b/i;
116
+
117
+ function isExcludedPath(relPath) {
118
+ const p = relPath.replace(/\\/g, '/');
119
+ if (ALIAS_EXCLUDED_TREES.test(p)) return true;
120
+ return ALIAS_EXCLUDED_PATHS.some((re) => re.test(p));
121
+ }
122
+
123
+ // isCountedLine — a line naming the alias that is a CALLER rather than commentary. Exported so the
124
+ // test and any future sweep share ONE decision (the gds-literal-scan precedent: a gate whose
125
+ // detector drifts from its sweep is worse than no gate).
126
+ function isCountedLine(line) {
127
+ if (!line.includes(ALIAS)) return false;
128
+ if (line.includes(CANONICAL)) return false;
129
+ if (ALIAS_COMMENTARY_RE.test(line)) return false;
130
+ return true;
131
+ }
132
+
133
+ // countInText — alias occurrences on counted lines only. A line can legitimately carry more than
134
+ // one (two fetches, a usage banner), so occurrences are counted rather than lines: that is what
135
+ // makes the number fall when a caller is actually moved.
136
+ function countInText(text) {
137
+ let n = 0;
138
+ for (const line of String(text).split(/\r?\n/)) {
139
+ if (!isCountedLine(line)) continue;
140
+ n += (line.match(ALIAS_RE) || []).length;
141
+ }
142
+ return n;
143
+ }
144
+
145
+ function trackedFiles() {
146
+ const out = execFileSync('git', ['ls-files'], { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
147
+ return out.split('\n').map((s) => s.trim()).filter(Boolean);
148
+ }
149
+
150
+ // scanAliasCallers — { total, byFile, scanned } across every tracked, countable file.
151
+ //
152
+ // `scanned` is the NON-VACUITY channel, and it is the whole reason this function returns it. A
153
+ // static scan breaks by finding nothing, and a green run looks identical to a working one: if
154
+ // `git ls-files` returns nothing, or every path is excluded, this reports total 0 and a caller
155
+ // that reads only `total` cannot tell a clean tree from a broken scanner. The ratchet and the test
156
+ // both assert `scanned > 0`, so an enumeration that collapses fails LOUDLY instead of passing.
157
+ //
158
+ // `files` and `readFile` are injectable for tests, so the real aggregation — exclusions, the
159
+ // commentary rule, per-file totals — is exercised without writing into the repo. The unit lane runs
160
+ // test files in parallel; a test that edited a tracked file would break whichever suite happened to
161
+ // be scanning the tree at that moment.
162
+ function scanAliasCallers({ files = null, readFile = null } = {}) {
163
+ const read = readFile || ((relPath) => fs.readFileSync(path.join(ROOT, relPath), 'utf8'));
164
+ const byFile = {};
165
+ let total = 0;
166
+ let scanned = 0;
167
+ for (const relPath of files || trackedFiles()) {
168
+ if (isExcludedPath(relPath)) continue;
169
+ let text;
170
+ try {
171
+ text = read(relPath);
172
+ } catch {
173
+ continue;
174
+ }
175
+ if (text.includes(String.fromCharCode(0))) continue; // binary (a NUL byte)
176
+ scanned += 1;
177
+ const n = countInText(text);
178
+ if (n) {
179
+ byFile[relPath] = n;
180
+ total += n;
181
+ }
182
+ }
183
+ return { total, byFile, scanned };
184
+ }
185
+
186
+ function main() {
187
+ const { total, byFile, scanned } = scanAliasCallers();
188
+ const ranked = Object.entries(byFile).sort((a, b) => b[1] - a[1]);
189
+ if (process.argv.includes('--json')) {
190
+ process.stdout.write(`${JSON.stringify({ total, byFile, scanned }, null, 2)}\n`);
191
+ return;
192
+ }
193
+ const show = process.argv.includes('--files') ? ranked : ranked.slice(0, 20);
194
+ console.log(`in-repo /api/gds callers: ${total} across ${ranked.length} file(s) (${scanned} file(s) scanned)`);
195
+ console.log('(the alias contract, the pinned OAuth callbacks, the uptime poller + smoke, the rename guardrails,');
196
+ console.log(' tests/, docs/, migrations/ and captured fixtures are excluded by construction; so is any line that');
197
+ console.log(' also names /api/bongos or carries the words alias / legacy / permanent / forever)');
198
+ console.log('');
199
+ for (const [f, n] of show) console.log(` ${String(n).padStart(5)} ${f}`);
200
+ if (show.length < ranked.length) console.log(` … and ${ranked.length - show.length} more file(s) — pass --files for all`);
201
+ }
202
+
203
+ if (require.main === module) main();
204
+
205
+ // Only what a consumer actually calls. The constants above stay module-private on purpose: an
206
+ // export nobody imports reads as dead code to knip and costs a ratchet point for nothing, and the
207
+ // rules are meant to be read here rather than re-implemented by a caller.
208
+ module.exports = {
209
+ isExcludedPath,
210
+ isCountedLine,
211
+ countInText,
212
+ scanAliasCallers,
213
+ };
@@ -347,6 +347,15 @@ const METRIC_HINTS = {
347
347
  + 'Write Bongos instead. Run `node scripts/gds/gds-literal-scan.js` to see every counted file. '
348
348
  + 'Version ids (GDS-V##), docs/adr, docs/session-logs, docs/audits, limitations and migrations '
349
349
  + 'are excluded by construction — if your literal is history, it belongs in one of those.',
350
+ legacy_api_gds_callers:
351
+ ' — a NEW in-repo caller was written against the permanent `/api/gds` alias (task 1004064, '
352
+ + 'idea 1001030). Ask for `/api/bongos` instead: the alias exists for callers we CANNOT update '
353
+ + '(shipped Dev Box binaries, live dev boxes), not for code in this repo. Run '
354
+ + '`node scripts/gds/api-alias-caller-scan.js --files` to see every counted line. The alias '
355
+ + 'contract, the pinned OAuth callbacks, the uptime poller + smoke, the rename guardrails, '
356
+ + 'tests/, docs/, migrations/ and captured fixtures are excluded by construction — and so is '
357
+ + 'any line that also names /api/bongos or carries the words alias / legacy / permanent / forever, '
358
+ + 'so if your line is ABOUT the alias rather than a call to it, say so on the line.',
350
359
  };
351
360
 
352
361
  function compareToBaselines(metrics, baselines) {
@@ -379,6 +388,27 @@ function gdsMetrics() {
379
388
  return { legacy_gds_literals: scanGdsLiterals().total };
380
389
  }
381
390
 
391
+ // legacy_api_gds_callers — the LOWERCASE half of the same rename (task 1004064, idea 1001030).
392
+ // `legacy_gds_literals` above is case-sensitive on the shouting acronym, so it read 1135 both
393
+ // before and after task 1003707 moved 665 in-repo callers off `/api/gds` — correct for what it
394
+ // measures, and exactly why a caller written against the alias tomorrow moves no number and reds
395
+ // no build. This counts that spelling instead. Detector + the reason for every exclusion:
396
+ // scripts/gds/api-alias-caller-scan.js.
397
+ //
398
+ // It FAILS CLOSED on a collapsed enumeration. A static scan breaks by finding nothing, and a
399
+ // green run looks identical to a working one — so if the scanner scanned no files at all, the
400
+ // metric is OMITTED with a reason on stderr rather than reported as a flattering 0 that would
401
+ // invite someone to ratchet the baseline down to it.
402
+ function apiAliasCallerMetrics() {
403
+ const { scanAliasCallers } = require('./api-alias-caller-scan.js');
404
+ const { total, scanned } = scanAliasCallers();
405
+ if (!scanned) {
406
+ process.stderr.write('fitness-ratchets: legacy_api_gds_callers NOT MEASURED — the caller scan enumerated ZERO files, which is a scan defect, not a clean tree\n');
407
+ return {};
408
+ }
409
+ return { legacy_api_gds_callers: total };
410
+ }
411
+
382
412
  // skill_listing_chars — the resident cost of the skill listing (every name +
383
413
  // description, both skill homes). Ratcheted rather than merely warned about
384
414
  // because this number has now been trimmed three times (tasks 1003548, 1003587,
@@ -411,6 +441,7 @@ function computeMetrics({ withDocs = true } = {}) {
411
441
  require_cycle_count: countCycles(requireGraph(contents)),
412
442
  console_call_count: consoleCallCount(contents),
413
443
  ...gdsMetrics(),
444
+ ...apiAliasCallerMetrics(),
414
445
  ...skillListingMetrics(),
415
446
  ...(withDocs ? docMetrics() : {}),
416
447
  };
@@ -231,7 +231,20 @@ app.use((req, res, next) => {
231
231
  return res.end(png);
232
232
  }
233
233
 
234
- if (req.method !== 'GET') {
234
+ // READ-SHAPED POSTS. A handful of routes are POSTs only because the INPUT is a
235
+ // draft rather than an id — they write nothing. Serving them the 501 below made
236
+ // their surfaces permanently un-demoable here: the page renders its degraded
237
+ // state forever, which is the "a page that only ever renders its empty state
238
+ // cannot be redesigned by looking" trap modules/hall-ui/CLAUDE.md names. They
239
+ // fall through to the ordinary fixture lookup instead.
240
+ //
241
+ // AN ALLOWLIST, and it stays short. Anything absent is still refused, so a real
242
+ // write cannot reach the harness by growing a comment — adding a member means
243
+ // asserting that route writes nothing, which is checkable at its handler.
244
+ const READ_SHAPED_POSTS = new Set([
245
+ 'inbox/preview-landing', // the landing verdict for a draft (task 1004083)
246
+ ]);
247
+ if (req.method !== 'GET' && !READ_SHAPED_POSTS.has(apiPath)) {
235
248
  return res.status(501).json({ error: { code: 'preview_readonly', message: 'the preview harness serves fixtures; writes are not wired' } });
236
249
  }
237
250
  // Your own account wins over the checked-in snapshot; a me-scoped read that
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.910'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.911'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,130 @@
1
+ // tests/api_alias_caller_ratchet.mjs — the lowercase `/api/gds` CALLER ratchet
2
+ // (task 1004064, from idea 1001030). Mirrors tests/fitness_gds_ratchet.mjs, its
3
+ // case-sensitive sibling.
4
+ //
5
+ // WHAT THIS FILE IS DEFENDING AGAINST. The brief names the trap exactly: "this is a static scan;
6
+ // it breaks by finding nothing, and a green run looks identical to a working one." Every case
7
+ // below is written so that a scanner which enumerates nothing, excludes everything, or counts
8
+ // nothing FAILS here rather than passing quietly. The aggregation cases inject their own file
9
+ // list and reader, so the real exclusion + counting path runs without writing into the repo —
10
+ // the unit lane runs test files in parallel, and a test that edited a tracked file would break
11
+ // whichever suite happened to be scanning the tree at that moment.
12
+ import assert from 'node:assert/strict';
13
+ import { test } from 'node:test';
14
+ import { readFileSync } from 'node:fs';
15
+ import { createRequire } from 'node:module';
16
+ import { fileURLToPath } from 'node:url';
17
+ import path from 'node:path';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+ const scan = require(path.join(ROOT, 'scripts', 'gds', 'api-alias-caller-scan.js'));
22
+
23
+ // A reader over an in-memory tree — no repo writes.
24
+ const fake = (tree) => ({ files: Object.keys(tree), readFile: (rel) => tree[rel] });
25
+
26
+ test('the counter COUNTS — a file that should count, does (the trap case)', () => {
27
+ const r = scan.scanAliasCallers(fake({
28
+ 'src/bongos/routes/thing.js': "await fetch(`${base}/api/gds/tasks/${id}`);\n",
29
+ }));
30
+ assert.equal(r.total, 1, 'a plain in-repo caller must be counted');
31
+ assert.deepEqual(Object.keys(r.byFile), ['src/bongos/routes/thing.js']);
32
+ assert.equal(r.scanned, 1, 'and the file must actually have been scanned');
33
+ });
34
+
35
+ test('a collapsed enumeration is visible as scanned=0, not as a flattering total of 0', () => {
36
+ const empty = scan.scanAliasCallers(fake({}));
37
+ assert.equal(empty.total, 0);
38
+ assert.equal(empty.scanned, 0, 'scanned is what distinguishes "nothing to find" from "nothing looked at"');
39
+
40
+ const allExcluded = scan.scanAliasCallers(fake({
41
+ 'tests/x.mjs': '/api/gds\n',
42
+ 'docs/x.md': '/api/gds\n',
43
+ }));
44
+ assert.equal(allExcluded.total, 0);
45
+ assert.equal(allExcluded.scanned, 0, 'an every-path-excluded run is also scanned=0 — the same signal');
46
+ });
47
+
48
+ test('the REAL scan enumerates the repo and lands on the committed baseline', () => {
49
+ const r = scan.scanAliasCallers();
50
+ assert.ok(r.scanned > 500, `the real tree must enumerate substantially (got ${r.scanned})`);
51
+ const baselines = JSON.parse(readFileSync(path.join(ROOT, 'config', 'fitness-baselines.json'), 'utf8'));
52
+ const floor = baselines.metrics.legacy_api_gds_callers;
53
+ assert.equal(typeof floor, 'number', 'the metric must have a baseline, or the ratchet only warns');
54
+ assert.ok(r.total <= floor, `caller count ${r.total} is ABOVE baseline ${floor} — a new /api/gds caller entered the tree`);
55
+ });
56
+
57
+ test('the exclusions are real and each one is reachable', () => {
58
+ // Rule numbers refer to the header of api-alias-caller-scan.js.
59
+ for (const p of [
60
+ 'src/bongos/api-prefix.js', // 1 the alias contract
61
+ 'src/bongos/routes/auth.js', // 2 the pinned OAuth callbacks
62
+ 'modules/provisioning/provisioning.js',
63
+ 'src/bongos/uptime-poller.js', // 3 the live proof
64
+ 'scripts/gds/smoke-gds.js',
65
+ 'scripts/gds/gds-literal-scan.js', // 4 the rename guardrails
66
+ 'scripts/gds/fitness-ratchets.js',
67
+ 'scripts/gds/api-alias-caller-scan.js',
68
+ 'tests/api_alias_exceptions.mjs', // 5 tests
69
+ 'modules/dev-box/app/test/thing.mjs',
70
+ 'docs/architecture.md', // 6 docs
71
+ 'migrations/033_audit_log.sql', // 7 migrations
72
+ 'scripts/hall-preview/fixtures/tasks.json', // 9 captured fixtures
73
+ 'node_modules/pkg/index.js', // vendored
74
+ ]) {
75
+ assert.equal(scan.isExcludedPath(p), true, `${p} must be excluded`);
76
+ }
77
+ // …and the exclusion is not a blanket that swallows the tree.
78
+ for (const p of [
79
+ 'src/bongos/serve-internal.js',
80
+ 'modules/lifecycle/abandon-leftovers.js',
81
+ 'clients/bongos-client/index.mjs', // a .mjs that is NOT a test — a blanket \.mjs$ would blind the ratchet
82
+ 'bin/bongos.js',
83
+ 'CLAUDE.md',
84
+ ]) {
85
+ assert.equal(scan.isExcludedPath(p), false, `${p} must NOT be excluded`);
86
+ }
87
+ });
88
+
89
+ test('a line ABOUT the alias is not a caller of it — and the rule is not so loose it excuses everything', () => {
90
+ // Excluded: names the canonical prefix too, or says what it is.
91
+ assert.equal(scan.isCountedLine('mount /api/bongos and /api/gds'), false);
92
+ assert.equal(scan.isCountedLine('// /api/gds is a permanent alias'), false);
93
+ assert.equal(scan.isCountedLine('// the legacy /api/gds mount'), false);
94
+ assert.equal(scan.isCountedLine('// /api/gds stays FOREVER'), false);
95
+ // Counted: a bare caller, with none of those tells.
96
+ assert.equal(scan.isCountedLine('const r = await fetch(`${base}/api/gds/me`);'), true);
97
+ assert.equal(scan.isCountedLine(' node scripts/gds/api.js GET /api/gds/tasks/1'), true);
98
+ // And a line with no alias at all is never counted, whatever else it says.
99
+ assert.equal(scan.isCountedLine('fetch(`${base}/api/bongos/me`)'), false);
100
+ assert.equal(scan.isCountedLine('a line about a legacy alias, forever'), false);
101
+ });
102
+
103
+ test('occurrences are counted, not lines — moving ONE of two callers on a line lowers the number', () => {
104
+ assert.equal(scan.countInText('f("/api/gds/a"); f("/api/gds/b");\n'), 2);
105
+ assert.equal(scan.countInText('f("/api/bongos/a"); f("/api/gds/b");\n'), 0, 'the canonical name on the line is the alias-commentary rule, by design');
106
+ assert.equal(scan.countInText('nothing here\n'), 0);
107
+ });
108
+
109
+ test('the ratchet is WIRED — computeMetrics reports the metric and compareToBaselines fails a rise', () => {
110
+ const ratchets = require(path.join(ROOT, 'scripts', 'gds', 'fitness-ratchets.js'));
111
+ const baselines = JSON.parse(readFileSync(path.join(ROOT, 'config', 'fitness-baselines.json'), 'utf8')).metrics;
112
+
113
+ // A rise is a violation…
114
+ const worse = ratchets.compareToBaselines(
115
+ { legacy_api_gds_callers: baselines.legacy_api_gds_callers + 1 },
116
+ baselines,
117
+ );
118
+ assert.equal(worse.violations.length, 1, 'one more caller must RED the build');
119
+ assert.match(worse.violations[0], /legacy_api_gds_callers regressed/);
120
+ assert.match(worse.violations[0], /api-alias-caller-scan/, 'the hint must name the tool that lists the offenders');
121
+
122
+ // …a fall is a ratchet-down warning, not a failure.
123
+ const better = ratchets.compareToBaselines(
124
+ { legacy_api_gds_callers: baselines.legacy_api_gds_callers - 1 },
125
+ baselines,
126
+ );
127
+ assert.equal(better.violations.length, 0);
128
+ assert.equal(better.warnings.length, 1);
129
+ assert.match(better.warnings[0], /ratchet-down available/);
130
+ });