@bongos/core 1.19.815 → 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.
- package/.bongos-core.json +50 -40
- package/.claude/skills/worktree-clean/SKILL.md +3 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +9 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +178 -3
- package/docs/api-reference.md +5 -2
- package/docs/copy-inventory.md +41 -38
- package/docs/copy-registry.json +67 -40
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/settings-specialities.js +56 -2
- package/modules/hall-ui/public/settings.html +1 -0
- package/modules/lifecycle/db-goals.js +36 -0
- package/modules/lifecycle/db.js +2 -0
- package/modules/lifecycle/goal-suggest.js +75 -9
- package/modules/lifecycle/routes/task-route-helpers.js +50 -3
- package/modules/specialities/db.js +41 -2
- package/modules/specialities/migrations/specialities_002_offered.sql +57 -0
- package/modules/specialities/routes/specialities.js +64 -0
- package/modules/specialities/specialities.js +30 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/capture.js +43 -8
- package/scripts/gds/category-advisory-guard.js +49 -8
- package/scripts/gds/stale-worktrees.js +50 -6
- package/src/module-api.js +1 -1
- package/tests/capture_goal_routing.mjs +65 -1
- package/tests/goal_suggest.mjs +148 -1
- package/tests/government_require_permission.mjs +90 -14
- package/tests/migration_allocation.mjs +70 -0
- package/tests/speciality_offered.mjs +212 -0
- package/tests/stale_worktrees.mjs +54 -0
|
@@ -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
|
-
|
|
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
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
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
|
-
|
|
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.
|
|
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');
|
|
@@ -181,7 +181,11 @@ await test('no --goal: the active-claim default fires and names how to opt out',
|
|
|
181
181
|
me: { ok: true, data: { active_claims: [{ task_id: 55, title: 'Fix the thing' }] } },
|
|
182
182
|
task: { ok: true, data: { task: { id: 55, goal_id: 1000071, title: 'Fix the thing' } } },
|
|
183
183
|
});
|
|
184
|
-
|
|
184
|
+
// The captured text shares 'thing' with the claim — the ordinary case the
|
|
185
|
+
// default exists for. (It used to be the bare title 't'; since task 1003859 the
|
|
186
|
+
// default only fires on a shared subject word, so a placeholder no longer
|
|
187
|
+
// exercises this path.)
|
|
188
|
+
const r = await capture.resolveGoalForCapture({ goalArg: null, version: 'BV1', title: 'the thing is broken', client });
|
|
185
189
|
assert.equal(r.goalId, 1000071);
|
|
186
190
|
assert.equal(r.lines.length, 1);
|
|
187
191
|
assert.match(r.lines[0], /1000071/);
|
|
@@ -239,4 +243,64 @@ await test('WIRING: a routing refusal is surfaced through the existing verbatim
|
|
|
239
243
|
assert.match(CAPTURE_SRC, /console\.error\(formatCaptureFailure\(r\.status, r\.data\)\);/);
|
|
240
244
|
});
|
|
241
245
|
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// the active-claim default must not mint UNRELATED work into the claim's goal
|
|
248
|
+
// (task 1003859)
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
//
|
|
251
|
+
// The default fired unconditionally, so filing an unrelated bug mid-session
|
|
252
|
+
// routed it into whatever goal you happened to be holding — task 1003859 itself
|
|
253
|
+
// was filed from a security claim and landed in Working area 10, and had to be
|
|
254
|
+
// moved by hand. ADR 0234: a guess must not mint work into a goal.
|
|
255
|
+
|
|
256
|
+
await test('no --goal, claim shares NO subject with the filing: does not inherit, and says why', async () => {
|
|
257
|
+
const { client, calls } = stubClient({
|
|
258
|
+
me: { ok: true, data: { active_claims: [{ task_id: 55, title: 'Harden the OAuth callback against replay' }] } },
|
|
259
|
+
task: { ok: true, data: { task: { id: 55, goal_id: 1000094, title: 'Harden the OAuth callback against replay' } } },
|
|
260
|
+
suggestions: { ok: true, data: { suggestions: [] } },
|
|
261
|
+
});
|
|
262
|
+
const r = await capture.resolveGoalForCapture({
|
|
263
|
+
goalArg: null, version: 'BV1', title: 'The goal suggester returns zero suggestions for every idea', client,
|
|
264
|
+
});
|
|
265
|
+
assert.equal(r.goalId, null, 'an unrelated claim must not decide where this work lives');
|
|
266
|
+
assert.equal(r.lines.length, 1, 'staying homeless silently would be the same bug with no evidence');
|
|
267
|
+
assert.match(r.lines[0], /NOT defaulting/);
|
|
268
|
+
assert.match(r.lines[0], /1000094/, 'it must name the goal it declined, so --goal can override it');
|
|
269
|
+
assert.equal(calls.suggestions.length, 1, 'with no default, the suggester still gets its turn');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await test('a declined default still yields to a real suggestion, keeping both lines', async () => {
|
|
273
|
+
const { client } = stubClient({
|
|
274
|
+
me: { ok: true, data: { active_claims: [{ task_id: 55, title: 'Harden the OAuth callback against replay' }] } },
|
|
275
|
+
task: { ok: true, data: { task: { id: 55, goal_id: 1000094, title: 'Harden the OAuth callback against replay' } } },
|
|
276
|
+
suggestions: { ok: true, data: { suggestions: [{ id: 1000091, title: 'Working area 7', score: 3 }] } },
|
|
277
|
+
});
|
|
278
|
+
const r = await capture.resolveGoalForCapture({
|
|
279
|
+
goalArg: null, version: 'BV1', title: 'The goal suggester returns zero suggestions', client,
|
|
280
|
+
});
|
|
281
|
+
assert.equal(r.goalId, null);
|
|
282
|
+
assert.equal(r.lines.length, 2, 'the refusal and the hint are different facts — both are printed');
|
|
283
|
+
assert.match(r.lines[0], /NOT defaulting/);
|
|
284
|
+
assert.match(r.lines[1], /1000091/);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
await test('--goal N still wins outright over the guard (it is not a veto)', async () => {
|
|
288
|
+
const { client, calls } = stubClient({
|
|
289
|
+
me: { ok: true, data: { active_claims: [{ task_id: 55, title: 'Something else entirely' }] } },
|
|
290
|
+
task: { ok: true, data: { task: { id: 55, goal_id: 1000094 } } },
|
|
291
|
+
});
|
|
292
|
+
const r = await capture.resolveGoalForCapture({ goalArg: '1000094', version: 'BV1', title: 'unrelated words here', client });
|
|
293
|
+
assert.deepEqual(r, { goalId: 1000094, lines: [] });
|
|
294
|
+
assert.equal(calls.me, 0);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
await test('a claim title with no subject words at all still inherits — the guard only fires on real disagreement', async () => {
|
|
298
|
+
const { client } = stubClient({
|
|
299
|
+
me: { ok: true, data: { active_claims: [{ task_id: 55, title: 'a' }] } },
|
|
300
|
+
task: { ok: true, data: { task: { id: 55, goal_id: 1000071, title: 'a' } } },
|
|
301
|
+
});
|
|
302
|
+
const r = await capture.resolveGoalForCapture({ goalArg: null, version: 'BV1', title: 'anything at all', client });
|
|
303
|
+
assert.equal(r.goalId, 1000071, 'nothing to compare against is not evidence of a mismatch');
|
|
304
|
+
});
|
|
305
|
+
|
|
242
306
|
summary();
|
package/tests/goal_suggest.mjs
CHANGED
|
@@ -199,13 +199,23 @@ await test('the handler clamps limit into a sane range', async () => {
|
|
|
199
199
|
// confident answer and declines whenever "confident" is not available.
|
|
200
200
|
// =========================================================================
|
|
201
201
|
|
|
202
|
-
const fakeDb = ({ versions = [], goals = [], throws = null, calls = null } = {}) => ({
|
|
202
|
+
const fakeDb = ({ versions = [], goals = [], throws = null, calls = null, criteria = null } = {}) => ({
|
|
203
203
|
listVersions: async () => { if (throws === 'versions') throw new Error('boom'); return versions; },
|
|
204
204
|
listGoals: async (args) => {
|
|
205
205
|
if (calls) calls.push(args);
|
|
206
206
|
if (throws === 'goals') throw new Error('boom');
|
|
207
207
|
return goals;
|
|
208
208
|
},
|
|
209
|
+
// task 1003859: the criterion prose the ranker reads alongside the title.
|
|
210
|
+
// Omitted (criteria: null) it is absent from the fake ENTIRELY, which is also
|
|
211
|
+
// the pinned shape for a db that predates this read.
|
|
212
|
+
...(criteria ? {
|
|
213
|
+
listCriterionTextForGoals: async (ids) => {
|
|
214
|
+
if (throws === 'criteria') throw new Error('boom');
|
|
215
|
+
if (calls) calls.push({ criteriaFor: ids.map(String) });
|
|
216
|
+
return new Map(Object.entries(criteria));
|
|
217
|
+
},
|
|
218
|
+
} : {}),
|
|
209
219
|
});
|
|
210
220
|
|
|
211
221
|
const BUILDING = { id: 'BONGOS-V1', status: 'building' };
|
|
@@ -298,4 +308,141 @@ await test('suggestGoalForCapture: the catch-all is never offered as a home', as
|
|
|
298
308
|
assert.equal(got, null, 'a bucket is not a workspace — dropped for EVERY open version, not just one');
|
|
299
309
|
});
|
|
300
310
|
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// ranking on DONE-WHEN CRITERIA (task 1003859)
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
//
|
|
315
|
+
// The real failure: since ADR 0264 every open BONGOS-V2 goal is titled
|
|
316
|
+
// 'Working area N — <subject>'. A subject word in a draft had nothing to overlap
|
|
317
|
+
// in the title, every goal scored 0, and the suggester returned [] for every idea
|
|
318
|
+
// walked at the 2026-09-12 triage — which also left suggested_goal_id NULL on
|
|
319
|
+
// every filing. These reproduce that board rather than a convenient one.
|
|
320
|
+
|
|
321
|
+
const WORKING_AREAS = [
|
|
322
|
+
{ id: 1000091, title: 'Working area 7', scope_modules: [], version_id: 'BONGOS-V2' },
|
|
323
|
+
{ id: 1000094, title: 'Working area 10', scope_modules: [], version_id: 'BONGOS-V2' },
|
|
324
|
+
];
|
|
325
|
+
const WA_CRITERIA = {
|
|
326
|
+
1000091: 'A release is published and an instance can move its pin to it. The grader runs and its findings are readable.',
|
|
327
|
+
1000094: 'Every authenticated route re-checks the caller rank. A credential never reaches a log.',
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
await test('the real board: title-only ranking scores every Working area goal ZERO', () => {
|
|
331
|
+
const got = suggestGoals(WORKING_AREAS, { title: 'the grader returns no findings' }, { versionId: 'BONGOS-V2', includeZero: true });
|
|
332
|
+
assert.deepEqual(got.map((g) => g.score), [0, 0],
|
|
333
|
+
'this is the bug, pinned: the naming convention left the ranker nothing to match');
|
|
334
|
+
assert.deepEqual(suggestGoals(WORKING_AREAS, { title: 'the grader returns no findings' }, { versionId: 'BONGOS-V2' }), [],
|
|
335
|
+
'and so the suggester returned nothing at all');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await test('with criteria, the same draft finds its goal, with the matched terms shown', () => {
|
|
339
|
+
const got = suggestGoals(
|
|
340
|
+
WORKING_AREAS,
|
|
341
|
+
{ title: 'the grader returns no findings' },
|
|
342
|
+
{ versionId: 'BONGOS-V2', criteriaByGoal: new Map(Object.entries(WA_CRITERIA)) }
|
|
343
|
+
);
|
|
344
|
+
assert.equal(got.length, 1, 'only the goal whose criteria talk about grading');
|
|
345
|
+
assert.equal(got[0].id, 1000091);
|
|
346
|
+
assert.ok(got[0].score > 0);
|
|
347
|
+
assert.ok(got[0].matched_terms.includes('grader'), `expected 'grader' among ${JSON.stringify(got[0].matched_terms)}`);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
await test('a criterion match weighs LESS than the same word in the title', () => {
|
|
351
|
+
const byTitle = { id: 10, title: 'Grader health', scope_modules: [], version_id: 'BONGOS-V2' };
|
|
352
|
+
const byCriteria = { id: 11, title: 'Working area 7', scope_modules: [], version_id: 'BONGOS-V2' };
|
|
353
|
+
const got = suggestGoals(
|
|
354
|
+
[byCriteria, byTitle],
|
|
355
|
+
{ title: 'grader' },
|
|
356
|
+
{ versionId: 'BONGOS-V2', criteriaByGoal: new Map([['11', 'the grader runs nightly']]) }
|
|
357
|
+
);
|
|
358
|
+
assert.equal(got[0].id, 10, 'a goal NAMED for the subject still outranks one that merely mentions it');
|
|
359
|
+
assert.ok(got[0].score > got[1].score);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
await test('a term in both title and criteria is counted ONCE, at the title weight', () => {
|
|
363
|
+
const g = { id: 12, title: 'Grader health', scope_modules: [], version_id: 'BONGOS-V2' };
|
|
364
|
+
const withCriteria = suggestGoals([g], { title: 'grader' }, { versionId: 'BONGOS-V2', criteriaByGoal: new Map([['12', 'grader grader grader']]) });
|
|
365
|
+
const without = suggestGoals([g], { title: 'grader' }, { versionId: 'BONGOS-V2' });
|
|
366
|
+
assert.equal(withCriteria[0].score, without[0].score, 'repeating itself must not inflate a goal');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
await test('no criteriaByGoal: ranking is EXACTLY what it was before (the pure function stays DB-free)', () => {
|
|
370
|
+
const draft = { title: 'the hall sidebar collapses on mobile' };
|
|
371
|
+
const a = suggestGoals([HALL_GOAL, ECON_GOAL], draft, { versionId: 'BONGOS-V1' });
|
|
372
|
+
const b = suggestGoals([HALL_GOAL, ECON_GOAL], draft, { versionId: 'BONGOS-V1', criteriaByGoal: null });
|
|
373
|
+
assert.deepEqual(a, b);
|
|
374
|
+
assert.equal(a[0].id, 81);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
await test('suggestGoalForCapture reads the criteria, so suggested_goal_id is populated on the real board', async () => {
|
|
378
|
+
const calls = [];
|
|
379
|
+
const got = await suggestGoalForCapture(
|
|
380
|
+
{ title: 'the grader returns no findings', body: '' },
|
|
381
|
+
{ db: fakeDb({ versions: [{ id: 'BONGOS-V2', status: 'building' }], goals: WORKING_AREAS, criteria: WA_CRITERIA, calls }) }
|
|
382
|
+
);
|
|
383
|
+
assert.ok(got, 'this returned NULL before task 1003859 — which is why suggested_goal_id was null on every filing');
|
|
384
|
+
assert.equal(got.id, 1000091);
|
|
385
|
+
assert.equal(got.version_id, 'BONGOS-V2');
|
|
386
|
+
assert.ok(calls.some((c) => c && c.criteriaFor), 'the criteria read must actually be issued');
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
await test('a criteria read that THROWS degrades to title-only ranking, never to a lost filing', async () => {
|
|
390
|
+
const got = await suggestGoalForCapture(
|
|
391
|
+
{ title: 'the hall sidebar collapses on mobile', body: '' },
|
|
392
|
+
{ db: fakeDb({ versions: [BUILDING], goals: [HALL_GOAL, ECON_GOAL], criteria: {}, throws: 'criteria' }) }
|
|
393
|
+
);
|
|
394
|
+
assert.ok(got, 'a bug report must survive a criteria read that fails');
|
|
395
|
+
assert.equal(got.id, 81, 'and still rank on what it can see');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
await test('a db with NO listCriterionTextForGoals at all still works (older core, or a lean fake)', async () => {
|
|
399
|
+
const got = await suggestGoalForCapture(
|
|
400
|
+
{ title: 'the hall sidebar collapses on mobile', body: '' },
|
|
401
|
+
{ db: fakeDb({ versions: [BUILDING], goals: [HALL_GOAL, ECON_GOAL] }) }
|
|
402
|
+
);
|
|
403
|
+
assert.equal(got.id, 81);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// a PRIVATE goal's criteria are never ranked on (task 1003859, grade round 1)
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
//
|
|
410
|
+
// score + matched_terms make a ranker into a word-guessing oracle: one candidate
|
|
411
|
+
// word per request tells you whether that word appears in the ranked text. The
|
|
412
|
+
// suggester already exposes private goals' TITLES (GET /goals does too — a wider
|
|
413
|
+
// status quo this task does not settle), but their done-when prose is a different
|
|
414
|
+
// order of exposure, so the criteria read stops at the visibility line.
|
|
415
|
+
|
|
416
|
+
const PRIVATE_GOAL = { id: 900, title: 'Working area 12', scope_modules: [], version_id: 'BONGOS-V2', visibility: 'private' };
|
|
417
|
+
const PUBLIC_GOAL = { id: 901, title: 'Working area 13', scope_modules: [], version_id: 'BONGOS-V2', visibility: 'public' };
|
|
418
|
+
|
|
419
|
+
await test('the criteria of a PRIVATE goal are never fetched — not even to rank', async () => {
|
|
420
|
+
const asked = [];
|
|
421
|
+
const db = {
|
|
422
|
+
listGoals: async () => [PRIVATE_GOAL, PUBLIC_GOAL],
|
|
423
|
+
listCriterionTextForGoals: async (ids) => { asked.push(...ids.map(String)); return new Map(); },
|
|
424
|
+
};
|
|
425
|
+
const res = fakeRes();
|
|
426
|
+
await goalSuggestHandler({ db, LIMITS: { TITLE: 200 } })(
|
|
427
|
+
{ query: { version: 'BONGOS-V2', title: 'embargo' } }, res
|
|
428
|
+
);
|
|
429
|
+
assert.deepEqual(asked, ['901'], 'only the public goal may contribute criterion text');
|
|
430
|
+
assert.ok(!asked.includes('900'), 'a private goal must not be probeable one word at a time');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
await test('a private goal still ranks on its TITLE — this narrows nothing that already worked', () => {
|
|
434
|
+
const got = suggestGoals([PRIVATE_GOAL], { title: 'working area 12' }, { versionId: 'BONGOS-V2' });
|
|
435
|
+
assert.equal(got.length, 1, 'the title-level status quo is a separate decision, left exactly as it was');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
await test('every goal private ⇒ no criteria read is issued at all', async () => {
|
|
439
|
+
let called = 0;
|
|
440
|
+
const db = {
|
|
441
|
+
listGoals: async () => [PRIVATE_GOAL],
|
|
442
|
+
listCriterionTextForGoals: async () => { called++; return new Map(); },
|
|
443
|
+
};
|
|
444
|
+
await goalSuggestHandler({ db, LIMITS: { TITLE: 200 } })({ query: { version: 'BONGOS-V2', title: 'x y z' } }, fakeRes());
|
|
445
|
+
assert.equal(called, 0, 'a query with nothing rankable must not be issued');
|
|
446
|
+
});
|
|
447
|
+
|
|
301
448
|
summary();
|
|
@@ -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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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 (
|
|
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',
|