@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,180 @@
|
|
|
1
|
+
// tests/provisioning_onboard_mode.mjs
|
|
2
|
+
//
|
|
3
|
+
// The onboard_mode leg of brownfield onboarding (ADR 0121, task 1002562, migration
|
|
4
|
+
// provisioning_004). Before this, greenfield-vs-adopt existed ONLY as a query param on
|
|
5
|
+
// GET /provisioning/onboard-plan that chose which checklist TEXT to render — it was never
|
|
6
|
+
// persisted, so the control-plane runner ran the greenfield scaffold unconditionally and
|
|
7
|
+
// adopting an existing repo always failed as an unrelated-history non-fast-forward.
|
|
8
|
+
//
|
|
9
|
+
// Covers the two layers that carry the mode from the browser to the runner:
|
|
10
|
+
// • createInstance — normalization (unknown/absent → greenfield) + what reaches SQL
|
|
11
|
+
// • POST /instances — validation + threading, driven over a real mounted router
|
|
12
|
+
// • publicInstance — the mode is readable back out (coalesced for pre-004 rows)
|
|
13
|
+
//
|
|
14
|
+
// DB-free: the pool is a recording fake and the data-access fns the route needs are
|
|
15
|
+
// stubbed on the cached module object. No network.
|
|
16
|
+
//
|
|
17
|
+
// Run: node tests/provisioning_onboard_mode.mjs
|
|
18
|
+
|
|
19
|
+
import { strict as assert } from 'node:assert';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import http from 'node:http';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const ROOT = new URL('..', import.meta.url).pathname;
|
|
25
|
+
|
|
26
|
+
const provisioning = require(`${ROOT}/modules/provisioning/provisioning.js`);
|
|
27
|
+
|
|
28
|
+
let passed = 0, failed = 0;
|
|
29
|
+
function t(name, fn) {
|
|
30
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
31
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
32
|
+
}
|
|
33
|
+
async function ta(name, fn) {
|
|
34
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
35
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── createInstance: normalization + what actually reaches SQL ─────────────────────
|
|
39
|
+
// A recording pool: capture the INSERT's params so we assert the STORED value, not just
|
|
40
|
+
// the returned row (a normalization bug that only shows up in the column is the one that
|
|
41
|
+
// would silently mislabel a row forever).
|
|
42
|
+
function recordingDb(returnRow = {}) {
|
|
43
|
+
const calls = [];
|
|
44
|
+
return {
|
|
45
|
+
calls,
|
|
46
|
+
query: async (text, params) => {
|
|
47
|
+
calls.push({ text, params });
|
|
48
|
+
return { rows: [{ id: 1, owner_builder_id: 7, slug: 'demo', ...returnRow }] };
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
await ta('createInstance: onboardMode "adopt" is persisted', async () => {
|
|
54
|
+
const db = recordingDb();
|
|
55
|
+
await provisioning.createInstance(db, {
|
|
56
|
+
ownerBuilderId: 7, slug: 'demo', targetRef: 'lars589/demo',
|
|
57
|
+
hostingShape: 'standalone', onboardMode: 'adopt',
|
|
58
|
+
});
|
|
59
|
+
const insert = db.calls.find((c) => /INSERT INTO provisioning_instances/i.test(c.text));
|
|
60
|
+
assert.ok(insert, 'issued an INSERT');
|
|
61
|
+
assert.match(insert.text, /onboard_mode/, 'the column is in the INSERT');
|
|
62
|
+
assert.ok(insert.params.includes('adopt'), `'adopt' reached the INSERT params (got ${JSON.stringify(insert.params)})`);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await ta('createInstance: absent / unknown onboardMode → greenfield, the SAFE default', async () => {
|
|
66
|
+
// Greenfield refuses to push over existing history (no --force anywhere), so an
|
|
67
|
+
// unrecognized value must land there. Defaulting to 'adopt' would put the layering path
|
|
68
|
+
// on a repo nobody declared as pre-existing.
|
|
69
|
+
for (const mode of [undefined, null, '', 'ADOPT', 'brownfield', 'nonsense']) {
|
|
70
|
+
const db = recordingDb();
|
|
71
|
+
await provisioning.createInstance(db, {
|
|
72
|
+
ownerBuilderId: 7, slug: 'demo', hostingShape: 'standalone', onboardMode: mode,
|
|
73
|
+
});
|
|
74
|
+
const insert = db.calls.find((c) => /INSERT INTO provisioning_instances/i.test(c.text));
|
|
75
|
+
assert.ok(insert.params.includes('greenfield'), `onboardMode=${JSON.stringify(mode)} → greenfield`);
|
|
76
|
+
assert.ok(!insert.params.includes('adopt'), `onboardMode=${JSON.stringify(mode)} must NOT become adopt`);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
t('ONBOARD_MODES matches the provisioning_004 CHECK constraint', () => {
|
|
81
|
+
assert.deepEqual(provisioning.ONBOARD_MODES, ['greenfield', 'adopt']);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
t('publicInstance: surfaces onboard_mode, coalescing a pre-004 row to greenfield', () => {
|
|
85
|
+
assert.equal(provisioning.publicInstance({ id: 1, onboard_mode: 'adopt' }).onboard_mode, 'adopt');
|
|
86
|
+
// A row read before the column existed (or a NULL) reads as greenfield — which is what
|
|
87
|
+
// it was, by construction: there was no other scaffold path.
|
|
88
|
+
assert.equal(provisioning.publicInstance({ id: 1 }).onboard_mode, 'greenfield');
|
|
89
|
+
assert.equal(provisioning.publicInstance({ id: 1, onboard_mode: null }).onboard_mode, 'greenfield');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ── POST /provisioning/instances over a real mounted router ───────────────────────
|
|
93
|
+
let lastCreateArgs = null;
|
|
94
|
+
provisioning.createInstance = async (_db, args) => {
|
|
95
|
+
lastCreateArgs = args;
|
|
96
|
+
return { instance: { id: 42, owner_builder_id: 7, slug: args.slug, onboard_mode: args.onboardMode }, created: true };
|
|
97
|
+
};
|
|
98
|
+
provisioning.enqueueIntent = async () => ({ intent: { id: 1, state: 'pending' }, created: true });
|
|
99
|
+
provisioning.recordEvent = async () => {};
|
|
100
|
+
provisioning.getInstanceBySlug = async () => null;
|
|
101
|
+
|
|
102
|
+
const api = require(`${ROOT}/src/module-api.js`);
|
|
103
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
104
|
+
|
|
105
|
+
const express = require('express');
|
|
106
|
+
const provisioningRoutes = require(`${ROOT}/modules/provisioning/routes/provisioning.js`);
|
|
107
|
+
const app = express();
|
|
108
|
+
app.use(express.json());
|
|
109
|
+
app.use((_req, res, next) => {
|
|
110
|
+
res.fail = (code, opts, details) => {
|
|
111
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
112
|
+
res.status(status).json({ error: { code, ...(details || (typeof opts === 'object' ? opts : {})) } });
|
|
113
|
+
};
|
|
114
|
+
next();
|
|
115
|
+
});
|
|
116
|
+
app.use('/api/bongos', provisioningRoutes());
|
|
117
|
+
|
|
118
|
+
const server = app.listen(0);
|
|
119
|
+
await new Promise((r) => server.once('listening', r));
|
|
120
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
121
|
+
|
|
122
|
+
function post(path, body) {
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
const payload = JSON.stringify(body);
|
|
125
|
+
const req = http.request(`${base}${path}`, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
128
|
+
}, (res) => {
|
|
129
|
+
let d = '';
|
|
130
|
+
res.on('data', (c) => { d += c; });
|
|
131
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
132
|
+
});
|
|
133
|
+
req.on('error', reject);
|
|
134
|
+
req.end(payload);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await ta('POST /instances: onboard_mode=adopt is threaded through to createInstance', async () => {
|
|
139
|
+
lastCreateArgs = null;
|
|
140
|
+
const res = await post('/provisioning/instances', {
|
|
141
|
+
slug: 'demo', hosting_shape: 'standalone', target_ref: 'lars589/demo', onboard_mode: 'adopt',
|
|
142
|
+
});
|
|
143
|
+
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
144
|
+
assert.equal(lastCreateArgs && lastCreateArgs.onboardMode, 'adopt');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await ta('POST /instances: onboard_mode absent → greenfield (unchanged behavior for every existing caller)', async () => {
|
|
148
|
+
lastCreateArgs = null;
|
|
149
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'standalone' });
|
|
150
|
+
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
151
|
+
assert.equal(lastCreateArgs && lastCreateArgs.onboardMode, 'greenfield');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await ta('POST /instances: an unknown onboard_mode is REFUSED (never silently coerced)', async () => {
|
|
155
|
+
// Coercing here would be worse than refusing: the caller believes it asked for one thing
|
|
156
|
+
// and the runner does another. The route names the valid set so the client can fix itself.
|
|
157
|
+
const res = await post('/provisioning/instances', {
|
|
158
|
+
slug: 'demo', hosting_shape: 'standalone', target_ref: 'a/b', onboard_mode: 'brownfield',
|
|
159
|
+
});
|
|
160
|
+
assert.equal(res.status, 400);
|
|
161
|
+
assert.equal(res.body.error.code, 'bad_onboard_mode');
|
|
162
|
+
assert.deepEqual(res.body.error.valid, ['greenfield', 'adopt']);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await ta('POST /instances: adopt WITHOUT target_ref is refused — there is no repo to layer onto', async () => {
|
|
166
|
+
// Without target_ref the runner would fall back to greenfield behavior on a row labelled
|
|
167
|
+
// adopt. Refuse at the door rather than store a lie.
|
|
168
|
+
for (const body of [
|
|
169
|
+
{ slug: 'demo', hosting_shape: 'standalone', onboard_mode: 'adopt' },
|
|
170
|
+
{ slug: 'demo', hosting_shape: 'standalone', onboard_mode: 'adopt', target_ref: ' ' },
|
|
171
|
+
]) {
|
|
172
|
+
const res = await post('/provisioning/instances', body);
|
|
173
|
+
assert.equal(res.status, 400, `expected 400 for ${JSON.stringify(body)}`);
|
|
174
|
+
assert.equal(res.body.error.code, 'target_ref_required_for_adopt');
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
server.close();
|
|
179
|
+
console.log(`\nprovisioning_onboard_mode: ${passed} passed, ${failed} failed`);
|
|
180
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -211,6 +211,9 @@ t('no publishable test statically imports an excluded module (R89 boundary)', ()
|
|
|
211
211
|
const offenders = []; // …and are still publishable (the actual failure)
|
|
212
212
|
for (const name of fs.readdirSync(testsDir)) {
|
|
213
213
|
if (!/\.(mjs|js|cjs)$/.test(name)) continue;
|
|
214
|
+
// Skip self: the regex specimens below are deliberate full import forms;
|
|
215
|
+
// this file's real imports pull scripts/gds code only, never modules/.
|
|
216
|
+
if (name === 'publish_manifest.mjs') continue;
|
|
214
217
|
const rel = `tests/${name}`;
|
|
215
218
|
const bad = new Set();
|
|
216
219
|
let m; IMPORT_RE.lastIndex = 0;
|
|
@@ -225,9 +228,19 @@ t('no publishable test statically imports an excluded module (R89 boundary)', ()
|
|
|
225
228
|
offenders.push(`${rel} imports excluded module(s) [${[...bad].sort().join(', ')}] but is still PUBLISHABLE — add it to CONTENT_EXCLUDE (task 2147)`);
|
|
226
229
|
}
|
|
227
230
|
}
|
|
228
|
-
// non-vacuous: the
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
+
// non-vacuous: prove the regex itself extracts a module key from every import
|
|
232
|
+
// form it claims to cover. A repo-state floor ("must see ≥7 host tests") went
|
|
233
|
+
// stale the moment the host-module tests moved to modules/<key>/tests/ under
|
|
234
|
+
// ADR 0107 — tests/ can legitimately contain zero live specimens (task 1002611).
|
|
235
|
+
for (const [specimen, key] of [
|
|
236
|
+
[`const x = require('../modules/game/db.js')`, 'game'],
|
|
237
|
+
[`import x from '../../modules/art-pipeline/pipeline/palette.js'`, 'art-pipeline'],
|
|
238
|
+
[`const y = await import('./modules/some-key/loader.js')`, 'some-key'],
|
|
239
|
+
]) {
|
|
240
|
+
IMPORT_RE.lastIndex = 0;
|
|
241
|
+
const m = IMPORT_RE.exec(specimen);
|
|
242
|
+
assert.ok(m && (m[2] || m[4]) === key, `import scan no longer matches: ${specimen}`);
|
|
243
|
+
}
|
|
231
244
|
assert.equal(offenders.length, 0, '\n ' + offenders.join('\n '));
|
|
232
245
|
});
|
|
233
246
|
|
|
@@ -58,7 +58,7 @@ function mkDeps({
|
|
|
58
58
|
refreshStaleImpl,
|
|
59
59
|
healAttempts = new Map(),
|
|
60
60
|
} = {}) {
|
|
61
|
-
const calls = { ship: [], broadcast: [], status: [], merge: [], ancestry: [], resolve: [], refresh: [], flag: [], logs: [], errors: [] };
|
|
61
|
+
const calls = { ship: [], broadcast: [], status: [], merge: [], ancestry: [], resolve: [], refresh: [], flag: [], events: [], logs: [], errors: [] };
|
|
62
62
|
// isSafeBranch mirrors github-push's pattern enough for the test (rejects
|
|
63
63
|
// option-like / traversal refs); we use the REAL module so the contract can't drift.
|
|
64
64
|
const githubPush = require('../modules/lifecycle/github-push.js');
|
|
@@ -108,23 +108,33 @@ function mkDeps({
|
|
|
108
108
|
return !!a;
|
|
109
109
|
},
|
|
110
110
|
// task 1547 per-task land proof. Mirrors the real github-push.landProvenForTask
|
|
111
|
-
// shape but routes its
|
|
111
|
+
// shape but routes its network hops (the ancestry compares) through the mock
|
|
112
112
|
// above so the sweep stays DB/network-free, and reuses the REAL prReferencesTask
|
|
113
113
|
// so that pure check can't drift. publishedSha (the server-pushed tip) is the
|
|
114
|
-
// robust proof; the
|
|
115
|
-
//
|
|
114
|
+
// robust proof; task 1002615 adds the merged-PR merge-commit proof (out-of-band /
|
|
115
|
+
// squash land) and gates the no-tip reference fallback on merged:true, because
|
|
116
|
+
// the sweep now consults this proof even while a PR is still in flight.
|
|
116
117
|
landProvenForTask: async ({ taskId, publishedSha, status }) => {
|
|
117
|
-
|
|
118
|
+
const hasTip = !!(publishedSha && /^[0-9a-f]{7,64}$/.test(String(publishedSha)));
|
|
119
|
+
if (hasTip) {
|
|
118
120
|
calls.ancestry.push(publishedSha);
|
|
119
121
|
const a = ancestryBySha[publishedSha];
|
|
120
122
|
if (a instanceof Error) throw a;
|
|
121
|
-
return
|
|
122
|
-
? { proven: true, reason: `published tip ${publishedSha.slice(0, 8)} in main` }
|
|
123
|
-
: { proven: false, reason: `published tip ${publishedSha.slice(0, 8)} not in main` };
|
|
123
|
+
if (a) return { proven: true, reason: `published tip ${publishedSha.slice(0, 8)} in main` };
|
|
124
124
|
}
|
|
125
|
-
|
|
125
|
+
if (status && status.merged === true && githubPush.prReferencesTask(status, taskId)
|
|
126
|
+
&& status.merge_sha && /^[0-9a-f]{7,64}$/.test(String(status.merge_sha))) {
|
|
127
|
+
calls.ancestry.push(status.merge_sha);
|
|
128
|
+
const m = ancestryBySha[status.merge_sha];
|
|
129
|
+
if (m instanceof Error) throw m;
|
|
130
|
+
if (m) return { proven: true, reason: `merged PR for task ${taskId} — merge in main (out-of-band/squash land)` };
|
|
131
|
+
}
|
|
132
|
+
if (hasTip) {
|
|
133
|
+
return { proven: false, reason: `published tip ${publishedSha.slice(0, 8)} not in main` };
|
|
134
|
+
}
|
|
135
|
+
return (status && status.merged === true && githubPush.prReferencesTask(status, taskId))
|
|
126
136
|
? { proven: true, reason: `matched PR references task ${taskId}` }
|
|
127
|
-
: { proven: false, reason: `no published tip and
|
|
137
|
+
: { proven: false, reason: `no published tip and no merged PR references task ${taskId}` };
|
|
128
138
|
},
|
|
129
139
|
},
|
|
130
140
|
// task.shipped is emitted on the kernel seam now; the discord module's listener
|
|
@@ -133,6 +143,9 @@ function mkDeps({
|
|
|
133
143
|
// broadcastImpl REPLACES the default push (the throwing-broadcast test relies
|
|
134
144
|
// on that to prove the reconcile stands even when the listener throws).
|
|
135
145
|
emit: async (event, task) => {
|
|
146
|
+
// task 1002606: record EVERY seam event so the strand push channel
|
|
147
|
+
// (task.strand-flagged) is observable alongside task.shipped.
|
|
148
|
+
calls.events.push({ event, payload: task });
|
|
136
149
|
if (event !== 'task.shipped') return;
|
|
137
150
|
if (broadcastImpl) return broadcastImpl(task);
|
|
138
151
|
calls.broadcast.push(task.id);
|
|
@@ -307,6 +320,50 @@ test('1002472 merged + deploy unconfigured but tip NOT in main → NO ship (1547
|
|
|
307
320
|
assert.equal(deps._calls.ship.length, 0);
|
|
308
321
|
});
|
|
309
322
|
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// task 1002615 (idea 1000613) — a land made OUT-OF-BAND must still ship.
|
|
325
|
+
// The 2026-07-29 reproduction: ship → confirmed → PR merged by the owner through
|
|
326
|
+
// the web UI → the work verified on main → the task sat at 'confirmed' for 9+ min
|
|
327
|
+
// (forever), builder unpaid, until a manual POST /ship.
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
const MERGE_SHA = 'c'.repeat(40);
|
|
330
|
+
|
|
331
|
+
test('1002615 web-UI squash merge (tip rewritten, PR merged) → ships via the merge-commit proof', async () => {
|
|
332
|
+
const deps = mkDeps({
|
|
333
|
+
rows: [{ task_id: 80, builder_id: 3, worktree_name: 'wt-squash', published_head_sha: NOT_ON_MAIN }],
|
|
334
|
+
statusByBranch: { 'claude/wt-squash': { ...landedPrFor(80), deploy_status: 'unconfigured', merge_sha: MERGE_SHA } },
|
|
335
|
+
ancestryBySha: { [NOT_ON_MAIN]: false, [MERGE_SHA]: true },
|
|
336
|
+
});
|
|
337
|
+
const r = await reconciler.runSweep(deps);
|
|
338
|
+
assert.equal(r.reconciled, 1, 'a squash-rewritten land must not strand unpaid');
|
|
339
|
+
assert.deepEqual(deps._calls.ship, [{ taskId: 80, builderId: 3 }]);
|
|
340
|
+
assert.deepEqual(deps._calls.broadcast, [80]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('1002615 manual git merge to main, PR closed un-merged → tip ancestry ships it (prNotLanded no longer suppresses the proof)', async () => {
|
|
344
|
+
const deps = mkDeps({
|
|
345
|
+
rows: [{ task_id: 81, builder_id: 3, worktree_name: 'wt-oob', published_head_sha: ON_MAIN,
|
|
346
|
+
confirmed_at: new Date().toISOString() }],
|
|
347
|
+
statusByBranch: { 'claude/wt-oob': { pr_state: 'closed', merged: false, merge_sha: null, deploy_status: 'pending' } },
|
|
348
|
+
ancestryBySha: { [ON_MAIN]: true },
|
|
349
|
+
});
|
|
350
|
+
const r = await reconciler.runSweep(deps);
|
|
351
|
+
assert.equal(r.reconciled, 1, 'work on main behind a dead PR must still land');
|
|
352
|
+
assert.deepEqual(deps._calls.ship, [{ taskId: 81, builderId: 3 }]);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('1002615 the reuse guard holds: merged PR for a DIFFERENT task + tip not in main → NO ship', async () => {
|
|
356
|
+
const deps = mkDeps({
|
|
357
|
+
rows: [{ task_id: 82, builder_id: 3, worktree_name: 'wt-reused', published_head_sha: NOT_ON_MAIN,
|
|
358
|
+
confirmed_at: new Date().toISOString() }],
|
|
359
|
+
statusByBranch: { 'claude/wt-reused': { ...landedPrFor(999), deploy_status: 'unconfigured', merge_sha: MERGE_SHA } },
|
|
360
|
+
ancestryBySha: { [NOT_ON_MAIN]: false, [MERGE_SHA]: true },
|
|
361
|
+
});
|
|
362
|
+
const r = await reconciler.runSweep(deps);
|
|
363
|
+
assert.equal(r.reconciled, 0, 'the merge-commit proof must bind to THIS task');
|
|
364
|
+
assert.equal(deps._calls.ship.length, 0);
|
|
365
|
+
});
|
|
366
|
+
|
|
310
367
|
test('getPublishStatus throws (transient) → swallowed, sweep continues to the next task', async () => {
|
|
311
368
|
const deps = mkDeps({
|
|
312
369
|
rows: [
|
|
@@ -906,6 +963,120 @@ test('1002461 the WARN re-arms once a credential appears and then disappears aga
|
|
|
906
963
|
assert.equal(badAgain._calls.errors.length, 1, 'a credential LOSS after a healthy period must warn again');
|
|
907
964
|
});
|
|
908
965
|
|
|
966
|
+
// ---------------------------------------------------------------------------
|
|
967
|
+
// task 1002606 (finding P-3, the DETECTION half) — a strand reaches its builder
|
|
968
|
+
// when it is DETECTED, not at the 30-minute stale WARN. Definitive shapes flag
|
|
969
|
+
// needs_rebase on the sweep that sees them AND emit task.strand-flagged (the
|
|
970
|
+
// push channel Discord listens on); ambiguous shapes keep the 30-min grace.
|
|
971
|
+
// ---------------------------------------------------------------------------
|
|
972
|
+
|
|
973
|
+
test('1002606 a hand-written conflict flags needs_rebase IMMEDIATELY (not at the 30-min WARN), with the file list', async () => {
|
|
974
|
+
const deps = mkDeps({
|
|
975
|
+
rows: [{ task_id: 500, builder_id: 6, worktree_name: 'wt-hand', title: 'x', security_sensitive: false,
|
|
976
|
+
confirmed_at: new Date().toISOString() }], // FRESH — no stale grace involved
|
|
977
|
+
mergeByBranch: { 'claude/wt-hand': { merged: false, merged_now: false, number: 500, reason: 'merge_conflict' } },
|
|
978
|
+
conflictResolveImpl: async () => ({ resolved: false, skipped: 'unresolvable_conflict', conflicted: ['src/bongos/db.js'] }),
|
|
979
|
+
});
|
|
980
|
+
const r = await reconciler.runSweep(deps);
|
|
981
|
+
assert.equal(deps._calls.flag.length, 1, 'flagged on THIS sweep, minutes after the conflict — not 30 later');
|
|
982
|
+
assert.equal(deps._calls.flag[0].taskId, 500);
|
|
983
|
+
assert.match(deps._calls.flag[0].reason, /^strand:unresolvable_conflict:src\/bongos\/db\.js/, 'the reason names the conflicted file so /builder-start says WHERE');
|
|
984
|
+
assert.ok(deps._calls.errors.some((m) => /STRAND task 500/.test(m) && /hand-written conflict/.test(m)), 'WARN\'d with the concrete cause');
|
|
985
|
+
assert.equal(r.strandsFlagged, 1);
|
|
986
|
+
assert.equal(r.reconciled, 0, 'a conflicted PR still never ships');
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
test('1002606 every NEW needs_rebase flag emits task.strand-flagged (the push channel)', async () => {
|
|
990
|
+
const deps = mkDeps({
|
|
991
|
+
rows: [{ task_id: 501, builder_id: 6, worktree_name: 'wt-hand2', title: 'the title', security_sensitive: true,
|
|
992
|
+
confirmed_at: new Date().toISOString() }],
|
|
993
|
+
mergeByBranch: { 'claude/wt-hand2': { merged: false, merged_now: false, number: 501, reason: 'merge_conflict' } },
|
|
994
|
+
conflictResolveImpl: async () => ({ resolved: false, skipped: 'unresolvable_conflict', conflicted: ['a.js'] }),
|
|
995
|
+
});
|
|
996
|
+
await reconciler.runSweep(deps);
|
|
997
|
+
const strandEvents = deps._calls.events.filter((e) => e.event === 'task.strand-flagged');
|
|
998
|
+
assert.equal(strandEvents.length, 1, 'exactly one push event per new flag');
|
|
999
|
+
assert.equal(strandEvents[0].payload.id, 501);
|
|
1000
|
+
assert.equal(strandEvents[0].payload.builder_id, 6);
|
|
1001
|
+
assert.equal(strandEvents[0].payload.security_sensitive, true, 'the security flag rides along so the listener can route safely');
|
|
1002
|
+
assert.match(strandEvents[0].payload.reason, /^strand:unresolvable_conflict/);
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
test('1002606 an ALREADY-flagged strand does not re-emit (flagTaskNeedsRebase idempotence gates the event)', async () => {
|
|
1006
|
+
const old = new Date(Date.now() - 50 * 3_600_000).toISOString();
|
|
1007
|
+
const deps = mkDeps({
|
|
1008
|
+
rows: [{ task_id: 502, builder_id: 6, worktree_name: null, published_head_sha: null, confirmed_at: old }],
|
|
1009
|
+
});
|
|
1010
|
+
deps.db.flagTaskNeedsRebase = async () => false; // already flagged on a prior sweep
|
|
1011
|
+
await reconciler.runSweep(deps);
|
|
1012
|
+
assert.equal(deps._calls.events.filter((e) => e.event === 'task.strand-flagged').length, 0,
|
|
1013
|
+
'a re-sweep of a flagged strand must not spam the channel');
|
|
1014
|
+
assert.ok(deps._calls.errors.some((m) => /STRAND task 502/.test(m) && /needs_rebase already set/.test(m)),
|
|
1015
|
+
'the WARN line still fires for journal visibility');
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
test('1002606 a PR CLOSED without merging is DEFINITIVE — flagged fresh, no 30-min grace', async () => {
|
|
1019
|
+
const deps = mkDeps({
|
|
1020
|
+
rows: [{ task_id: 503, builder_id: 8, worktree_name: 'wt-deadpr', published_head_sha: NOT_ON_MAIN,
|
|
1021
|
+
confirmed_at: new Date().toISOString() }], // FRESH
|
|
1022
|
+
statusByBranch: { 'claude/wt-deadpr': { pr_state: 'closed', merged: false, merge_sha: null, deploy_status: 'none' } },
|
|
1023
|
+
ancestryBySha: { [NOT_ON_MAIN]: false }, // and the work is NOT on main out-of-band
|
|
1024
|
+
});
|
|
1025
|
+
const r = await reconciler.runSweep(deps);
|
|
1026
|
+
assert.deepEqual(deps._calls.flag, [{ taskId: 503, reason: 'strand:pr_closed_unmerged' }],
|
|
1027
|
+
'a dead PR will never land on its own — the builder is told now');
|
|
1028
|
+
assert.ok(deps._calls.errors.some((m) => /STRAND task 503/.test(m) && /closed without merging/.test(m)));
|
|
1029
|
+
assert.equal(r.strandsFlagged, 1);
|
|
1030
|
+
assert.equal(r.staleStrands, 0, 'not an AGED strand — this is the immediate path');
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
test('1002606 the 1002615 out-of-band land still wins over the dead-PR flag (proof runs first)', async () => {
|
|
1034
|
+
// Same closed-unmerged PR shape, but the work IS on main — it must ship, not flag.
|
|
1035
|
+
const deps = mkDeps({
|
|
1036
|
+
rows: [{ task_id: 504, builder_id: 8, worktree_name: 'wt-oob2', published_head_sha: ON_MAIN,
|
|
1037
|
+
confirmed_at: new Date().toISOString() }],
|
|
1038
|
+
statusByBranch: { 'claude/wt-oob2': { pr_state: 'closed', merged: false, merge_sha: null, deploy_status: 'none' } },
|
|
1039
|
+
ancestryBySha: { [ON_MAIN]: true },
|
|
1040
|
+
});
|
|
1041
|
+
const r = await reconciler.runSweep(deps);
|
|
1042
|
+
assert.equal(r.reconciled, 1, 'landed work behind a dead PR ships (1002615)');
|
|
1043
|
+
assert.equal(deps._calls.flag.length, 0, 'a landed task is never flagged');
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
test('1002606 an OPEN PR still gets the grace — fresh, not flagged (CI may be legitimately running)', async () => {
|
|
1047
|
+
const deps = mkDeps({
|
|
1048
|
+
rows: [{ task_id: 505, builder_id: 8, worktree_name: 'wt-open2', confirmed_at: new Date().toISOString() }],
|
|
1049
|
+
statusByBranch: { 'claude/wt-open2': PENDING },
|
|
1050
|
+
});
|
|
1051
|
+
const r = await reconciler.runSweep(deps);
|
|
1052
|
+
assert.equal(deps._calls.flag.length, 0, 'an in-flight open PR is not a definitive strand');
|
|
1053
|
+
assert.equal(r.strandsFlagged, 0);
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
test('1002606 a MERGED PR mid-deploy keeps the grace (merged=true never hits the dead-PR path)', async () => {
|
|
1057
|
+
const deps = mkDeps({
|
|
1058
|
+
rows: [{ task_id: 506, builder_id: 8, worktree_name: 'wt-middeploy', published_head_sha: ON_MAIN,
|
|
1059
|
+
confirmed_at: new Date().toISOString() }],
|
|
1060
|
+
statusByBranch: { 'claude/wt-middeploy': { pr_state: 'closed', merged: true, merge_sha: 'cafe1234', deploy_status: 'pending' } },
|
|
1061
|
+
ancestryBySha: { [ON_MAIN]: true },
|
|
1062
|
+
});
|
|
1063
|
+
const r = await reconciler.runSweep(deps);
|
|
1064
|
+
assert.equal(deps._calls.flag.length, 0, 'a merge whose deploy is finishing is the NORMAL landing window');
|
|
1065
|
+
assert.equal(r.reconciled, 0, 'the deploy gate still governs (1002615 exception intact)');
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
test('1002606 the aged stale strand now ALSO emits task.strand-flagged', async () => {
|
|
1069
|
+
const old = new Date(Date.now() - 50 * 3_600_000).toISOString();
|
|
1070
|
+
const deps = mkDeps({
|
|
1071
|
+
rows: [{ task_id: 507, builder_id: 9, worktree_name: null, published_head_sha: null, confirmed_at: old }],
|
|
1072
|
+
});
|
|
1073
|
+
await reconciler.runSweep(deps);
|
|
1074
|
+
const strandEvents = deps._calls.events.filter((e) => e.event === 'task.strand-flagged');
|
|
1075
|
+
assert.equal(strandEvents.length, 1);
|
|
1076
|
+
assert.equal(strandEvents[0].payload.id, 507);
|
|
1077
|
+
assert.match(strandEvents[0].payload.reason, /^strand:no_branch_no_tip/);
|
|
1078
|
+
});
|
|
1079
|
+
|
|
909
1080
|
// Drain the microtask queue, then report + exit with the right code.
|
|
910
1081
|
setTimeout(() => {
|
|
911
1082
|
console.log(`\npublish reconciler (task 1134 + 1915) tests: ${passed} passed, ${failed} failed`);
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// tests/rank_tier_single_source.mjs — the rank-tier single-source guard (BV1.R108).
|
|
2
|
+
//
|
|
3
|
+
// The canonical rank → tier map is db-kernel.RANK_TIER, DERIVED from
|
|
4
|
+
// LIVE_RANK_LADDER and exposed on the doorway (api.RANK_TIER). This guard makes
|
|
5
|
+
// the collapse hold three ways:
|
|
6
|
+
//
|
|
7
|
+
// 1. IMPORTABLE code holds the SAME OBJECT, not a copy — modules/lifecycle/db.js
|
|
8
|
+
// (the claim-floor brain) and the doorway getter are Object.is-identical to
|
|
9
|
+
// the kernel map.
|
|
10
|
+
// 2. Every DELIBERATELY-LOCAL copy is pinned byte-equivalent. These exist for
|
|
11
|
+
// documented reasons and may not be force-merged (the D1 decision,
|
|
12
|
+
// modules/discord/discord-channels.js): dependency-free substrate
|
|
13
|
+
// (permission-path-check runs in a bare git hook), source-parsed literals
|
|
14
|
+
// (src/bongos/db.js — gate-review.js + diagram-facts.js regex its text),
|
|
15
|
+
// standalone CLIs (sequence, box-connect-lib), the require-free dev-box
|
|
16
|
+
// module template (boxes.js), zero-dep catalog + its text-parsing auditor
|
|
17
|
+
// (route-rank-check), and the hall browser statics.
|
|
18
|
+
// 3. NO NEW parallel map can land: a repo scan flags any rank→number map or
|
|
19
|
+
// rank-ladder array literal outside the allowlist below, both directions —
|
|
20
|
+
// an unlisted literal fails, and a stale allowlist entry fails too.
|
|
21
|
+
//
|
|
22
|
+
// Run: node tests/rank_tier_single_source.mjs
|
|
23
|
+
|
|
24
|
+
import { strict as assert } from 'node:assert';
|
|
25
|
+
import { test } from 'node:test';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { execFileSync } from 'node:child_process';
|
|
29
|
+
import { createRequire } from 'node:module';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
34
|
+
const kernel = require(path.join(ROOT, 'src', 'bongos', 'db-kernel.js'));
|
|
35
|
+
const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
36
|
+
const coreDb = require(path.join(ROOT, 'src', 'bongos', 'db.js'));
|
|
37
|
+
const lifecycleDb = require(path.join(ROOT, 'modules', 'lifecycle', 'db.js'));
|
|
38
|
+
const ppc = require(path.join(ROOT, 'src', 'bongos', 'permission-path-check.js'));
|
|
39
|
+
const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
|
|
40
|
+
const sequence = require(path.join(ROOT, 'scripts', 'gds', 'sequence.js'));
|
|
41
|
+
const boxConnect = require(path.join(ROOT, 'scripts', 'gds', 'box-connect-lib.js'));
|
|
42
|
+
const boxes = require(path.join(ROOT, 'modules', 'dev-box', 'boxes.js'));
|
|
43
|
+
const catalog = require(path.join(ROOT, 'modules', 'governance', 'catalog.js'));
|
|
44
|
+
|
|
45
|
+
const LADDER = kernel.LIVE_RANK_LADDER;
|
|
46
|
+
const TIER = kernel.RANK_TIER;
|
|
47
|
+
const RANK_KEYS = ['xenos', 'thetes', 'metic', 'archon'];
|
|
48
|
+
|
|
49
|
+
// ===========================================================================
|
|
50
|
+
// 1 — the canonical derivation
|
|
51
|
+
// ===========================================================================
|
|
52
|
+
|
|
53
|
+
test('RANK_TIER is derived from LIVE_RANK_LADDER (1-based) and frozen', () => {
|
|
54
|
+
assert.deepEqual(LADDER, RANK_KEYS, 'the live ladder itself changed — update this guard deliberately');
|
|
55
|
+
assert.ok(Object.isFrozen(TIER));
|
|
56
|
+
assert.deepEqual(TIER, Object.fromEntries(LADDER.map((r, i) => [r, i + 1])));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('the doorway and the lifecycle brain hold the SAME object, not copies', () => {
|
|
60
|
+
assert.ok(Object.is(api.RANK_TIER, TIER), 'api.RANK_TIER must be the kernel map itself');
|
|
61
|
+
assert.ok(Object.is(api.LIVE_RANK_LADDER, LADDER));
|
|
62
|
+
assert.ok(Object.is(lifecycleDb.REQUIRES_RANK_TIER, TIER),
|
|
63
|
+
'modules/lifecycle/db.js must derive from the doorway, never hand-copy');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ===========================================================================
|
|
67
|
+
// 2 — every deliberately-local copy is pinned
|
|
68
|
+
// ===========================================================================
|
|
69
|
+
|
|
70
|
+
test('src/bongos/db.js literal (the source-parsed brain) matches the kernel map', () => {
|
|
71
|
+
assert.deepEqual(coreDb.REQUIRES_RANK_TIER, TIER);
|
|
72
|
+
// The literal must stay PARSEABLE TEXT — gate-review.js's root-of-trust region
|
|
73
|
+
// watch and diagram-facts.js both regex it out of the source.
|
|
74
|
+
const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'db.js'), 'utf8');
|
|
75
|
+
const m = /REQUIRES_RANK_TIER\s*=\s*\{([^}]*)\}/.exec(src);
|
|
76
|
+
assert.ok(m, 'db.js must keep an inline REQUIRES_RANK_TIER object literal');
|
|
77
|
+
assert.deepEqual(parsePairs(m[1]), TIER, 'the db.js literal text drifted from the kernel map');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('1-based local copies match: permission-path-check, sequence CLI', () => {
|
|
81
|
+
assert.deepEqual(ppc.RANK_TIER, TIER, 'permission-path-check drifted — the #863 failure class');
|
|
82
|
+
assert.deepEqual(sequence.RANK_TIER, TIER, 'sequence.js CLI copy drifted');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('0-based local copies match: box-connect-lib CLI, dev-box boxes.js', () => {
|
|
86
|
+
for (const [name, map] of [['box-connect-lib', boxConnect.RANK_ORDER], ['boxes', boxes.RANK_ORDER]]) {
|
|
87
|
+
assert.deepEqual(Object.keys(map).sort(), [...RANK_KEYS].sort(), `${name} keys drifted`);
|
|
88
|
+
for (const r of RANK_KEYS) {
|
|
89
|
+
assert.equal(map[r], TIER[r] - 1, `${name}.RANK_ORDER.${r} must be tier-1 (0-based)`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('ladder mirrors match: governance catalog, route-rank-check', () => {
|
|
95
|
+
assert.deepEqual([...catalog.RANK_ORDER], [...LADDER]);
|
|
96
|
+
assert.deepEqual([...rrc.PERMISSION_RANK_ORDER], [...LADDER]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Files that cannot require server code (browser statics, dependency-free
|
|
100
|
+
// module CLIs) — their literal must be EXACTLY the canonical form. The loose
|
|
101
|
+
// scanner below flags any rank-ish literal in these files; this exact check is
|
|
102
|
+
// what turns "flagged but allowlisted" into "provably not drifted".
|
|
103
|
+
const EXACT_LADDER_RE = /\[\s*'xenos',\s*'thetes',\s*'metic',\s*'archon'\s*\]/;
|
|
104
|
+
const STATIC_LADDER_FILES = [
|
|
105
|
+
'modules/discord/discord-channels.js',
|
|
106
|
+
'modules/memory/search.js',
|
|
107
|
+
'modules/hall-ui/public/ranks.js',
|
|
108
|
+
'modules/hall-ui/public/profile.js',
|
|
109
|
+
'modules/hall-ui/public/watch.js',
|
|
110
|
+
];
|
|
111
|
+
test('static ladder copies carry the exact canonical ladder', () => {
|
|
112
|
+
for (const rel of STATIC_LADDER_FILES) {
|
|
113
|
+
const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
114
|
+
assert.ok(EXACT_LADDER_RE.test(src), `${rel}: ladder literal missing or drifted from canonical order`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const STATIC_MAP_FILES = ['modules/hall-ui/public/builders.js', 'modules/hall-ui/public/goals-page.js'];
|
|
119
|
+
test('hall static tier maps match the kernel map', () => {
|
|
120
|
+
for (const rel of STATIC_MAP_FILES) {
|
|
121
|
+
const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
122
|
+
const maps = extractRankMaps(src);
|
|
123
|
+
assert.ok(maps.length > 0, `${rel}: expected a rank tier map`);
|
|
124
|
+
for (const map of maps) assert.deepEqual(map, TIER, `${rel}: tier map drifted`);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ===========================================================================
|
|
129
|
+
// 3 — the reintroduction scanner (both directions vs the allowlist)
|
|
130
|
+
// ===========================================================================
|
|
131
|
+
|
|
132
|
+
// Where a rank→number map literal is allowed to exist (each is pinned above).
|
|
133
|
+
const MAP_ALLOWLIST = new Set([
|
|
134
|
+
'src/bongos/db.js',
|
|
135
|
+
'src/bongos/permission-path-check.js',
|
|
136
|
+
'scripts/gds/sequence.js',
|
|
137
|
+
'scripts/gds/box-connect-lib.js',
|
|
138
|
+
'modules/dev-box/boxes.js',
|
|
139
|
+
'modules/hall-ui/public/builders.js',
|
|
140
|
+
'modules/hall-ui/public/goals-page.js',
|
|
141
|
+
]);
|
|
142
|
+
// Where a rank-ladder array literal is allowed to exist.
|
|
143
|
+
const LADDER_ALLOWLIST = new Set([
|
|
144
|
+
'src/bongos/db-kernel.js', // the canonical source itself
|
|
145
|
+
'modules/governance/catalog.js',
|
|
146
|
+
'src/bongos/route-rank-check.js',
|
|
147
|
+
...STATIC_LADDER_FILES,
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
// A rank→digit pair inside an object block. Hex values (RANK_COLORS) never
|
|
151
|
+
// match: \d+ must run straight into , or }.
|
|
152
|
+
const pairRe = (rank) => new RegExp(`(?:^|[{,\\s'"])${rank}['"]?\\s*:\\s*\\d+\\s*[,}]`);
|
|
153
|
+
// ≥3 rank keys with digit values in one object literal = a tier map — including
|
|
154
|
+
// the drifted 3-key shape that caused #863 (a rank silently missing).
|
|
155
|
+
function hasRankMap(src) {
|
|
156
|
+
for (const m of src.matchAll(/\{[^{}]*\}/g)) {
|
|
157
|
+
if (RANK_KEYS.filter((r) => pairRe(r).test(m[0])).length >= 3) return true;
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
function parsePairs(inner) {
|
|
162
|
+
const out = {};
|
|
163
|
+
for (const m of inner.matchAll(/([a-z]+)['"]?\s*:\s*(\d+)/g)) out[m[1]] = Number(m[2]);
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
function extractRankMaps(src) {
|
|
167
|
+
const out = [];
|
|
168
|
+
for (const m of src.matchAll(/\{[^{}]*\}/g)) {
|
|
169
|
+
if (RANK_KEYS.filter((r) => pairRe(r).test(m[0])).length >= 3) {
|
|
170
|
+
out.push(parsePairs(m[0].slice(1, -1)));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
// ≥3 CONSECUTIVE live-rank strings in one array literal = a ladder copy.
|
|
176
|
+
const LOOSE_LADDER_RE = /\[\s*(?:['"](?:xenos|thetes|metic|archon)['"]\s*,?\s*){3,}\]/;
|
|
177
|
+
|
|
178
|
+
test('no rank map or ladder literal exists outside the allowlist (and none is stale)', () => {
|
|
179
|
+
const tracked = execFileSync('git', ['-C', ROOT, 'ls-files', 'src', 'modules', 'scripts'], { encoding: 'utf8' })
|
|
180
|
+
.split('\n').map((l) => l.trim())
|
|
181
|
+
.filter((f) => f.endsWith('.js') && !f.includes('/tests/') && !f.endsWith('.test.js'));
|
|
182
|
+
assert.ok(tracked.length > 50, `suspiciously few tracked files (${tracked.length}) — scanner broken?`);
|
|
183
|
+
|
|
184
|
+
const mapHits = new Set();
|
|
185
|
+
const ladderHits = new Set();
|
|
186
|
+
for (const rel of tracked) {
|
|
187
|
+
const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
188
|
+
if (hasRankMap(src)) mapHits.add(rel);
|
|
189
|
+
if (LOOSE_LADDER_RE.test(src)) ladderHits.add(rel);
|
|
190
|
+
}
|
|
191
|
+
for (const f of mapHits) {
|
|
192
|
+
assert.ok(MAP_ALLOWLIST.has(f),
|
|
193
|
+
`${f} declares a rank→tier map literal. Derive it from api.RANK_TIER / db-kernel.RANK_TIER instead — or, if it genuinely cannot require server code, add it here WITH a pinning assertion above.`);
|
|
194
|
+
}
|
|
195
|
+
for (const f of ladderHits) {
|
|
196
|
+
assert.ok(LADDER_ALLOWLIST.has(f),
|
|
197
|
+
`${f} declares a rank-ladder array literal. Derive it from api.LIVE_RANK_LADDER instead — or, if it genuinely cannot require server code, add it here WITH a pinning assertion above.`);
|
|
198
|
+
}
|
|
199
|
+
for (const f of MAP_ALLOWLIST) {
|
|
200
|
+
assert.ok(mapHits.has(f), `allowlist entry ${f} no longer contains a rank map — prune it`);
|
|
201
|
+
}
|
|
202
|
+
for (const f of LADDER_ALLOWLIST) {
|
|
203
|
+
assert.ok(ladderHits.has(f), `allowlist entry ${f} no longer contains a ladder literal — prune it`);
|
|
204
|
+
}
|
|
205
|
+
});
|