@bongos/core 1.18.19 → 1.19.3

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.
Files changed (259) hide show
  1. package/.bongos-core.json +547 -197
  2. package/.claude/skills/builder-box/SKILL.md +1 -1
  3. package/.claude/skills/builder-claim/SKILL.md +1 -1
  4. package/.claude/skills/goal-create/SKILL.md +1 -1
  5. package/.claude/skills/idea-triage/SKILL.md +1 -1
  6. package/.claude/skills/planning-session/SKILL.md +5 -5
  7. package/.claude/skills/scan-before-install/SKILL.md +165 -0
  8. package/bin/bongos.js +33 -7
  9. package/clients/bongos-client/README.md +1 -1
  10. package/clients/bongos-client/bongos-client.global.js +63 -41
  11. package/clients/bongos-client/index.cjs +63 -41
  12. package/clients/bongos-client/index.d.ts +78 -44
  13. package/clients/bongos-client/index.mjs +63 -41
  14. package/docs/adr/0020-security-model-public-stub.md +1 -1
  15. package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -0
  16. package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +1 -1
  17. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
  18. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -1
  19. package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +51 -0
  20. package/docs/adr/0156-user-set-display-name-wins.md +62 -0
  21. package/docs/adr/0157-archon-is-rank-and-identity-only.md +93 -0
  22. package/docs/adr/0158-choke-point-verdict-taxonomy.md +174 -0
  23. package/docs/adr/0159-merge-gate-reconciliation.md +88 -0
  24. package/docs/adr/0160-main-audit-repo-verifiable-caller.md +77 -0
  25. package/docs/adr/0161-publish-on-merge.md +34 -0
  26. package/docs/adr/README.md +9 -0
  27. package/docs/api/openapi.json +1209 -233
  28. package/docs/api-reference.md +61 -46
  29. package/docs/architecture.md +1 -1
  30. package/docs/canonical-permissions.md +61 -16
  31. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  32. package/docs/file-map.md +4 -1
  33. package/docs/onboarding/diagrams/assertions.json +3 -3
  34. package/docs/recipes/core-release-pipeline.md +181 -0
  35. package/docs/recipes/packaging-the-core.md +2 -0
  36. package/docs/recipes/parallel-goal-waves.md +1 -3
  37. package/docs/recipes/private-npm-distribution.md +6 -2
  38. package/docs/recipes/upgrading-the-core.md +2 -0
  39. package/migrations/core_197_display_name_source.sql +56 -0
  40. package/migrations/core_204_rekey_active_claim_per_session_worktree.sql +77 -0
  41. package/migrations/core_205_versions_track_default.sql +28 -0
  42. package/modules/autonomy/routes/autonomy.js +1 -1
  43. package/modules/builder-settings/db.js +7 -1
  44. package/modules/dev-box/app/src/vendor/bongos-client.cjs +63 -41
  45. package/modules/dev-box/routes/box.js +11 -9
  46. package/modules/discord/auth.js +3 -1
  47. package/modules/discord/routes/discord.js +8 -2
  48. package/modules/discord/ship-broadcast.js +48 -1
  49. package/modules/governance/catalog.js +70 -20
  50. package/modules/governance/db.js +102 -0
  51. package/modules/governance/migrations/governance_006_version_create.sql +38 -0
  52. package/modules/governance/migrations/governance_007_delegate_archon_ops_to_metic.sql +72 -0
  53. package/modules/governance/routes/governance.js +235 -6
  54. package/modules/grading/grader-workers/index.js +18 -9
  55. package/modules/hall-ui/public/gate.js +6 -2
  56. package/modules/hall-ui/public/goals-page.js +6 -2
  57. package/modules/hall-ui/public/governance.css +100 -0
  58. package/modules/hall-ui/public/governance.html +77 -0
  59. package/modules/hall-ui/public/governance.js +353 -0
  60. package/modules/hall-ui/public/harbor.js +14 -8
  61. package/modules/hall-ui/public/instances-panel.js +3 -1
  62. package/modules/hall-ui/public/new-project-wizard.js +6 -0
  63. package/modules/hall-ui/public/people.html +51 -0
  64. package/modules/hall-ui/public/people.js +100 -0
  65. package/modules/hall-ui/public/profile.html +2 -1
  66. package/modules/hall-ui/public/profile.js +9 -3
  67. package/modules/hall-ui/public/sessions.js +4 -2
  68. package/modules/hall-ui/public/shell.js +28 -7
  69. package/modules/hall-ui/public/task-detail.js +6 -3
  70. package/modules/hall-ui/public/watch.js +17 -9
  71. package/modules/ideas/blockers.js +44 -7
  72. package/modules/ideas/routes/blockers.js +12 -7
  73. package/modules/ideas/routes/inbox.js +2 -2
  74. package/modules/lifecycle/db.js +84 -17
  75. package/modules/lifecycle/github-push.js +37 -10
  76. package/modules/lifecycle/publish-reconciler.js +128 -31
  77. package/modules/lifecycle/routes/claims.js +10 -4
  78. package/modules/lifecycle/routes/dependencies.js +2 -2
  79. package/modules/lifecycle/routes/done-when.js +4 -4
  80. package/modules/lifecycle/routes/gate-approvals.js +9 -3
  81. package/modules/lifecycle/routes/goals.js +5 -5
  82. package/modules/lifecycle/routes/tasks.js +99 -69
  83. package/modules/lifecycle/routes/versions.js +13 -2
  84. package/modules/lifecycle/ship-card.js +38 -7
  85. package/modules/memory/routes/memory.js +2 -2
  86. package/modules/onboarding/routes/access-requests.js +3 -3
  87. package/modules/platform-identity/routes/projects.js +10 -6
  88. package/modules/platform-identity/routes/scouting.js +2 -2
  89. package/modules/provisioning/migrations/provisioning_004_onboard_mode.sql +30 -0
  90. package/modules/provisioning/provisioning.js +24 -6
  91. package/modules/provisioning/routes/provisioning.js +24 -6
  92. package/modules/security/artifact-scan/aggregate.js +133 -0
  93. package/modules/security/artifact-scan/detectors/capabilities.js +53 -0
  94. package/modules/security/artifact-scan/detectors/config-obfuscation.js +41 -0
  95. package/modules/security/artifact-scan/detectors/credentials.js +112 -0
  96. package/modules/security/artifact-scan/detectors/dependencies.js +115 -0
  97. package/modules/security/artifact-scan/detectors/hooks.js +116 -0
  98. package/modules/security/artifact-scan/detectors/injection.js +118 -0
  99. package/modules/security/artifact-scan/detectors/mcp.js +102 -0
  100. package/modules/security/artifact-scan/detectors/network.js +67 -0
  101. package/modules/security/artifact-scan/detectors/npm-lifecycle.js +67 -0
  102. package/modules/security/artifact-scan/detectors/permissions.js +91 -0
  103. package/modules/security/artifact-scan/detectors/shared.js +219 -0
  104. package/modules/security/artifact-scan/fence-local.js +99 -0
  105. package/modules/security/artifact-scan/fetch.js +330 -0
  106. package/modules/security/artifact-scan/floor.js +319 -0
  107. package/modules/security/artifact-scan/limits.js +66 -0
  108. package/modules/security/artifact-scan/manifest.js +203 -0
  109. package/modules/security/artifact-scan/mirror.js +158 -0
  110. package/modules/security/artifact-scan/quarantine.js +149 -0
  111. package/modules/security/artifact-scan/rebuild-spec.js +120 -0
  112. package/modules/security/artifact-scan/report.js +155 -0
  113. package/modules/security/artifact-scan/sanitize.js +317 -0
  114. package/modules/security/migrations/security_001_tool_scans.sql +145 -0
  115. package/modules/security/module.json +2 -2
  116. package/modules/security/routes/reports.js +11 -7
  117. package/modules/security/routes/scans.js +115 -0
  118. package/modules/security/scan-store.js +214 -0
  119. package/modules/security/tests/aggregate.mjs +283 -0
  120. package/modules/security/tests/artifact_scan.mjs +591 -0
  121. package/modules/security/tests/floor.mjs +549 -0
  122. package/modules/security/tests/mirror.mjs +350 -0
  123. package/modules/security/tests/sanitize_rebuild.mjs +499 -0
  124. package/modules/security/tests/scan_store.mjs +250 -0
  125. package/modules/security/tests/tool_scans_migration.mjs +116 -0
  126. package/modules/sessions/routes/sessions.js +6 -5
  127. package/package-lock.json +2 -2
  128. package/package.json +1 -1
  129. package/public-landing/create.html +13 -3
  130. package/scripts/gds/abandon.js +1 -1
  131. package/scripts/gds/audit-authorship.sh +6 -0
  132. package/scripts/gds/box.js +1 -1
  133. package/scripts/gds/bump-version.js +76 -0
  134. package/scripts/gds/claim.js +15 -0
  135. package/scripts/gds/cli-lib.js +16 -4
  136. package/scripts/gds/gating.js +28 -13
  137. package/scripts/gds/gen-api-docs.js +1 -1
  138. package/scripts/gds/goal.js +234 -0
  139. package/scripts/gds/init.js +77 -15
  140. package/scripts/gds/main-audit.js +80 -11
  141. package/scripts/gds/mirror-redact.js +6 -0
  142. package/scripts/gds/newcomer-floor.js +2 -2
  143. package/scripts/gds/provision.js +230 -94
  144. package/scripts/gds/run-unit-tests.js +9 -1
  145. package/scripts/gds/sandbox-stage.js +66 -9
  146. package/scripts/gds/search-ingest-md.js +1 -0
  147. package/scripts/gds/sequence.js +67 -8
  148. package/scripts/gds/ship.js +183 -50
  149. package/scripts/hall-preview/server.js +1 -1
  150. package/scripts/migrate.sh +5 -5
  151. package/src/bongos/auth.js +129 -27
  152. package/src/bongos/db-kernel.js +66 -5
  153. package/src/bongos/db.js +6 -7
  154. package/src/bongos/github-repos.js +19 -7
  155. package/src/bongos/goal-scope-check.js +1 -1
  156. package/src/bongos/module-scope-map.js +3 -1
  157. package/src/bongos/permission-path-check.js +1 -1
  158. package/src/bongos/route-rank-check.js +73 -30
  159. package/src/bongos/routes/audit-log.js +4 -2
  160. package/src/bongos/routes/auth.js +8 -4
  161. package/src/bongos/routes/backup.js +7 -4
  162. package/src/bongos/routes/builders.js +23 -11
  163. package/src/bongos/routes/github.js +15 -4
  164. package/src/bongos/routes/llm-cache.js +5 -2
  165. package/src/bongos/routes/modules.js +9 -5
  166. package/src/bongos/routes/security.js +21 -12
  167. package/src/bongos/serve-internal.js +22 -7
  168. package/src/module-api.js +96 -13
  169. package/src/platform-server.js +14 -0
  170. package/tests/api_client.mjs +18 -2
  171. package/tests/api_path_404.mjs +3 -0
  172. package/tests/art_stage.mjs +2 -1
  173. package/tests/atlas.mjs +32 -4
  174. package/tests/auth_admission_gate.mjs +17 -5
  175. package/tests/auth_error_brand.mjs +41 -21
  176. package/tests/auth_resolve_failure.mjs +155 -0
  177. package/tests/blocker_link.mjs +88 -0
  178. package/tests/bongos_cli.mjs +91 -0
  179. package/tests/bongos_downloads.mjs +2 -1
  180. package/tests/box_dns_repoint.mjs +2 -0
  181. package/tests/box_ship_permissions.mjs +2 -0
  182. package/tests/box_terminal_ssrf.mjs +23 -17
  183. package/tests/bump_version.mjs +71 -0
  184. package/tests/claim_error_surface.mjs +23 -0
  185. package/tests/claude_materialize.mjs +4 -0
  186. package/tests/cli_box_scope.mjs +40 -0
  187. package/tests/cloud_bongos_pack.mjs +34 -4
  188. package/tests/conductor.mjs +2 -1
  189. package/tests/criterion_authoring_gate.mjs +28 -5
  190. package/tests/currency_label.mjs +10 -1
  191. package/tests/deploy_instance.mjs +2 -0
  192. package/tests/devbox_downloads.mjs +2 -1
  193. package/tests/display_name.mjs +95 -6
  194. package/tests/eviction_policy.mjs +2 -1
  195. package/tests/file_map.mjs +10 -0
  196. package/tests/first_admin_bootstrap.mjs +10 -2
  197. package/tests/first_run_engine_check.mjs +2 -1
  198. package/tests/fitness.mjs +26 -6
  199. package/tests/gate_approvals.mjs +15 -2
  200. package/tests/github_push_land_proof.mjs +50 -4
  201. package/tests/github_repos_helpers.mjs +30 -5
  202. package/tests/github_token_store.mjs +23 -0
  203. package/tests/goal_abuse_matrix.mjs +13 -3
  204. package/tests/goal_cli.mjs +89 -0
  205. package/tests/governance_abuse_matrix.mjs +386 -0
  206. package/tests/governance_fold.mjs +156 -0
  207. package/tests/governance_fold_bench.mjs +50 -0
  208. package/tests/governance_live_proof.mjs +201 -0
  209. package/tests/governance_page_gates.mjs +21 -9
  210. package/tests/governance_parity.mjs +11 -2
  211. package/tests/governance_require_permission.mjs +125 -1
  212. package/tests/governance_routes.mjs +334 -0
  213. package/tests/grader.mjs +7 -3
  214. package/tests/hall_nav.mjs +14 -4
  215. package/tests/helpers.mjs +73 -0
  216. package/tests/hook_core_resolve.mjs +2 -1
  217. package/tests/host_topology_skips.mjs +97 -0
  218. package/tests/infra_exec_paths.mjs +14 -4
  219. package/tests/init.mjs +147 -0
  220. package/tests/known_failing_ratchet.mjs +89 -0
  221. package/tests/lifecycle-card.mjs +2 -1
  222. package/tests/main_audit.mjs +72 -1
  223. package/tests/memory_isolation.mjs +56 -9
  224. package/tests/migrate_root_resolution.mjs +1 -1
  225. package/tests/mirror_redact.mjs +19 -0
  226. package/tests/module_api.mjs +3 -1
  227. package/tests/module_loader.mjs +4 -0
  228. package/tests/modules.mjs +35 -7
  229. package/tests/modules_route.mjs +29 -5
  230. package/tests/onboarding_observability.mjs +49 -11
  231. package/tests/onboarding_route_signals.mjs +1 -0
  232. package/tests/one_active_claim_per_session.mjs +80 -0
  233. package/tests/patch_gate_ordering.mjs +102 -0
  234. package/tests/pre_compact.mjs +2 -1
  235. package/tests/preview_server.mjs +2 -1
  236. package/tests/profile_route.mjs +1 -0
  237. package/tests/provision.mjs +170 -0
  238. package/tests/provisioning_onboard_mode.mjs +180 -0
  239. package/tests/publish_manifest.mjs +16 -3
  240. package/tests/publish_reconciler.mjs +181 -10
  241. package/tests/rank_tier_single_source.mjs +205 -0
  242. package/tests/recall_hints.mjs +2 -1
  243. package/tests/repo_map.mjs +4 -0
  244. package/tests/route_permission_check.mjs +14 -5
  245. package/tests/rubric_fixtures.mjs +3 -0
  246. package/tests/run_routine_two_root.mjs +2 -0
  247. package/tests/sandbox_stage.mjs +69 -7
  248. package/tests/search_ingest.mjs +7 -0
  249. package/tests/security_docs.mjs +28 -6
  250. package/tests/sequence.mjs +64 -0
  251. package/tests/session-cards.mjs +2 -1
  252. package/tests/session_start_freshness.mjs +2 -1
  253. package/tests/ship_broadcast_server.mjs +63 -1
  254. package/tests/ship_card.mjs +66 -0
  255. package/tests/ship_ci_deploy.mjs +66 -1
  256. package/tests/ship_smoke_gate.mjs +76 -0
  257. package/tests/versions_route.mjs +15 -0
  258. package/tests/workflow_exec_paths.mjs +16 -4
  259. package/tests/worktree_guard.mjs +2 -1
@@ -0,0 +1,334 @@
1
+ // tests/governance_routes.mjs — the governance CRUD API (BV1.R110, ADR 0151).
2
+ //
3
+ // Two layers, both DB-free (the memory_isolation.mjs pattern):
4
+ //
5
+ // 1. Gate classification + pins: route-rank-check's own classifier is run over
6
+ // the real routes source — every /governance route must gate on the
7
+ // `governance.manage` atom through real middleware, and every declared
8
+ // route must be pinned in MODULE_EXPECTED_RANKS. The pin set and the
9
+ // declared set are compared BOTH ways, so adding an unpinned governance
10
+ // route (or orphaning a pin) fails here before the grader sees it.
11
+ //
12
+ // 2. Real in-process HTTP through the actual router: express app + the real
13
+ // requirePermission gate resolving a STUB `governance` port on the seam,
14
+ // db sibling monkeypatched by property. What matters (ADR 0151 §2):
15
+ // system permissions are rejected as NON-GRANTABLE server-side, seeded
16
+ // rank-roles are immutable through this API (their grants derive from
17
+ // floors; their assignment derives from rank — R95b), and every route
18
+ // denies without the `governance.manage` grant.
19
+ //
20
+ // Run: node tests/governance_routes.mjs
21
+
22
+ import { strict as assert } from 'node:assert';
23
+ import { test, after } from 'node:test';
24
+ import http from 'node:http';
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import { createRequire } from 'node:module';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+ const api = require(path.join(ROOT, 'src', 'module-api.js'));
33
+ const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
34
+ const catalog = require(path.join(ROOT, 'modules', 'governance', 'catalog.js'));
35
+ const govDb = require(path.join(ROOT, 'modules', 'governance', 'db.js'));
36
+ const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
37
+ const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
38
+ const express = require('express');
39
+
40
+ const ROUTES_SRC = path.join(ROOT, 'modules', 'governance', 'routes', 'governance.js');
41
+
42
+ // ===========================================================================
43
+ // Layer 1 — gate classification + route-rank-check pins
44
+ // ===========================================================================
45
+
46
+ const classified = rrc.classifyText(fs.readFileSync(ROUTES_SRC, 'utf8'))
47
+ .filter((r) => r.route.startsWith('/governance'));
48
+
49
+ test('the routes file declares the full R110 surface', () => {
50
+ const declared = new Set(classified.map((r) => r.key));
51
+ const wanted = [
52
+ 'GET /governance/roles',
53
+ 'GET /governance/permissions',
54
+ 'GET /governance/assignments',
55
+ 'POST /governance/roles',
56
+ 'PUT /governance/roles/:roleKey/permissions/:permissionKey',
57
+ 'DELETE /governance/roles/:roleKey/permissions/:permissionKey',
58
+ 'POST /governance/assignments',
59
+ 'DELETE /governance/assignments/:builderId/:roleKey',
60
+ ];
61
+ for (const key of wanted) assert.ok(declared.has(key), `missing route ${key}`);
62
+ });
63
+
64
+ test('every /governance route gates on governance.manage via REAL middleware', () => {
65
+ assert.ok(classified.length > 0, 'no /governance routes found to classify');
66
+ for (const r of classified) {
67
+ assert.equal(r.via, 'middleware', `${r.key}: gate must be middleware, not an annotation`);
68
+ assert.deepEqual(r.perms, ['governance.manage'], `${r.key}: must gate on exactly governance.manage`);
69
+ assert.equal(r.rank, 'archon', `${r.key}: governance.manage floors at archon`);
70
+ }
71
+ });
72
+
73
+ test('every declared /governance route is pinned, and every pin exists (both directions)', () => {
74
+ const pins = rrc.MODULE_EXPECTED_RANKS.governance;
75
+ assert.ok(pins, 'MODULE_EXPECTED_RANKS has no governance block');
76
+ const declared = new Set(classified.map((r) => r.key));
77
+ for (const key of declared) {
78
+ assert.equal(pins[key], `${rrc.PERM_PIN_PREFIX}governance.manage`,
79
+ `${key} must be pinned to perm:governance.manage`);
80
+ }
81
+ for (const key of Object.keys(pins)) {
82
+ assert.ok(declared.has(key), `pin ${key} names a route the file no longer declares`);
83
+ }
84
+ });
85
+
86
+ test('checkRouteRanks passes over the real tree (no governance findings)', () => {
87
+ const result = rrc.checkRouteRanks();
88
+ const ours = result.findings.filter((f) => String(f.file).includes('modules/governance/'));
89
+ assert.deepEqual(ours, [], `governance findings: ${JSON.stringify(ours, null, 2)}`);
90
+ });
91
+
92
+ // ===========================================================================
93
+ // Layer 2 — real HTTP through the actual router (stub port + stubbed db)
94
+ // ===========================================================================
95
+
96
+ // The db stub: every function the routes call, recording args. Reset per test.
97
+ const calls = [];
98
+ const stub = {
99
+ listRolesWithGrants: async () => [{ role_key: 'metic', label: 'Metic', description: null, is_system: true, seeded_rank: 'metic', created_by: null, created_at: 't', permissions: ['task.create'] }],
100
+ listAssignments: async () => [{ builder_id: 7, github_login: 'x', display_name: 'X', rank: 'metic', role_key: 'metic', role_label: 'Metic', is_system: true, seeded_rank: 'metic', assigned_by: null, assigned_at: 't' }],
101
+ getRoleByKey: async (k) => (k === 'missing' ? null : { role_key: k, label: k, is_system: k === 'metic' || k === 'archon', seeded_rank: (k === 'metic' || k === 'archon') ? k : null }),
102
+ createRole: async (a) => ({ role_key: a.roleKey, label: a.label, description: a.description ?? null, is_system: false, seeded_rank: null, created_by: a.createdBy, created_at: 't' }),
103
+ grantRolePermission: async () => true,
104
+ revokeRolePermission: async () => true,
105
+ assignRole: async () => true,
106
+ unassignRole: async () => true,
107
+ };
108
+ const REAL = {};
109
+ for (const k of Object.keys(stub)) {
110
+ REAL[k] = govDb[k];
111
+ govDb[k] = (...args) => { calls.push([k, ...args]); return stub[k](...args); };
112
+ }
113
+
114
+ // Fake the session (requireBuilder is captured at router build time), keep the
115
+ // REAL requirePermission gate: it resolves the `governance` port per request, so
116
+ // swapping the seam provider flips admit/deny without rebuilding the router.
117
+ const realRequireBuilder = api.requireBuilder;
118
+ api.requireBuilder = (req, res, next) => { req.builder = { id: 42, rank: 'archon' }; next(); };
119
+
120
+ function grant(keys) {
121
+ seams._reset();
122
+ seams.registerProvider('governance', { resolveBuilderPermissions: async () => keys });
123
+ }
124
+ grant(['governance.manage']);
125
+
126
+ const buildGovernanceRouter = require(path.join(ROOT, 'modules', 'governance', 'routes', 'governance.js'));
127
+ const app = express();
128
+ app.use(express.json());
129
+ app.use(attachFail);
130
+ app.use(buildGovernanceRouter());
131
+ const server = http.createServer(app);
132
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
133
+ const BASE = `http://127.0.0.1:${server.address().port}`;
134
+
135
+ after(() => {
136
+ server.close();
137
+ api.requireBuilder = realRequireBuilder;
138
+ for (const k of Object.keys(REAL)) govDb[k] = REAL[k];
139
+ seams._reset();
140
+ });
141
+
142
+ async function call(method, p, body) {
143
+ calls.length = 0;
144
+ const res = await fetch(`${BASE}${p}`, {
145
+ method,
146
+ headers: body === undefined ? {} : { 'content-type': 'application/json' },
147
+ body: body === undefined ? undefined : JSON.stringify(body),
148
+ });
149
+ let json = null;
150
+ try { json = await res.json(); } catch { /* no body */ }
151
+ return { status: res.status, json };
152
+ }
153
+
154
+ // --- the deny wall -----------------------------------------------------------
155
+
156
+ test('without governance.manage EVERY route 403s (reads included)', async () => {
157
+ grant(['idea.triage', 'task.create']);
158
+ for (const [method, p] of [
159
+ ['GET', '/governance/roles'],
160
+ ['GET', '/governance/permissions'],
161
+ ['GET', '/governance/assignments'],
162
+ ['POST', '/governance/roles'],
163
+ ['PUT', '/governance/roles/r/permissions/task.create'],
164
+ ['DELETE', '/governance/roles/r/permissions/task.create'],
165
+ ['POST', '/governance/assignments'],
166
+ ['DELETE', '/governance/assignments/7/r'],
167
+ ]) {
168
+ const r = await call(method, p, method === 'GET' ? undefined : {});
169
+ assert.equal(r.status, 403, `${method} ${p} must deny`);
170
+ assert.equal(r.json.error.code, 'permission_forbidden', `${method} ${p}`);
171
+ }
172
+ grant(['governance.manage']);
173
+ });
174
+
175
+ // --- reads -------------------------------------------------------------------
176
+
177
+ test('GET /governance/roles returns the role list with grants', async () => {
178
+ const r = await call('GET', '/governance/roles');
179
+ assert.equal(r.status, 200);
180
+ assert.equal(r.json.roles[0].role_key, 'metic');
181
+ assert.deepEqual(r.json.roles[0].permissions, ['task.create']);
182
+ });
183
+
184
+ test('GET /governance/permissions renders the catalog: grantable flags + principals, no DB call', async () => {
185
+ const r = await call('GET', '/governance/permissions');
186
+ assert.equal(r.status, 200);
187
+ assert.equal(calls.length, 0, 'the permission catalog is code, not a table');
188
+ const byKey = new Map(r.json.permissions.map((p) => [p.key, p]));
189
+ assert.equal(byKey.size, catalog.permissionKeys().length, 'every catalog key is listed');
190
+ const sys = byKey.get('governance.manage');
191
+ assert.equal(sys.system, true);
192
+ assert.equal(sys.grantable, false, 'a system permission renders non-grantable');
193
+ const op = byKey.get('idea.triage');
194
+ assert.equal(op.grantable, true);
195
+ assert.equal(op.floor, 'metic');
196
+ assert.ok(Array.isArray(r.json.principals), 'principals are listed read-only');
197
+ assert.ok(r.json.principals.every((p) => p.grantable === false));
198
+ });
199
+
200
+ test('GET /governance/assignments returns builder↔role rows', async () => {
201
+ const r = await call('GET', '/governance/assignments');
202
+ assert.equal(r.status, 200);
203
+ assert.equal(r.json.assignments[0].builder_id, 7);
204
+ assert.equal(r.json.assignments[0].role_key, 'metic');
205
+ });
206
+
207
+ // --- create role -------------------------------------------------------------
208
+
209
+ test('POST /governance/roles creates a custom role, created_by = the session builder', async () => {
210
+ const r = await call('POST', '/governance/roles', { role_key: 'triage-helper', label: 'Triage helper' });
211
+ assert.equal(r.status, 201);
212
+ assert.equal(r.json.role.role_key, 'triage-helper');
213
+ assert.equal(r.json.role.is_system, false);
214
+ const c = calls.find(([k]) => k === 'createRole');
215
+ assert.equal(c[1].createdBy, 42, 'created_by must be the session builder, never input');
216
+ });
217
+
218
+ test('POST /governance/roles validates: label required, role_key must be a slug', async () => {
219
+ const missing = await call('POST', '/governance/roles', { role_key: 'x-role' });
220
+ assert.equal(missing.status, 400);
221
+ assert.equal(missing.json.error.code, 'validation_failed');
222
+ for (const bad of ['Has Space', 'UPPER', '-lead', 'dots.bad', '']) {
223
+ const r = await call('POST', '/governance/roles', { role_key: bad, label: 'L' });
224
+ assert.equal(r.status, 400, `role_key "${bad}" must be rejected`);
225
+ }
226
+ assert.equal(calls.filter(([k]) => k === 'createRole').length, 0, 'nothing reached the db');
227
+ });
228
+
229
+ test('POST /governance/roles maps a unique-violation to role_exists 409', async () => {
230
+ const prev = stub.createRole;
231
+ stub.createRole = async () => { const e = new Error('dup'); e.code = '23505'; throw e; };
232
+ const r = await call('POST', '/governance/roles', { role_key: 'metic-two', label: 'L' });
233
+ stub.createRole = prev;
234
+ assert.equal(r.status, 409);
235
+ assert.equal(r.json.error.code, 'role_exists');
236
+ });
237
+
238
+ // --- toggle a grant ----------------------------------------------------------
239
+
240
+ test('PUT grant: an operational permission lands on a custom role', async () => {
241
+ const r = await call('PUT', '/governance/roles/triage-helper/permissions/idea.triage');
242
+ assert.equal(r.status, 200);
243
+ const c = calls.find(([k]) => k === 'grantRolePermission');
244
+ assert.deepEqual(c[1], { roleKey: 'triage-helper', permissionKey: 'idea.triage' });
245
+ });
246
+
247
+ test('PUT grant: a SYSTEM permission is rejected as non-grantable — the R110 wall', async () => {
248
+ for (const sysKey of ['governance.manage', 'deploy.push_main', 'builder.rank.set']) {
249
+ const r = await call('PUT', `/governance/roles/triage-helper/permissions/${sysKey}`);
250
+ assert.equal(r.status, 400, `${sysKey} must be refused`);
251
+ assert.equal(r.json.error.code, 'permission_not_grantable');
252
+ assert.equal(calls.filter(([k]) => k === 'grantRolePermission').length, 0, 'the refusal is BEFORE the db');
253
+ }
254
+ });
255
+
256
+ test('PUT grant: an unknown permission fails closed', async () => {
257
+ const r = await call('PUT', '/governance/roles/triage-helper/permissions/no.such.key');
258
+ assert.equal(r.status, 400);
259
+ assert.equal(r.json.error.code, 'unknown_permission');
260
+ });
261
+
262
+ test('PUT grant: a seeded rank-role is immutable (grants derive from floors)', async () => {
263
+ const r = await call('PUT', '/governance/roles/metic/permissions/idea.triage');
264
+ assert.equal(r.status, 403);
265
+ assert.equal(r.json.error.code, 'seeded_role_immutable');
266
+ });
267
+
268
+ test('PUT grant: an unknown role 404s', async () => {
269
+ const r = await call('PUT', '/governance/roles/missing/permissions/idea.triage');
270
+ assert.equal(r.status, 404);
271
+ assert.equal(r.json.error.code, 'role_not_found');
272
+ });
273
+
274
+ test('DELETE grant revokes without consulting the catalog (stale keys stay removable)', async () => {
275
+ const r = await call('DELETE', '/governance/roles/triage-helper/permissions/some.retired.key');
276
+ assert.equal(r.status, 200);
277
+ const c = calls.find(([k]) => k === 'revokeRolePermission');
278
+ assert.deepEqual(c[1], { roleKey: 'triage-helper', permissionKey: 'some.retired.key' });
279
+ });
280
+
281
+ test('DELETE grant on a seeded rank-role is refused like the PUT', async () => {
282
+ const r = await call('DELETE', '/governance/roles/archon/permissions/idea.triage');
283
+ assert.equal(r.status, 403);
284
+ assert.equal(r.json.error.code, 'seeded_role_immutable');
285
+ });
286
+
287
+ // --- assignments -------------------------------------------------------------
288
+
289
+ test('POST /governance/assignments assigns a custom role, assigned_by = the session builder', async () => {
290
+ const r = await call('POST', '/governance/assignments', { builder_id: 7, role_key: 'triage-helper' });
291
+ assert.equal(r.status, 201);
292
+ const c = calls.find(([k]) => k === 'assignRole');
293
+ assert.deepEqual(c[1], { builderId: 7, roleKey: 'triage-helper', assignedBy: 42 });
294
+ });
295
+
296
+ test('POST /governance/assignments refuses a seeded rank-role (rank is the writer — R95b)', async () => {
297
+ const r = await call('POST', '/governance/assignments', { builder_id: 7, role_key: 'archon' });
298
+ assert.equal(r.status, 403);
299
+ assert.equal(r.json.error.code, 'seeded_role_assignment_forbidden');
300
+ assert.equal(calls.filter(([k]) => k === 'assignRole').length, 0);
301
+ });
302
+
303
+ test('POST /governance/assignments maps a missing builder (FK) to 404', async () => {
304
+ const prev = stub.assignRole;
305
+ stub.assignRole = async () => { const e = new Error('fk'); e.code = '23503'; throw e; };
306
+ const r = await call('POST', '/governance/assignments', { builder_id: 999, role_key: 'triage-helper' });
307
+ stub.assignRole = prev;
308
+ assert.equal(r.status, 404);
309
+ assert.equal(r.json.error.code, 'builder_not_found');
310
+ });
311
+
312
+ test('POST /governance/assignments validates builder_id and role_key', async () => {
313
+ const r = await call('POST', '/governance/assignments', { builder_id: 'seven', role_key: 'triage-helper' });
314
+ assert.equal(r.status, 400);
315
+ assert.equal(r.json.error.code, 'validation_failed');
316
+ });
317
+
318
+ test('DELETE /governance/assignments/:builderId/:roleKey unassigns a custom role', async () => {
319
+ const r = await call('DELETE', '/governance/assignments/7/triage-helper');
320
+ assert.equal(r.status, 200);
321
+ const c = calls.find(([k]) => k === 'unassignRole');
322
+ assert.deepEqual(c[1], { builderId: 7, roleKey: 'triage-helper' });
323
+ });
324
+
325
+ test('DELETE /governance/assignments refuses a seeded rank-role', async () => {
326
+ const r = await call('DELETE', '/governance/assignments/7/metic');
327
+ assert.equal(r.status, 403);
328
+ assert.equal(r.json.error.code, 'seeded_role_assignment_forbidden');
329
+ });
330
+
331
+ test('DELETE /governance/assignments validates the builder id param', async () => {
332
+ const r = await call('DELETE', '/governance/assignments/not-a-number/triage-helper');
333
+ assert.equal(r.status, 400);
334
+ });
package/tests/grader.mjs CHANGED
@@ -1410,12 +1410,16 @@ await testAsync('v4 runWorker (#465): one retry only — if the retry also error
1410
1410
 
1411
1411
  // --- #467: per-worker timeouts + transient-error set + audit-exports core ---
1412
1412
 
1413
- test('v4 #467: timeoutForWorker gives Read-heavy workers a longer budget', () => {
1413
+ test('v4 #467 / task 1002580: every gating/advisory worker gets the 360s heavy-tier budget', () => {
1414
+ // task 1002580: Hacker/Efficiency were 240s on the (false) assumption they are
1415
+ // "light". On a security- or perf-heavy diff those lenses ARE the heavy ones,
1416
+ // and a gating-worker timeout hard-blocks — so all four now share 360s. Worst-
1417
+ // case wall time is unchanged (parallel workers; max was already 360s).
1414
1418
  const tfw = grader.WORKERS.timeoutForWorker;
1415
1419
  assert.equal(tfw({ KIND: 'narc' }, {}), 360000);
1416
1420
  assert.equal(tfw({ KIND: 'quality' }, {}), 360000);
1417
- assert.equal(tfw({ KIND: 'hacker' }, {}), 240000);
1418
- assert.equal(tfw({ KIND: 'efficiency' }, {}), 240000);
1421
+ assert.equal(tfw({ KIND: 'hacker' }, {}), 360000);
1422
+ assert.equal(tfw({ KIND: 'efficiency' }, {}), 360000);
1419
1423
  });
1420
1424
 
1421
1425
  test('v4 #467: an explicit module MAX_TIMEOUT_MS overrides the KIND map', () => {
@@ -127,14 +127,20 @@ function hrefResolves(item) {
127
127
 
128
128
  test('sidebar groups read by audience, in order', () => {
129
129
  const nav = parseNav(boot().sidebarHtml);
130
- assert.deepEqual(nav.map((g) => g.label), ['', 'You', 'Community', 'Docs', 'Archon']);
130
+ // 'Archon' → 'Governance' in ADR 0157: the group's pages opened to Metic, so a header
131
+ // reading "Archon" over items a Metic can open would misdescribe the ladder.
132
+ assert.deepEqual(nav.map((g) => g.label), ['', 'You', 'Community', 'Docs', 'Governance']);
131
133
  assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work'],
132
134
  'the unlabeled group is the project itself (task 1002425 order)');
133
135
  assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
134
- assert.deepEqual(nav[2].items.map((i) => i.id), ['leaderboard'], 'Community = everyone else');
136
+ // People (task 1002577): the /scouting directory graduated into the hall.
137
+ // Archon-gated cosmetically to match its data endpoint; requireProjectCuratePage
138
+ // is the wall server-side.
139
+ assert.deepEqual(nav[2].items.map((i) => i.id), ['leaderboard', 'people'], 'Community = everyone else');
135
140
  assert.deepEqual(nav[3].items.map((i) => i.id),
136
141
  ['primer', 'ranks', 'economy', 'diagrams', 'atlas', 'modules', 'apidocs'], 'Docs = reference material');
137
- assert.deepEqual(nav[4].items.map((i) => i.id), ['watch', 'harbor', 'gate', 'sessions']);
142
+ // 'governance' (the Roles tab, BV1.R113) is the group's one archon-gated item.
143
+ assert.deepEqual(nav[4].items.map((i) => i.id), ['watch', 'harbor', 'gate', 'sessions', 'governance']);
138
144
  });
139
145
 
140
146
  test('Economy lives in Docs and points at the reward-schedule page', () => {
@@ -183,7 +189,11 @@ test('Achievements has no nav entry, but #achievements still routes to Standing'
183
189
 
184
190
  test('collapsible groups carry a stable storage key (collapse persistence)', () => {
185
191
  const html = boot().sidebarHtml;
186
- for (const g of ['you', 'community', 'docs', 'archon']) {
192
+ // 'archon' → 'governance' follows the ADR 0157 relabel. The slug is the
193
+ // hall-nav-collapsed storage key, so any builder who had collapsed the old "archon"
194
+ // group gets the new one expanded once — cosmetic, and preferable to keeping a stale
195
+ // slug that no longer matches the visible label.
196
+ for (const g of ['you', 'community', 'docs', 'governance']) {
187
197
  assert.match(html, new RegExp(`data-group="${g}"`), `${g} is collapsible (hall-nav-collapsed key)`);
188
198
  }
189
199
  assert.ok(!/nav-group--collapsible[^>]*>(?!<button)/.test(html.split('<div class="nav-group')[1] || ''),
package/tests/helpers.mjs CHANGED
@@ -50,3 +50,76 @@ export function makeSqlAwareClient(handler) {
50
50
  };
51
51
  return { connect: async () => client, _client: client, queries };
52
52
  }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Host-topology guards (task 1002470). Pattern established by task 1002234 in
56
+ // tests/stop_refcheck.mjs, generalised here.
57
+ //
58
+ // tests/ is PUBLISHABLE, but the paths some tests assert are on
59
+ // publish-manifest.js's PROTECTED_TOPOLOGY_EXCLUDE list (`.claude/hooks/`,
60
+ // `.claude/settings.json`, `infra/`, `.github/`, `scripts/deploy/`) or are
61
+ // instance-only content (`devbox-app/`, `config/<instance>.json`). A neutral
62
+ // core checkout carries none of it, so an unconditional load CRASHED the file
63
+ // at import and red-X'd the unit gate for every consumer.
64
+ //
65
+ // These skip cleanly when the topology is absent and run in FULL when it is
66
+ // present — no assertion is weakened, only deferred to the layout that has the
67
+ // thing under test. tests/host_topology_skips.mjs ratchets the roster so a new
68
+ // skip cannot be added silently.
69
+ // ---------------------------------------------------------------------------
70
+ import fs from 'node:fs';
71
+ import path from 'node:path';
72
+ import { fileURLToPath } from 'node:url';
73
+
74
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
75
+
76
+ export const SKIP_MARKER = 'HOST-TOPOLOGY-SKIP';
77
+
78
+ function emitSkip(missing, note) {
79
+ console.log(`${SKIP_MARKER}: absent from this checkout — ${missing.join(', ')}`);
80
+ console.log(` ${note || 'Host/instance topology the neutral core does not carry (publish-manifest.js PROTECTED_TOPOLOGY_EXCLUDE).'}`);
81
+ console.log(' This file runs in full on an instance where the topology is materialized.');
82
+ process.exit(0);
83
+ }
84
+
85
+ // Exit 0 with a SKIP line unless every repo-relative path exists.
86
+ export function skipUnlessPresent(relPaths, note) {
87
+ const wanted = Array.isArray(relPaths) ? relPaths : [relPaths];
88
+ const missing = wanted.filter((p) => !fs.existsSync(path.join(ROOT, p)));
89
+ if (missing.length) emitSkip(missing, note);
90
+ }
91
+
92
+ // onDiskModuleKeys — the module roster read STRAIGHT FROM DISK (task 1002470).
93
+ //
94
+ // Several tests compare a component's idea of the installed modules against this.
95
+ // The independence is the whole point, and it is a real check rather than a
96
+ // tautology *because this reads disk directly* — not through fitness.js's
97
+ // discoveredModuleKeys(), gen-atlas's listDir(), or the module loader. If any of
98
+ // those drops, adds, or mis-kinds a module, its answer diverges from this one and
99
+ // the comparing test fails. (Task 1002449 flagged exactly this trap: a check that
100
+ // asks the component under test what it found can never catch the component being
101
+ // wrong.) Living here rather than being copy-pasted per test keeps that rationale
102
+ // stated once — the callers just compare.
103
+ //
104
+ // Deliberately NOT the same shape as the production discovery: a directory counts
105
+ // only if it carries a module.json, which is what makes a module a module.
106
+ export function onDiskModuleKeys(modulesDir = path.join(ROOT, 'modules')) {
107
+ return fs.readdirSync(modulesDir, { withFileTypes: true })
108
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(modulesDir, e.name, 'module.json')))
109
+ .map((e) => e.name)
110
+ .sort();
111
+ }
112
+
113
+ // require() a host-topology module, skipping the file if THAT module is absent.
114
+ // A MODULE_NOT_FOUND naming anything else is a genuine broken dependency and
115
+ // still throws — masking it would hide a real regression.
116
+ export function requireHostModule(require, spec, note) {
117
+ try {
118
+ return require(spec);
119
+ } catch (err) {
120
+ if (err && err.code === 'MODULE_NOT_FOUND' && String(err.message).includes(`'${spec}'`)) {
121
+ emitSkip([spec], note);
122
+ }
123
+ throw err;
124
+ }
125
+ }
@@ -28,10 +28,11 @@ import { tmpdir } from 'node:os';
28
28
  import { join, dirname } from 'node:path';
29
29
  import { fileURLToPath } from 'node:url';
30
30
  import { createRequire } from 'node:module';
31
+ import { requireHostModule } from './helpers.mjs';
31
32
 
32
33
  const require = createRequire(import.meta.url);
33
34
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
34
- const resolver = require('../.claude/hooks/_core-resolve.js');
35
+ const resolver = requireHostModule(require, '../.claude/hooks/_core-resolve.js');
35
36
 
36
37
  let passed = 0;
37
38
  let failed = 0;
@@ -0,0 +1,97 @@
1
+ // tests/host_topology_skips.mjs — the RATCHET on host-topology skips (task 1002470).
2
+ //
3
+ // tests/ is publishable, but some tests assert paths the neutral core deliberately does
4
+ // not carry: publish-manifest.js's PROTECTED_TOPOLOGY_EXCLUDE (`.claude/hooks/`,
5
+ // `.claude/settings.json`, `infra/`, `.github/`, `scripts/deploy/`) plus instance-only
6
+ // content (`devbox-app/`, `config/<instance>.json`, an uninstalled module). Those files
7
+ // used to CRASH at import and red-X the unit gate for every consumer; they now skip
8
+ // cleanly via the helpers in tests/helpers.mjs and run in full wherever the topology is
9
+ // materialized.
10
+ //
11
+ // A skip is real coverage that does not execute here, so it must never be the quiet
12
+ // default. This file is the explicit, justified allowlist the fix is ratcheted against:
13
+ // - a NEW file taking a whole-file skip fails until it is added here deliberately;
14
+ // - a file that stops needing one fails until it is removed, so the roster can't rot.
15
+ //
16
+ // This deliberately does NOT cover per-assertion guards (a single `if (!existsSync) return`
17
+ // inside one test of an otherwise-running file) — those leave the file's other assertions
18
+ // executing. Only whole-file skips, which turn an entire file into a no-op, are ratcheted.
19
+ //
20
+ // DB-free, no network. Run: node tests/host_topology_skips.mjs
21
+
22
+ import { strict as assert } from 'node:assert';
23
+ import { test } from 'node:test';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const TESTS_DIR = path.dirname(fileURLToPath(import.meta.url));
29
+
30
+ // Every test file permitted to skip itself wholesale, with the topology it needs.
31
+ // Adding a line here is a deliberate, reviewable act — that is the point.
32
+ const ALLOWED = {
33
+ art_stage: 'scripts/gds/art-stage.js (art-pipeline tooling, not in the core)',
34
+ bongos_downloads: 'config/bongos-cli.json (instance content)',
35
+ box_dns_repoint: 'infra/box-dns-cloudflare.sh (host topology)',
36
+ box_ship_permissions: '.claude/settings.json (package-excluded)',
37
+ conductor: '.claude/hooks/conductor.js (package-excluded)',
38
+ deploy_instance: 'scripts/deploy/deploy-instance.sh (host topology)',
39
+ devbox_downloads: 'config/devbox-app.json (instance content)',
40
+ eviction_policy: '.claude/hooks/eviction-policy.js (package-excluded)',
41
+ first_run_engine_check: '.claude/hooks/session-start.js (package-excluded)',
42
+ hook_core_resolve: '.claude/hooks/_core-resolve.js (package-excluded)',
43
+ 'lifecycle-card': '.claude/hooks/lifecycle-card.js (package-excluded)',
44
+ pre_compact: '.claude/hooks/pre-compact.js (package-excluded)',
45
+ preview_server: 'src/preview-server.js (game module, not in the core)',
46
+ recall_hints: '.claude/hooks/recall-hints.js (package-excluded)',
47
+ rubric_fixtures: 'modules/art-pipeline/template/rubric_fixtures (module not installed)',
48
+ run_routine_two_root: '.claude/hooks/_core-resolve.js (package-excluded)',
49
+ 'session-cards': '.claude/hooks/session-cards.js (package-excluded)',
50
+ session_start_freshness: '.claude/hooks/session-start.js (package-excluded)',
51
+ stop_refcheck: '.claude/hooks/stop-refcheck.js (package-excluded; the task-1002234 precedent)',
52
+ worktree_guard: '.claude/hooks/worktree-guard.js (package-excluded)',
53
+ };
54
+
55
+ // A file takes a whole-file skip if it calls either helper, or hand-rolls the same
56
+ // exit-0-on-MODULE_NOT_FOUND shape that task 1002234 established in stop_refcheck.
57
+ const HELPER_CALL = /\b(skipUnlessPresent|requireHostModule)\s*\(/;
58
+ const HANDROLLED = /MODULE_NOT_FOUND[\s\S]{0,400}?process\.exit\(0\)/;
59
+
60
+ function filesTakingWholeFileSkip() {
61
+ return fs.readdirSync(TESTS_DIR)
62
+ .filter((f) => f.endsWith('.mjs') && f !== 'helpers.mjs' && f !== path.basename(fileURLToPath(import.meta.url)))
63
+ .filter((f) => {
64
+ const src = fs.readFileSync(path.join(TESTS_DIR, f), 'utf8');
65
+ return HELPER_CALL.test(src) || HANDROLLED.test(src);
66
+ })
67
+ .map((f) => f.replace(/\.mjs$/, ''))
68
+ .sort();
69
+ }
70
+
71
+ test('every host-topology skip is on the allowlist — the set cannot grow silently', () => {
72
+ const actual = filesTakingWholeFileSkip();
73
+ const allowed = Object.keys(ALLOWED).sort();
74
+ const added = actual.filter((f) => !allowed.includes(f));
75
+ assert.deepEqual(added, [],
76
+ 'these test files skip themselves but are not on the allowlist. A skip means the file '
77
+ + 'asserts nothing here — add it to ALLOWED in tests/host_topology_skips.mjs with the '
78
+ + 'topology it needs, or make the test run without that topology.');
79
+ });
80
+
81
+ test('the allowlist has no stale entries — a file that no longer skips must be removed', () => {
82
+ const actual = filesTakingWholeFileSkip();
83
+ const stale = Object.keys(ALLOWED).sort().filter((f) => !actual.includes(f));
84
+ assert.deepEqual(stale, [],
85
+ 'these files are on the allowlist but no longer take a host-topology skip — drop them '
86
+ + 'from ALLOWED so the roster keeps meaning something.');
87
+ });
88
+
89
+ test('the ratchet is non-vacuous and actually detects a skip', () => {
90
+ // If the detection regexes ever stopped matching, both checks above would pass while
91
+ // proving nothing. Pin that they see the real thing.
92
+ const actual = filesTakingWholeFileSkip();
93
+ assert.ok(actual.length >= 15, `expected the known skip set, found ${actual.length}`);
94
+ assert.ok(actual.includes('stop_refcheck'), 'the hand-rolled precedent must still be detected');
95
+ assert.ok(actual.includes('conductor'), 'a requireHostModule caller must be detected');
96
+ assert.ok(actual.includes('box_dns_repoint'), 'a skipUnlessPresent caller must be detected');
97
+ });
@@ -31,8 +31,18 @@ function test(name, fn) {
31
31
  catch (err) { console.error(` FAIL ${name}`); console.error(' ', err.message); failed++; }
32
32
  }
33
33
 
34
+ // infra/ is HOST topology (publish-manifest.js PROTECTED_TOPOLOGY_EXCLUDE) — the neutral
35
+ // core carries no units and no launcher (task 1002470). The checks that read the REAL tree
36
+ // only mean something where that tree exists; the synthetic counterfactuals below always run.
37
+ const HAS_INFRA = fs.existsSync(path.join(REPO, 'infra'));
38
+ const HAS_LAUNCHER = fs.existsSync(LAUNCHER);
39
+ function hostTest(name, present, fn) {
40
+ if (!present) { console.log(` skip ${name} — infra/ is absent in this checkout (host topology)`); return; }
41
+ test(name, fn);
42
+ }
43
+
34
44
  // ---- A. the lint is green on the real tree, and covers all 11 core-script units ----------------
35
- test('lint-infra-exec: every core-script unit resolves in a consumer layout (publishable + present)', () => {
45
+ hostTest('lint-infra-exec: every core-script unit resolves in a consumer layout (publishable + present)', HAS_INFRA, () => {
36
46
  const r = lintInfraExec({ instanceRoot: REPO, coreRoot: REPO });
37
47
  assert.deepEqual(r.violations, [], `expected no violations, got: ${JSON.stringify(r.violations)}`);
38
48
  assert.ok(r.ok, 'lint should pass');
@@ -68,7 +78,7 @@ function runLauncher(rel, instanceRoot) {
68
78
  }).trim();
69
79
  }
70
80
 
71
- test('core-node.sh resolves the monolith layout (script at the instance root)', () => {
81
+ hostTest('core-node.sh resolves the monolith layout (script at the instance root)', HAS_LAUNCHER, () => {
72
82
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cn-mono-'));
73
83
  try {
74
84
  fs.mkdirSync(path.join(tmp, 'scripts', 'gds'), { recursive: true });
@@ -77,7 +87,7 @@ test('core-node.sh resolves the monolith layout (script at the instance root)',
77
87
  } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
78
88
  });
79
89
 
80
- test('core-node.sh falls back to the consumer layout (script only under node_modules/@bongos/core)', () => {
90
+ hostTest('core-node.sh falls back to the consumer layout (script only under node_modules/@bongos/core)', HAS_LAUNCHER, () => {
81
91
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cn-cons-'));
82
92
  try {
83
93
  const pkgDir = path.join(tmp, 'node_modules', '@bongos', 'core', 'scripts', 'gds');
@@ -88,7 +98,7 @@ test('core-node.sh falls back to the consumer layout (script only under node_mod
88
98
  } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
89
99
  });
90
100
 
91
- test('core-node.sh exits non-zero when the script is in NO layout', () => {
101
+ hostTest('core-node.sh exits non-zero when the script is in NO layout', HAS_LAUNCHER, () => {
92
102
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cn-none-'));
93
103
  let threw = false;
94
104
  try {