@bongos/core 1.19.970 → 1.19.972

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.
@@ -395,18 +395,35 @@ async function getGoalDependencies(goalId, deps = {}) {
395
395
  // table to `g` would have been tidier and would have rewritten four lines whose
396
396
  // literal text tests/goal_tier.mjs pins ("priority-first, then stable
397
397
  // ordering"). An additive change should read as additive in the diff.
398
- async function listGoals({ versionId = null, status = null, limit = null } = {}, deps = {}) {
398
+ //
399
+ // `viewerId` adds `viewer_is_member` — is the READER of this list one of this
400
+ // goal's members? The "My goals" lens on the goal board used to answer that from
401
+ // the per-goal DETAIL prefetch, which meant the lens silently returned NOTHING
402
+ // whenever that best-effort fan-out had not landed (or had failed), and the Tasks
403
+ // board could not ask the question at all without N extra requests. One EXISTS on
404
+ // a list the client already fetches answers it for both.
405
+ //
406
+ // `null` viewerId yields `false` everywhere, never NULL: an unauthenticated or
407
+ // identity-less read gets a well-formed row whose answer is "not yours", which is
408
+ // the truthful answer and keeps the wire shape one type. EXISTS (not a LEFT JOIN)
409
+ // for the `admitted` reason directly above — goal_members is keyed
410
+ // (goal_id, builder_id) so a join could not duplicate today, but the shape of the
411
+ // query should not depend on that.
412
+ async function listGoals({ versionId = null, status = null, limit = null, viewerId = null } = {}, deps = {}) {
399
413
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
400
414
  const { rows } = await activePool.query(
401
415
  `SELECT ${GOAL_COLS},
402
416
  EXISTS (SELECT 1 FROM lifecycle_goal_version_admissions a
403
- WHERE a.goal_id = goals.id) AS admitted
417
+ WHERE a.goal_id = goals.id) AS admitted,
418
+ ($4::bigint IS NOT NULL
419
+ AND EXISTS (SELECT 1 FROM goal_members gm
420
+ WHERE gm.goal_id = goals.id AND gm.builder_id = $4::bigint)) AS viewer_is_member
404
421
  FROM goals
405
422
  WHERE ($1::text IS NULL OR version_id = $1)
406
423
  AND ($2::text IS NULL OR status = $2)
407
424
  ORDER BY version_id, sort_order, created_at, id
408
425
  LIMIT $3::int`,
409
- [versionId, status, limit]
426
+ [versionId, status, limit, viewerId]
410
427
  );
411
428
  return rows;
412
429
  }
@@ -215,7 +215,7 @@ module.exports = function buildGoalsRouter() {
215
215
  // rank: any-builder — GET /goals/graph, the goal board's map (task 1003479, ADR 0227); before /goals/:id.
216
216
  router.get('/goals/graph', auth.requireBuilder, goalGraph.goalGraphHandler({ log }));
217
217
 
218
- // GET /goals?version=&status= — list goals (read).
218
+ // GET /goals?version=&status= — list goals (read); each row carries `viewer_is_member` for the reader.
219
219
  // rank: any-builder — viewing the goal board.
220
220
  router.get('/goals', auth.requireBuilder, async (req, res) => {
221
221
  const { version, status } = req.query;
@@ -223,7 +223,7 @@ module.exports = function buildGoalsRouter() {
223
223
  return res.fail('bad_status', 400, { valid: VALID_GOAL_STATUSES });
224
224
  }
225
225
  try {
226
- const goals = await db.listGoals({ versionId: version || null, status: status || null });
226
+ const goals = await db.listGoals({ versionId: version || null, status: status || null, viewerId: req.builder.id });
227
227
  // task 1003275: ONE category query decorates the whole board (never a join
228
228
  // per goal). Purely a label for the reader — nothing downstream branches on it.
229
229
  const byId = await goalCategory.categoryIndex();
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.970",
3
+ "version": "1.19.972",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.970",
9
+ "version": "1.19.972",
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.970",
3
+ "version": "1.19.972",
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",
@@ -310,7 +310,7 @@ const MAX_LISTED_TASKS = 20;
310
310
  function clip(s, max = 160) {
311
311
  if (s == null) return '';
312
312
  // eslint-disable-next-line no-control-regex
313
- const flat = String(s).replace(/[--Ÿ]+/g, ' ').trim();
313
+ const flat = String(s).replace(/[\u0000--Ÿ]+/g, ' ').trim();
314
314
  return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
315
315
  }
316
316
 
@@ -1204,8 +1204,94 @@ function checkSkillFrontmatter() {
1204
1204
  };
1205
1205
  }
1206
1206
 
1207
+ // ============================================================================
1208
+ // Check 35 — no raw NUL byte in tracked publishable text (task 1002854)
1209
+ // ============================================================================
1210
+ //
1211
+ // scripts/public-mirror-export.js decides binary-vs-text by sniffing for a NUL byte in
1212
+ // the first 8 KiB (`isBinary`, ~line 86), and `redactBlob` skips `redactText` entirely
1213
+ // for anything it classifies binary. So a TEXT source containing a raw NUL is published
1214
+ // to the public AGPL mirror WITHOUT REDACTION.
1215
+ //
1216
+ // WHAT THIS IS AND IS NOT. The binary branch is not unguarded: it still leak-scans the
1217
+ // blob's latin1 view, so an embedded instance literal trips the gate. But the gate uses
1218
+ // `sensitiveLiterals` — raw substrings — while `redactText` applies the full `rules` set.
1219
+ // The exposure is therefore the DELTA between them: anything redaction would have
1220
+ // rewritten but the literal gate does not recognise ships verbatim. That is a latent
1221
+ // unredacted-publish, not a live one, which is why this is a lint rather than a fix.
1222
+ //
1223
+ // THE CLASS RECURS, WHICH IS THE ARGUMENT FOR A LINT RATHER THAN ANOTHER FIX.
1224
+ // Task 1703 found and fixed three instances, all the same mistake: a raw NUL used as a
1225
+ // join delimiter instead of the `\u0000` escape. Task 1002507 fixed a fourth in session
1226
+ // digests. Open idea 1000523 covers a fifth (search-ingest rollback). This check found
1227
+ // four more — modules/copy-desk/proposals.js, scripts/gds/claim.js,
1228
+ // scripts/gds/probe-cache.js and tests/copy_inventory.mjs — fixed in the same commit so
1229
+ // the check lands green. Nothing prevented any of them; nothing would prevent the next.
1230
+ //
1231
+ // The fix is always the same and never changes behaviour: `\u0000` compiles to exactly
1232
+ // the same string, so hashes, joins and regex ranges are byte-identical at runtime. Only
1233
+ // the SOURCE stops carrying the byte.
1234
+ //
1235
+ // SCOPE. Tracked files only (git ls-files — an untracked scratch file is not published),
1236
+ // with publishable text extensions. Binary formats are out of scope by definition: a PNG
1237
+ // is *supposed* to contain NULs and the mirror is right to pass it through.
1238
+ const NUL_TEXT_EXTS = ['.js', '.mjs', '.cjs', '.mts', '.md', '.sql', '.json'];
1239
+
1240
+ // Exemptions must be justified, not just listed. There are none today, and that is the
1241
+ // preferred state: the fix is a one-character escape with no behavioural cost, so
1242
+ // "exempt it" should always lose to "escape it". Kept as a named, empty constant so a
1243
+ // future addition is a visible, reviewable edit rather than a quiet change to a regex.
1244
+ const NUL_EXEMPT_PATHS = [];
1245
+
1246
+ function scanForRawNul(root, { listFiles, readFile } = {}) {
1247
+ const list = listFiles || (() => execFileSync('git', ['ls-files', '-z'], {
1248
+ cwd: root, encoding: 'latin1', maxBuffer: 1 << 26,
1249
+ }).split('\u0000').filter(Boolean));
1250
+ const read = readFile || ((f) => fs.readFileSync(path.join(root, f)));
1251
+
1252
+ const hits = [];
1253
+ for (const f of list()) {
1254
+ if (!NUL_TEXT_EXTS.includes(path.extname(f))) continue;
1255
+ if (NUL_EXEMPT_PATHS.some((e) => f === e || f.startsWith(e))) continue;
1256
+ let buf;
1257
+ try { buf = read(f); } catch (_) { continue; } // deleted-but-tracked, or unreadable
1258
+ const at = buf.indexOf(0);
1259
+ if (at === -1) continue;
1260
+ // Report the LINE, not the byte offset — a byte offset is not actionable.
1261
+ const line = buf.slice(0, at).toString('latin1').split('\n').length;
1262
+ hits.push(`${f}:${line} — raw NUL byte; use the \\u0000 escape (same string at runtime, no NUL in source)`);
1263
+ }
1264
+ return hits;
1265
+ }
1266
+
1267
+ function checkNoRawNulInText() {
1268
+ let violations = [];
1269
+ let note = '';
1270
+ try {
1271
+ violations = scanForRawNul(REPO_ROOT);
1272
+ note = `tracked ${NUL_TEXT_EXTS.join('/')} sources carry no raw NUL byte, so none can be misclassified `
1273
+ + 'as binary and skip public-mirror redaction (task 1002854).';
1274
+ } catch (e) {
1275
+ // Never let the scan itself fail the build — a git failure is not a boundary breach.
1276
+ return {
1277
+ name: 'no raw NUL byte in tracked publishable text (task 1002854)',
1278
+ ok: true, hardFail: false, violations: [], warnings: [`skipped: ${e.message}`],
1279
+ note: 'could not list tracked files — check skipped.',
1280
+ };
1281
+ }
1282
+ return {
1283
+ name: 'no raw NUL byte in tracked publishable text (task 1002854)',
1284
+ ok: violations.length === 0,
1285
+ hardFail: violations.length > 0,
1286
+ violations,
1287
+ warnings: [],
1288
+ note,
1289
+ };
1290
+ }
1291
+
1207
1292
  const CHECKS = [
1208
1293
  checkCoreHostBoundary,
1294
+ checkNoRawNulInText, // Check 35 — task 1002854: a raw NUL makes the mirror skip redaction
1209
1295
  checkClaudeMdBudget,
1210
1296
  // Check 32 — task 1002990 / criterion wa6-kernel-and-packs: the root CLAUDE.md is
1211
1297
  // now a role-neutral KERNEL and each craft's playbook lives in a pack under
@@ -1222,6 +1308,9 @@ const CHECKS = [
1222
1308
  checkCoreNeverImportsModule,
1223
1309
  checkModulesImportOnlyApi,
1224
1310
  checkScopeMapImportsProtected,
1311
+ // Check 36 — task 1002900: a carved module with no roster key walls nothing. Extracted
1312
+ // to its own file (the role-pack-guard.js precedent) to keep fitness.js under the size ratchet.
1313
+ () => require('./scope-roster-guard.js').checkScopeRosterCoversModules({ repoRoot: REPO_ROOT, discoveredModuleKeys }),
1225
1314
  checkCoreCarriesNoModuleCode,
1226
1315
  checkKernelImportsNoDomain,
1227
1316
  checkModuleMigrationsAdditive,
@@ -1307,6 +1396,7 @@ function main() {
1307
1396
  if (require.main === module) main();
1308
1397
 
1309
1398
  module.exports = {
1399
+ scanForRawNul,
1310
1400
  checkVocabularyWall, VOCAB_ALLOW, VOCAB_CITATION, VOCAB_EXTS, vocabFilesToScan,
1311
1401
  checkGoalMembershipVocabulary, GOAL_VOCAB_SURFACE, checkGovernmentRenameIdentity, RENAME_IDENTITY_RE, checkPrelaunchVocabulary, PRELAUNCH_SURFACE, // 19c task 1003120, 19d task 1003152
1312
1402
  runAll,
Binary file
@@ -0,0 +1,118 @@
1
+ // scripts/gds/scope-roster-guard.js
2
+ //
3
+ // WHAT. The fitness check behind the module scope roster (task 1002900): every module
4
+ // discovered at modules/<key>/module.json must have a covering key in MODULE_GLOBS
5
+ // (src/bongos/module-scope-map.js).
6
+ //
7
+ // WHY IT IS A HARD FAIL, when the runtime symptom is a denial rather than a crash.
8
+ // The roster is maintained by HAND. A module lands, nobody adds the entry, and any goal
9
+ // scoped to that work hits an authorize-NOTHING wall: goal-scope-check.check() denies
10
+ // with empty_scope / unknown_scope. Nothing announces it — it surfaces only when someone
11
+ // tries to set a goal's wall and cannot, which is long after the module landed and far
12
+ // from the edit that caused it. CI is the only place the gap is visible at the moment it
13
+ // is created.
14
+ //
15
+ // It has happened three times by hand: task 1002690 (provisioning + public-landing,
16
+ // goal 1000059), task 1002322 (platform-identity, goal 1000046) and `agents`, the
17
+ // instance this check was filed for. Each was fixed after someone tripped over it.
18
+ //
19
+ // WHY SUBSET AND NOT EQUALITY. The roster legitimately carries keys with no
20
+ // modules/<key>/ directory, for two different reasons, and equality would fail on all
21
+ // five of them in this tree:
22
+ // - NON-MODULE SCOPES — `kernel`, `methodology`, `devsecops` are surfaces, not modules.
23
+ // - ABSENT MODULES — `art-pipeline`, `game` are rostered but not present in this
24
+ // instance (ADR 0108: a host may carry a different module set).
25
+ // So the invariant is one-directional: every DISCOVERED module must be rostered; a
26
+ // rostered key with no module is not a fault. Getting that backwards would produce a
27
+ // check nobody can keep green, which is worse than no check.
28
+ //
29
+ // Extracted from fitness.js (which the size ratchet caps) following the
30
+ // role-pack-guard.js precedent. The pure scanner is exported for tests/fitness_scope_roster.mjs.
31
+
32
+ 'use strict';
33
+
34
+ const fs = require('node:fs');
35
+ const path = require('node:path');
36
+
37
+ // The machinery already existed — fitness.js's discoveredModuleKeys() feeds the
38
+ // kernel-machinery check — but nothing cross-checked it against moduleKeys().
39
+ // checkScopeMapImportsProtected guards HOW `protected` is derived, not WHETHER every
40
+ // module is listed.
41
+
42
+ // SCOPE: modules/ ONLY — the two named orphans, decided rather than left open.
43
+ // Task 1002900 asked for a ruling on two files said to be covered by no glob. Both
44
+ // answers changed when checked against origin/main on 2026-09-22:
45
+ // - scripts/gds/fitness.js — NOT uncovered. The `devsecops` key matches it. The row's
46
+ // claim was stale.
47
+ // - src/server.js — does not exist any more.
48
+ // - src/platform-server.js — genuinely uncovered, and DELIBERATELY still is.
49
+ // This check asserts what its name says: every carved MODULE has a roster key. Widening
50
+ // it to arbitrary src/ entrypoints is a different question about a different registry —
51
+ // `kernel` lists individual files by hand, so "should platform-server join it" is a call
52
+ // for whoever owns that scope, not a side effect of adding a module-coverage lint. Left
53
+ // as a recorded decision rather than an exemption entry, because there is nothing to
54
+ // exempt: modules/ is the stated scope and platform-server.js was never in it.
55
+
56
+ const SCOPE_ROSTER_REL = 'src/bongos/module-scope-map.js';
57
+
58
+ function scanScopeRosterCoverage({ discovered, rostered, globs, exists }) {
59
+ const missing = discovered.filter((k) => !rostered.includes(k)).sort();
60
+ const violations = missing.map((k) =>
61
+ `module '${k}' (modules/${k}/module.json) has no key in ${SCOPE_ROSTER_REL} MODULE_GLOBS — `
62
+ + 'any goal scoped to it will deny with empty_scope/unknown_scope');
63
+
64
+ // The mirror-image rot, reported as a WARNING rather than a failure: a roster glob that
65
+ // names an exact FILE which no longer exists. It cannot wall anything off, so it is a
66
+ // silent hole in a scope rather than a build breakage — and unlike a missing key it has
67
+ // no runtime symptom at all. Only exact file paths are checked; a directory glob may
68
+ // legitimately point at an absent optional module (see SUBSET above).
69
+ const warnings = [];
70
+ for (const [key, list] of Object.entries(globs || {})) {
71
+ for (const g of list || []) {
72
+ if (g.endsWith('/') || g.includes('*')) continue;
73
+ if (!exists(g)) warnings.push(`${SCOPE_ROSTER_REL}: scope '${key}' lists '${g}', which does not exist — dead entry, walls nothing`);
74
+ }
75
+ }
76
+ return { violations, warnings };
77
+ }
78
+
79
+ // REPO_ROOT and discoveredModuleKeys come from fitness.js, which owns module discovery
80
+ // (it unions across MODULE_ROOTS per ADR 0108). Passed in rather than re-derived so the
81
+ // two can never disagree about what a module is.
82
+ function checkScopeRosterCoversModules({ repoRoot, discoveredModuleKeys }) {
83
+ let res;
84
+ try {
85
+ const scopeMap = require(path.join(repoRoot, SCOPE_ROSTER_REL));
86
+ res = scanScopeRosterCoverage({
87
+ discovered: discoveredModuleKeys(),
88
+ rostered: scopeMap.moduleKeys(),
89
+ globs: scopeMap.MODULE_GLOBS,
90
+ exists: (g) => fs.existsSync(path.join(repoRoot, g)),
91
+ });
92
+ } catch (e) {
93
+ // FAILS OPEN, deliberately and narrowly. This catch fires only if requiring the scope
94
+ // map throws — i.e. the file is syntactically broken — and that is not this check's
95
+ // job to report: src/bongos/module-scope-map.js has its own unit coverage, and every
96
+ // sibling scope check (checkScopeMapImportsProtected, the ownership checks) would red
97
+ // on the same input. A coverage lint that hard-fails on someone else's syntax error
98
+ // adds a second confusing message to a build that is already failing for the real
99
+ // reason. The skip is surfaced as a warning rather than swallowed.
100
+ return {
101
+ name: 'every discovered module has a scope-roster key (task 1002900)',
102
+ ok: true, hardFail: false, violations: [], warnings: [`skipped: ${e.message}`],
103
+ note: `could not load ${SCOPE_ROSTER_REL} — check skipped.`,
104
+ };
105
+ }
106
+ return {
107
+ name: 'every discovered module has a scope-roster key (task 1002900)',
108
+ ok: res.violations.length === 0,
109
+ hardFail: res.violations.length > 0,
110
+ violations: res.violations,
111
+ warnings: res.warnings,
112
+ note: `every modules/<key>/ carrying module.json has a MODULE_GLOBS entry, so a goal can be `
113
+ + `scoped to it. Subset, not equality: a rostered key with no module is legitimate `
114
+ + `(non-module scopes like kernel/methodology/devsecops, and modules absent from this instance).`,
115
+ };
116
+ }
117
+
118
+ module.exports = { checkScopeRosterCoversModules, scanScopeRosterCoverage, SCOPE_ROSTER_REL };
@@ -451,6 +451,7 @@ const MODULE_GLOBS = {
451
451
  'scripts/gds/fitness-checks-packaging.js', 'scripts/gds/fitness-checks-route-pins.js',
452
452
  'scripts/gds/fitness-checks-vocabulary.js', 'scripts/gds/fitness-checks-write-validation.js',
453
453
  'scripts/gds/fitness-migration-contraction.js', 'scripts/gds/role-pack-guard.js',
454
+ 'scripts/gds/scope-roster-guard.js',
454
455
  'scripts/gds/route-shadow-guard.js', 'scripts/gds/routine-schedule.js',
455
456
  'scripts/gds/seam-wiring-guard.js', 'scripts/gds/skill-preflight.js',
456
457
  'scripts/gds/test-path-guard.js', 'scripts/gds/version-literal-guard.js',
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.970'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.972'; // 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');
Binary file
@@ -0,0 +1,113 @@
1
+ // tests/fitness_no_raw_nul.mjs
2
+ //
3
+ // Task 1002854 — scripts/public-mirror-export.js sniffs for a NUL byte in the first
4
+ // 8 KiB to decide binary-vs-text (`isBinary`), and `redactBlob` skips `redactText`
5
+ // entirely for anything it classifies binary. So a TEXT source containing a raw NUL is
6
+ // published to the public AGPL mirror WITHOUT redaction.
7
+ //
8
+ // The binary branch is not unguarded — it still leak-scans the latin1 view — but that
9
+ // gate uses `sensitiveLiterals` (raw substrings) while redaction applies the full
10
+ // `rules` set. The exposure is the DELTA: anything redaction would rewrite that the
11
+ // literal gate does not recognise ships verbatim. Latent, not live — hence a lint.
12
+ //
13
+ // THE CLASS RECURS, which is why this is a lint and not a fifth point fix. Task 1703
14
+ // fixed three instances, task 1002507 a fourth, idea 1000523 covers a fifth, and this
15
+ // task found four more. Every one was the same mistake: a raw NUL used as a join
16
+ // delimiter instead of the \u0000 escape.
17
+ //
18
+ // These tests drive the PURE scanner over INJECTED FIXTURES rather than over the real
19
+ // tree. A test that only asserts "the repo is clean today" passes vacuously the moment
20
+ // the scanner breaks — it would report success on a scanner that always returns [].
21
+ //
22
+ // Run: node --test tests/fitness_no_raw_nul.mjs
23
+
24
+ import { strict as assert } from 'node:assert';
25
+ import { test } from 'node:test';
26
+ import { createRequire } from 'node:module';
27
+ import path from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+ const { scanForRawNul } = require(path.join(REPO_ROOT, 'scripts/gds/fitness.js'));
33
+
34
+ // Built at runtime so THIS file never carries the byte it is about — otherwise the
35
+ // check's own test would be its first violation.
36
+ const NUL = String.fromCharCode(0);
37
+
38
+ // A fake tree: { 'path' => 'contents' }, injected through the scanner's seams so no
39
+ // temp files are needed and every fixture is visible in the test.
40
+ function fakeTree(files) {
41
+ return {
42
+ listFiles: () => Object.keys(files),
43
+ readFile: (f) => Buffer.from(files[f], 'utf8'),
44
+ };
45
+ }
46
+
47
+ test('THE BUG: a raw NUL in a .js source is caught', () => {
48
+ const hits = scanForRawNul('/fake', fakeTree({
49
+ 'src/join.js': `const k = a + '${NUL}' + b;\n`,
50
+ }));
51
+ assert.equal(hits.length, 1, 'a raw NUL in tracked JS must be a violation');
52
+ assert.match(hits[0], /src\/join\.js:1/, 'must name the file and LINE — a byte offset is not actionable');
53
+ assert.match(hits[0], /u0000/, 'and must name the fix');
54
+ });
55
+
56
+ test('THE FIX: the escape form is NOT caught — it leaves no NUL in the source', () => {
57
+ // The whole premise of the remedy: the escape compiles to the same string at runtime,
58
+ // and only the source stops carrying the byte. If the check flagged the escape too,
59
+ // the violation would be unfixable.
60
+ const hits = scanForRawNul('/fake', fakeTree({
61
+ 'src/join.js': "const k = a + '\\u0000' + b;",
62
+ }));
63
+ assert.deepEqual(hits, [], 'the escape is the prescribed fix and must pass');
64
+ });
65
+
66
+ test('the reported line is the line the NUL is on, not the first line', () => {
67
+ const hits = scanForRawNul('/fake', fakeTree({
68
+ 'a.js': `line one\nline two\nconst k = '${NUL}';\n`,
69
+ }));
70
+ assert.match(hits[0], /a\.js:3/);
71
+ });
72
+
73
+ test('binary extensions are out of scope — a PNG is SUPPOSED to contain NULs', () => {
74
+ const hits = scanForRawNul('/fake', fakeTree({
75
+ 'public/logo.png': `PNG${NUL}${NUL}`,
76
+ 'docs/diagram.svg': `<svg>${NUL}</svg>`,
77
+ }));
78
+ assert.deepEqual(hits, [],
79
+ 'the mirror is right to pass binaries through verbatim; only TEXT sources are the hole');
80
+ });
81
+
82
+ test('every publishable text extension is scanned', () => {
83
+ const files = {};
84
+ for (const ext of ['.js', '.mjs', '.cjs', '.mts', '.md', '.sql', '.json']) files[`f${ext}`] = `x${NUL}y`;
85
+ assert.equal(scanForRawNul('/fake', fakeTree(files)).length, 7,
86
+ 'a gap in the extension list is a gap in the guard');
87
+ });
88
+
89
+ test('an unreadable or deleted-but-tracked file is skipped, not a crash', () => {
90
+ const hits = scanForRawNul('/fake', {
91
+ listFiles: () => ['gone.js', 'here.js'],
92
+ readFile: (f) => {
93
+ if (f === 'gone.js') throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
94
+ return Buffer.from(`ok${NUL}`, 'utf8');
95
+ },
96
+ });
97
+ assert.equal(hits.length, 1, 'the readable file is still checked');
98
+ assert.match(hits[0], /here\.js/);
99
+ });
100
+
101
+ test('a clean tree reports nothing', () => {
102
+ assert.deepEqual(scanForRawNul('/fake', fakeTree({
103
+ 'a.js': 'const k = a + b;',
104
+ 'b.md': '# heading',
105
+ })), []);
106
+ });
107
+
108
+ test('the REAL tree is clean (the check must land green)', () => {
109
+ // Deliberately last, and deliberately paired with the fixtures above: on its own this
110
+ // assertion would pass against a scanner that always returns [].
111
+ assert.deepEqual(scanForRawNul(REPO_ROOT), [],
112
+ 'a raw NUL landed in a tracked text source — escape it (identical string at runtime)');
113
+ });
@@ -0,0 +1,117 @@
1
+ // tests/fitness_scope_roster.mjs
2
+ //
3
+ // Task 1002900 — the scope roster in src/bongos/module-scope-map.js (MODULE_GLOBS) is
4
+ // maintained by hand, and nothing asserted that every carved module has a covering key.
5
+ // A module lands in modules/<key>/, nobody adds the entry, and any goal scoped to that
6
+ // work hits an authorize-NOTHING wall: goal-scope-check.check() denies with
7
+ // empty_scope / unknown_scope. It surfaces only when someone tries to set a goal's wall.
8
+ //
9
+ // Three hand-fixed instances before this check existed: task 1002690 (provisioning +
10
+ // public-landing), task 1002322 (platform-identity), and `agents` — the one this row
11
+ // was filed for, since added at module-scope-map.js:322.
12
+ //
13
+ // SUBSET, NOT EQUALITY. The roster legitimately carries keys with no modules/<key>/ dir,
14
+ // for two distinct reasons, and equality would fail on all five in this tree:
15
+ // - non-module SCOPES: kernel, methodology, devsecops
16
+ // - modules ABSENT from this instance: art-pipeline, game (ADR 0108)
17
+ // So the invariant is one-directional. These tests pin that direction explicitly,
18
+ // because getting it wrong turns a useful check into one nobody can keep green.
19
+ //
20
+ // Run: node --test tests/fitness_scope_roster.mjs
21
+
22
+ import { strict as assert } from 'node:assert';
23
+ import { test } from 'node:test';
24
+ import { createRequire } from 'node:module';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
30
+ const { discoveredModuleKeys } = require(path.join(REPO_ROOT, 'scripts/gds/fitness.js'));
31
+ // The check lives in its own file (the role-pack-guard.js precedent) so fitness.js stays
32
+ // under the size ratchet; module discovery stays in fitness.js, which owns it.
33
+ const { scanScopeRosterCoverage } = require(path.join(REPO_ROOT, 'scripts/gds/scope-roster-guard.js'));
34
+ const scopeMap = require(path.join(REPO_ROOT, 'src/bongos/module-scope-map.js'));
35
+
36
+ const nothingExists = () => false;
37
+ const allExist = () => true;
38
+
39
+ test('THE BUG: a discovered module with no roster key is a violation', () => {
40
+ const { violations } = scanScopeRosterCoverage({
41
+ discovered: ['lifecycle', 'agents'],
42
+ rostered: ['lifecycle'],
43
+ globs: {},
44
+ exists: allExist,
45
+ });
46
+ assert.equal(violations.length, 1);
47
+ assert.match(violations[0], /'agents'/, 'must name the uncovered module');
48
+ assert.match(violations[0], /empty_scope|unknown_scope/,
49
+ 'and must name the symptom, since that is how this is actually discovered');
50
+ });
51
+
52
+ test('SUBSET NOT EQUALITY: a rostered key with no module is NOT a fault', () => {
53
+ // kernel/methodology/devsecops are surfaces, not modules; art-pipeline/game may be
54
+ // absent from a given instance. Failing on these would make the check unkeepable.
55
+ const { violations } = scanScopeRosterCoverage({
56
+ discovered: ['lifecycle'],
57
+ rostered: ['lifecycle', 'kernel', 'methodology', 'devsecops', 'art-pipeline', 'game'],
58
+ globs: {},
59
+ exists: allExist,
60
+ });
61
+ assert.deepEqual(violations, []);
62
+ });
63
+
64
+ test('several uncovered modules are all reported, sorted', () => {
65
+ const { violations } = scanScopeRosterCoverage({
66
+ discovered: ['zeta', 'alpha', 'lifecycle'],
67
+ rostered: ['lifecycle'],
68
+ globs: {},
69
+ exists: allExist,
70
+ });
71
+ assert.equal(violations.length, 2, 'one line per uncovered module — not just the first');
72
+ assert.match(violations[0], /'alpha'/);
73
+ assert.match(violations[1], /'zeta'/);
74
+ });
75
+
76
+ test('a dead exact-file glob is a WARNING, never a failure', () => {
77
+ // The mirror-image rot: a roster entry naming a file that no longer exists cannot wall
78
+ // anything off, but it breaks no build either — so it is reported, not enforced.
79
+ const { violations, warnings } = scanScopeRosterCoverage({
80
+ discovered: [], rostered: [],
81
+ globs: { kernel: ['server.js'] },
82
+ exists: nothingExists,
83
+ });
84
+ assert.deepEqual(violations, [], 'a dead glob must not fail the build');
85
+ assert.equal(warnings.length, 1);
86
+ assert.match(warnings[0], /server\.js/);
87
+ assert.match(warnings[0], /walls nothing/);
88
+ });
89
+
90
+ test('directory and wildcard globs are exempt from the existence warning', () => {
91
+ // A directory glob may legitimately point at an optional module absent from this
92
+ // instance — warning on those would bury the real signal in noise.
93
+ const { warnings } = scanScopeRosterCoverage({
94
+ discovered: [], rostered: [],
95
+ globs: { game: ['modules/game/', 'src/**/game-*.js'] },
96
+ exists: nothingExists,
97
+ });
98
+ assert.deepEqual(warnings, []);
99
+ });
100
+
101
+ test('THE REAL TREE: every discovered module is rostered (the check must land green)', () => {
102
+ // Last, and paired with the fixtures above: alone this would pass against a scanner
103
+ // that always returns [].
104
+ const { violations } = scanScopeRosterCoverage({
105
+ discovered: discoveredModuleKeys(),
106
+ rostered: scopeMap.moduleKeys(),
107
+ globs: {},
108
+ exists: allExist,
109
+ });
110
+ assert.deepEqual(violations, [],
111
+ 'a module was carved without a MODULE_GLOBS key — any goal scoped to it denies everything');
112
+ });
113
+
114
+ test('agents specifically is rostered (the instance this row was filed for)', () => {
115
+ assert.ok(scopeMap.moduleKeys().includes('agents'));
116
+ assert.ok(discoveredModuleKeys().includes('agents'), 'and it is a real discovered module, not just a roster entry');
117
+ });