@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.
- package/.bongos-core.json +44 -34
- 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 +2 -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/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/category-advisory-guard.js +49 -8
- package/scripts/gds/stale-worktrees.js +50 -6
- package/src/module-api.js +1 -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
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// and
|
|
78
|
-
// is
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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');
|
|
@@ -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',
|