@bongos/core 1.18.19 → 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 +514 -194
- 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 +5 -5
- 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 -13
- 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 +1 -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
package/tests/recall_hints.mjs
CHANGED
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
|
|
11
11
|
import { strict as assert } from 'node:assert';
|
|
12
12
|
import { createRequire } from 'node:module';
|
|
13
|
+
import { requireHostModule } from './helpers.mjs';
|
|
13
14
|
|
|
14
15
|
const require = createRequire(import.meta.url);
|
|
15
16
|
const { taskQuery, pickActiveClaim, formatHints, DEADLINE_MS } =
|
|
16
|
-
require
|
|
17
|
+
requireHostModule(require, '../.claude/hooks/recall-hints.js');
|
|
17
18
|
|
|
18
19
|
let passed = 0;
|
|
19
20
|
let failed = 0;
|
package/tests/repo_map.mjs
CHANGED
|
@@ -71,6 +71,10 @@ test('each nested CLAUDE.md carries a delimited generated block with signatures'
|
|
|
71
71
|
const doc = readFileSync(path.join(ROOT, dir, 'CLAUDE.md'), 'utf8');
|
|
72
72
|
assert.ok(doc.includes(BEGIN), `${dir}/CLAUDE.md should have a generated-block start marker`);
|
|
73
73
|
assert.ok(doc.includes(END), `${dir}/CLAUDE.md should have a generated-block end marker`);
|
|
74
|
+
// In a PUBLISHED core the nested agent notes are replaced by package-core.js's
|
|
75
|
+
// CORE_AGENT_DOC_STUB, so there is no hand-written prose to preserve (task 1002470).
|
|
76
|
+
// Where the real doc is present the prose must survive the generated-block injection.
|
|
77
|
+
if (doc.includes('@bongos/core — installed platform dependency')) continue;
|
|
74
78
|
// the hand-written prose is preserved alongside the generated block
|
|
75
79
|
assert.match(doc, /Nested CLAUDE\.md — loads on demand/);
|
|
76
80
|
}
|
|
@@ -41,12 +41,16 @@ const rowOf = (src) => rrc.classifyText(src)[0];
|
|
|
41
41
|
// here ONCE by hand. This is the independent side of every classification assertion
|
|
42
42
|
// below — if someone lowers `backup.manage` to 'xenos', the classifier follows the
|
|
43
43
|
// catalog and this file goes red, which is the whole point.
|
|
44
|
+
// ADR 0157 moved 20 atoms archon→metic. `audit_log.read` and `builder.roster.read` are
|
|
45
|
+
// transcribed at their new floor below; the archon entries that remain are the
|
|
46
|
+
// rank/identity core, which is the point of that ADR.
|
|
44
47
|
const HAND_WRITTEN_FLOORS = {
|
|
45
48
|
'builder.rank.set': 'archon',
|
|
46
|
-
'audit_log.read': 'archon',
|
|
47
49
|
'auth.session.revoke': 'archon',
|
|
48
|
-
'builder.roster.read': 'archon',
|
|
49
50
|
'builder.status.set': 'archon',
|
|
51
|
+
'access_request.review': 'archon',
|
|
52
|
+
'audit_log.read': 'metic',
|
|
53
|
+
'builder.roster.read': 'metic',
|
|
50
54
|
'backup.manage': 'metic',
|
|
51
55
|
'idea.triage': 'metic',
|
|
52
56
|
'security.report.file': 'metic',
|
|
@@ -132,13 +136,17 @@ test('P1: the label agrees with the REAL threshold function over the whole rank
|
|
|
132
136
|
});
|
|
133
137
|
|
|
134
138
|
test('P1: AND semantics — a multi-atom gate takes the STRICTEST floor', () => {
|
|
135
|
-
|
|
139
|
+
// Uses `builder.rank.set` as the archon end of the pair: ADR 0157 lowered
|
|
140
|
+
// `audit_log.read` (the previous example) to metic, which would no longer exercise the
|
|
141
|
+
// metic-vs-archon spread this test exists to prove. Rank-setting is the one power that
|
|
142
|
+
// ADR 0157 guarantees stays archon, so it is the stable choice here.
|
|
143
|
+
const src = "router.post('/x', auth.requireBuilder, auth.requirePermission('idea.file', 'builder.rank.set'), h);";
|
|
136
144
|
assert.equal(rankOf(src), 'archon',
|
|
137
145
|
'requirePermission ANDs, so the gate is as strict as its strictest atom (archon), not its loosest');
|
|
138
146
|
const src2 = "router.post('/x', auth.requireBuilder, auth.requirePermission('cost.log', 'backup.manage'), h);";
|
|
139
147
|
assert.equal(rankOf(src2), 'metic+archon');
|
|
140
148
|
// order must not matter
|
|
141
|
-
const src3 = "router.post('/x', auth.requireBuilder, auth.requirePermission('
|
|
149
|
+
const src3 = "router.post('/x', auth.requireBuilder, auth.requirePermission('builder.rank.set', 'idea.file'), h);";
|
|
142
150
|
assert.equal(rankOf(src3), 'archon');
|
|
143
151
|
});
|
|
144
152
|
|
|
@@ -274,7 +282,8 @@ test('P4: gen-api-docs labels a permission-gated route by its floor, not any-bui
|
|
|
274
282
|
const trigger = model.find((r) => r.route === '/backup/trigger');
|
|
275
283
|
const audit = model.find((r) => r.route === '/audit-log');
|
|
276
284
|
assert.equal(trigger.rank, 'metic+archon', 'the published rank must be the real floor');
|
|
277
|
-
|
|
285
|
+
// ADR 0157: `audit_log.read` floor archon→metic, so the published rank follows.
|
|
286
|
+
assert.equal(audit.rank, 'metic+archon');
|
|
278
287
|
assert.deepEqual(trigger.perms, ['backup.manage'], 'the docs also name the authority atom');
|
|
279
288
|
|
|
280
289
|
const spec = genApiDocs.buildOpenapi(model);
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { strict as assert } from 'node:assert';
|
|
24
24
|
import { readFileSync, existsSync } from 'node:fs';
|
|
25
25
|
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { skipUnlessPresent } from './helpers.mjs';
|
|
26
27
|
import { dirname, resolve } from 'node:path';
|
|
27
28
|
|
|
28
29
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -30,6 +31,8 @@ const ROOT = resolve(HERE, '..');
|
|
|
30
31
|
const FIXTURES_DIR = resolve(ROOT, 'modules', 'art-pipeline', 'template', 'rubric_fixtures');
|
|
31
32
|
const MANIFEST = resolve(FIXTURES_DIR, 'fixtures.json');
|
|
32
33
|
const RUBRIC = resolve(ROOT, 'modules', 'art-pipeline', 'template', 'rubric.json');
|
|
34
|
+
skipUnlessPresent('modules/art-pipeline/template/rubric_fixtures',
|
|
35
|
+
'The art-pipeline module is not installed in this instance.');
|
|
33
36
|
|
|
34
37
|
let passed = 0;
|
|
35
38
|
function test(name, fn) {
|
|
@@ -19,11 +19,13 @@ import fs from 'node:fs';
|
|
|
19
19
|
import os from 'node:os';
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { skipUnlessPresent } from './helpers.mjs';
|
|
22
23
|
|
|
23
24
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
25
|
const REPO = path.resolve(__dirname, '..'); // the core root (this repo), fixed by resolveCoreRoot()
|
|
25
26
|
const RUN_ROUTINE = path.join(REPO, 'scripts', 'gds', 'run-routine.js');
|
|
26
27
|
const CORE_RESOLVE = path.join(REPO, '.claude', 'hooks', '_core-resolve.js');
|
|
28
|
+
skipUnlessPresent('.claude/hooks/_core-resolve.js');
|
|
27
29
|
|
|
28
30
|
let passed = 0, failed = 0;
|
|
29
31
|
function test(name, fn) {
|
package/tests/sandbox_stage.mjs
CHANGED
|
@@ -240,7 +240,10 @@ function boxDeps(markerFile, overrides = {}) {
|
|
|
240
240
|
if (cmd === 'git') return execFileSync(cmd, args, opts);
|
|
241
241
|
return '';
|
|
242
242
|
},
|
|
243
|
-
|
|
243
|
+
// task 1002607: emulate a real PREVIEW — 200 on the health URL, 404 on the
|
|
244
|
+
// /api/gds/healthz discriminator (the game-only entry mounts no /api/gds).
|
|
245
|
+
// A flat always-200 mock would now read as "the MAIN server answered".
|
|
246
|
+
fetchImpl: async (url) => ({ status: String(url).includes('/api/gds/healthz') ? 404 : 200 }),
|
|
244
247
|
markerPath: markerFile,
|
|
245
248
|
cwd: tmp,
|
|
246
249
|
pollMs: 1,
|
|
@@ -346,11 +349,19 @@ await ta('runShipGate: --skip-stage → ok, untouched', async () => {
|
|
|
346
349
|
assert.equal(fs.existsSync(mp), false);
|
|
347
350
|
});
|
|
348
351
|
|
|
349
|
-
|
|
352
|
+
// task 1002607 (§4.4 station 9): a staging failure used to WARN one stderr line
|
|
353
|
+
// and return ok — the review gate silently turned itself off. It now fails
|
|
354
|
+
// CLOSED (like the smoke gate, task 1002605): abort, claim intact, --skip-stage
|
|
355
|
+
// as the explicit bypass, and the reason says plainly the review DID NOT run.
|
|
356
|
+
await ta('1002607 runShipGate: staging failure (health never green) ABORTS — review gate cannot silently turn itself off', async () => {
|
|
350
357
|
const mp = path.join(markerDir, 'm8.json');
|
|
351
358
|
const deps = boxDeps(mp, { fetchImpl: async () => ({ status: 502 }), timeoutMs: 10 });
|
|
352
359
|
const r = await sbx.runShipGate({ taskId: 927, files: ['public/game/a.js'], skipStage: false, tty: true }, deps);
|
|
353
|
-
assert.equal(r.ok,
|
|
360
|
+
assert.equal(r.ok, false, 'a failed stage must not ship as if the review happened');
|
|
361
|
+
assert.equal(r.review, undefined, 'a failure to present is NOT a calm review pause');
|
|
362
|
+
assert.match(r.reason, /DID NOT run/, 'says plainly the review step did not run');
|
|
363
|
+
assert.match(r.reason, /--skip-stage/, 'names the explicit bypass');
|
|
364
|
+
assert.match(r.reason, /claim is still active/i, 'claim-preserving');
|
|
354
365
|
});
|
|
355
366
|
|
|
356
367
|
// ---------- recorded self-approval (#1048), box ----------
|
|
@@ -502,12 +513,62 @@ await ta('stage(box): a HARD verify failure → {ok:false,hard:true}, no marker'
|
|
|
502
513
|
assert.equal(fs.existsSync(mp), false, 'no marker on a hard verify failure');
|
|
503
514
|
});
|
|
504
515
|
|
|
505
|
-
await ta('runShipGate(box): a SOFT failure (preview never healthy)
|
|
516
|
+
await ta('1002607 runShipGate(box): a SOFT failure (preview never healthy) now fails closed — abort, claim intact', async () => {
|
|
506
517
|
const mp = path.join(markerDir, 'soft1.json');
|
|
507
|
-
// sync ok (injected), but health never green →
|
|
518
|
+
// sync ok (injected), but health never green → the ship must NOT continue
|
|
519
|
+
// as if the review ran (that was the silent-disable defect).
|
|
508
520
|
const deps = boxDeps(mp, { fetchImpl: async () => ({ status: 502 }), timeoutMs: 10 });
|
|
509
521
|
const r = await sbx.runShipGate({ taskId: 1068, files: ['public/game/a.js'], skipStage: false, tty: true }, deps);
|
|
510
|
-
assert.equal(r.ok,
|
|
522
|
+
assert.equal(r.ok, false, 'the soft failure aborts instead of silently skipping the review');
|
|
523
|
+
assert.match(r.reason, /sandbox staging failed/);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// ---------- task 1002607: waitHealthy truth table ----------
|
|
527
|
+
|
|
528
|
+
await ta('1002607 waitHealthy: 404 at the health URL is NOT healthy (was: anything < 500 passed)', async () => {
|
|
529
|
+
const r = await sbx.waitHealthy({ fetchImpl: async () => ({ status: 404 }), timeoutMs: 10, pollMs: 1 });
|
|
530
|
+
assert.equal(r.ok, false);
|
|
531
|
+
assert.match(r.reason, /HTTP 404/);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
await ta('1002607 waitHealthy: a 200 answered by the MAIN server (api healthz 200) is an impostor, not a healthy preview', async () => {
|
|
535
|
+
// The full server binds the same 127.0.0.1:3000 — root answers 200, and so does
|
|
536
|
+
// /api/gds/healthz. The game-only preview (ADR 0052) would 404 the latter.
|
|
537
|
+
const r = await sbx.waitHealthy({ fetchImpl: async () => ({ status: 200 }), timeoutMs: 1000, pollMs: 1 });
|
|
538
|
+
assert.equal(r.ok, false, 'a dead preview must not report healthy off the main server');
|
|
539
|
+
assert.match(r.reason, /MAIN server/);
|
|
540
|
+
assert.match(r.reason, /preview did not start/);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
await ta('1002607 waitHealthy: a real preview (root 200, api healthz 404) is healthy', async () => {
|
|
544
|
+
const r = await sbx.waitHealthy({
|
|
545
|
+
fetchImpl: async (url) => ({ status: String(url).includes('/api/gds/healthz') ? 404 : 200 }),
|
|
546
|
+
timeoutMs: 1000, pollMs: 1,
|
|
547
|
+
});
|
|
548
|
+
assert.equal(r.ok, true);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
await ta('1002607 waitHealthy: a discriminator probe that ERRORS counts as "not the main server" (preview healthy)', async () => {
|
|
552
|
+
const r = await sbx.waitHealthy({
|
|
553
|
+
fetchImpl: async (url) => {
|
|
554
|
+
if (String(url).includes('/api/gds/healthz')) throw new Error('ECONNREFUSED on a path the preview never mounts');
|
|
555
|
+
return { status: 200 };
|
|
556
|
+
},
|
|
557
|
+
timeoutMs: 1000, pollMs: 1,
|
|
558
|
+
});
|
|
559
|
+
assert.equal(r.ok, true, 'only a definite 200 on api healthz marks the impostor');
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
t('1002607 apiHealthzProbeUrl: same origin as the health URL, api path; null on garbage', () => {
|
|
563
|
+
assert.equal(sbx.apiHealthzProbeUrl('http://127.0.0.1:3000/'), 'http://127.0.0.1:3000/api/gds/healthz');
|
|
564
|
+
assert.equal(sbx.apiHealthzProbeUrl('http://127.0.0.1:3100/healthz'), 'http://127.0.0.1:3100/api/gds/healthz');
|
|
565
|
+
assert.equal(sbx.apiHealthzProbeUrl('not a url'), null);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
t('1002607 NO_SANDBOX_MSG: names what is actually missing, not the builder\'s clone', () => {
|
|
569
|
+
assert.match(sbx.NO_SANDBOX_MSG, /src\/preview-server\.js/, 'names the missing entry file');
|
|
570
|
+
assert.match(sbx.NO_SANDBOX_MSG, /ADR 0052/, 'points at the contract that defines it');
|
|
571
|
+
assert.doesNotMatch(sbx.NO_SANDBOX_MSG, /this clone has no game preview entry/, 'the old your-clone-is-at-fault phrasing is gone');
|
|
511
572
|
});
|
|
512
573
|
|
|
513
574
|
// ---------- runShipGate: LOCAL preview context + recorded approval (task 1056) ----------
|
|
@@ -529,7 +590,8 @@ function localDeps(markerFile, overrides = {}) {
|
|
|
529
590
|
writeFileSync: (p, d) => fs.writeFileSync(p, d),
|
|
530
591
|
},
|
|
531
592
|
execFileSync: (cmd, args, opts) => (cmd === 'git' ? execFileSync(cmd, args, opts) : ''),
|
|
532
|
-
|
|
593
|
+
// task 1002607: preview-shaped mock — health 200, API-healthz discriminator 404.
|
|
594
|
+
fetchImpl: async (url) => ({ status: String(url).includes('/api/gds/healthz') ? 404 : 200 }),
|
|
533
595
|
markerPath: markerFile,
|
|
534
596
|
cwd: tmp,
|
|
535
597
|
pollMs: 1,
|
package/tests/search_ingest.mjs
CHANGED
|
@@ -30,6 +30,13 @@ test('metic-tagging: read-sensitive doc surfaces → metic', () => {
|
|
|
30
30
|
assert.equal(minRankForDoc('docs/audits/2026-06-08-server-security-audit.md'), 'metic');
|
|
31
31
|
assert.equal(minRankForDoc('docs/adr/0016-trust-boundary-server-enforced-permissions.md'), 'metic');
|
|
32
32
|
assert.equal(minRankForDoc('docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md'), 'metic');
|
|
33
|
+
// task 1002598: docs/governance/ holds the complete gate enumeration (the authority
|
|
34
|
+
// sweep) and the choke-point map, which catalogue live fail-open paths by file:line.
|
|
35
|
+
// Served at xenos they were a searchable exploitation index for the population they
|
|
36
|
+
// describe; the read floor matches security.report.adjudicate, where ADR 0158 §6
|
|
37
|
+
// sends the same class of detail.
|
|
38
|
+
assert.equal(minRankForDoc('docs/governance/rank-choke-point-map-2026-08-06.md'), 'metic');
|
|
39
|
+
assert.equal(minRankForDoc('docs/governance/authority-sweep-2026-07-24.md'), 'metic');
|
|
33
40
|
});
|
|
34
41
|
|
|
35
42
|
test('metic-tagging: ordinary docs → xenos (read-public)', () => {
|
package/tests/security_docs.mjs
CHANGED
|
@@ -18,8 +18,11 @@
|
|
|
18
18
|
// - the slug character-class regex rejects non-kebab slugs
|
|
19
19
|
//
|
|
20
20
|
// Rank wiring (source assertion — mirrors tests/cli_token_reissue.mjs):
|
|
21
|
-
// - all three /security/docs* routes
|
|
22
|
-
//
|
|
21
|
+
// - all three /security/docs* routes gate on the atom
|
|
22
|
+
// auth.requirePermission('security.report.adjudicate') (BV1.R104; was
|
|
23
|
+
// requireRank('archon') and admitted exactly the same builders, ADR 0151)
|
|
24
|
+
// - that atom's floor moved archon→metic in ADR 0157, so the routes now classify
|
|
25
|
+
// 'metic+archon' and are pinned BY ATOM (`perm:…`) rather than by rank
|
|
23
26
|
//
|
|
24
27
|
// We stub the pool by pre-seeding the require cache for src/db.js so the db
|
|
25
28
|
// module under test never opens a socket — the same monkeypatch seam the repo
|
|
@@ -167,18 +170,37 @@ await test('slug character-class regex rejects non-kebab slugs', () => {
|
|
|
167
170
|
|
|
168
171
|
console.log('\nsecurity_docs — rank wiring (source + pin):');
|
|
169
172
|
|
|
170
|
-
await test('all three /security/docs* routes
|
|
173
|
+
await test('all three /security/docs* routes gate on security.report.adjudicate (metic floor)', () => {
|
|
171
174
|
const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'routes', 'security.js'), 'utf8');
|
|
172
175
|
const routes = rrc.classifyText(src).filter((r) => r.route.startsWith('/security/docs'));
|
|
173
176
|
assert.equal(routes.length, 3, 'expected GET list, GET :slug, POST');
|
|
174
177
|
for (const r of routes) {
|
|
175
|
-
|
|
178
|
+
// The ATOM is the authority once governance grows custom roles; the rank is what
|
|
179
|
+
// that atom's catalog floor resolves to today. Assert both, so neither a swapped
|
|
180
|
+
// atom nor an UNINTENDED floor edit can quietly widen who reads the exploit cookbook.
|
|
181
|
+
//
|
|
182
|
+
// ADR 0157 made that widening INTENTIONALLY: `security.report.adjudicate` moved
|
|
183
|
+
// archon→metic with the rest of the operational block, so the engagement-report
|
|
184
|
+
// store is now readable by the working rank. That is the one place in the ADR where
|
|
185
|
+
// delegation carries a genuine CONFIDENTIALITY cost rather than just an
|
|
186
|
+
// administrative one — every other delegated atom widens who may *act*, this one
|
|
187
|
+
// widens who may *read attacker-usable detail*. If that trade is unwanted, the fix
|
|
188
|
+
// is to split the atom the way ADR 0157 §4 split `gate_approval.read` off
|
|
189
|
+
// `gate_approval.decide`: keep a `security.docs.read` at archon for the cookbook and
|
|
190
|
+
// let report adjudication delegate. Recorded here so the next reader sees the choice
|
|
191
|
+
// rather than inferring it from a floor value.
|
|
192
|
+
assert.deepEqual(r.perms, ['security.report.adjudicate'],
|
|
193
|
+
`${r.key} must gate on the adjudicate atom (serves exploit detail)`);
|
|
194
|
+
assert.equal(r.rank, 'metic+archon', `${r.key} must classify as metic+archon (ADR 0157)`);
|
|
176
195
|
}
|
|
177
196
|
});
|
|
178
197
|
|
|
179
|
-
await test('route-rank-check pins all three /security/docs* routes
|
|
198
|
+
await test('route-rank-check pins all three /security/docs* routes by atom', () => {
|
|
199
|
+
// Re-pinned from the rank 'archon' to the ATOM in ADR 0157: the invariant worth
|
|
200
|
+
// guarding is "these gate on the adjudicate permission", which survives a floor move.
|
|
180
201
|
for (const key of ['GET /security/docs', 'GET /security/docs/:slug', 'POST /security/docs']) {
|
|
181
|
-
assert.equal(rrc.EXPECTED_RANKS[key], '
|
|
202
|
+
assert.equal(rrc.EXPECTED_RANKS[key], 'perm:security.report.adjudicate',
|
|
203
|
+
`${key} must be pinned to the adjudicate atom in EXPECTED_RANKS`);
|
|
182
204
|
}
|
|
183
205
|
});
|
|
184
206
|
|
package/tests/sequence.mjs
CHANGED
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
topoOrder,
|
|
14
14
|
chainFrom,
|
|
15
15
|
haltReasons,
|
|
16
|
+
pickNext,
|
|
16
17
|
summarize,
|
|
17
18
|
MIN_SPEC_CHARS,
|
|
18
19
|
} = require('../scripts/gds/sequence.js');
|
|
@@ -263,5 +264,68 @@ t('summarize tolerates missing numbers', () => {
|
|
|
263
264
|
{ count: 1, credits: 0, minutes: 0 });
|
|
264
265
|
});
|
|
265
266
|
|
|
267
|
+
// ------------------------------------------------------- pickNext (--next)
|
|
268
|
+
|
|
269
|
+
// The live regression this closes (goal 1000030, 2026-08-05): a backlog task
|
|
270
|
+
// sorted to position 0 and --next reported "nothing runnable" while two ready
|
|
271
|
+
// tasks sat behind it.
|
|
272
|
+
const GOAL_1000030 = () => [
|
|
273
|
+
task({ id: 1002556, status: 'backlog', title: 'perf: fold the resolve' }),
|
|
274
|
+
task({ id: 1001850, status: 'ready', title: 'R108 — collapse rank-tier maps' }),
|
|
275
|
+
task({ id: 1001858, status: 'ready', title: 'R110 — governance API routes' }),
|
|
276
|
+
];
|
|
277
|
+
const claimable = (...ids) => ({ rank: 'archon', claimableIds: new Set(ids) });
|
|
278
|
+
|
|
279
|
+
t('pickNext offers the head when the head is clear', () => {
|
|
280
|
+
const plan = GOAL_1000030().slice(1);
|
|
281
|
+
const r = pickNext(plan, claimable(1001850, 1001858));
|
|
282
|
+
assert.equal(r.next.id, 1001850);
|
|
283
|
+
assert.deepEqual(r.skipped, []);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
t('pickNext skips a blocked head and offers the first runnable task', () => {
|
|
287
|
+
const r = pickNext(GOAL_1000030(), claimable(1001850, 1001858));
|
|
288
|
+
assert.equal(r.next.id, 1001850, 'must not dead-end on the backlog task at position 0');
|
|
289
|
+
assert.equal(r.skipped.length, 1);
|
|
290
|
+
assert.equal(r.skipped[0].id, 1002556);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
t('pickNext reports WHY each skipped task was passed over', () => {
|
|
294
|
+
const r = pickNext(GOAL_1000030(), claimable(1001850, 1001858));
|
|
295
|
+
assert.ok(r.skipped[0].reasons.some((x) => x.code === 'deps_unmet'));
|
|
296
|
+
assert.ok(r.skipped[0].title, 'skipped entries carry a title for the printout');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
t('pickNext keeps topological order among the runnable — first clear wins', () => {
|
|
300
|
+
const r = pickNext(GOAL_1000030(), claimable(1001850, 1001858));
|
|
301
|
+
assert.equal(r.next.id, 1001850, 'not 1001858, which sorts later');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
t('pickNext still halts when NOTHING in the plan clears', () => {
|
|
305
|
+
const plan = [
|
|
306
|
+
task({ id: 1, status: 'backlog' }),
|
|
307
|
+
task({ id: 2, status: 'shipped' }),
|
|
308
|
+
task({ id: 3, status: 'ready', requires_rank: 'archon' }),
|
|
309
|
+
];
|
|
310
|
+
const r = pickNext(plan, { rank: 'xenos', claimableIds: new Set([3]) });
|
|
311
|
+
assert.equal(r.next, null);
|
|
312
|
+
assert.equal(r.skipped.length, 3, 'every task was tried');
|
|
313
|
+
assert.equal(r.head.id, 1, 'the head is preserved so the halt prints its reasons');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
t('pickNext does not weaken a gate — a ready-but-unclaimable task is skipped, not offered', () => {
|
|
317
|
+
const plan = [task({ id: 9, status: 'ready' })];
|
|
318
|
+
const r = pickNext(plan, claimable(/* 9 absent from the claimable feed */));
|
|
319
|
+
assert.equal(r.next, null);
|
|
320
|
+
assert.ok(r.skipped[0].reasons.some((x) => x.code === 'not_claimable'));
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
t('pickNext on an empty plan returns no pick and nothing skipped', () => {
|
|
324
|
+
const r = pickNext([], claimable());
|
|
325
|
+
assert.equal(r.next, null);
|
|
326
|
+
assert.deepEqual(r.skipped, []);
|
|
327
|
+
assert.equal(r.head, null);
|
|
328
|
+
});
|
|
329
|
+
|
|
266
330
|
console.log(`\nsequence: ${passed} passed, ${failed} failed`);
|
|
267
331
|
process.exit(failed ? 1 : 0);
|
package/tests/session-cards.mjs
CHANGED
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
|
|
13
13
|
import { strict as assert } from 'node:assert';
|
|
14
14
|
import { createRequire } from 'node:module';
|
|
15
|
+
import { requireHostModule } from './helpers.mjs';
|
|
15
16
|
|
|
16
17
|
const require = createRequire(import.meta.url);
|
|
17
|
-
const hook = require
|
|
18
|
+
const hook = requireHostModule(require, '../.claude/hooks/session-cards.js');
|
|
18
19
|
|
|
19
20
|
let passed = 0;
|
|
20
21
|
let failed = 0;
|
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
|
|
17
17
|
import { strict as assert } from 'node:assert';
|
|
18
18
|
import { createRequire } from 'node:module';
|
|
19
|
+
import { requireHostModule } from './helpers.mjs';
|
|
19
20
|
|
|
20
21
|
const require = createRequire(import.meta.url);
|
|
21
|
-
const { decideFreshness } = require
|
|
22
|
+
const { decideFreshness } = requireHostModule(require, '../.claude/hooks/session-start.js');
|
|
22
23
|
|
|
23
24
|
let passed = 0;
|
|
24
25
|
let failed = 0;
|
|
@@ -171,5 +171,67 @@ test('laptop deploy mode → never server-handled, even with server push (false)
|
|
|
171
171
|
});
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
-
|
|
174
|
+
// ---- 3. task 1002606: the strand notice (task.strand-flagged → Discord) ----
|
|
175
|
+
// A strand previously had NO push channel — Discord fired only on task.shipped, so
|
|
176
|
+
// a builder learned of a dead land at their next /builder-start, up to a session
|
|
177
|
+
// later. These pin the poster the reconciler's task.strand-flagged listener fires.
|
|
178
|
+
|
|
179
|
+
test('1002606 chooseStrandTargets: non-security strand → #ship-feed; line does NOT lead with #<id>', () => {
|
|
180
|
+
const t = shipBroadcast.chooseStrandTargets({ securitySensitive: false, taskId: 88, reason: 'strand:pr_closed_unmerged' });
|
|
181
|
+
assert.equal(t.length, 1);
|
|
182
|
+
assert.equal(t[0].channel, 'ship_feed');
|
|
183
|
+
assert.match(t[0].line, /task 88/);
|
|
184
|
+
assert.match(t[0].line, /strand:pr_closed_unmerged/);
|
|
185
|
+
// A LEADING #<id> is the applause marker (discord-reaction.parseShipMarker) —
|
|
186
|
+
// a strand must never collect 👍-votes as if it were shipped work.
|
|
187
|
+
assert.ok(!/^\s*#\d/.test(t[0].line), 'strand line must not start with a #<id> ship marker');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('1002606 chooseStrandTargets: security strand → ONLY #security-ship-feed (reason can name an unpatched fix)', () => {
|
|
191
|
+
const t = shipBroadcast.chooseStrandTargets({ securitySensitive: true, taskId: 91, reason: 'strand:unresolvable_conflict:src/gds/auth.js' });
|
|
192
|
+
assert.equal(t.length, 1);
|
|
193
|
+
assert.equal(t[0].channel, 'security_ship_feed');
|
|
194
|
+
for (const { channel } of t) assert.ok(!PUBLIC_CHANNELS.has(channel));
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await atest('1002606 postStrandNotice: posts to #ship-feed for a plain strand', async () => {
|
|
198
|
+
const { fn, calls } = fakePoster();
|
|
199
|
+
const res = await shipBroadcast.postStrandNotice(
|
|
200
|
+
{ id: 88, builder_id: 7, security_sensitive: false, reason: 'strand:pr_closed_unmerged' },
|
|
201
|
+
{ postWebhook: fn, log: quietLog }
|
|
202
|
+
);
|
|
203
|
+
assert.deepEqual(res.posted, ['ship_feed']);
|
|
204
|
+
assert.equal(calls.length, 1);
|
|
205
|
+
assert.match(calls[0].line, /task 88/);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await atest('1002606 postStrandNotice: indeterminate security flag → FAIL CLOSED (no post)', async () => {
|
|
209
|
+
for (const sec of [null, undefined, 'true', 1]) {
|
|
210
|
+
const { fn, calls } = fakePoster();
|
|
211
|
+
const res = await shipBroadcast.postStrandNotice(
|
|
212
|
+
{ id: 9, security_sensitive: sec, reason: 'strand:x' },
|
|
213
|
+
{ postWebhook: fn, log: quietLog }
|
|
214
|
+
);
|
|
215
|
+
assert.equal(calls.length, 0, `flag=${String(sec)} must suppress`);
|
|
216
|
+
assert.equal(res.skipped, 'security_flag_indeterminate');
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await atest('1002606 postStrandNotice: best-effort — a throwing webhook never propagates; no task → no_task', async () => {
|
|
221
|
+
const boom = async () => { throw new Error('webhook down'); };
|
|
222
|
+
let threw = false;
|
|
223
|
+
let res;
|
|
224
|
+
try {
|
|
225
|
+
res = await shipBroadcast.postStrandNotice(
|
|
226
|
+
{ id: 10, security_sensitive: false, reason: 'strand:y' },
|
|
227
|
+
{ postWebhook: boom, log: quietLog }
|
|
228
|
+
);
|
|
229
|
+
} catch (_) { threw = true; }
|
|
230
|
+
assert.equal(threw, false, 'postStrandNotice must never throw');
|
|
231
|
+
assert.deepEqual(res.posted, [], 'a failed post is not recorded as posted');
|
|
232
|
+
const { fn } = fakePoster();
|
|
233
|
+
assert.equal((await shipBroadcast.postStrandNotice(null, { postWebhook: fn, log: quietLog })).skipped, 'no_task');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
console.log(`\nship broadcast server (task 1045 + 1002606) tests: ${passed} passed, ${failed} failed`);
|
|
175
237
|
process.exit(failed > 0 ? 1 : 0);
|
package/tests/ship_card.mjs
CHANGED
|
@@ -105,6 +105,72 @@ test('confirmed: info tone, auto-landing deploy, merge-mode override action', ()
|
|
|
105
105
|
assert.equal(m.action.cmd, '/merge-mode'); // kept as an optional manual override
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
// ---- task 1002541: the card must never claim a bailed merge is landing ----
|
|
109
|
+
//
|
|
110
|
+
// The bug: deployText came from the STATUS alone, so a 'confirmed' task printed
|
|
111
|
+
// "landing automatically" even when auto-merge had already given up — no PR, no
|
|
112
|
+
// merge, and (ADR 0120, pay on land) no credits. The failure announced itself as
|
|
113
|
+
// success, which is why these strands survived for days.
|
|
114
|
+
const bailed = {
|
|
115
|
+
...shippedInput,
|
|
116
|
+
postGradeStatus: 'confirmed',
|
|
117
|
+
land: { ok: false, pending: false, reason: 'the main checkout is on claude/foo, not main', nextStep: '/merge-mode' },
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
test('confirmed + bailed land: says NOT landed, names the reason, warns', () => {
|
|
121
|
+
const m = card.buildCardModel(bailed);
|
|
122
|
+
assert.equal(m.stage, 'confirmed');
|
|
123
|
+
assert.equal(row(m, 'Deploy'), 'NOT landed — the main checkout is on claude/foo, not main');
|
|
124
|
+
assert.equal(m.cfg.tone, 'warning'); // not the reassuring 'info'
|
|
125
|
+
assert.equal(m.cfg.statusWord, 'Not landed');
|
|
126
|
+
assert.match(m.cfg.headline(1266), /NOT landed/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('confirmed + bailed land: the bail reaches the printed monospace card', () => {
|
|
130
|
+
// The monospace card is the ALWAYS-printed fallback (headless, cron, dev box).
|
|
131
|
+
// If the reason stops at the model, the builder still never sees it.
|
|
132
|
+
const out = card.renderMonospaceCard(card.buildCardModel(bailed));
|
|
133
|
+
assert.match(out, /NOT landed/);
|
|
134
|
+
assert.ok(!/landing automatically/.test(out), 'the lie must not survive anywhere on the card');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('confirmed + bailed land: the action is the bail-specific recovery command', () => {
|
|
138
|
+
const m = card.buildCardModel({
|
|
139
|
+
...bailed,
|
|
140
|
+
land: { ok: false, pending: false, reason: 'no commits ahead of main', nextStep: 'node scripts/gds/ship.js 7 --db-only' },
|
|
141
|
+
});
|
|
142
|
+
assert.equal(m.action.cmd, 'node scripts/gds/ship.js 7 --db-only'); // beats the generic /merge-mode
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('confirmed + PENDING land still reads "landing automatically"', () => {
|
|
146
|
+
// A poll timeout with auto-merge armed is success-PENDING (task 1049): CI or the
|
|
147
|
+
// server reconciler really does finish it. Crying failure here would be a NEW
|
|
148
|
+
// lie in the opposite direction, and would train builders to ignore the warning.
|
|
149
|
+
const m = card.buildCardModel({ ...bailed, land: { ok: false, pending: true, reason: null, nextStep: null } });
|
|
150
|
+
assert.equal(row(m, 'Deploy'), 'landing automatically');
|
|
151
|
+
assert.equal(m.cfg.tone, 'info');
|
|
152
|
+
assert.equal(m.cfg.statusWord, 'Confirmed');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('confirmed with NO land info is unchanged (back-compat)', () => {
|
|
156
|
+
// Older land-watch breadcrumbs replay stored cardInputs that have no `land` key,
|
|
157
|
+
// and other callers (resumeMerge, the claim card) may pass none. Absent ⇒ the
|
|
158
|
+
// pre-existing behaviour, byte for byte.
|
|
159
|
+
const m = card.buildCardModel({ ...shippedInput, postGradeStatus: 'confirmed', land: null });
|
|
160
|
+
assert.equal(row(m, 'Deploy'), 'landing automatically');
|
|
161
|
+
assert.equal(m.cfg.tone, 'info');
|
|
162
|
+
assert.equal(m.action.cmd, '/merge-mode');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('a stale bailed land never overrides a task that DID reach shipped', () => {
|
|
166
|
+
// land-watch replays the breadcrumb after the server lands it; the status is
|
|
167
|
+
// then 'shipped' and the old bail is history. The card must show live, not NOT-landed.
|
|
168
|
+
const m = card.buildCardModel({ ...bailed, postGradeStatus: 'shipped' });
|
|
169
|
+
assert.equal(m.stage, 'shipped');
|
|
170
|
+
assert.equal(row(m, 'Deploy'), 'live · 967fb4a');
|
|
171
|
+
assert.equal(m.cfg.tone, 'success');
|
|
172
|
+
});
|
|
173
|
+
|
|
108
174
|
test('failed: danger tone, no credits, issue count, regrade action', () => {
|
|
109
175
|
const m = card.buildCardModel({
|
|
110
176
|
...shippedInput, postGradeStatus: 'completed',
|
package/tests/ship_ci_deploy.mjs
CHANGED
|
@@ -44,7 +44,7 @@ function test(name, fn) {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
const { parseDeployMode, effectiveDeployMode, buildPrCreateArgv, buildPrMergeArgv, deployRunSucceeded, shouldBailOnFetchFailure, classifyLandOutcome, strandNextStep, pickStaleGateReviewRun, landPollAfterAutoMerge, reconciledCardStatus } = ship;
|
|
47
|
+
const { parseDeployMode, effectiveDeployMode, buildPrCreateArgv, buildPrMergeArgv, deployRunSucceeded, shouldBailOnFetchFailure, classifyLandOutcome, strandNextStep, pickStaleGateReviewRun, landPollAfterAutoMerge, reconciledCardStatus, landed, landPending, landBailed, strandCommand, landPendingLines } = ship;
|
|
48
48
|
|
|
49
49
|
// ---- 1. parseDeployMode precedence + safe default ----
|
|
50
50
|
|
|
@@ -266,6 +266,44 @@ test('reconciledCardStatus: status read failed (null) → keep the pre-merge sta
|
|
|
266
266
|
assert.equal(reconciledCardStatus(undefined, 'confirmed'), 'confirmed');
|
|
267
267
|
});
|
|
268
268
|
|
|
269
|
+
// ---- 3d. the land outcome: why this is a 3-way object, not a boolean ----
|
|
270
|
+
// task 1002541. The land chain used to answer true/false, so every bail site's
|
|
271
|
+
// precise reason was discarded and the card fell back to the STATUS — printing
|
|
272
|
+
// "landing automatically" over a merge that had already given up.
|
|
273
|
+
|
|
274
|
+
test('landBailed carries the reason + the recovery command', () => {
|
|
275
|
+
const o = landBailed('the main checkout is on claude/foo, not main', '/merge-mode');
|
|
276
|
+
assert.equal(o.ok, false);
|
|
277
|
+
assert.equal(o.pending, false);
|
|
278
|
+
assert.equal(o.reason, 'the main checkout is on claude/foo, not main');
|
|
279
|
+
assert.equal(o.nextStep, '/merge-mode');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('THE TRAP: every outcome is truthy, so consumers must test .ok', () => {
|
|
283
|
+
// mergeAndDeploy's guard used to be `if (!merged) return;`. With objects that
|
|
284
|
+
// check NEVER fires, so a bailed ship would fall through and POST /ship for a
|
|
285
|
+
// task that never landed. This is the assertion that catches a re-introduction.
|
|
286
|
+
assert.ok(landBailed('nope', '/merge-mode'), 'a bail object is truthy — !merged cannot detect it');
|
|
287
|
+
assert.ok(landPending(), 'a pending object is truthy too');
|
|
288
|
+
assert.equal(landBailed('nope', '/merge-mode').ok, false);
|
|
289
|
+
assert.equal(landPending().ok, false);
|
|
290
|
+
assert.equal(landed().ok, true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('pending is NOT a bail: no reason, so the card keeps "landing automatically"', () => {
|
|
294
|
+
// A poll timeout with auto-merge armed is success-pending (task 1049) — it does
|
|
295
|
+
// land on its own. The card gates its warning on `reason`, so this stays quiet.
|
|
296
|
+
const o = landPending();
|
|
297
|
+
assert.equal(o.pending, true);
|
|
298
|
+
assert.equal(o.reason, null);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('strandCommand: a box gets the resume, a laptop gets /merge-mode', () => {
|
|
302
|
+
// /merge-mode needs gh + a main checkout a dev box doesn't have (task 1052).
|
|
303
|
+
assert.equal(strandCommand(77, 'server'), 'node scripts/gds/ship.js 77');
|
|
304
|
+
assert.equal(strandCommand(77, 'local'), '/merge-mode');
|
|
305
|
+
});
|
|
306
|
+
|
|
269
307
|
test('reconciledCardStatus: already shipped stays shipped', () => {
|
|
270
308
|
assert.equal(reconciledCardStatus('shipped', 'shipped'), 'shipped');
|
|
271
309
|
});
|
|
@@ -506,6 +544,33 @@ test('effectiveDeployMode: tolerates a missing options object (defaults to no fa
|
|
|
506
544
|
assert.equal(effectiveDeployMode('laptop'), 'ci'); // canPushLocally undefined ⇒ falsy ⇒ fall back
|
|
507
545
|
});
|
|
508
546
|
|
|
547
|
+
// ---- task 1002606: the success-pending banner carries the strand-detection story ----
|
|
548
|
+
// Finding P-3: the banner ended at "This is NOT a failure", so when a land DID die
|
|
549
|
+
// (hand-written conflict, dead PR, red check) the builder's last signal said all was
|
|
550
|
+
// well. The banner must keep the reassurance AND say how a real failure reaches them.
|
|
551
|
+
|
|
552
|
+
test('1002606 landPendingLines: keeps the "NOT a failure" reassurance + the resume command', () => {
|
|
553
|
+
const lines = landPendingLines(940).join('\n');
|
|
554
|
+
assert.match(lines, /NOT a failure/);
|
|
555
|
+
assert.match(lines, /node scripts\/gds\/ship\.js 940/, 'the resume command names THIS task');
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test('1002606 landPendingLines: tells the builder how a strand WILL reach them (needs_rebase + /builder-start)', () => {
|
|
559
|
+
const lines = landPendingLines(940).join('\n');
|
|
560
|
+
assert.match(lines, /CANNOT land on its own/i, 'the failure story is present');
|
|
561
|
+
assert.match(lines, /needs_rebase/, 'names the flag the reconciler sets');
|
|
562
|
+
assert.match(lines, /\/builder-start/, 'names WHERE the warning appears');
|
|
563
|
+
assert.match(lines, /release/, 'names the release exit too');
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test('1002606 landPendingLines: merged variant says deploy-only', () => {
|
|
567
|
+
const merged = landPendingLines(1, { merged: true }).join('\n');
|
|
568
|
+
assert.match(merged, /and the PR has merged/);
|
|
569
|
+
assert.match(merged, /GitHub will deploy automatically/);
|
|
570
|
+
const unmerged = landPendingLines(1, {}).join('\n');
|
|
571
|
+
assert.match(unmerged, /GitHub will merge \+ deploy automatically/);
|
|
572
|
+
});
|
|
573
|
+
|
|
509
574
|
console.log('');
|
|
510
575
|
console.log(`ship_ci_deploy: ${passed} passed, ${failed} failed`);
|
|
511
576
|
process.exit(failed === 0 ? 0 : 1);
|