@bongos/core 1.18.18 → 1.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +515 -195
- package/.claude/skills/builder-box/SKILL.md +1 -1
- package/.claude/skills/builder-claim/SKILL.md +1 -1
- package/.claude/skills/goal-create/SKILL.md +1 -1
- package/.claude/skills/idea-triage/SKILL.md +1 -1
- package/.claude/skills/planning-session/SKILL.md +5 -5
- package/.claude/skills/scan-before-install/SKILL.md +165 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +63 -41
- package/clients/bongos-client/index.cjs +63 -41
- package/clients/bongos-client/index.d.ts +78 -44
- package/clients/bongos-client/index.mjs +63 -41
- package/docs/adr/0020-security-model-public-stub.md +1 -1
- package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -0
- package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
- package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -1
- package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +51 -0
- package/docs/adr/0156-user-set-display-name-wins.md +62 -0
- package/docs/adr/0157-archon-is-rank-and-identity-only.md +93 -0
- package/docs/adr/0158-choke-point-verdict-taxonomy.md +174 -0
- package/docs/adr/0159-merge-gate-reconciliation.md +88 -0
- package/docs/adr/0160-main-audit-repo-verifiable-caller.md +77 -0
- package/docs/adr/README.md +8 -0
- package/docs/api/openapi.json +1209 -233
- package/docs/api-reference.md +61 -46
- package/docs/architecture.md +1 -1
- package/docs/canonical-permissions.md +61 -16
- package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
- package/docs/file-map.md +4 -1
- package/docs/onboarding/diagrams/assertions.json +3 -3
- package/docs/recipes/core-release-pipeline.md +179 -0
- package/docs/recipes/packaging-the-core.md +2 -0
- package/docs/recipes/parallel-goal-waves.md +1 -3
- package/docs/recipes/private-npm-distribution.md +2 -0
- package/docs/recipes/upgrading-the-core.md +2 -0
- package/migrations/core_197_display_name_source.sql +56 -0
- package/migrations/core_204_rekey_active_claim_per_session_worktree.sql +77 -0
- package/modules/autonomy/routes/autonomy.js +1 -1
- package/modules/builder-settings/db.js +7 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +63 -41
- package/modules/dev-box/routes/box.js +11 -9
- package/modules/discord/auth.js +3 -1
- package/modules/discord/routes/discord.js +8 -2
- package/modules/discord/ship-broadcast.js +48 -1
- package/modules/governance/catalog.js +70 -20
- package/modules/governance/db.js +102 -0
- package/modules/governance/migrations/governance_006_version_create.sql +38 -0
- package/modules/governance/migrations/governance_007_delegate_archon_ops_to_metic.sql +72 -0
- package/modules/governance/routes/governance.js +235 -6
- package/modules/grading/grader-workers/index.js +18 -9
- package/modules/hall-ui/public/gate.js +6 -2
- package/modules/hall-ui/public/goals-page.js +6 -2
- package/modules/hall-ui/public/governance.css +100 -0
- package/modules/hall-ui/public/governance.html +77 -0
- package/modules/hall-ui/public/governance.js +353 -0
- package/modules/hall-ui/public/harbor.js +14 -8
- package/modules/hall-ui/public/instances-panel.js +3 -1
- package/modules/hall-ui/public/new-project-wizard.js +6 -0
- package/modules/hall-ui/public/people.html +51 -0
- package/modules/hall-ui/public/people.js +100 -0
- package/modules/hall-ui/public/profile.html +2 -1
- package/modules/hall-ui/public/profile.js +9 -3
- package/modules/hall-ui/public/sessions.js +4 -2
- package/modules/hall-ui/public/shell.js +28 -7
- package/modules/hall-ui/public/task-detail.js +6 -3
- package/modules/hall-ui/public/watch.js +17 -9
- package/modules/ideas/blockers.js +44 -7
- package/modules/ideas/routes/blockers.js +12 -7
- package/modules/ideas/routes/inbox.js +2 -2
- package/modules/lifecycle/db.js +84 -17
- package/modules/lifecycle/github-push.js +37 -10
- package/modules/lifecycle/publish-reconciler.js +128 -31
- package/modules/lifecycle/routes/claims.js +10 -4
- package/modules/lifecycle/routes/dependencies.js +2 -2
- package/modules/lifecycle/routes/done-when.js +4 -4
- package/modules/lifecycle/routes/gate-approvals.js +9 -3
- package/modules/lifecycle/routes/goals.js +5 -5
- package/modules/lifecycle/routes/tasks.js +16 -16
- package/modules/lifecycle/routes/versions.js +13 -2
- package/modules/lifecycle/ship-card.js +38 -7
- package/modules/memory/routes/memory.js +2 -2
- package/modules/onboarding/routes/access-requests.js +3 -3
- package/modules/platform-identity/routes/projects.js +10 -6
- package/modules/platform-identity/routes/scouting.js +2 -2
- package/modules/provisioning/migrations/provisioning_004_onboard_mode.sql +30 -0
- package/modules/provisioning/provisioning.js +24 -6
- package/modules/provisioning/routes/provisioning.js +24 -6
- package/modules/security/artifact-scan/aggregate.js +133 -0
- package/modules/security/artifact-scan/detectors/capabilities.js +53 -0
- package/modules/security/artifact-scan/detectors/config-obfuscation.js +41 -0
- package/modules/security/artifact-scan/detectors/credentials.js +112 -0
- package/modules/security/artifact-scan/detectors/dependencies.js +115 -0
- package/modules/security/artifact-scan/detectors/hooks.js +116 -0
- package/modules/security/artifact-scan/detectors/injection.js +118 -0
- package/modules/security/artifact-scan/detectors/mcp.js +102 -0
- package/modules/security/artifact-scan/detectors/network.js +67 -0
- package/modules/security/artifact-scan/detectors/npm-lifecycle.js +67 -0
- package/modules/security/artifact-scan/detectors/permissions.js +91 -0
- package/modules/security/artifact-scan/detectors/shared.js +219 -0
- package/modules/security/artifact-scan/fence-local.js +99 -0
- package/modules/security/artifact-scan/fetch.js +330 -0
- package/modules/security/artifact-scan/floor.js +319 -0
- package/modules/security/artifact-scan/limits.js +66 -0
- package/modules/security/artifact-scan/manifest.js +203 -0
- package/modules/security/artifact-scan/mirror.js +158 -0
- package/modules/security/artifact-scan/quarantine.js +149 -0
- package/modules/security/artifact-scan/rebuild-spec.js +120 -0
- package/modules/security/artifact-scan/report.js +155 -0
- package/modules/security/artifact-scan/sanitize.js +317 -0
- package/modules/security/migrations/security_001_tool_scans.sql +145 -0
- package/modules/security/module.json +2 -2
- package/modules/security/routes/reports.js +11 -7
- package/modules/security/routes/scans.js +115 -0
- package/modules/security/scan-store.js +214 -0
- package/modules/security/tests/aggregate.mjs +283 -0
- package/modules/security/tests/artifact_scan.mjs +591 -0
- package/modules/security/tests/floor.mjs +549 -0
- package/modules/security/tests/mirror.mjs +350 -0
- package/modules/security/tests/sanitize_rebuild.mjs +499 -0
- package/modules/security/tests/scan_store.mjs +250 -0
- package/modules/security/tests/tool_scans_migration.mjs +116 -0
- package/modules/sessions/routes/sessions.js +6 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/create.html +13 -3
- package/scripts/gds/abandon.js +1 -1
- package/scripts/gds/audit-authorship.sh +6 -0
- package/scripts/gds/box.js +1 -1
- package/scripts/gds/claim.js +15 -0
- package/scripts/gds/cli-lib.js +16 -4
- package/scripts/gds/gating.js +28 -13
- package/scripts/gds/gen-api-docs.js +1 -1
- package/scripts/gds/goal.js +234 -0
- package/scripts/gds/init.js +70 -15
- package/scripts/gds/main-audit.js +80 -11
- package/scripts/gds/mirror-redact.js +6 -0
- package/scripts/gds/newcomer-floor.js +2 -2
- package/scripts/gds/provision.js +230 -94
- package/scripts/gds/run-unit-tests.js +9 -1
- package/scripts/gds/sandbox-stage.js +66 -9
- package/scripts/gds/search-ingest-md.js +1 -0
- package/scripts/gds/sequence.js +67 -8
- package/scripts/gds/ship.js +183 -50
- package/scripts/hall-preview/server.js +1 -1
- package/scripts/migrate.sh +99 -14
- package/src/bongos/auth.js +129 -27
- package/src/bongos/db-kernel.js +66 -5
- package/src/bongos/db.js +6 -7
- package/src/bongos/github-repos.js +19 -7
- package/src/bongos/goal-scope-check.js +1 -1
- package/src/bongos/module-scope-map.js +3 -1
- package/src/bongos/permission-path-check.js +1 -1
- package/src/bongos/route-rank-check.js +73 -30
- package/src/bongos/routes/audit-log.js +4 -2
- package/src/bongos/routes/auth.js +8 -4
- package/src/bongos/routes/backup.js +7 -4
- package/src/bongos/routes/builders.js +23 -11
- package/src/bongos/routes/github.js +15 -4
- package/src/bongos/routes/llm-cache.js +5 -2
- package/src/bongos/routes/modules.js +9 -5
- package/src/bongos/routes/security.js +21 -12
- package/src/bongos/serve-internal.js +22 -7
- package/src/module-api.js +86 -1
- package/src/platform-server.js +14 -0
- package/tests/api_client.mjs +18 -2
- package/tests/api_path_404.mjs +3 -0
- package/tests/art_stage.mjs +2 -1
- package/tests/atlas.mjs +32 -4
- package/tests/auth_admission_gate.mjs +17 -5
- package/tests/auth_error_brand.mjs +41 -21
- package/tests/auth_resolve_failure.mjs +155 -0
- package/tests/blocker_link.mjs +88 -0
- package/tests/bongos_downloads.mjs +2 -1
- package/tests/box_dns_repoint.mjs +2 -0
- package/tests/box_ship_permissions.mjs +2 -0
- package/tests/box_terminal_ssrf.mjs +23 -17
- package/tests/claim_error_surface.mjs +23 -0
- package/tests/claude_materialize.mjs +4 -0
- package/tests/cli_box_scope.mjs +40 -0
- package/tests/cloud_bongos_pack.mjs +34 -4
- package/tests/conductor.mjs +2 -1
- package/tests/criterion_authoring_gate.mjs +28 -5
- package/tests/currency_label.mjs +10 -1
- package/tests/deploy_instance.mjs +2 -0
- package/tests/devbox_downloads.mjs +2 -1
- package/tests/display_name.mjs +95 -6
- package/tests/eviction_policy.mjs +2 -1
- package/tests/file_map.mjs +10 -0
- package/tests/first_admin_bootstrap.mjs +10 -2
- package/tests/first_run_engine_check.mjs +2 -1
- package/tests/fitness.mjs +26 -6
- package/tests/gate_approvals.mjs +15 -2
- package/tests/github_push_land_proof.mjs +50 -4
- package/tests/github_repos_helpers.mjs +30 -5
- package/tests/github_token_store.mjs +23 -0
- package/tests/goal_abuse_matrix.mjs +13 -3
- package/tests/goal_cli.mjs +89 -0
- package/tests/governance_abuse_matrix.mjs +386 -0
- package/tests/governance_fold.mjs +156 -0
- package/tests/governance_fold_bench.mjs +50 -0
- package/tests/governance_live_proof.mjs +201 -0
- package/tests/governance_page_gates.mjs +21 -9
- package/tests/governance_parity.mjs +11 -2
- package/tests/governance_require_permission.mjs +125 -1
- package/tests/governance_routes.mjs +334 -0
- package/tests/grader.mjs +7 -3
- package/tests/hall_nav.mjs +14 -4
- package/tests/helpers.mjs +73 -0
- package/tests/hook_core_resolve.mjs +2 -1
- package/tests/host_topology_skips.mjs +97 -0
- package/tests/infra_exec_paths.mjs +14 -4
- package/tests/init.mjs +128 -0
- package/tests/known_failing_ratchet.mjs +89 -0
- package/tests/lifecycle-card.mjs +2 -1
- package/tests/main_audit.mjs +72 -1
- package/tests/memory_isolation.mjs +56 -9
- package/tests/migrate_root_resolution.mjs +124 -1
- package/tests/mirror_redact.mjs +19 -0
- package/tests/module_api.mjs +3 -1
- package/tests/module_loader.mjs +4 -0
- package/tests/modules.mjs +35 -7
- package/tests/modules_route.mjs +29 -5
- package/tests/onboarding_observability.mjs +49 -11
- package/tests/onboarding_route_signals.mjs +1 -0
- package/tests/one_active_claim_per_session.mjs +80 -0
- package/tests/pre_compact.mjs +2 -1
- package/tests/preview_server.mjs +2 -1
- package/tests/profile_route.mjs +1 -0
- package/tests/provision.mjs +170 -0
- package/tests/provisioning_onboard_mode.mjs +180 -0
- package/tests/publish_manifest.mjs +16 -3
- package/tests/publish_reconciler.mjs +181 -10
- package/tests/rank_tier_single_source.mjs +205 -0
- package/tests/recall_hints.mjs +2 -1
- package/tests/repo_map.mjs +4 -0
- package/tests/route_permission_check.mjs +14 -5
- package/tests/rubric_fixtures.mjs +3 -0
- package/tests/run_routine_two_root.mjs +2 -0
- package/tests/sandbox_stage.mjs +69 -7
- package/tests/search_ingest.mjs +7 -0
- package/tests/security_docs.mjs +28 -6
- package/tests/sequence.mjs +64 -0
- package/tests/session-cards.mjs +2 -1
- package/tests/session_start_freshness.mjs +2 -1
- package/tests/ship_broadcast_server.mjs +63 -1
- package/tests/ship_card.mjs +66 -0
- package/tests/ship_ci_deploy.mjs +66 -1
- package/tests/ship_smoke_gate.mjs +76 -0
- package/tests/versions_route.mjs +15 -0
- package/tests/workflow_exec_paths.mjs +16 -4
- 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:
|
|
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' }, {}),
|
|
1418
|
-
assert.equal(tfw({ KIND: 'efficiency' }, {}),
|
|
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', () => {
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|