@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.
- package/.bongos-core.json +62 -37
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0314-module-protection-is-per-path-not-per-module.md +7 -2
- package/docs/adr/0315-a-plane-with-no-proxy-is-refused-not-given-a-no-op-ingress.md +96 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +62 -2
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +25 -25
- package/docs/copy-registry.json +29 -29
- package/docs/module-api-changelog.md +5 -1
- package/docs/recipes/self-host.md +1 -1
- package/modules/hall-ui/public/ideas.css +25 -0
- package/modules/hall-ui/public/ideas.html +12 -0
- package/modules/hall-ui/public/ideas.js +115 -2
- package/modules/ideas/landing-preview.js +180 -0
- package/modules/ideas/routes/inbox.js +47 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/api-alias-caller-scan.js +213 -0
- package/scripts/gds/fitness-ratchets.js +31 -0
- package/scripts/gds/provision-net.js +41 -0
- package/scripts/gds/provision.js +11 -3
- package/scripts/hall-preview/server.js +14 -1
- package/src/bongos/module-scope-map.js +43 -5
- package/src/module-api.js +1 -1
- package/tests/api_alias_caller_ratchet.mjs +130 -0
- package/tests/idea_landing_preview.mjs +337 -0
- package/tests/idea_spark_hall.mjs +183 -0
- package/tests/module-scope-map.mjs +87 -3
- package/tests/provision_caddy_wiring.mjs +76 -2
- 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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
};
|