@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.
@@ -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 };
@@ -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, // 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)
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
- if (!latest) continue;
183
- const pinnedMajor = parseInt(String(range).replace(/^[^0-9]*/, ''), 10);
184
- const latestMajor = parseInt(String(latest), 10);
185
- if (Number.isFinite(pinnedMajor) && Number.isFinite(latestMajor) && latestMajor > pinnedMajor) {
186
- findings.push({ severity: 'medium', item: `stale override: ${name} pinned "${range}" while latest is ${latest} — re-justify or lift the pin` });
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.366'; // CI auto-patch carrier (ADR 0161); see changelog above
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
+ });
@@ -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, so $1–$6 keep the positions this test has always pinned.
70
- assert.deepEqual(ins.params, ['BONGOS-V1', 'Goal hierarchy', 'desc', ['gds-core'], 'open', 42, null],
71
- 'version_id, title, description, scope_modules, status, created_by, then subtitle — in that order');
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) defaults to null too');
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
  // =========================================================================