@bongos/core 1.19.910 → 1.19.912

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.bongos-core.json +62 -37
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0314-module-protection-is-per-path-not-per-module.md +7 -2
  8. package/docs/adr/0315-a-plane-with-no-proxy-is-refused-not-given-a-no-op-ingress.md +96 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +62 -2
  11. package/docs/api-reference.md +3 -2
  12. package/docs/copy-inventory.md +25 -25
  13. package/docs/copy-registry.json +29 -29
  14. package/docs/module-api-changelog.md +5 -1
  15. package/docs/recipes/self-host.md +1 -1
  16. package/modules/hall-ui/public/ideas.css +25 -0
  17. package/modules/hall-ui/public/ideas.html +12 -0
  18. package/modules/hall-ui/public/ideas.js +115 -2
  19. package/modules/ideas/landing-preview.js +180 -0
  20. package/modules/ideas/routes/inbox.js +47 -0
  21. package/package-lock.json +2 -2
  22. package/package.json +1 -1
  23. package/scripts/gds/api-alias-caller-scan.js +213 -0
  24. package/scripts/gds/fitness-ratchets.js +31 -0
  25. package/scripts/gds/provision-net.js +41 -0
  26. package/scripts/gds/provision.js +11 -3
  27. package/scripts/hall-preview/server.js +14 -1
  28. package/src/bongos/module-scope-map.js +43 -5
  29. package/src/module-api.js +1 -1
  30. package/tests/api_alias_caller_ratchet.mjs +130 -0
  31. package/tests/idea_landing_preview.mjs +337 -0
  32. package/tests/idea_spark_hall.mjs +183 -0
  33. package/tests/module-scope-map.mjs +87 -3
  34. package/tests/provision_caddy_wiring.mjs +76 -2
  35. package/tests/quarantine_canonical.mjs +32 -4
@@ -88,6 +88,12 @@
88
88
  let goalTouchedByUser = false; // once the builder picks, stop clobbering it with fresh suggestions
89
89
  let quickSuggestTimer = null;
90
90
  let quickFiling = false;
91
+ // GRAVITY (task 1004083 / spec R02). The landing verdict for the draft as it
92
+ // currently stands, read from POST /inbox/preview-landing. A monotonic token
93
+ // guards against the out-of-order arrival a debounced-but-concurrent read
94
+ // invites: two previews in flight can land in either order, and the older one
95
+ // winning would leave the line describing a draft that no longer exists.
96
+ let gravitySeq = 0;
91
97
 
92
98
  // ---------------------------------------------------------------------------
93
99
  // draft persistence
@@ -501,9 +507,108 @@
501
507
  }
502
508
  }
503
509
 
510
+ // ---------------------------------------------------------------------------
511
+ // gravity — which goal will catch this thought, said before it is released
512
+ // (task 1004083 / spec R02)
513
+ // ---------------------------------------------------------------------------
514
+
515
+ const fqGravityEl = () => $('#fq-gravity');
516
+
517
+ // THE SENTENCES, AND NOTHING ELSE. Every branch below READS a field of the
518
+ // server's verdict; not one of them decides a landing. That split is the whole
519
+ // point of the endpoint: modules/ideas/routing.js `decideLanding` stays the one
520
+ // place the matrix lives, and a second copy of it in this file is precisely the
521
+ // failure the #fq-kind comment above already records ("this markup used to
522
+ // spell the whole vocabulary and had gone stale ... a third copy nobody was
523
+ // watching"). If a sentence here ever needs an `if` over kind or rank, the
524
+ // verdict is missing a field and the fix belongs on the server.
525
+ //
526
+ // Returns null for "say nothing" — an unscorable draft, or a read that failed.
527
+ // Silence is the honest degraded state: it leaves the filer exactly where they
528
+ // were before this feature, rather than replacing a quiet lie with a loud one.
529
+ function gravitySentence(v) {
530
+ if (!v || !v.outcome) return null;
531
+ const goalName = v.goal ? (v.goal.title || `goal ${v.goal.id}`) : null;
532
+ const claimable = v.status === 'ready';
533
+
534
+ if (v.outcome === 'refused') {
535
+ // The protected-scope wall. The server wrote the reason for a human; it is
536
+ // the one branch that already reads as a sentence, so it is passed through.
537
+ return { tone: 'refused', text: v.refusal && v.refusal.reason ? v.refusal.reason : `${goalName} will not take this filing.` };
538
+ }
539
+ if (v.outcome === 'routed') {
540
+ return {
541
+ tone: 'lands',
542
+ text: claimable
543
+ ? `This lands in ${goalName} as a task anyone can claim.`
544
+ : `This lands in ${goalName}, waiting for a nod before anyone can claim it.`,
545
+ };
546
+ }
547
+ if (v.outcome === 'routed_home') {
548
+ // The label on the picker says "homeless"; since ADR 0262 that is not true
549
+ // of a bug, and this line is the correction. Naming the version rather than
550
+ // the goal is deliberate — the maintenance goal is created by the capture,
551
+ // so the preview has no row to name without making one (landing-preview.js).
552
+ return {
553
+ tone: 'lands',
554
+ text: claimable
555
+ ? `A bug always lands. With no goal picked this goes to ${v.version_id}'s maintenance goal, claimable straight away.`
556
+ : `A bug always lands. With no goal picked this goes to ${v.version_id}'s maintenance goal, waiting for a nod.`,
557
+ };
558
+ }
559
+ // outcome === 'floats' — the inbox. Two ways to get here, and they are
560
+ // different facts: nobody named a goal, or a goal was named and the matrix
561
+ // declined it. The second is the one the picker currently lies about.
562
+ if (v.reason === 'judgment_kind') {
563
+ return { tone: 'floats', text: `${goalName} will not catch this — this kind is judged, not queued. It waits in the inbox for triage.` };
564
+ }
565
+ if (v.reason === 'full_idea_never_routes') {
566
+ return { tone: 'floats', text: 'A Full Idea is ratified by the board rather than routed. It waits in the inbox.' };
567
+ }
568
+ return { tone: 'floats', text: 'This floats in the inbox until someone triages it.' };
569
+ }
570
+
571
+ function renderGravity(v) {
572
+ const el = fqGravityEl();
573
+ if (!el) return;
574
+ const said = gravitySentence(v);
575
+ // dataset, not setAttribute: it writes the same data-tone the sheet selects
576
+ // on, and it is the half of the element a DOM the page is rendered into and
577
+ // a test stub both really carry.
578
+ if (!said) { el.hidden = true; el.textContent = ''; el.dataset.tone = ''; return; }
579
+ el.textContent = said.text;
580
+ el.dataset.tone = said.tone;
581
+ el.hidden = false;
582
+ }
583
+
584
+ // The read. Shares the suggester's debounce rather than adding a second clock:
585
+ // the two answer the same question at the same moment ("given what is typed so
586
+ // far, where does this go?"), and spec decision 12 — filing never gets slower —
587
+ // is a reason not to grow the number of things a keystroke sets running.
588
+ //
589
+ // A draft with no title is not previewed: it cannot be filed either, so there
590
+ // is no outcome to state.
591
+ async function refreshGravity() {
592
+ const seq = ++gravitySeq;
593
+ const title = fqTitleEl().value.trim();
594
+ if (!title) { renderGravity(null); return; }
595
+ const goalId = fqGoalEl().value;
596
+ const body = { kind: fqKindEl().value };
597
+ if (goalId) body.goal_id = Number(goalId);
598
+ let r;
599
+ try {
600
+ r = await api.inbox.postInboxPreviewLanding({ body });
601
+ } catch (_) {
602
+ r = null;
603
+ }
604
+ // A stale answer never paints. See gravitySeq above.
605
+ if (seq !== gravitySeq) return;
606
+ renderGravity(r && r.ok ? r.data : null);
607
+ }
608
+
504
609
  function onQuickTitleEdit() {
505
610
  clearTimeout(quickSuggestTimer);
506
- quickSuggestTimer = setTimeout(refreshGoalSuggestions, QUICK_SUGGEST_DEBOUNCE_MS);
611
+ quickSuggestTimer = setTimeout(() => { refreshGoalSuggestions(); refreshGravity(); }, QUICK_SUGGEST_DEBOUNCE_MS);
507
612
  }
508
613
 
509
614
  function showQuickErr(msg) {
@@ -570,6 +675,10 @@
570
675
  fqKindEl().value = 'bug';
571
676
  goalTouchedByUser = false;
572
677
  renderGoalOptions([]);
678
+ // The form is empty again, so the line has nothing to describe. The RECEIPT
679
+ // above is what survives the filing (task 1004082); gravity is a statement
680
+ // about a draft, and this draft is gone.
681
+ renderGravity(null);
573
682
  }
574
683
 
575
684
  async function bootQuickComposer() {
@@ -577,7 +686,11 @@
577
686
  renderGoalOptions([]);
578
687
  fqTitleEl().addEventListener('input', onQuickTitleEdit);
579
688
  fqKindEl().addEventListener('change', onQuickTitleEdit);
580
- fqGoalEl().addEventListener('change', () => { goalTouchedByUser = true; });
689
+ // Changing the goal is the one edit that changes gravity WITHOUT changing
690
+ // what the suggester would return, so it redraws the line directly rather
691
+ // than going through onQuickTitleEdit. Deliberately undebounced: a select is a
692
+ // deliberate single act, not a stream of keystrokes.
693
+ fqGoalEl().addEventListener('change', () => { goalTouchedByUser = true; refreshGravity(); });
581
694
  $('#fq-form').addEventListener('submit', fileQuick);
582
695
 
583
696
  // Before any network call: the receipts are local, so a builder who reloads
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+
3
+ // modules/ideas/landing-preview.js — THE LANDING VERDICT, BEFORE THE WRITE
4
+ // (task 1004083, BV2.R02 — "Gravity is visible before release").
5
+ //
6
+ // THE PROBLEM THIS ANSWERS. The composer's goal `<select>` is not invisible; it
7
+ // is visible and it MISLEADS, in two directions that the filer cannot see:
8
+ //
9
+ // 1. `No goal (homeless)` is FALSE FOR A BUG. Since ADR 0262 a homeless
10
+ // kind='bug' filed through the `api` vector routes into the version's
11
+ // maintenance goal (./homeless-home.js). The filer is told their report
12
+ // will float. It will not.
13
+ // 2. NAMING A GOAL IS FALSE FOR A JUDGMENT KIND. routing.NEVER_ROUTE_KINDS
14
+ // ('criterion-proposal', 'decision') and grade='full' never route, so the
15
+ // select shows a goal that will not catch the thought.
16
+ //
17
+ // So "gravity is visible" is not a restyle of the dropdown — the dropdown's own
18
+ // labels are the thing that is wrong. It is met by stating the matrix's verdict.
19
+ //
20
+ // NO NEW ROUTING, and this file is where that promise is kept. It DECIDES
21
+ // nothing: it calls the same `decideLanding` and `authorizeRoutedLanding` the
22
+ // capture calls, over the same inputs, and reports what they said. A second copy
23
+ // of the matrix in browser JS is the failure mode this exists to avoid — the
24
+ // same one the composer's `#fq-kind` comment already records ("this markup used
25
+ // to spell the whole vocabulary and had gone stale against the DB CHECK ... a
26
+ // third copy nobody was watching"), and the same one `ideas.js` already forbids
27
+ // in prose ("read it, never re-derive the landing matrix here").
28
+ //
29
+ // READ-ONLY IS A HARD CONTRACT, not an intention. Every statement below is a
30
+ // SELECT. In particular it calls `currentBuildingVersionId` and NEVER
31
+ // `ensureMaintenanceGoal` — that one is find-or-CREATE, and the preview runs on
32
+ // every keystroke of a debounced composer. Previewing a homeless bug must not
33
+ // mint a goal as a side effect of typing, so the preview names the version whose
34
+ // maintenance goal will catch it and leaves the row to the real capture.
35
+ //
36
+ // ITS OWN FILE for the reason homeless-home.js gives: inbox.js sits at the
37
+ // fitness line cap with no headroom, and "what will happen if I file this" is
38
+ // one question with one answer that a reader should find as a file.
39
+
40
+ const api = require('../../src/module-api');
41
+ const { pool } = api;
42
+ const { decideLanding, authorizeRoutedLanding } = require('./routing');
43
+
44
+ // The verdict vocabulary the composer renders. Stable machine tokens, never
45
+ // prose: the wording is the UI's business and the copy registry's, and a client
46
+ // that wants a different sentence must not have to string-match English.
47
+ //
48
+ // 'routed' — it becomes a task in a named goal. `status` says whether
49
+ // that task is claimable ('ready') or awaiting a nod
50
+ // ('backlog'); `goal` names the home.
51
+ // 'routed_home' — it becomes a task, but the goal is the MAINTENANCE goal the
52
+ // system picks, not one the filer named (the homeless bug).
53
+ // `version_id` names whose maintenance goal.
54
+ // 'floats' — it waits in the inbox for triage. The honest reading of
55
+ // "no goal", and also of a goal the matrix declined.
56
+ // 'refused' — it will not be filed at all (the protected-scope wall).
57
+ const OUTCOMES = Object.freeze({
58
+ ROUTED: 'routed',
59
+ ROUTED_HOME: 'routed_home',
60
+ FLOATS: 'floats',
61
+ REFUSED: 'refused',
62
+ });
63
+
64
+ // previewLanding — what pressing "File it" would do, without doing it.
65
+ //
66
+ // Takes exactly what the capture takes, and the caller resolves `filerRank` and
67
+ // `vector` the same way POST /inbox does: the rank off the AUTHENTICATED builder
68
+ // and the vector hardcoded. A preview that read either from the body would let a
69
+ // filer see — and then, by symmetry of argument, demand — a landing they do not
70
+ // hold. It is a read, so being wrong here leaks nothing; being wrong here
71
+ // TEACHES something false, which is the whole defect this task is about.
72
+ //
73
+ // NEVER THROWS. A composer redrawing on every keystroke must degrade to "we
74
+ // cannot say yet", never to a red error under a half-typed title. An outage in
75
+ // any read below collapses to `{ outcome: null }` and the UI says nothing —
76
+ // which leaves today's behaviour exactly as it is, rather than replacing a
77
+ // silent lie with a loud one.
78
+ async function previewLanding(input, deps = {}) {
79
+ // NORMALISE FIRST, DESTRUCTURE AFTER — the trap ./routing.js's `decideLanding`
80
+ // already records, and which the first cut of this function walked straight
81
+ // into. A default in the parameter list fires on `undefined` and NOT on
82
+ // `null`, so `previewLanding(null)` threw from the signature — BEFORE the
83
+ // try/catch below, so the "never throws" contract above was false for the one
84
+ // input a caller spreading an absent object actually produces.
85
+ const { goalId = null, kind = null, grade = null, filerRank = null, vector = null } = input || {};
86
+ const activePool = ((deps || {}).pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
87
+ try {
88
+ const landing = decideLanding({ goalId, kind, grade, filerRank, vector });
89
+
90
+ if (!landing.route) {
91
+ // A goal was named and the matrix declined it — the second lie above. The
92
+ // reason token travels so the UI can say WHY it still floats rather than
93
+ // showing a goal that is not going to catch anything.
94
+ return { outcome: OUTCOMES.FLOATS, reason: landing.reason, status: null, goal: null, version_id: null, refusal: null };
95
+ }
96
+
97
+ if (landing.homeless) {
98
+ // The first lie above. Read-only: name the version, never make the goal.
99
+ const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
100
+ const versionId = lifecycle && lifecycle.currentBuildingVersionId
101
+ ? await lifecycle.currentBuildingVersionId()
102
+ : null;
103
+ // No building version means the real capture would fail closed
104
+ // (homeless-home.js NO_BUILDING_VERSION), so the preview must not promise
105
+ // a landing. It says nothing rather than naming a home that isn't there.
106
+ if (!versionId) return { outcome: null, reason: landing.reason, status: null, goal: null, version_id: null, refusal: null };
107
+ return {
108
+ outcome: OUTCOMES.ROUTED_HOME,
109
+ reason: landing.reason,
110
+ status: landing.status,
111
+ goal: null,
112
+ version_id: versionId,
113
+ refusal: null,
114
+ };
115
+ }
116
+
117
+ // A named goal. The same three facts the transaction checks — the goal is
118
+ // real, open, and on a building version — read WITHOUT the FOR SHARE lock,
119
+ // because a preview holds nothing and races nothing: it describes the world
120
+ // a moment before the write, and the write re-checks under its own lock.
121
+ const { rows } = await activePool.query(
122
+ `SELECT g.id, g.title, g.status, g.version_id, g.scope_modules, v.status AS version_status
123
+ FROM goals g
124
+ JOIN versions v ON v.id = g.version_id
125
+ WHERE g.id = $1`,
126
+ [goalId]
127
+ );
128
+ const goal = rows[0];
129
+ // A goal that is gone, archived, or on a closed version would be refused by
130
+ // the capture. The preview declines to guess a sentence for a state the
131
+ // filer cannot reach from the picker anyway (it lists open goals only), and
132
+ // lets the real 400 name it if they get there by other means.
133
+ if (!goal || goal.status !== 'open' || goal.version_status !== 'building') {
134
+ return { outcome: null, reason: landing.reason, status: null, goal: null, version_id: null, refusal: null };
135
+ }
136
+
137
+ // The binding gate, verbatim. `touches` is never declared by the composer —
138
+ // a person reporting a bug does not know which files it lives in — so the
139
+ // scope wall has nothing to subset-check and `scopeCheck` is null, exactly
140
+ // as it is for a human filing through POST /inbox. What CAN bite is the
141
+ // protected-scope refusal, and that is a server-only fact: it is the reason
142
+ // this preview cannot live in the browser.
143
+ const authorized = authorizeRoutedLanding({
144
+ landing,
145
+ filerRank,
146
+ scopeProtected: api.moduleScopeMap.scopeIncludesProtected(goal.scope_modules),
147
+ scopeCheck: null,
148
+ });
149
+ // The refusal NAMES the goal, and that is checked rather than assumed: the
150
+ // composer's own picker is filled from `GET /goals?status=open`, which applies
151
+ // no rank filter, so every open goal's title is already on screen for this
152
+ // caller — they picked this one FROM that list. Withholding it here would hide
153
+ // nothing and would cost the refusal its subject. If that read ever becomes
154
+ // rank-scoped, this line has to be revisited with it.
155
+ if (!authorized.ok) {
156
+ return {
157
+ outcome: OUTCOMES.REFUSED,
158
+ reason: landing.reason,
159
+ status: null,
160
+ goal: { id: goal.id, title: goal.title },
161
+ version_id: goal.version_id,
162
+ refusal: { code: authorized.code, reason: authorized.reason },
163
+ };
164
+ }
165
+
166
+ return {
167
+ outcome: OUTCOMES.ROUTED,
168
+ reason: landing.reason,
169
+ status: authorized.status,
170
+ goal: { id: goal.id, title: goal.title },
171
+ version_id: goal.version_id,
172
+ refusal: null,
173
+ };
174
+ } catch (_) {
175
+ // See the NEVER THROWS note above.
176
+ return { outcome: null, reason: null, status: null, goal: null, version_id: null, refusal: null };
177
+ }
178
+ }
179
+
180
+ module.exports = { previewLanding, OUTCOMES };
@@ -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.912",
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.912",
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.912",
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
+ };