@bongos/core 1.19.366 → 1.19.368
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 +57 -27
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +9 -1
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0192-a-category-orients-and-authorises-nothing.md +97 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +117 -1
- package/docs/api-reference.md +11 -3
- package/migrations/core_226_work_categories.sql +100 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -0
- package/modules/lifecycle/db-goals.js +5 -5
- package/modules/lifecycle/goal-category.js +273 -0
- package/modules/lifecycle/routes/goals.js +38 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/category-advisory-guard.js +153 -0
- package/scripts/gds/fitness.js +6 -1
- package/scripts/gds/redteam-patrol.js +48 -8
- package/src/bongos/route-rank-check.js +11 -0
- package/src/module-api.js +5 -1
- package/tests/category_advisory_guard.mjs +135 -0
- package/tests/goal_category.mjs +199 -0
- package/tests/goal_tier.mjs +7 -5
- package/tests/redteam_patrol.mjs +70 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// scripts/gds/category-advisory-guard.js — the fitness check that keeps a work
|
|
4
|
+
// CATEGORY advisory (task 1003275, goal 1000072, criterion legibility-categories;
|
|
5
|
+
// ADR 0192).
|
|
6
|
+
//
|
|
7
|
+
// WHY A CHECK AND NOT A COMMENT. The owner said it twice about this organisation:
|
|
8
|
+
// it "has no hierarchical purpose", and these categories have "no effect on the
|
|
9
|
+
// creation and agentic management of these elements". The risk is specific and
|
|
10
|
+
// named in the goal's own prose: the system ALREADY has a real hierarchy
|
|
11
|
+
// (Project → Version → Goal → Criteria → Task, ADR 0086) with real enforcement —
|
|
12
|
+
// the scope_modules wall, per-task rank floors, goal membership — so a SECOND
|
|
13
|
+
// thing that looks like a hierarchy "will be mistaken for one within a release".
|
|
14
|
+
// The mistake does not arrive as a decision; it arrives as one plausible line in
|
|
15
|
+
// a gate ("skip Legal goals in the feed"), and from then on work is refused
|
|
16
|
+
// because of a label nobody meant as a permission. A comment cannot refuse that
|
|
17
|
+
// line. This check can.
|
|
18
|
+
//
|
|
19
|
+
// WHAT IT ENFORCES, in two halves:
|
|
20
|
+
// 1. NO GATE READS A CATEGORY. Every file that decides authority, claim
|
|
21
|
+
// eligibility, feed membership or dependency satisfaction is scanned with
|
|
22
|
+
// comments stripped; any surviving mention of a category is a hard failure.
|
|
23
|
+
// Comments are stripped rather than flagged so a gate may CITE this rule (the
|
|
24
|
+
// checkVocabularyWall precedent) — what it may not do is branch on it.
|
|
25
|
+
// 2. THE FEATURE STILL EXISTS. A guard whose subject was renamed away passes
|
|
26
|
+
// silently and reads as proof (the ADR 0022 assurance-of-assurance finding
|
|
27
|
+
// that produced control-manifest.js). So the check also asserts the category
|
|
28
|
+
// surface is present; if the feature is gone, the guard must fail loudly
|
|
29
|
+
// rather than congratulate an empty repo.
|
|
30
|
+
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const path = require('node:path');
|
|
33
|
+
|
|
34
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
35
|
+
|
|
36
|
+
// Every file that DECIDES something: who may act, what may be claimed, what the
|
|
37
|
+
// feed offers, when a dependency is satisfied. A category may not appear in any
|
|
38
|
+
// of them. Add to this list when a new decision surface lands — that is the
|
|
39
|
+
// maintenance cost, and it is the point.
|
|
40
|
+
const GATE_FILES = [
|
|
41
|
+
'src/bongos/auth.js',
|
|
42
|
+
'src/bongos/auth-admission.js',
|
|
43
|
+
'src/bongos/permission-path-check.js',
|
|
44
|
+
'src/bongos/goal-scope-check.js',
|
|
45
|
+
'src/bongos/module-scope-map.js',
|
|
46
|
+
'src/bongos/stealth-gate.js',
|
|
47
|
+
'modules/lifecycle/claim-eligibility.js',
|
|
48
|
+
'modules/lifecycle/claim-feed.js',
|
|
49
|
+
'modules/lifecycle/goal-authz.js',
|
|
50
|
+
'modules/lifecycle/routes/goal-route-authz.js',
|
|
51
|
+
'modules/lifecycle/db-rank-authz.js',
|
|
52
|
+
'modules/lifecycle/db-deps-criteria.js',
|
|
53
|
+
'scripts/gds/gating.js',
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// DELIBERATELY NOT a gate: src/bongos/route-rank-check.js. It is the pin TABLE
|
|
57
|
+
// the rank fitness check reads, so it names every route including the category
|
|
58
|
+
// ones by design, and it decides nothing at runtime. Scanning it would force the
|
|
59
|
+
// pins out of the registry that exists to hold them.
|
|
60
|
+
|
|
61
|
+
// The category surface half 2 asserts still exists. Path + a token that must
|
|
62
|
+
// appear in it, so a rename or a gutting is caught rather than a bare `existsSync`.
|
|
63
|
+
const FEATURE_ANCHORS = [
|
|
64
|
+
{ file: 'migrations/core_226_work_categories.sql', needle: 'CREATE TABLE IF NOT EXISTS work_categories' },
|
|
65
|
+
{ file: 'modules/lifecycle/goal-category.js', needle: 'decorateGoalCategory' },
|
|
66
|
+
{ file: 'modules/lifecycle/routes/goals.js', needle: "router.patch('/goals/:id/category'" },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
// Word-bounded on purpose: `subcategory_id` in some unrelated module is not this
|
|
70
|
+
// feature, and a guard that cries wolf gets suppressed rather than obeyed. The
|
|
71
|
+
// table name needs no boundary — nothing else is called that.
|
|
72
|
+
const CATEGORY_RE = /\bcategor(y|ies|ised|ized|isation|ization)\b|\bcategory_id\b|work_categories/i;
|
|
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.
|
|
79
|
+
function stripComments(src) {
|
|
80
|
+
return src
|
|
81
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
82
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readFromDisk(rel) {
|
|
86
|
+
try { return fs.readFileSync(path.join(ROOT, rel), 'utf8'); } catch { return null; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The scan, with its file list and its reader INJECTED. A guard tested only
|
|
90
|
+
// against the live tree proves nothing about what it would refuse — the tree is
|
|
91
|
+
// clean today by construction. `read(rel)` returns the source or null.
|
|
92
|
+
function scanGates(files, read) {
|
|
93
|
+
const violations = [];
|
|
94
|
+
const warnings = [];
|
|
95
|
+
let scanned = 0;
|
|
96
|
+
|
|
97
|
+
for (const rel of files) {
|
|
98
|
+
const src = read(rel);
|
|
99
|
+
if (src == null) {
|
|
100
|
+
// A missing gate file is a warning, not a pass: the list has drifted from
|
|
101
|
+
// the repo and someone must re-point it.
|
|
102
|
+
warnings.push(`${rel} not found — the GATE_FILES list in scripts/gds/category-advisory-guard.js has drifted; re-point it at the file that replaced it.`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
scanned += 1;
|
|
106
|
+
const code = stripComments(src);
|
|
107
|
+
const hits = code.split('\n')
|
|
108
|
+
.map((line, i) => ({ line: line.trim(), n: i + 1 }))
|
|
109
|
+
.filter(({ line }) => CATEGORY_RE.test(line));
|
|
110
|
+
for (const { line, n } of hits) {
|
|
111
|
+
violations.push(
|
|
112
|
+
`${rel}:${n} reads a work CATEGORY inside a decision surface — \`${line.slice(0, 100)}\`. `
|
|
113
|
+
+ 'A category is ADVISORY: it orients a human and steers Claude, and authorises nothing (task 1003275, ADR 0192). '
|
|
114
|
+
+ 'The real hierarchy with real enforcement is ADR 0086 — gate on scope_modules, rank, membership or dependencies instead. '
|
|
115
|
+
+ 'If this label genuinely must gate something, that is an owner decision and needs its own ADR before this check is relaxed.'
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { violations, warnings, scanned };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Half 2: the feature this guard constrains must still be there, or the guard is
|
|
124
|
+
// vacuous and its green tick is a lie (the ADR 0022 assurance-of-assurance finding).
|
|
125
|
+
function scanAnchors(anchors, read) {
|
|
126
|
+
const violations = [];
|
|
127
|
+
for (const { file, needle } of anchors) {
|
|
128
|
+
const src = read(file);
|
|
129
|
+
if (src == null || !src.includes(needle)) {
|
|
130
|
+
violations.push(
|
|
131
|
+
`${file} no longer carries \`${needle}\` — the category feature this guard exists to constrain has moved or been removed, so the guard is now vacuous and would pass an empty repo. `
|
|
132
|
+
+ 'Re-point FEATURE_ANCHORS in scripts/gds/category-advisory-guard.js, or delete the guard along with the feature.'
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return violations;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function checkCategoryIsAdvisory({ files = GATE_FILES, anchors = FEATURE_ANCHORS, read = readFromDisk } = {}) {
|
|
141
|
+
const { violations: gateViolations, warnings, scanned } = scanGates(files, read);
|
|
142
|
+
const violations = gateViolations.concat(scanAnchors(anchors, read));
|
|
143
|
+
return {
|
|
144
|
+
name: 'a work category is ADVISORY — no gate reads it (task 1003275 / ADR 0192)',
|
|
145
|
+
ok: violations.length === 0,
|
|
146
|
+
hardFail: violations.length > 0,
|
|
147
|
+
violations,
|
|
148
|
+
warnings,
|
|
149
|
+
note: `${scanned} decision surface(s) scanned (authority, claim eligibility, claimable feed, dependency satisfaction) with comments stripped — a gate may CITE this rule, never branch on it. Plus ${anchors.length} feature anchor(s), so a renamed-away feature fails loudly instead of passing vacuously.`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { checkCategoryIsAdvisory, scanGates, scanAnchors, GATE_FILES, FEATURE_ANCHORS, stripComments };
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -1307,7 +1307,12 @@ const CHECKS = [
|
|
|
1307
1307
|
require('./control-manifest.js').checkControlsExist, // Check 21 — task 1003131: goal-1000076 controls assert their own existence (the ADR 0022 assurance-of-assurance)
|
|
1308
1308
|
require('./routine-schedule.js').checkRoutineCadencesScheduled, // Check 25 — task 1003209: a routine that declares a cadence must have a scheduler firing it AT that period (ADR 0188)
|
|
1309
1309
|
require('./baseline-staleness.js').checkBaselineFloors, // Check 26 — task 1003210: a shrink-only baseline that never shrinks is unmanaged debt; a floor exempting one must say why
|
|
1310
|
-
require('./client-baseurl-guard.js').checkClientBaseUrl,
|
|
1310
|
+
require('./client-baseurl-guard.js').checkClientBaseUrl,
|
|
1311
|
+
// Check 29 — task 1003275 / ADR 0189: a work category ORIENTS a human and steers
|
|
1312
|
+
// Claude; it authorises nothing. The owner said so twice, and the goal's own prose
|
|
1313
|
+
// predicts the failure: a second thing that looks like a hierarchy gets mistaken
|
|
1314
|
+
// for one within a release. A comment cannot refuse the line that does it.
|
|
1315
|
+
require('./category-advisory-guard.js').checkCategoryIsAdvisory, // Check 27 — task 1003263: a page must not combine baseUrl:'' with a generated namespaced call (that pairing 404s every request; three pages shipped it, two of them dead in prod)
|
|
1311
1316
|
// Check 28 — task 1001654 / ADR 0095: does each source file open with a module
|
|
1312
1317
|
// header saying what it does and why. ADVISORY ONLY (never hardFail) — a
|
|
1313
1318
|
// doc-comment gate that blocks a ship buys filler prose, not understanding.
|
|
@@ -134,6 +134,43 @@ function lockedVersion(lock, name) {
|
|
|
134
134
|
return entry && entry.version;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// Pure: what to say about ONE `overrides` entry. Extracted so the rule is a table
|
|
138
|
+
// a test can enumerate — the networked half (npm view) stays outside.
|
|
139
|
+
//
|
|
140
|
+
// THE BUG THIS REPLACES (task 1003283, report 1000025). The old rule compared the
|
|
141
|
+
// pinned MAJOR against `dist-tags.latest` and filed a medium whenever latest was
|
|
142
|
+
// higher. That flags every override which deliberately holds a major line — which
|
|
143
|
+
// is the normal reason an override exists at all. It fired on `undici ^6.28.0`
|
|
144
|
+
// "while latest is 8.10.0", when 6.28.0 is the newest 6.x, `discord.js` (the only
|
|
145
|
+
// consumer) requires the 6.x line, and `npm audit` finds nothing. Acting on it
|
|
146
|
+
// would have BROKEN the dependent; ignoring it weekly is the alert-fatigue failure
|
|
147
|
+
// ADR 0188 §2.4 exists to prevent — here inside the patrol itself.
|
|
148
|
+
//
|
|
149
|
+
// The real signal is narrower: a pin whose LOCKED version has fallen behind what
|
|
150
|
+
// its own range already allows. That is rot nobody chose. Trailing the latest
|
|
151
|
+
// major is reported as a NOTE instead, because only a human can say whether a
|
|
152
|
+
// dependent still constrains the line.
|
|
153
|
+
//
|
|
154
|
+
// `satisfying` is every published version matching `range`, ASCENDING (npm's own
|
|
155
|
+
// order), so the newest in-range version is simply the last element — no semver
|
|
156
|
+
// comparator needed, and no dependency added to do it.
|
|
157
|
+
function overrideVerdict({ name, range, locked, satisfying = [], latest = null } = {}) {
|
|
158
|
+
const newestInRange = satisfying.length ? satisfying[satisfying.length - 1] : null;
|
|
159
|
+
if (newestInRange && locked && newestInRange !== locked && satisfying.includes(locked)) {
|
|
160
|
+
return {
|
|
161
|
+
finding: { severity: 'medium', item: `stale override: ${name} is locked at ${locked} but its own pin "${range}" already allows ${newestInRange} — refresh the lockfile` },
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const pinnedMajor = parseInt(String(range).replace(/^[^0-9]*/, ''), 10);
|
|
165
|
+
const latestMajor = parseInt(String(latest), 10);
|
|
166
|
+
if (Number.isFinite(pinnedMajor) && Number.isFinite(latestMajor) && latestMajor > pinnedMajor) {
|
|
167
|
+
return {
|
|
168
|
+
note: `override ${name} "${range}" trails the latest major (${latest}) — expected when a dependent requires that line; verify before lifting, do NOT bump blind`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return {};
|
|
172
|
+
}
|
|
173
|
+
|
|
137
174
|
function dependencyReview() {
|
|
138
175
|
const findings = [];
|
|
139
176
|
const notes = [];
|
|
@@ -175,16 +212,19 @@ function dependencyReview() {
|
|
|
175
212
|
}
|
|
176
213
|
}
|
|
177
214
|
|
|
178
|
-
// 2c — override staleness (the undici lesson: a pin nobody watches rots)
|
|
215
|
+
// 2c — override staleness (the undici lesson: a pin nobody watches rots) —
|
|
216
|
+
// measured INSIDE the pinned range, see overrideVerdict above.
|
|
179
217
|
for (const [name, range] of Object.entries(pkg.overrides || {})) {
|
|
180
218
|
if (typeof range !== 'string') continue;
|
|
181
219
|
const latest = npmViewJson(`${name} dist-tags.latest`);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
220
|
+
// Every published version matching the range, ascending. npm returns a bare
|
|
221
|
+
// string when exactly one matches, so normalise to an array.
|
|
222
|
+
const matched = npmViewJson(`"${name}@${range}" version`);
|
|
223
|
+
const satisfying = matched == null ? [] : (Array.isArray(matched) ? matched : [matched]);
|
|
224
|
+
if (!latest && !satisfying.length) { notes.push(`npm view failed for override ${name} — staleness check skipped`); continue; }
|
|
225
|
+
const v = overrideVerdict({ name, range, locked: lockedVersion(lock, name), satisfying, latest });
|
|
226
|
+
if (v.finding) findings.push(v.finding);
|
|
227
|
+
if (v.note) notes.push(v.note);
|
|
188
228
|
}
|
|
189
229
|
return { findings, notes };
|
|
190
230
|
}
|
|
@@ -393,5 +433,5 @@ if (require.main === module) {
|
|
|
393
433
|
module.exports = {
|
|
394
434
|
parseGitleaksReport, unpinnedUses, ageDays, mapSeverity, maxSeverity,
|
|
395
435
|
buildClassReport, lockedVersion, COOLDOWN_DAYS, YOUNG_PKG_DAYS,
|
|
396
|
-
stepSummary, filingBlocker, disposition,
|
|
436
|
+
stepSummary, filingBlocker, disposition, overrideVerdict,
|
|
397
437
|
};
|
|
@@ -370,6 +370,17 @@ const MODULE_EXPECTED_RANKS = {
|
|
|
370
370
|
// authorizeGoalManagement) corrects its own title/description — same shape
|
|
371
371
|
// and same gate as the visibility route above.
|
|
372
372
|
'PATCH /goals/:id': 'any-builder',
|
|
373
|
+
// task 1003275 (goal 1000072, legibility-categories): the ADVISORY human
|
|
374
|
+
// category. The set is a VOCABULARY, so reading it is any-builder — a client
|
|
375
|
+
// that cannot read it hard-codes the labels. Creating one is a planning act,
|
|
376
|
+
// pinned to the same permission as authoring a goal. Setting a goal's category
|
|
377
|
+
// rides the same owner|manager|Archon in-handler gate as the prose edit above.
|
|
378
|
+
// These pins are also the tripwire for the rule that outranks the feature: a
|
|
379
|
+
// category must never GATE anything, so if one of these ever needs a WIDER
|
|
380
|
+
// gate than its neighbours, the label has grown into a permission.
|
|
381
|
+
'GET /work-categories': 'any-builder',
|
|
382
|
+
'POST /work-categories': 'perm:goal.create',
|
|
383
|
+
'PATCH /goals/:id/category': 'any-builder',
|
|
373
384
|
'POST /goals/:id/requests': 'any-builder',
|
|
374
385
|
'POST /goals/:id/requests/:reqId/respond': 'any-builder',
|
|
375
386
|
'POST /goals/:id/invitations': 'any-builder',
|
package/src/module-api.js
CHANGED
|
@@ -1162,8 +1162,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1162
1162
|
// landed since 1.19.364 with no explicit bump. run 32893443215. (task 1002620)
|
|
1163
1163
|
// 1.19.366 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1164
1164
|
// landed since 1.19.365 with no explicit bump. run 32895705036. (task 1002620)
|
|
1165
|
+
// 1.19.367 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1166
|
+
// landed since 1.19.366 with no explicit bump. run 32902059088. (task 1002620)
|
|
1167
|
+
// 1.19.368 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1168
|
+
// landed since 1.19.367 with no explicit bump. run 32902864988. (task 1002620)
|
|
1165
1169
|
// ---------------------------------------------------------------------------
|
|
1166
|
-
const CORE_VERSION = '1.19.
|
|
1170
|
+
const CORE_VERSION = '1.19.368'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1167
1171
|
|
|
1168
1172
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1169
1173
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// tests/category_advisory_guard.mjs — the guard that keeps a work CATEGORY from
|
|
2
|
+
// becoming a permission (task 1003275, goal 1000072, criterion legibility-categories).
|
|
3
|
+
//
|
|
4
|
+
// The whole point of that criterion's load-bearing constraint is that the failure
|
|
5
|
+
// arrives as ONE plausible line inside a gate, not as a decision anyone announces.
|
|
6
|
+
// So the guard has to be tested on synthetic sources: a scan that passes because
|
|
7
|
+
// today's tree happens to be clean proves nothing about what it would REFUSE
|
|
8
|
+
// tomorrow (the client_baseurl_guard.mjs precedent).
|
|
9
|
+
//
|
|
10
|
+
// Run: node tests/category_advisory_guard.mjs
|
|
11
|
+
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const { checkCategoryIsAdvisory, scanGates, scanAnchors, GATE_FILES, FEATURE_ANCHORS, stripComments } =
|
|
17
|
+
require('../scripts/gds/category-advisory-guard.js');
|
|
18
|
+
|
|
19
|
+
let passed = 0;
|
|
20
|
+
let failed = 0;
|
|
21
|
+
function test(name, fn) {
|
|
22
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
23
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const from = (map) => (rel) => (rel in map ? map[rel] : null);
|
|
27
|
+
|
|
28
|
+
// --- THE FAILURE THE GUARD EXISTS FOR --------------------------------------
|
|
29
|
+
|
|
30
|
+
test('a claimable feed that hides a category is refused', () => {
|
|
31
|
+
const { violations } = scanGates(['modules/lifecycle/claim-feed.js'], from({
|
|
32
|
+
'modules/lifecycle/claim-feed.js':
|
|
33
|
+
"const rows = await pool.query('SELECT * FROM tasks');\n"
|
|
34
|
+
+ "return rows.filter((t) => t.category_id !== LEGAL_CATEGORY);\n",
|
|
35
|
+
}));
|
|
36
|
+
assert.equal(violations.length, 1);
|
|
37
|
+
assert.match(violations[0], /claim-feed\.js:2/);
|
|
38
|
+
assert.match(violations[0], /ADVISORY/, 'the message states the rule');
|
|
39
|
+
assert.match(violations[0], /ADR 0086/, 'and points at the hierarchy that DOES enforce');
|
|
40
|
+
assert.match(violations[0], /owner decision/, 'and names the only legitimate way to change it');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('an authorisation branch on a category is refused', () => {
|
|
44
|
+
const { violations } = scanGates(['modules/lifecycle/goal-authz.js'], from({
|
|
45
|
+
'modules/lifecycle/goal-authz.js':
|
|
46
|
+
'function authorizeGoalManagement({ goal, actorRank }) {\n'
|
|
47
|
+
+ " if (goal.category === 'security' && actorRank !== 'archon') return { ok: false };\n"
|
|
48
|
+
+ ' return { ok: true };\n}\n',
|
|
49
|
+
}));
|
|
50
|
+
assert.equal(violations.length, 1);
|
|
51
|
+
assert.match(violations[0], /goal-authz\.js:2/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('the plural, the past tense and the raw table name are all caught', () => {
|
|
55
|
+
for (const line of [
|
|
56
|
+
'const c = row.categories;',
|
|
57
|
+
'if (!goal.categorised) return refuse();',
|
|
58
|
+
"await pool.query('SELECT slug FROM work_categories');",
|
|
59
|
+
]) {
|
|
60
|
+
const { violations } = scanGates(['scripts/gds/gating.js'], from({ 'scripts/gds/gating.js': `${line}\n` }));
|
|
61
|
+
assert.equal(violations.length, 1, `not caught: ${line}`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// --- WHAT IT MUST NOT REFUSE ----------------------------------------------
|
|
66
|
+
|
|
67
|
+
test('a gate may CITE the rule in a comment', () => {
|
|
68
|
+
const { violations } = scanGates(['modules/lifecycle/claim-eligibility.js'], from({
|
|
69
|
+
'modules/lifecycle/claim-eligibility.js':
|
|
70
|
+
'// A work category is advisory and is deliberately NOT read here (task 1003275).\n'
|
|
71
|
+
+ '/* Not even the category set — see ADR 0192. */\n'
|
|
72
|
+
+ 'return { ok: true };\n',
|
|
73
|
+
}));
|
|
74
|
+
assert.deepEqual(violations, [], 'citing the rule is the whole reason comments are stripped');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a gate that reads scope, rank or membership is untouched', () => {
|
|
78
|
+
const { violations } = scanGates(['src/bongos/goal-scope-check.js'], from({
|
|
79
|
+
'src/bongos/goal-scope-check.js':
|
|
80
|
+
"if (scopeIncludesProtected(goal.scope_modules) && rank !== 'archon') return refuse();\n",
|
|
81
|
+
}));
|
|
82
|
+
assert.deepEqual(violations, []);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a word that merely CONTAINS the letters is not a category', () => {
|
|
86
|
+
const { violations } = scanGates(['src/bongos/auth.js'], from({
|
|
87
|
+
'src/bongos/auth.js': 'const c = categorizeNothing;\nconst d = subcategory_id_like;\n',
|
|
88
|
+
}));
|
|
89
|
+
assert.deepEqual(violations, [], 'the word boundary matters — `categorize` alone is not `category`');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// --- THE GUARD MUST NOT BE ABLE TO PASS VACUOUSLY -------------------------
|
|
93
|
+
|
|
94
|
+
test('a renamed-away feature fails the guard instead of passing it silently', () => {
|
|
95
|
+
const violations = scanAnchors(
|
|
96
|
+
[{ file: 'modules/lifecycle/goal-category.js', needle: 'decorateGoalCategory' }],
|
|
97
|
+
from({ 'modules/lifecycle/goal-category.js': '// gutted\n' })
|
|
98
|
+
);
|
|
99
|
+
assert.equal(violations.length, 1);
|
|
100
|
+
assert.match(violations[0], /vacuous/, 'it says WHY a green tick here would be a lie');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a deleted feature file fails the guard', () => {
|
|
104
|
+
const violations = scanAnchors(FEATURE_ANCHORS.slice(0, 1), from({}));
|
|
105
|
+
assert.equal(violations.length, 1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// --- A DRIFTED LIST WARNS, IT DOES NOT PASS -------------------------------
|
|
109
|
+
|
|
110
|
+
test('a gate file that has moved warns rather than silently shrinking the scan', () => {
|
|
111
|
+
const { violations, warnings, scanned } = scanGates(['modules/lifecycle/gone.js'], from({}));
|
|
112
|
+
assert.deepEqual(violations, []);
|
|
113
|
+
assert.equal(scanned, 0);
|
|
114
|
+
assert.equal(warnings.length, 1);
|
|
115
|
+
assert.match(warnings[0], /drifted/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// --- COMMENT STRIPPING KEEPS TEXT WHEN UNSURE ----------------------------
|
|
119
|
+
|
|
120
|
+
test('stripComments errs toward KEEPING text (a guard should fail loud, not quiet)', () => {
|
|
121
|
+
assert.match(stripComments("const url = 'http://x/category';\n"), /category/,
|
|
122
|
+
'a `//` inside a string must not be able to hide a real read from the scan');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// --- THE LIVE TREE -------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
test('the live tree passes, and the gate list is not empty', () => {
|
|
128
|
+
assert.ok(GATE_FILES.length >= 10, 'a decision-surface list this short would not be covering the gates');
|
|
129
|
+
const r = checkCategoryIsAdvisory();
|
|
130
|
+
assert.deepEqual(r.violations, [], r.violations.join('\n'));
|
|
131
|
+
assert.equal(r.hardFail, false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
135
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// tests/goal_category.mjs — the human CATEGORY on a goal, and the rule that
|
|
2
|
+
// outranks it (task 1003275, goal 1000072, criterion legibility-categories).
|
|
3
|
+
//
|
|
4
|
+
// Two things are pinned here, and the second matters more than the first.
|
|
5
|
+
//
|
|
6
|
+
// 1. The feature works: the set is readable, a category can be created at the
|
|
7
|
+
// moment Claude could not place a goal, a goal can be labelled and unlabelled,
|
|
8
|
+
// and a board of goals costs ONE category query rather than a join per goal.
|
|
9
|
+
//
|
|
10
|
+
// 2. UNCATEGORISED IS LEGAL. The criterion is explicit that Claude failing to
|
|
11
|
+
// place a goal is "a designed prompt, not an error". The moment a missing
|
|
12
|
+
// category can refuse a write, the label has become a gate — which is the exact
|
|
13
|
+
// failure the goal's own prose predicts ("a second thing that LOOKS like a
|
|
14
|
+
// hierarchy will be mistaken for one within a release"). The
|
|
15
|
+
// scripts/gds/category-advisory-guard.js check covers the gates; these cover
|
|
16
|
+
// the write paths.
|
|
17
|
+
//
|
|
18
|
+
// Run: node --test tests/goal_category.mjs (or: node tests/goal_category.mjs)
|
|
19
|
+
import { strict as assert } from 'node:assert';
|
|
20
|
+
import { test } from 'node:test';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
const gc = require(path.join(ROOT, 'modules', 'lifecycle', 'goal-category.js'));
|
|
29
|
+
const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
|
|
30
|
+
|
|
31
|
+
const fakeRes = () => ({
|
|
32
|
+
code: null, statusCode: null, payload: null,
|
|
33
|
+
fail(code, status, extra) { this.code = code; this.statusCode = typeof status === 'number' ? status : status?.status ?? 400; this.payload = { error: code, ...(extra || {}) }; return this; },
|
|
34
|
+
status(c) { this.statusCode = c; return this; },
|
|
35
|
+
json(p) { this.payload = p; return this; },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ---- slugs -----------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
test('a label a human types reduces to a usable slug', () => {
|
|
41
|
+
assert.equal(gc.slugify('Government (Decision Making)'), 'government-decision-making');
|
|
42
|
+
assert.equal(gc.slugify('User/ABI Experience'), 'user-abi-experience');
|
|
43
|
+
assert.equal(gc.slugify(' Developer Relations '), 'developer-relations');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('a slug that could break a client filter is refused, not silently mangled', () => {
|
|
47
|
+
assert.equal(gc.isValidSlug('security'), true);
|
|
48
|
+
assert.equal(gc.isValidSlug('user-experience'), true);
|
|
49
|
+
assert.equal(gc.isValidSlug('a'), false, 'one char is not a key');
|
|
50
|
+
assert.equal(gc.isValidSlug('-legal'), false);
|
|
51
|
+
assert.equal(gc.isValidSlug('legal-'), false);
|
|
52
|
+
assert.equal(gc.isValidSlug('a--b'), false);
|
|
53
|
+
assert.equal(gc.isValidSlug('Legal'), false, 'case matters — two spellings of one category is not a set');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a label of pure punctuation yields no slug, and the route says so', async () => {
|
|
57
|
+
// slugify('***') is '' — isValidSlug rejects it, so the caller gets a message
|
|
58
|
+
// telling them to send an explicit slug rather than a mystery 400.
|
|
59
|
+
const handler = gc.categoryCreateHandler({ create: async () => { throw new Error('must not reach the DB'); }, validateOrRespond: () => false, LIMITS: { TITLE: 200 } });
|
|
60
|
+
const res = fakeRes();
|
|
61
|
+
await handler({ body: { label: '***' }, builder: { id: 1 } }, res);
|
|
62
|
+
assert.equal(res.code, 'bad_slug');
|
|
63
|
+
assert.match(res.payload.message, /send a `slug` explicitly/i);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ---- creating one at the moment Claude could not place a goal ---------------
|
|
67
|
+
|
|
68
|
+
test('creating a category returns 201 and attributes it to the human who made it', async () => {
|
|
69
|
+
let seen = null;
|
|
70
|
+
const handler = gc.categoryCreateHandler({
|
|
71
|
+
create: async (args) => { seen = args; return { id: 9, slug: args.slug, label: args.label, created: true }; },
|
|
72
|
+
validateOrRespond: () => false, LIMITS: { TITLE: 200 },
|
|
73
|
+
});
|
|
74
|
+
const res = fakeRes();
|
|
75
|
+
await handler({ body: { label: 'Developer Relations' }, builder: { id: 42 } }, res);
|
|
76
|
+
assert.equal(res.statusCode, 201);
|
|
77
|
+
assert.equal(res.payload.created, true);
|
|
78
|
+
assert.deepEqual(seen, { slug: 'developer-relations', label: 'Developer Relations', createdBy: 42 });
|
|
79
|
+
assert.equal('created' in res.payload.category, false, 'the internal created flag does not leak onto the row');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('a duplicate converges on the existing category instead of stranding a goal', async () => {
|
|
83
|
+
// Two people prompted to create "Developer Relations" in the same hour must end
|
|
84
|
+
// up with ONE category. A 409 here would leave whichever lost the race with no
|
|
85
|
+
// label and no obvious next step — the opposite of a designed prompt.
|
|
86
|
+
const handler = gc.categoryCreateHandler({
|
|
87
|
+
create: async ({ slug }) => ({ id: 9, slug, label: 'Developer Relations', created: false }),
|
|
88
|
+
validateOrRespond: () => false, LIMITS: { TITLE: 200 },
|
|
89
|
+
});
|
|
90
|
+
const res = fakeRes();
|
|
91
|
+
await handler({ body: { label: 'developer relations' }, builder: { id: 7 } }, res);
|
|
92
|
+
assert.equal(res.statusCode, 200, 'not a 201, and not an error');
|
|
93
|
+
assert.equal(res.payload.created, false);
|
|
94
|
+
assert.equal(res.payload.category.id, 9);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---- labelling a goal ------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
const setDeps = {
|
|
100
|
+
db: { getGoal: async () => ({ id: 7, status: 'open', created_by: 1 }), getGoalMember: async () => null },
|
|
101
|
+
authorizeGoalManagement: () => ({ ok: true }),
|
|
102
|
+
validateOrRespond: () => false,
|
|
103
|
+
parseId: () => 7,
|
|
104
|
+
index: async () => new Map(),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
test('setting a goal\'s category rides the SAME gate as editing its prose', async () => {
|
|
108
|
+
// Not a wider gate and not a narrower one: a label must never change who may
|
|
109
|
+
// write to a goal. Pinning the refusal is how a future widening gets noticed.
|
|
110
|
+
let asked = null;
|
|
111
|
+
const handler = gc.goalCategoryHandler({
|
|
112
|
+
...setDeps,
|
|
113
|
+
authorizeGoalManagement: (args) => { asked = args; return { ok: false, status: 403, body: { error: 'not_yours' } }; },
|
|
114
|
+
set: async () => { throw new Error('must not write when refused'); },
|
|
115
|
+
read: async () => ({ id: 3 }),
|
|
116
|
+
});
|
|
117
|
+
const res = fakeRes();
|
|
118
|
+
await handler({ params: { id: '7' }, body: { category_id: 3 }, builder: { id: 99, rank: 'thetes' } }, res);
|
|
119
|
+
assert.equal(res.statusCode, 403);
|
|
120
|
+
assert.equal(asked.actorId, 99, 'the same authorizeGoalManagement decision the prose edit uses');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('clearing a category is an explicit null, not an omission', async () => {
|
|
124
|
+
let wrote;
|
|
125
|
+
const handler = gc.goalCategoryHandler({ ...setDeps, set: async (a) => { wrote = a; return { id: 7, category_id: null }; }, read: async () => ({ id: 1 }) });
|
|
126
|
+
const res = fakeRes();
|
|
127
|
+
await handler({ params: { id: '7' }, body: { category_id: null }, builder: { id: 1, rank: 'archon' } }, res);
|
|
128
|
+
assert.equal(res.code, null, `must not fail — got ${res.code}`);
|
|
129
|
+
assert.deepEqual(wrote, { goalId: 7, categoryId: null });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('an omitted category_id is a 400 that says what to send', async () => {
|
|
133
|
+
const handler = gc.goalCategoryHandler({ ...setDeps, set: async () => { throw new Error('must not write'); } });
|
|
134
|
+
const res = fakeRes();
|
|
135
|
+
await handler({ params: { id: '7' }, body: {}, builder: { id: 1, rank: 'archon' } }, res);
|
|
136
|
+
assert.equal(res.code, 'category_id_required');
|
|
137
|
+
assert.match(res.payload.message, /category_id: null/, 'the message shows how to clear it too');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('an unknown category is a 404, not a silently-created one', async () => {
|
|
141
|
+
const handler = gc.goalCategoryHandler({ ...setDeps, read: async () => null, set: async () => { throw new Error('must not write'); } });
|
|
142
|
+
const res = fakeRes();
|
|
143
|
+
await handler({ params: { id: '7' }, body: { category_id: 4242 }, builder: { id: 1, rank: 'archon' } }, res);
|
|
144
|
+
assert.equal(res.code, 'category_not_found');
|
|
145
|
+
assert.equal(res.statusCode, 404);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ---- decoration ------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
test('a goal carries a readable category, and an uncategorised one carries null', () => {
|
|
151
|
+
const byId = new Map([['3', { id: 3, slug: 'security', label: 'Security' }]]);
|
|
152
|
+
assert.deepEqual(gc.decorateGoalCategory({ id: 1, category_id: 3 }, byId).category,
|
|
153
|
+
{ id: '3', slug: 'security', label: 'Security' });
|
|
154
|
+
assert.equal(gc.decorateGoalCategory({ id: 2, category_id: null }, byId).category, null,
|
|
155
|
+
'uncategorised reads as null — a prompt, never an omission the client has to guess at');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('a goal carrying a RETIRED category still renders its label', () => {
|
|
159
|
+
// Retiring is `active = false`, never a DELETE, and categoryIndex reads inactive
|
|
160
|
+
// rows for exactly this reason: a goal must not silently become uncategorised
|
|
161
|
+
// because someone tidied the set.
|
|
162
|
+
const byId = new Map([['5', { id: 5, slug: 'legal', label: 'Legal', active: false }]]);
|
|
163
|
+
assert.equal(gc.decorateGoalCategory({ id: 1, category_id: 5 }, byId).category.label, 'Legal');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('decoration never mutates the goal it was handed', () => {
|
|
167
|
+
const goal = { id: 1, category_id: 3 };
|
|
168
|
+
gc.decorateGoalCategory(goal, new Map());
|
|
169
|
+
assert.equal('category' in goal, false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ---- the rule that outranks the feature ------------------------------------
|
|
173
|
+
|
|
174
|
+
test('a task has no category column — inheritance is literal', () => {
|
|
175
|
+
const migration = read('migrations', 'core_226_work_categories.sql');
|
|
176
|
+
assert.match(migration, /ALTER TABLE goals/);
|
|
177
|
+
assert.equal(/ALTER TABLE tasks/.test(migration), false,
|
|
178
|
+
'a task INHERITS its goal\'s category (criterion wording). A stored copy would be a second answer to one question.');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('the category is nullable with no default, so uncategorised stays a legal state', () => {
|
|
182
|
+
const migration = read('migrations', 'core_226_work_categories.sql');
|
|
183
|
+
assert.match(migration, /ADD COLUMN IF NOT EXISTS category_id bigint REFERENCES work_categories\(id\)/);
|
|
184
|
+
assert.equal(/category_id[^\n]*NOT NULL/.test(migration), false, 'NOT NULL would turn the designed prompt into a refused write');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('the eight defaults the owner named all ship', () => {
|
|
188
|
+
const migration = read('migrations', 'core_226_work_categories.sql');
|
|
189
|
+
for (const label of ['Builders Hall', 'Context Management', 'Government (Decision Making)',
|
|
190
|
+
'Modules', 'Security', 'Economics', 'Legal', 'User/ABI Experience']) {
|
|
191
|
+
assert.ok(migration.includes(label), `missing default category: ${label}`);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('POST /goals accepts a category but never requires one', () => {
|
|
196
|
+
const routes = read('modules', 'lifecycle', 'routes', 'goals.js');
|
|
197
|
+
assert.match(routes, /category_id: \{ type: 'integer', min: 1 \}/, 'accepted on create');
|
|
198
|
+
assert.equal(/category_id: \{ required: true/.test(routes), false, 'never required — that would make the prompt a refusal');
|
|
199
|
+
});
|
package/tests/goal_tier.mjs
CHANGED
|
@@ -66,9 +66,11 @@ await test('createGoal: INSERT binds all six fields in order and returns the row
|
|
|
66
66
|
assert.deepEqual(out, GOAL_ROW, 'returns the inserted row');
|
|
67
67
|
const ins = find(pool.queries, /INSERT INTO goals/);
|
|
68
68
|
// task 1003224 appended `subtitle` as $7 rather than inserting it next to
|
|
69
|
-
// title,
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
// title, and task 1003275 appended `category_id` as $8 the same way, so $1–$6
|
|
70
|
+
// keep the positions this test has always pinned. New goal fields go on the
|
|
71
|
+
// END of the bind list for exactly this reason.
|
|
72
|
+
assert.deepEqual(ins.params, ['BONGOS-V1', 'Goal hierarchy', 'desc', ['gds-core'], 'open', 42, null, null],
|
|
73
|
+
'version_id, title, description, scope_modules, status, created_by, then subtitle, then category_id — in that order');
|
|
72
74
|
assert.match(ins.sql, /RETURNING/, 'returns the row, not just inserts');
|
|
73
75
|
});
|
|
74
76
|
|
|
@@ -89,8 +91,8 @@ await test('createGoal: defaults — null description/createdBy, empty scope, op
|
|
|
89
91
|
const pool = fakePool((s) => (/INSERT INTO goals/.test(s) ? { rows: [GOAL_ROW] } : null));
|
|
90
92
|
await createGoal({ versionId: 'V1', title: 'general' }, { pool });
|
|
91
93
|
const ins = find(pool.queries, /INSERT INTO goals/);
|
|
92
|
-
assert.deepEqual(ins.params, ['V1', 'general', null, [], 'open', null, null],
|
|
93
|
-
'unspecified fields fall back to null / [] / open — subtitle ($7)
|
|
94
|
+
assert.deepEqual(ins.params, ['V1', 'general', null, [], 'open', null, null, null],
|
|
95
|
+
'unspecified fields fall back to null / [] / open — subtitle ($7) and category_id ($8) default to null too');
|
|
94
96
|
});
|
|
95
97
|
|
|
96
98
|
// =========================================================================
|