@bongos/core 1.19.816 → 1.19.817

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.
@@ -0,0 +1,57 @@
1
+ -- specialities_002_offered.sql — a project OFFERS a speciality to its builders
2
+ -- (task 1003972; the micro-roles residue of task 1003856, built on ADR 0296).
3
+ --
4
+ -- WHAT THIS IS. Task 1003855 let a builder or a project OWN a speciality and set
5
+ -- who may adopt it. What it could not express is a recommendation: "this is the
6
+ -- set we suggest you start from." That is what `offered` adds, and it is one
7
+ -- column rather than a preset table because a preset IS a speciality — the list
8
+ -- is just the specialities someone chose to put their name behind.
9
+ --
10
+ -- IT OFFERS. IT DOES NOT ADOPT. This is the whole design decision and it is not a
11
+ -- convenience call. A speciality's `contract_md` is injected into every session
12
+ -- where the adopter holds a claim in that craft, and ADR 0296's consent work
13
+ -- (task 1003855's second grade) exists because that text is ANOTHER BUILDER'S
14
+ -- text arriving in this builder's session. Auto-adopting a project's presets on
15
+ -- join would hand every new builder a standing instruction they never read, which
16
+ -- is precisely the failure that grade caught — so an offer is a suggestion a
17
+ -- person accepts, every time, and there is no column here that could make it
18
+ -- otherwise.
19
+ --
20
+ -- WHY NO MEMBERSHIP LOOKUP IS NEEDED FOR THE COMMON CASE. "Project" in the
21
+ -- platform-identity sense is a HUB concept (ADR 0141 §3): a `client_id` in the
22
+ -- hub's cross-project directory, keyed by `github_id`. A normal instance is not a
23
+ -- hub and has no such rows — it simply IS the project. So an instance-level
24
+ -- recommendation is a speciality at `visibility='public'` (already "anyone on
25
+ -- this instance may adopt it") flagged `offered` — no membership resolution, no
26
+ -- optional seam, nothing to be absent. The federated case rides the same column
27
+ -- once the `project.membership` port exists: a project-owned speciality at
28
+ -- `visibility='project'` and `offered` is a suggestion to that project's members.
29
+
30
+ ALTER TABLE specialities_catalog
31
+ ADD COLUMN IF NOT EXISTS offered BOOLEAN NOT NULL DEFAULT FALSE;
32
+
33
+ -- You cannot recommend something nobody may adopt. Same shape as the `sellable`
34
+ -- constraint beside it, and for the same reason: a private-but-offered row is
35
+ -- incoherent rather than merely useless, and the DB is where that is cheapest to
36
+ -- refuse. Named for what it protects.
37
+ ALTER TABLE specialities_catalog
38
+ DROP CONSTRAINT IF EXISTS specialities_catalog_offered_needs_visibility;
39
+ ALTER TABLE specialities_catalog
40
+ ADD CONSTRAINT specialities_catalog_offered_needs_visibility
41
+ CHECK (offered = FALSE OR visibility <> 'private');
42
+
43
+ -- The suggestions read: "what does this instance/project recommend to me". Partial
44
+ -- because the offered set is by nature small and the unoffered majority is never
45
+ -- scanned by it.
46
+ CREATE INDEX IF NOT EXISTS specialities_catalog_offered_idx
47
+ ON specialities_catalog (discipline) WHERE offered;
48
+
49
+ -- WHO OFFERED IT, and when. Recorded because a recommendation carries someone's
50
+ -- name by definition — the settings surface says who suggested it, so a builder
51
+ -- deciding whether to accept a standing instruction can see whose judgement they
52
+ -- are taking. Nullable: a row offered before this column existed has no witness,
53
+ -- and inventing one would be worse than admitting it.
54
+ ALTER TABLE specialities_catalog
55
+ ADD COLUMN IF NOT EXISTS offered_by BIGINT REFERENCES builders(id) ON DELETE SET NULL;
56
+ ALTER TABLE specialities_catalog
57
+ ADD COLUMN IF NOT EXISTS offered_at TIMESTAMPTZ;
@@ -246,6 +246,22 @@ module.exports = function buildSpecialitiesRouter() {
246
246
  }
247
247
  }));
248
248
 
249
+ // GET /specialities/offered — the suggestions for THIS builder. Mounted before
250
+ // '/specialities/:id' would otherwise capture 'offered' as an id.
251
+ router.get('/specialities/offered', api.requireBuilder, asyncHandler('GET /specialities/offered', async (req, res) => {
252
+ const builder = req.builder;
253
+ const projectIds = await viewerProjectIds(builder);
254
+ const [rows, adoptions] = await Promise.all([
255
+ db.listOffered({ builderId: builder.id, projectIds, discipline: req.query.discipline || null }),
256
+ db.listAdoptions(builder.id),
257
+ ]);
258
+ // The SQL is the prefilter; offeredTo is the rule — it drops what this
259
+ // builder already holds and what would collide with a craft they have already
260
+ // chosen for. Nothing here adopts anything.
261
+ const suggestions = S.offeredTo(rows, { builderId: builder.id, projectIds }, adoptions);
262
+ res.json({ suggestions });
263
+ }));
264
+
249
265
  router.get('/specialities/:id', api.requireBuilder, asyncHandler('GET /specialities/:id', async (req, res) => {
250
266
  const id = parseId(req, res, { code: 'bad_id' });
251
267
  if (id === null) return;
@@ -371,6 +387,54 @@ module.exports = function buildSpecialitiesRouter() {
371
387
  res.json({ ok: true, was_active: dropped });
372
388
  }));
373
389
 
390
+ // ── offering: a project puts its name behind a speciality ─────────────────
391
+ //
392
+ // OFFERING IS CURATION, NOT OWNERSHIP, so it is gated differently from every
393
+ // other write here. Editing your own speciality is yours; recommending one to
394
+ // everybody on the instance is a claim on their attention and their sessions,
395
+ // which is the same act `project.curate` already governs for featuring a
396
+ // project (metic floor). An owner may therefore edit a speciality they cannot
397
+ // offer, and a curator may offer one they do not own — which is correct: the
398
+ // two decisions are about different things.
399
+ router.post('/specialities/:id/offer', api.requireBuilder, api.requirePermission('project.curate'),
400
+ asyncHandler('POST /specialities/:id/offer', async (req, res) => {
401
+ const id = parseId(req, res, { code: 'bad_id' });
402
+ if (id === null) return;
403
+ const s = await db.getSpeciality(id);
404
+ // visibleTo, like every sibling route. project.curate says "you may
405
+ // curate", NOT "you may curate this row" — without this a curator could
406
+ // offer a project-owned speciality belonging to a project they are not in,
407
+ // and the 403-vs-404 shape would confirm its existence besides.
408
+ if (!s || !(await visibleTo(s, req.builder))) return res.fail('not_found', 404);
409
+ // The DB refuses this too; refusing here as well turns a constraint
410
+ // violation into a sentence that says what to do about it.
411
+ if (s.visibility === 'private') {
412
+ return res.fail('offer_needs_visibility', {
413
+ status: 400,
414
+ message: 'A private speciality cannot be offered — nobody it is offered to could adopt it. Share it first.',
415
+ });
416
+ }
417
+ res.json({ speciality: await db.setOffered(id, { offered: true, builderId: req.builder.id }) });
418
+ }));
419
+
420
+ router.post('/specialities/:id/unoffer', api.requireBuilder, api.requirePermission('project.curate'),
421
+ asyncHandler('POST /specialities/:id/unoffer', async (req, res) => {
422
+ const id = parseId(req, res, { code: 'bad_id' });
423
+ if (id === null) return;
424
+ const s = await db.getSpeciality(id);
425
+ if (!s || !(await visibleTo(s, req.builder))) return res.fail('not_found', 404);
426
+ // Withdrawing a recommendation does NOT un-adopt anybody. A builder who
427
+ // accepted it chose it; taking that away silently would be the auto-adopt
428
+ // mistake in reverse.
429
+ //
430
+ // The actor is passed even though the row stores it only while an offer
431
+ // stands (setOffered nulls the witness on withdrawal, because a
432
+ // recommendation nobody is making has nobody behind it). WHO withdrew it is
433
+ // not lost: every authenticated POST through this router is recorded by
434
+ // src/bongos/middleware/audit.js with the builder, the body and the status.
435
+ res.json({ speciality: await db.setOffered(id, { offered: false, builderId: req.builder.id }) });
436
+ }));
437
+
374
438
  router.get('/me/specialities', api.requireBuilder, asyncHandler('GET /me/specialities', async (req, res) => {
375
439
  res.json({ adoptions: await db.listAdoptions(req.builder.id) });
376
440
  }));
@@ -195,4 +195,33 @@ function describeForApi(active, { documentCount = 0, learningCount = 0, selfAuth
195
195
  return { inject: contract.trim().length > 0, contract, speciality_id: active.id, slug: active.slug };
196
196
  }
197
197
 
198
- module.exports = { VISIBILITIES, LIMITS, slugify, validateDraft, canAdopt, describeForApi };
198
+ // WHAT A BUILDER IS OFFERED. The suggestions half of task 1003972: a speciality
199
+ // someone with project.curate has put the project's name behind.
200
+ //
201
+ // IT OFFERS, IT DOES NOT ADOPT — and this function is where that is enforced in
202
+ // the read, not merely intended. It returns suggestions; nothing here adopts, and
203
+ // the caller has no adoption path that does not go through the builder's own
204
+ // explicit POST. A speciality's contract is injected into every session in its
205
+ // craft, so handing someone a standing instruction they never accepted is the
206
+ // exact failure ADR 0296's consent work exists to prevent.
207
+ //
208
+ // Three filters, in the order that makes each one cheap:
209
+ // 1. offered — somebody recommended it
210
+ // 2. canAdopt — and this viewer may actually take it (the SAME rule the
211
+ // catalogue uses; never a second, looser copy)
212
+ // 3. not already — a speciality you already hold is not a suggestion, and one
213
+ // in a craft you already have an ACTIVE speciality for is a
214
+ // conflict rather than an offer (the adoption index allows one
215
+ // active per craft), so it is surfaced as neither.
216
+ function offeredTo(specialities, viewer, adoptions = []) {
217
+ const held = new Set((adoptions || []).map((a) => String(a.id ?? a.speciality_id)));
218
+ const activeCrafts = new Set((adoptions || []).filter((a) => a.active).map((a) => a.discipline));
219
+ return (specialities || []).filter((s) => {
220
+ if (!s || !s.offered) return false;
221
+ if (!canAdopt(s, viewer)) return false;
222
+ if (held.has(String(s.id))) return false;
223
+ return !activeCrafts.has(s.discipline);
224
+ });
225
+ }
226
+
227
+ module.exports = { VISIBILITIES, LIMITS, slugify, validateDraft, canAdopt, describeForApi, offeredTo };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.816",
3
+ "version": "1.19.817",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.816",
9
+ "version": "1.19.817",
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.816",
3
+ "version": "1.19.817",
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",
@@ -71,15 +71,56 @@ const FEATURE_ANCHORS = [
71
71
  // table name needs no boundary — nothing else is called that.
72
72
  const CATEGORY_RE = /\bcategor(y|ies|ised|ized|isation|ization)\b|\bcategory_id\b|work_categories/i;
73
73
 
74
- // Strip // line comments and /* */ blocks so a citation of the rule is legal and
75
- // only executable text is judged. Crude on purpose: it errs toward keeping text
76
- // (a `//` inside a string literal turns the rest of that line into a "comment"),
77
- // and erring toward keeping text would make the check LOUDER, not quieter — which
78
- // is the safe direction for a guard.
74
+ // Strip // line comments and /* */ blocks so a citation of a rule is legal and only
75
+ // executable text is judged.
76
+ //
77
+ // String-aware, and that is the whole point rather than a refinement. The direction
78
+ // a stripper errs in is a safety property: keeping a comment only re-raises a false
79
+ // alarm, which is LOUD and gets fixed, while dropping real code makes a guard report
80
+ // clean over a live violation, which is indistinguishable from a tree that is
81
+ // actually clean. So the scanner must never remove text it is not sure about.
82
+ //
83
+ // The previous regex pair claimed to err that way and did the opposite. `//` inside
84
+ // an ordinary string literal — a protocol-relative URL, a path fragment — matched the
85
+ // line-comment rule and erased the rest of the line, real code included:
86
+ //
87
+ // router.post('//legacy', requirePermission('task.abandon.own'), handler);
88
+ //
89
+ // stripped to `router.post('`, so the guard scanning for that very call found nothing
90
+ // (task 1003832; the equivalent hole in the ownership-scope scan in
91
+ // tests/government_require_permission.mjs). Tracking quotes fixes it at the root, for
92
+ // both callers, and makes the erring-toward-keeping promise above actually true.
93
+ //
94
+ // Unterminated constructs deliberately keep their text rather than swallowing to EOF.
95
+ // A regex literal is not tracked: an unescaped `//` cannot appear in one (it would be
96
+ // an empty regex), so the only way in is a `/`-delimited literal containing an escaped
97
+ // pair, which reads as escaped here too.
79
98
  function stripComments(src) {
80
- return src
81
- .replace(/\/\*[\s\S]*?\*\//g, ' ')
82
- .replace(/(^|[^:])\/\/[^\n]*/g, '$1');
99
+ let out = '';
100
+ let quote = null; // ' " or ` — inside a string literal, nothing is a comment
101
+ for (let i = 0; i < src.length;) {
102
+ const c = src[i];
103
+ const d = src[i + 1];
104
+ if (quote) {
105
+ if (c === '\\') { out += c + (d ?? ''); i += 2; continue; }
106
+ if (c === quote) quote = null;
107
+ out += c; i += 1; continue;
108
+ }
109
+ if (c === '"' || c === "'" || c === '`') { quote = c; out += c; i += 1; continue; }
110
+ if (c === '/' && d === '/') {
111
+ while (i < src.length && src[i] !== '\n') i += 1; // stop AT the newline, never past it
112
+ continue;
113
+ }
114
+ if (c === '/' && d === '*') {
115
+ const end = src.indexOf('*/', i + 2);
116
+ if (end === -1) { out += src.slice(i); break; } // unterminated: keep it
117
+ out += ' ';
118
+ i = end + 2;
119
+ continue;
120
+ }
121
+ out += c; i += 1;
122
+ }
123
+ return out;
83
124
  }
84
125
 
85
126
  function readFromDisk(rel) {
@@ -13,6 +13,28 @@
13
13
  // deleted it). Both are pure git facts, so this runs in well under a second at
14
14
  // SessionStart. The primary checkout is never listed.
15
15
  //
16
+ // THE THIRD LEAK — UNSTARTED CLAIM TREES (task 1003912). "Ancestor of origin/main"
17
+ // is also true of a branch that is origin/main, which is exactly what
18
+ // `worktree.js add` leaves behind: it branches off origin/main and commits
19
+ // nothing. So between `worktree.js add` and the builder's FIRST COMMIT, a live
20
+ // claim's worktree was reported as "merged into main". Observed 2026-09-13: the
21
+ // session working task 1002594 saw its own tree listed as stale. The classifier
22
+ // had the git fact right and the intent wrong — nothing had landed, nothing had
23
+ // even started.
24
+ //
25
+ // That window is small and the consequence is not: /worktree-clean removes every
26
+ // name on the stale list, so a cleanup run by another session (or the SessionStart
27
+ // nag) inside it would take a live claim's tree. `worktree.js remove` refuses a
28
+ // dirty tree, which bounds the damage — but a claim worktree that is still empty
29
+ // is precisely the one that is NOT dirty, so the guard does not cover this case.
30
+ //
31
+ // The fix stays a pure git fact, keeping the "no API call" property this header is
32
+ // explicit about wanting: a branch whose TIP EQUALS origin/main is zero commits
33
+ // ahead, so it cannot have landed anything, and is reported live-but-unstarted.
34
+ // The alternative — cross-checking branch names against active claims — would put
35
+ // a network round trip in a SessionStart probe to learn something git already
36
+ // knows.
37
+ //
16
38
  // THE SECOND LEAK — HUSKS (task 1002594). Under OneDrive, `git worktree remove`
17
39
  // often errors 'Permission denied' / 'Device or resource busy' but still
18
40
  // DEREGISTERS the worktree, so `worktree.js remove` treats deregistration as
@@ -65,7 +87,12 @@ function parseWorktreeList(porcelain) {
65
87
 
66
88
  // Pure classifier so the decision is unit-testable without a repo: `isMerged(branch)`
67
89
  // and `branchExists(branch)` are the two git facts it needs.
68
- function classifyWorktrees(worktrees, { isMerged, branchExists }) {
90
+ // `isUnstarted(branch)` is the task 1003912 guard: branch tip === origin/main tip.
91
+ // It is checked BEFORE isMerged because every unstarted branch is also trivially
92
+ // "merged" — that coincidence is the whole bug, and order is what separates them.
93
+ // Optional, so a caller that does not supply it gets the old behaviour rather than
94
+ // a crash.
95
+ function classifyWorktrees(worktrees, { isMerged, branchExists, isUnstarted = () => false }) {
69
96
  const stale = [];
70
97
  const live = [];
71
98
  for (const wt of worktrees) {
@@ -73,6 +100,7 @@ function classifyWorktrees(worktrees, { isMerged, branchExists }) {
73
100
  const name = path.basename(wt.path);
74
101
  if (!wt.branch) { live.push({ name, path: wt.path, branch: null }); continue; } // detached: not ours to judge
75
102
  if (!branchExists(wt.branch)) stale.push({ name, path: wt.path, branch: wt.branch, why: 'branch gone' });
103
+ else if (isUnstarted(wt.branch)) live.push({ name, path: wt.path, branch: wt.branch, why: 'unstarted (no commits yet)' });
76
104
  else if (isMerged(wt.branch)) stale.push({ name, path: wt.path, branch: wt.branch, why: 'merged into main' });
77
105
  else live.push({ name, path: wt.path, branch: wt.branch });
78
106
  }
@@ -93,22 +121,38 @@ function classifyHusks({ dirNames, worktreePaths, worktreesRoot }) {
93
121
  .map((name) => ({ name, path: path.join(worktreesRoot, name) }));
94
122
  }
95
123
 
96
- // Three git calls total, whatever the worktree count: the list, every local branch,
97
- // every branch already merged into origin/main. Per-worktree subprocesses would be
98
- // N+1 over exactly the number this probe exists to shrink.
124
+ // FOUR git calls total, whatever the worktree count: the worktree list, every local
125
+ // branch WITH ITS TIP (one read, both facts — task 1003912), every branch already
126
+ // merged into origin/main, and origin/main's own tip. Per-worktree subprocesses
127
+ // would be N+1 over exactly the number this probe exists to shrink.
99
128
  function refNames(out) {
100
129
  return new Set(String(out).split('\n').map((s) => s.trim()).filter(Boolean));
101
130
  }
102
131
 
103
132
  function scan(cwd) {
104
133
  const worktrees = parseWorktreeList(git(['worktree', 'list', '--porcelain'], cwd));
105
- const existing = refNames(git(['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], cwd));
134
+ // ONE read of refs/heads/, carrying both facts it is asked for: which branches
135
+ // exist, and where each one points. The tip is what the task 1003912 guard needs
136
+ // (equal to origin/main's ⇒ zero commits ahead ⇒ nothing has landed from it,
137
+ // because nothing has been written in it) and asking for it separately would
138
+ // have spawned a second subprocess over the identical ref set — in the one file
139
+ // whose header makes a promise about its git-call count.
140
+ const tipOf = new Map();
141
+ for (const line of git(['for-each-ref', '--format=%(refname:short) %(objectname)', 'refs/heads/'], cwd).split('\n')) {
142
+ const [name, sha] = line.trim().split(/\s+/);
143
+ if (name && sha) tipOf.set(name, sha);
144
+ }
145
+ const existing = new Set(tipOf.keys());
106
146
  let merged;
107
147
  try { merged = refNames(git(['branch', '--merged', MAIN_REF, '--format=%(refname:short)'], cwd)); }
108
148
  catch { merged = new Set(); } // no origin/main yet (fresh clone, no fetch) → nothing is provably stale
149
+ let mainTip = null;
150
+ try { mainTip = git(['rev-parse', MAIN_REF], cwd).trim(); }
151
+ catch { mainTip = null; } // same fail-open as `merged`: unknown ⇒ claim nothing
109
152
  const r = classifyWorktrees(worktrees, {
110
153
  branchExists: (b) => existing.has(b),
111
154
  isMerged: (b) => merged.has(b),
155
+ isUnstarted: (b) => Boolean(mainTip) && tipOf.get(b) === mainTip,
112
156
  });
113
157
  // The main checkout is always the first porcelain entry, so the husk directory is
114
158
  // derivable without a second git call.
@@ -157,7 +201,7 @@ function main() {
157
201
  console.log(` Worktrees: ${r.live.length} live, ${r.stale.length} stale, ${husks.length} husks`);
158
202
  for (const s of r.stale) console.log(` stale ${s.name} (${s.why})`);
159
203
  for (const h of husks) console.log(` husk ${h.name} (on disk, not registered)`);
160
- for (const l of r.live) console.log(` live ${l.name} [${l.branch || 'detached'}]`);
204
+ for (const l of r.live) console.log(` live ${l.name} [${l.branch || 'detached'}]${l.why ? ` (${l.why})` : ''}`);
161
205
  if (r.stale.length) console.log(' Remove with: node scripts/gds/worktree.js remove <name> (or /worktree-clean)');
162
206
  if (husks.length) console.log(' Sweep husks with: node scripts/gds/worktree.js prune-husks');
163
207
  if (r.stale.length || husks.length) process.exit(1);
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.816'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.817'; // 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');
@@ -24,6 +24,10 @@ const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
24
24
  const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
25
25
  const { classify } = require(path.join(ROOT, 'src', 'bongos', 'api-errors.js'));
26
26
  const catalog = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
27
+ // The same stripper the category guard scans with, rather than a second copy: both
28
+ // checks are answering "which text here is executable", and two answers in one tree
29
+ // is how one of them quietly stops agreeing with the other (task 1003832).
30
+ const { stripComments } = require(path.join(ROOT, 'scripts', 'gds', 'category-advisory-guard.js'));
27
31
 
28
32
  // Minimal res double capturing status + body, with res.fail() installed exactly
29
33
  // as the router installs it (mirrors tests/rank_gate.mjs).
@@ -479,29 +483,101 @@ test('a throwing isOwnershipScoped does not admit and does not crash the gate',
479
483
  assert.equal(res._status, 403);
480
484
  });
481
485
 
482
- test('no route in the tree is gated on an ownership-scoped key today', () => {
483
- // The enumeration the fix was written against, kept honest. A new gate mounted on
484
- // an own-scoped atom would now 403 at runtime; this fails at BUILD time instead,
485
- // naming the file, so nobody ships a route that can only ever deny.
486
- const owned = catalog.permissionKeys().filter((k) => catalog.isOwnershipScoped(k));
487
- const dirs = [path.join(ROOT, 'src', 'bongos', 'routes'), path.join(ROOT, 'modules')];
486
+ // The scan, with its file list and its reader INJECTED — the shape
487
+ // category-advisory-guard's scanGates() uses, and for the same reason. A scan run
488
+ // only against the live tree proves nothing about what it would REFUSE, because the
489
+ // tree is clean today by construction; and a scan that can only be run against the
490
+ // live tree cannot be shown to ignore a comment without dirtying a real file.
491
+ //
492
+ // Comments are stripped before the match because a gate is a CALL. This repo's guards
493
+ // routinely CITE the spelling they forbid — that is how the rule stays legible at the
494
+ // site it governs — and in task 1003194 exactly such a comment reds CI, on a file whose
495
+ // executable text was clean. Stripping makes a citation legal and judges only code.
496
+ function scanForOwnedGates(files, read, ownedKeys) {
497
+ // A gate is a call: requirePermission('<key>'), in any of its spellings. Compiled
498
+ // once per key rather than once per (file, key) pair.
499
+ const patterns = ownedKeys.map((key) => [
500
+ key,
501
+ new RegExp(`requirePermission\\s*\\([^)]*['"\`]${key.replace(/\./g, '\\.')}['"\`]`),
502
+ ]);
488
503
  const offenders = [];
504
+ for (const rel of files) {
505
+ const src = read(rel);
506
+ if (src == null) continue;
507
+ const code = stripComments(src);
508
+ for (const [key, re] of patterns) {
509
+ if (re.test(code)) offenders.push(`${rel} :: ${key}`);
510
+ }
511
+ }
512
+ return offenders;
513
+ }
514
+
515
+ // ROOT-relative .js paths under `dirs`, so the reader and the offender labels speak
516
+ // the same language the injected corpus below does.
517
+ function jsFilesUnder(dirs) {
518
+ const out = [];
489
519
  const walk = (dir) => {
490
520
  if (!fs.existsSync(dir)) return;
491
521
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
492
522
  const full = path.join(dir, entry.name);
493
523
  if (entry.isDirectory()) { if (entry.name !== 'node_modules') walk(full); continue; }
494
- if (!entry.name.endsWith('.js')) continue;
495
- const text = fs.readFileSync(full, 'utf8');
496
- for (const key of owned) {
497
- // A gate is a call: requirePermission('<key>'), in any of its spellings.
498
- if (new RegExp(`requirePermission\\s*\\([^)]*['"\`]${key.replace(/\./g, '\\.')}['"\`]`).test(text)) {
499
- offenders.push(`${path.relative(ROOT, full)} :: ${key}`);
500
- }
501
- }
524
+ if (entry.name.endsWith('.js')) out.push(path.relative(ROOT, full));
502
525
  }
503
526
  };
504
527
  dirs.forEach(walk);
528
+ return out;
529
+ }
530
+
531
+ test('the own-gate scan reads a citation as prose and a call as a gate', () => {
532
+ // The half the live scan below cannot assert about itself. A scanner breaks by
533
+ // quietly finding NOTHING — a stripper that over-matches blanks the source and every
534
+ // file passes, which looks exactly like a clean tree. So this pins BOTH directions in
535
+ // one assertion: the two citations must be ignored AND the two calls must still be
536
+ // caught. Dropping either half is what makes the guard a green light over nothing.
537
+ const KEY = 'task.abandon.own';
538
+ const corpus = {
539
+ 'cited-in-a-line-comment.js':
540
+ `// never mount requirePermission('${KEY}') here — it would admit unconditionally\n`
541
+ + "router.post('/a', requirePermission('task.abandon'), handler);\n",
542
+ 'cited-in-a-block-comment.js':
543
+ `/*\n * requirePermission('${KEY}') cannot answer an ownership question.\n */\n`
544
+ + "router.post('/b', requirePermission('idea.triage'), handler);\n",
545
+ 'a-real-gate.js': `router.post('/c', requirePermission('${KEY}'), handler);\n`,
546
+ // The over-strip canary: a line comment must end at its newline. If stripping ate
547
+ // the rest of the file this gate would vanish and the scan would report clean.
548
+ 'a-real-gate-below-a-comment.js':
549
+ `// this module is documented first\nrouter.post('/d', requirePermission('${KEY}'), handler);\n`,
550
+ // The blind spot that failed this task's first grade. A `//` inside an ordinary
551
+ // STRING is not a comment, and a stripper that thinks it is erases the rest of the
552
+ // line — here, the live gate sitting right after it. The guard then reports clean
553
+ // over a real violation, which reads exactly like a clean tree.
554
+ 'a-real-gate-after-a-string-holding-a-slash-slash.js':
555
+ `router.post('//legacy', requirePermission('${KEY}'), handler);\n`,
556
+ 'a-real-gate-after-a-string-holding-a-block-open.js':
557
+ `const note = '/* not a comment */';\nrouter.post('/e', requirePermission('${KEY}'), handler);\n`,
558
+ };
559
+ const found = scanForOwnedGates(Object.keys(corpus), (rel) => corpus[rel], [KEY]);
560
+ assert.deepEqual(found.sort(), [
561
+ `a-real-gate-after-a-string-holding-a-block-open.js :: ${KEY}`,
562
+ `a-real-gate-after-a-string-holding-a-slash-slash.js :: ${KEY}`,
563
+ `a-real-gate-below-a-comment.js :: ${KEY}`,
564
+ `a-real-gate.js :: ${KEY}`,
565
+ ], 'a citation is documentation; a call is a gate');
566
+ });
567
+
568
+ test('no route in the tree is gated on an ownership-scoped key today', () => {
569
+ // The enumeration the fix was written against, kept honest. A new gate mounted on
570
+ // an own-scoped atom would now 403 at runtime; this fails at BUILD time instead,
571
+ // naming the file, so nobody ships a route that can only ever deny.
572
+ const owned = catalog.permissionKeys().filter((k) => catalog.isOwnershipScoped(k));
573
+ assert.ok(owned.length > 0, 'no ownership-scoped atoms — the scan would pass vacuously');
574
+ const files = jsFilesUnder([path.join(ROOT, 'src', 'bongos', 'routes'), path.join(ROOT, 'modules')]);
575
+ // A mis-pointed root is the other way this passes while scanning nothing, and it
576
+ // reads identically to a clean tree. Pin the corpus so the roots must still resolve.
577
+ assert.ok(files.length > 100, `expected the route corpus; scanned only ${files.length} files`);
578
+ const offenders = scanForOwnedGates(files, (rel) => {
579
+ try { return fs.readFileSync(path.join(ROOT, rel), 'utf8'); } catch { return null; }
580
+ }, owned);
505
581
  assert.deepEqual(offenders, [],
506
582
  'requirePermission cannot answer an ownership-scoped key — the handler must call ' +
507
583
  'authorizeOwned() with the row\'s owner instead');
@@ -168,6 +168,76 @@ function shouldReserveMigrationNumber(task) {
168
168
  }
169
169
  });
170
170
 
171
+ // ---- task 1003910: "schema" must be QUALIFIED to flag ------------------
172
+ //
173
+ // The live false positive (task 1003902, flagged though it alters no table) was
174
+ // NOT caused by the `--json-schema` flag the bug report blamed — that compound
175
+ // was already stripped. It was a bare back-reference later in the same
176
+ // paragraph. The first case below is that exact sentence, so a regression
177
+ // reintroducing a bare-"schema" rule fails on the wording that actually shipped
178
+ // the bug rather than on a paraphrase of it.
179
+ await test('a BARE "schema" no longer flags — the live task 1003902 wording (task 1003910)', () => {
180
+ const falsePositives = [
181
+ '`result` is EMPTY when a schema is in play, which reads as a failure if you parse the wrong field',
182
+ 'Require a structured verdict via --json-schema',
183
+ 'a JSON Schema for the payload',
184
+ 'see the schema.org markup',
185
+ 'the --output-schema flag takes a path',
186
+ 'the worker returns a schema',
187
+ ];
188
+ for (const text of falsePositives) {
189
+ assert.equal(detectsMigration(text), false, `must NOT flag: ${JSON.stringify(text)}`);
190
+ }
191
+ });
192
+
193
+ await test('a DB noun or a change verb is what makes "schema" count (task 1003910)', () => {
194
+ const truePositives = [
195
+ 'DB schema change',
196
+ 'the database schema for tasks',
197
+ 'table-schema drift',
198
+ 'schema bump for the counter',
199
+ 'change the schema of tasks',
200
+ 'altering the task schema',
201
+ ];
202
+ for (const text of truePositives) {
203
+ assert.equal(detectsMigration(text), true, `must flag: ${JSON.stringify(text)}`);
204
+ }
205
+ });
206
+
207
+ // The direction of the failure is the point: an unrecognised phrasing must fall
208
+ // to NOT-flagged. A false positive reserves a migration number that is never
209
+ // used (task 1002614 burned 213), floors the task at Metic (ADR 0270) and halts
210
+ // an unattended chain; a missed one is a PATCH, the same correction a false one
211
+ // always needed.
212
+ await test('an unknown "schema" phrasing fails SAFE, not loud (task 1003910)', () => {
213
+ assert.equal(detectsMigration('the widget-schema thing nobody has named yet'), false);
214
+ assert.equal(detectsMigration('schema'), false, 'the bare noun alone decides nothing');
215
+ });
216
+
217
+ // A change VERB is not evidence on its own. The first cut of task 1003910's
218
+ // allowlist let any verb legitimise any noun, so 'update the response schema'
219
+ // flagged — the exact false-positive class task 1002648 fixed for the old
220
+ // blocklist, reintroduced by the mechanism that replaced it. The noun slot now
221
+ // excludes API/transport nouns, and these pin that it stays excluded.
222
+ await test('a change verb does NOT legitimise an API noun (task 1003910)', () => {
223
+ const falsePositives = [
224
+ 'update the response schema',
225
+ 'modify the API schema',
226
+ 'change the request schema',
227
+ 'updating the json schema',
228
+ 'extend the payload schema',
229
+ ];
230
+ for (const text of falsePositives) {
231
+ assert.equal(detectsMigration(text), false, `must NOT flag: ${JSON.stringify(text)}`);
232
+ }
233
+ });
234
+
235
+ await test('the same verbs still flag a DB-shaped or unqualified noun (task 1003910)', () => {
236
+ for (const text of ['change the schema of tasks', 'alter the table schema', 'altering the task schema', 'change the schema']) {
237
+ assert.equal(detectsMigration(text), true, `must flag: ${JSON.stringify(text)}`);
238
+ }
239
+ });
240
+
171
241
  await test('classifier still flags real schema work (regression)', () => {
172
242
  const truePositives = [
173
243
  'add a migration to backfill the marker',