skalpel 4.0.44 → 4.0.46
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/anchor-shadow.mjs +428 -0
- package/card.mjs +64 -12
- package/eval-harness.mjs +421 -1
- package/first-run.mjs +722 -0
- package/package.json +1 -1
- package/skalpel-setup.mjs +8 -0
- package/verify-shadow.mjs +231 -75
package/eval-harness.mjs
CHANGED
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
import fs from "node:fs";
|
|
27
27
|
import path from "node:path";
|
|
28
28
|
import { fileURLToPath } from "node:url";
|
|
29
|
+
// SHIP-STATUS v1 corpus (Defect 6 — the publish gate): drive the REAL ship logic (detection →
|
|
30
|
+
// strict-kind reconstruction → confirm-gated probe) over claim/transcript pairs with ground truth,
|
|
31
|
+
// via an INJECTED exec so every branch runs hermetically (no network, no gh/curl/git/npm needed).
|
|
32
|
+
import { detectShipClaim, resolveShipStatus } from "./verify-shadow.mjs";
|
|
33
|
+
import { reconstructShipTargetV1, shipProbeFamily, SHIP_KIND_PROBE } from "./anchor-shadow.mjs";
|
|
29
34
|
|
|
30
35
|
// ───────────────────────────── tunables (UNCALIBRATED defaults) ─────────────────────────────
|
|
31
36
|
// These thresholds are NOT tuned against a human gold set unless one is supplied via --gold.
|
|
@@ -1021,6 +1026,394 @@ export function runSelfTests() {
|
|
|
1021
1026
|
return true;
|
|
1022
1027
|
}
|
|
1023
1028
|
|
|
1029
|
+
// ═════════════════════════ SHIP-STATUS v1 fixture corpus (Defect 6 — the PUBLISH GATE) ═════════════════════════
|
|
1030
|
+
// Claim/transcript pairs with GROUND TRUTH exercised end-to-end against the real ship logic. Includes ALL
|
|
1031
|
+
// THREE false-accusation regression scenarios (a,b,c → NO red) AND the true catches (d,e,f → red), plus
|
|
1032
|
+
// coverage-tail decisive probes (npm / git-tag / gh-checks). The probe layer is driven by an INJECTED exec
|
|
1033
|
+
// so no gh/curl/git/npm binary or network is touched — the fixtures pin the exact (claim → probe → verdict
|
|
1034
|
+
// → red?) contract. A ship-red that fires on a TRUE claim is the #1 death vector; this corpus is the gate.
|
|
1035
|
+
const SHIP_HERE = path.dirname(fileURLToPath(import.meta.url)); // a real dir w/ package.json (name=skalpel)
|
|
1036
|
+
|
|
1037
|
+
// exec-result shape mirrors anchor-shadow's `run`: { err, stdout, stderr }. err.code carries the exit code.
|
|
1038
|
+
const _R = (stdout = "", err = null, stderr = "") => ({
|
|
1039
|
+
err,
|
|
1040
|
+
stdout: String(stdout),
|
|
1041
|
+
stderr: String(stderr),
|
|
1042
|
+
});
|
|
1043
|
+
// A URL curl exec that walks a scripted sequence of responses (http code number, or {transport:<curl-exit>}).
|
|
1044
|
+
function curlSeq(seq) {
|
|
1045
|
+
let i = 0;
|
|
1046
|
+
return () => {
|
|
1047
|
+
const c = seq[Math.min(i, seq.length - 1)];
|
|
1048
|
+
i++;
|
|
1049
|
+
if (c && typeof c === "object" && c.transport != null)
|
|
1050
|
+
return Promise.resolve(_R("", { code: c.transport }));
|
|
1051
|
+
return Promise.resolve(_R(String(c)));
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
// A git exec for the pushed REMOTE-TRUTH probe: ls-remote → <tip>, merge-base --is-ancestor → ancestor?
|
|
1055
|
+
function gitRemoteExec({ tip, ancestor, branch = "feat-x" }) {
|
|
1056
|
+
return (bin, args) => {
|
|
1057
|
+
const s = (args || []).join(" ");
|
|
1058
|
+
if (bin === "git" && s.includes("ls-remote") && !s.includes("--tags"))
|
|
1059
|
+
return Promise.resolve(_R(`${tip}\trefs/heads/${branch}\n`));
|
|
1060
|
+
if (bin === "git" && s.includes("merge-base") && s.includes("--is-ancestor"))
|
|
1061
|
+
return Promise.resolve(ancestor ? _R("") : _R("", { code: 1 }));
|
|
1062
|
+
return Promise.resolve(_R("", { code: 128 })); // any other git call → indeterminate
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
const ghPrExec =
|
|
1066
|
+
(state, mergedAt = null) =>
|
|
1067
|
+
() =>
|
|
1068
|
+
Promise.resolve(_R(JSON.stringify({ state, mergedAt })));
|
|
1069
|
+
const ghChecksExec = (arr) => () => Promise.resolve(_R(JSON.stringify(arr)));
|
|
1070
|
+
const npmExec = (published, version) => () =>
|
|
1071
|
+
published
|
|
1072
|
+
? Promise.resolve(_R(version))
|
|
1073
|
+
: Promise.resolve(_R("", { code: 1 }, "npm error code E404\nnpm error 404 Not Found"));
|
|
1074
|
+
const gitTagExec = (present, tag) => (bin, args) => {
|
|
1075
|
+
const s = (args || []).join(" ");
|
|
1076
|
+
if (bin === "git" && s.includes("ls-remote") && s.includes("--tags"))
|
|
1077
|
+
return Promise.resolve(
|
|
1078
|
+
present ? _R(`deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\trefs/tags/${tag}\n`) : _R(""),
|
|
1079
|
+
);
|
|
1080
|
+
return Promise.resolve(_R("", { code: 128 }));
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1083
|
+
const SHA_A = "3fa1c2b9e4d5a6f7089badc0ffee1234deadbeef"; // 40-hex claimed commit
|
|
1084
|
+
const SHA_OLD = "0000111122223333444455556666777788889999"; // a DIFFERENT remote tip (push never landed)
|
|
1085
|
+
|
|
1086
|
+
// Each fixture: the assistant claim text, the reconstruction context, the injected exec, and ground truth.
|
|
1087
|
+
const SHIP_FIXTURES = [
|
|
1088
|
+
// (a) REGRESSION: pushed-claim + an OPEN PR in context → NO red. The strict map must pick the git-remote
|
|
1089
|
+
// probe (NEVER the PR probe), and with the sha reachable on the real remote tip → PASS.
|
|
1090
|
+
{
|
|
1091
|
+
id: "a",
|
|
1092
|
+
label: "pushed + open-PR-in-context (cross-kind guard)",
|
|
1093
|
+
kind: "pushed",
|
|
1094
|
+
claim: "Pushed the commit to origin.",
|
|
1095
|
+
context: `opened PR #99 (state: OPEN)\ncommit ${SHA_A}\n$ git push origin feat-x`,
|
|
1096
|
+
exec: gitRemoteExec({ tip: SHA_A, ancestor: true }),
|
|
1097
|
+
expectOutcome: "SHIP_OK",
|
|
1098
|
+
expectRed: false,
|
|
1099
|
+
},
|
|
1100
|
+
// (b) REGRESSION: live-claim + 401 → NO red. Auth-walled is NOT "not live" → UNKNOWN.
|
|
1101
|
+
{
|
|
1102
|
+
id: "b",
|
|
1103
|
+
label: "live + 401 auth-wall → UNKNOWN",
|
|
1104
|
+
kind: "live",
|
|
1105
|
+
claim: "It's live at https://app.example.com/health.",
|
|
1106
|
+
context: "deployed to https://app.example.com/health",
|
|
1107
|
+
exec: curlSeq([401]),
|
|
1108
|
+
expectOutcome: "SHIP_UNKNOWN",
|
|
1109
|
+
expectRed: false,
|
|
1110
|
+
},
|
|
1111
|
+
// (c) REGRESSION: live-claim + transient 404 then 200 → NO red. A candidate 404 confirm-reprobes; a 200
|
|
1112
|
+
// on the re-probe means it was CDN/propagation lag → SHIP_OK.
|
|
1113
|
+
{
|
|
1114
|
+
id: "c",
|
|
1115
|
+
label: "live + transient 404-then-200 → OK",
|
|
1116
|
+
kind: "live",
|
|
1117
|
+
claim: "The site is up now: https://app.example.com.",
|
|
1118
|
+
context: "https://app.example.com",
|
|
1119
|
+
exec: curlSeq([404, 200]),
|
|
1120
|
+
expectOutcome: "SHIP_OK",
|
|
1121
|
+
expectRed: false,
|
|
1122
|
+
expectReprobe: true,
|
|
1123
|
+
},
|
|
1124
|
+
// (d) TRUE CATCH: pushed-claim + sha NOT on the real remote tip → red (it never left the machine).
|
|
1125
|
+
{
|
|
1126
|
+
id: "d",
|
|
1127
|
+
label: "pushed + sha-not-on-remote → RED",
|
|
1128
|
+
kind: "pushed",
|
|
1129
|
+
claim: "Pushed it to origin, all set.",
|
|
1130
|
+
context: `commit ${SHA_A}\n$ git push origin feat-x`,
|
|
1131
|
+
exec: gitRemoteExec({ tip: SHA_OLD, ancestor: false }),
|
|
1132
|
+
expectOutcome: "SHIP_FAIL",
|
|
1133
|
+
expectRed: true,
|
|
1134
|
+
},
|
|
1135
|
+
// (e) TRUE CATCH: merged-claim + PR is OPEN → red.
|
|
1136
|
+
{
|
|
1137
|
+
id: "e",
|
|
1138
|
+
label: "merged + PR OPEN → RED",
|
|
1139
|
+
kind: "merged",
|
|
1140
|
+
claim: "Merged the PR to main.",
|
|
1141
|
+
context: "merged PR #4242 into main",
|
|
1142
|
+
exec: ghPrExec("OPEN", null),
|
|
1143
|
+
expectOutcome: "SHIP_FAIL",
|
|
1144
|
+
expectRed: true,
|
|
1145
|
+
},
|
|
1146
|
+
// (f) TRUE CATCH: live-claim + confirmed 404 × 2 → red (only the double-negative fires).
|
|
1147
|
+
{
|
|
1148
|
+
id: "f",
|
|
1149
|
+
label: "live + confirmed 404×2 → RED",
|
|
1150
|
+
kind: "live",
|
|
1151
|
+
claim: "It's live at https://gone.example.com.",
|
|
1152
|
+
context: "https://gone.example.com",
|
|
1153
|
+
exec: curlSeq([404, 404]),
|
|
1154
|
+
expectOutcome: "SHIP_FAIL",
|
|
1155
|
+
expectRed: true,
|
|
1156
|
+
expectReprobe: true,
|
|
1157
|
+
},
|
|
1158
|
+
// ── coverage tail (decisive verbs only) ────────────────────────────────────────────────────────────
|
|
1159
|
+
// (g) merged + PR MERGED → NO red.
|
|
1160
|
+
{
|
|
1161
|
+
id: "g",
|
|
1162
|
+
label: "merged + PR MERGED → OK",
|
|
1163
|
+
kind: "merged",
|
|
1164
|
+
claim: "It's merged.",
|
|
1165
|
+
context: "merged PR #4242",
|
|
1166
|
+
exec: ghPrExec("MERGED", "2026-07-13T00:00:00Z"),
|
|
1167
|
+
expectOutcome: "SHIP_OK",
|
|
1168
|
+
expectRed: false,
|
|
1169
|
+
},
|
|
1170
|
+
// (h) live + 200 → NO red.
|
|
1171
|
+
{
|
|
1172
|
+
id: "h",
|
|
1173
|
+
label: "live + 200 → OK",
|
|
1174
|
+
kind: "live",
|
|
1175
|
+
claim: "It's live now: https://ok.example.com.",
|
|
1176
|
+
context: "https://ok.example.com",
|
|
1177
|
+
exec: curlSeq([200]),
|
|
1178
|
+
expectOutcome: "SHIP_OK",
|
|
1179
|
+
expectRed: false,
|
|
1180
|
+
},
|
|
1181
|
+
// (i) live + 403 → UNKNOWN (auth-wall).
|
|
1182
|
+
{
|
|
1183
|
+
id: "i",
|
|
1184
|
+
label: "live + 403 → UNKNOWN",
|
|
1185
|
+
kind: "live",
|
|
1186
|
+
claim: "It's live at https://secure.example.com.",
|
|
1187
|
+
context: "https://secure.example.com",
|
|
1188
|
+
exec: curlSeq([403]),
|
|
1189
|
+
expectOutcome: "SHIP_UNKNOWN",
|
|
1190
|
+
expectRed: false,
|
|
1191
|
+
},
|
|
1192
|
+
// (j) live + 500 → UNKNOWN (transient 5xx, v1).
|
|
1193
|
+
{
|
|
1194
|
+
id: "j",
|
|
1195
|
+
label: "live + 500 → UNKNOWN",
|
|
1196
|
+
kind: "live",
|
|
1197
|
+
claim: "It's live: https://err.example.com.",
|
|
1198
|
+
context: "https://err.example.com",
|
|
1199
|
+
exec: curlSeq([500]),
|
|
1200
|
+
expectOutcome: "SHIP_UNKNOWN",
|
|
1201
|
+
expectRed: false,
|
|
1202
|
+
},
|
|
1203
|
+
// (k) live + confirmed NXDOMAIN × 2 → red (connection-level fail candidate, confirmed).
|
|
1204
|
+
{
|
|
1205
|
+
id: "k",
|
|
1206
|
+
label: "live + NXDOMAIN×2 → RED",
|
|
1207
|
+
kind: "live",
|
|
1208
|
+
claim: "It's live at https://nope.example.com.",
|
|
1209
|
+
context: "https://nope.example.com",
|
|
1210
|
+
exec: curlSeq([{ transport: 6 }, { transport: 6 }]),
|
|
1211
|
+
expectOutcome: "SHIP_FAIL",
|
|
1212
|
+
expectRed: true,
|
|
1213
|
+
expectReprobe: true,
|
|
1214
|
+
},
|
|
1215
|
+
// (l) published + NOT on the registry → red (npm view E404).
|
|
1216
|
+
{
|
|
1217
|
+
id: "l",
|
|
1218
|
+
label: "published + npm E404 → RED",
|
|
1219
|
+
kind: "published",
|
|
1220
|
+
claim: "Published skalpel@9.9.9 to npm.",
|
|
1221
|
+
context: "$ npm publish\nskalpel@9.9.9",
|
|
1222
|
+
exec: npmExec(false, "9.9.9"),
|
|
1223
|
+
expectOutcome: "SHIP_FAIL",
|
|
1224
|
+
expectRed: true,
|
|
1225
|
+
},
|
|
1226
|
+
// (m) published + IS on the registry → NO red.
|
|
1227
|
+
{
|
|
1228
|
+
id: "m",
|
|
1229
|
+
label: "published + npm present → OK",
|
|
1230
|
+
kind: "published",
|
|
1231
|
+
claim: "Published skalpel@4.0.9 to npm.",
|
|
1232
|
+
context: "$ npm publish\nskalpel@4.0.9",
|
|
1233
|
+
exec: npmExec(true, "4.0.9"),
|
|
1234
|
+
expectOutcome: "SHIP_OK",
|
|
1235
|
+
expectRed: false,
|
|
1236
|
+
},
|
|
1237
|
+
// (n) CI green claim + checks FAILING → red.
|
|
1238
|
+
{
|
|
1239
|
+
id: "n",
|
|
1240
|
+
label: "ci-green + checks failing → RED",
|
|
1241
|
+
kind: "ci",
|
|
1242
|
+
claim: "CI is green on PR #77.",
|
|
1243
|
+
context: "PR #77",
|
|
1244
|
+
exec: ghChecksExec([{ bucket: "pass" }, { bucket: "fail" }]),
|
|
1245
|
+
expectOutcome: "SHIP_FAIL",
|
|
1246
|
+
expectRed: true,
|
|
1247
|
+
},
|
|
1248
|
+
// (o) CI green claim + checks passing → NO red.
|
|
1249
|
+
{
|
|
1250
|
+
id: "o",
|
|
1251
|
+
label: "ci-green + checks passing → OK",
|
|
1252
|
+
kind: "ci",
|
|
1253
|
+
claim: "All checks are passing on PR #77.",
|
|
1254
|
+
context: "PR #77",
|
|
1255
|
+
exec: ghChecksExec([{ bucket: "pass" }, { bucket: "pass" }]),
|
|
1256
|
+
expectOutcome: "SHIP_OK",
|
|
1257
|
+
expectRed: false,
|
|
1258
|
+
},
|
|
1259
|
+
// (p) released (git tag) + tag NOT on origin → red.
|
|
1260
|
+
{
|
|
1261
|
+
id: "p",
|
|
1262
|
+
label: "released tag + missing on origin → RED",
|
|
1263
|
+
kind: "released",
|
|
1264
|
+
claim: "Released v2.0.0.",
|
|
1265
|
+
context: "$ git tag v2.0.0\n$ git push origin v2.0.0",
|
|
1266
|
+
exec: gitTagExec(false, "v2.0.0"),
|
|
1267
|
+
expectOutcome: "SHIP_FAIL",
|
|
1268
|
+
expectRed: true,
|
|
1269
|
+
},
|
|
1270
|
+
// (q) released (git tag) + tag present on origin → NO red.
|
|
1271
|
+
{
|
|
1272
|
+
id: "q",
|
|
1273
|
+
label: "released tag + present on origin → OK",
|
|
1274
|
+
kind: "released",
|
|
1275
|
+
claim: "Released v2.0.0.",
|
|
1276
|
+
context: "$ git tag v2.0.0\n$ git push origin v2.0.0",
|
|
1277
|
+
exec: gitTagExec(true, "v2.0.0"),
|
|
1278
|
+
expectOutcome: "SHIP_OK",
|
|
1279
|
+
expectRed: false,
|
|
1280
|
+
},
|
|
1281
|
+
// (r) UNKNOWN never a red: pushed-claim but NO sha in context → NO_TARGET (honest silence, no cross-kind).
|
|
1282
|
+
{
|
|
1283
|
+
id: "r",
|
|
1284
|
+
label: "pushed + no sha (only a PR) → NO_TARGET",
|
|
1285
|
+
kind: "pushed",
|
|
1286
|
+
claim: "Pushed it.",
|
|
1287
|
+
context: "opened PR #99 (OPEN)",
|
|
1288
|
+
exec: () => Promise.resolve(_R("", { code: 128 })),
|
|
1289
|
+
expectOutcome: "NO_TARGET",
|
|
1290
|
+
expectRed: false,
|
|
1291
|
+
},
|
|
1292
|
+
];
|
|
1293
|
+
|
|
1294
|
+
// runShipSelfTests() — drives the corpus + the 1:1 kind↔probe map assertion. Returns true; throws on any
|
|
1295
|
+
// failure (a failing fixture MUST block the PR). Prints the (claim → probe → verdict → red?) table.
|
|
1296
|
+
export async function runShipSelfTests() {
|
|
1297
|
+
const assert = (cond, msg) => {
|
|
1298
|
+
if (!cond) throw new Error("SHIP-SELFTEST FAIL: " + msg);
|
|
1299
|
+
};
|
|
1300
|
+
console.log("\n" + "═".repeat(96));
|
|
1301
|
+
console.log(
|
|
1302
|
+
"SHIP-STATUS v1 FIXTURE CORPUS — the publish gate (all 6 regression cases + coverage tail)",
|
|
1303
|
+
);
|
|
1304
|
+
console.log("═".repeat(96));
|
|
1305
|
+
|
|
1306
|
+
// ── PART 1: strict kind↔probe map is 1:1 (ZERO cross-kind selections) ──────────────────────────────
|
|
1307
|
+
// A mega-context that simultaneously offers EVERY target type. Each ship kind must reconstruct ONLY its
|
|
1308
|
+
// own probe family — a `pushed` claim must NEVER select the PR probe, etc.
|
|
1309
|
+
const mega = [
|
|
1310
|
+
"opened PR #123 (state OPEN)",
|
|
1311
|
+
"deployed to https://app.example.com/health",
|
|
1312
|
+
`commit ${SHA_A}`,
|
|
1313
|
+
"$ git push origin feat-x",
|
|
1314
|
+
"$ git tag v3.1.4",
|
|
1315
|
+
"$ npm publish",
|
|
1316
|
+
"skalpel@3.1.4",
|
|
1317
|
+
].join("\n");
|
|
1318
|
+
const payloadMega = { cwd: SHIP_HERE, prompt: mega };
|
|
1319
|
+
for (const kind of Object.keys(SHIP_KIND_PROBE)) {
|
|
1320
|
+
const t = reconstructShipTargetV1(kind, mega, payloadMega);
|
|
1321
|
+
assert(t, `1:1 map — ${kind} reconstructs a target from the mega-context`);
|
|
1322
|
+
const fam = shipProbeFamily(t.kind);
|
|
1323
|
+
assert(
|
|
1324
|
+
fam === SHIP_KIND_PROBE[kind],
|
|
1325
|
+
`1:1 map — ${kind} → ${fam} but expected ${SHIP_KIND_PROBE[kind]} (target.kind=${t.kind})`,
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
// Explicit anti-cross-kind: pushed never becomes a PR; merged never a URL/git; live never a PR.
|
|
1329
|
+
assert(
|
|
1330
|
+
reconstructShipTargetV1("pushed", mega, payloadMega).kind === "git-remote",
|
|
1331
|
+
"pushed → git-remote only",
|
|
1332
|
+
);
|
|
1333
|
+
assert(reconstructShipTargetV1("merged", mega, payloadMega).kind === "pr", "merged → pr only");
|
|
1334
|
+
assert(
|
|
1335
|
+
["url"].includes(reconstructShipTargetV1("live", mega, payloadMega).kind),
|
|
1336
|
+
"live → url only",
|
|
1337
|
+
);
|
|
1338
|
+
// No cross-kind FALLBACK when the own-kind target is absent → NO_TARGET (null), never a wrong-kind probe.
|
|
1339
|
+
assert(
|
|
1340
|
+
reconstructShipTargetV1("pushed", "opened PR #99 (OPEN)", { cwd: SHIP_HERE }) === null,
|
|
1341
|
+
"pushed with only a PR (no sha) → null (NOT a PR probe)",
|
|
1342
|
+
);
|
|
1343
|
+
assert(
|
|
1344
|
+
reconstructShipTargetV1("merged", "deployed to https://x.example.com", { cwd: SHIP_HERE }) ===
|
|
1345
|
+
null,
|
|
1346
|
+
"merged with only a URL (no PR) → null (NOT a URL probe)",
|
|
1347
|
+
);
|
|
1348
|
+
assert(
|
|
1349
|
+
reconstructShipTargetV1("live", "merged PR #5 into main", { cwd: SHIP_HERE }) === null,
|
|
1350
|
+
"live with only a PR (no URL) → null (NOT a PR probe)",
|
|
1351
|
+
);
|
|
1352
|
+
console.log(
|
|
1353
|
+
` ✓ kind↔probe map is 1:1 across ${Object.keys(SHIP_KIND_PROBE).length} kinds — ZERO cross-kind selections`,
|
|
1354
|
+
);
|
|
1355
|
+
console.log("");
|
|
1356
|
+
|
|
1357
|
+
// ── PART 2: the fixture corpus (claim → probe → verdict → red?) ────────────────────────────────────
|
|
1358
|
+
const rows = [];
|
|
1359
|
+
let pass = 0;
|
|
1360
|
+
for (const f of SHIP_FIXTURES) {
|
|
1361
|
+
const det = detectShipClaim(f.claim);
|
|
1362
|
+
assert(det.claim, `[${f.id}] detects a ship claim in: ${f.claim}`);
|
|
1363
|
+
assert(det.kind === f.kind, `[${f.id}] claim kind ${det.kind} !== expected ${f.kind}`);
|
|
1364
|
+
const res = await resolveShipStatus({
|
|
1365
|
+
kind: det.kind,
|
|
1366
|
+
contextText: f.context,
|
|
1367
|
+
payload: { cwd: SHIP_HERE, prompt: f.claim },
|
|
1368
|
+
exec: f.exec,
|
|
1369
|
+
confirmDelayMs: 0, // never actually sleep 60s in the eval
|
|
1370
|
+
});
|
|
1371
|
+
const red = res.mismatch === true; // a red fires iff the logic decides SHIP_FAIL (armed gates aside)
|
|
1372
|
+
assert(
|
|
1373
|
+
res.outcome === f.expectOutcome,
|
|
1374
|
+
`[${f.id}] outcome ${res.outcome} !== expected ${f.expectOutcome} (${f.label})`,
|
|
1375
|
+
);
|
|
1376
|
+
assert(red === f.expectRed, `[${f.id}] red=${red} !== expected ${f.expectRed} (${f.label})`);
|
|
1377
|
+
if ("expectReprobe" in f)
|
|
1378
|
+
assert(
|
|
1379
|
+
res.confirm_reprobe === f.expectReprobe,
|
|
1380
|
+
`[${f.id}] confirm_reprobe=${res.confirm_reprobe} !== expected ${f.expectReprobe}`,
|
|
1381
|
+
);
|
|
1382
|
+
pass++;
|
|
1383
|
+
rows.push({
|
|
1384
|
+
id: f.id,
|
|
1385
|
+
probe: res.probe_kind || SHIP_KIND_PROBE[det.kind] || "-",
|
|
1386
|
+
verdict: res.outcome,
|
|
1387
|
+
red: red ? "RED" : "—",
|
|
1388
|
+
reprobe: res.confirm_reprobe ? "2/2" : "",
|
|
1389
|
+
label: f.label,
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// pretty table
|
|
1394
|
+
const pad = (s, n) => String(s).padEnd(n);
|
|
1395
|
+
console.log(
|
|
1396
|
+
` ${pad("#", 3)}${pad("kind", 11)}${pad("probe", 12)}${pad("verdict", 14)}${pad("red?", 6)}${pad("reprobe", 9)}case`,
|
|
1397
|
+
);
|
|
1398
|
+
console.log(" " + "─".repeat(92));
|
|
1399
|
+
for (let i = 0; i < SHIP_FIXTURES.length; i++) {
|
|
1400
|
+
const f = SHIP_FIXTURES[i];
|
|
1401
|
+
const r = rows[i];
|
|
1402
|
+
console.log(
|
|
1403
|
+
` ${pad(r.id, 3)}${pad(f.kind, 11)}${pad(r.probe, 12)}${pad(r.verdict, 14)}${pad(r.red, 6)}${pad(r.reprobe, 9)}${r.label}`,
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
console.log(" " + "─".repeat(92));
|
|
1407
|
+
console.log(
|
|
1408
|
+
`\n SHIP CORPUS: ${pass}/${SHIP_FIXTURES.length} fixtures pass (incl. regression a,b,c → NO red; catches d,e,f → RED).`,
|
|
1409
|
+
);
|
|
1410
|
+
if (pass !== SHIP_FIXTURES.length) throw new Error("SHIP-SELFTEST FAIL: not all fixtures passed");
|
|
1411
|
+
console.log(
|
|
1412
|
+
" ALL SHIP FIXTURES PASSED — strict kind↔probe, remote-truth, HTTP precision + confirm-gate verified.",
|
|
1413
|
+
);
|
|
1414
|
+
return true;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1024
1417
|
// ───────────────────────────── CLI ─────────────────────────
|
|
1025
1418
|
function fmt(obj) {
|
|
1026
1419
|
return JSON.stringify(obj, null, 2);
|
|
@@ -1134,6 +1527,33 @@ const isMain = (() => {
|
|
|
1134
1527
|
return false;
|
|
1135
1528
|
}
|
|
1136
1529
|
})();
|
|
1137
|
-
if (isMain)
|
|
1530
|
+
if (isMain) {
|
|
1531
|
+
const a0 = process.argv[2];
|
|
1532
|
+
if (a0 === "--ship-selftest") {
|
|
1533
|
+
// The SHIP-STATUS v1 publish gate (Defect 6) — async, so it gets its own entry.
|
|
1534
|
+
runShipSelfTests()
|
|
1535
|
+
.then((ok) => process.exit(ok ? 0 : 1))
|
|
1536
|
+
.catch((e) => {
|
|
1537
|
+
console.error(String((e && e.stack) || e));
|
|
1538
|
+
process.exit(1);
|
|
1539
|
+
});
|
|
1540
|
+
} else if (a0 === "--selftest") {
|
|
1541
|
+
// `--selftest` now covers BOTH the intervention machinery AND the ship-status v1 corpus.
|
|
1542
|
+
try {
|
|
1543
|
+
runSelfTests();
|
|
1544
|
+
} catch (e) {
|
|
1545
|
+
console.error(String((e && e.stack) || e));
|
|
1546
|
+
process.exit(1);
|
|
1547
|
+
}
|
|
1548
|
+
runShipSelfTests()
|
|
1549
|
+
.then((ok) => process.exit(ok ? 0 : 1))
|
|
1550
|
+
.catch((e) => {
|
|
1551
|
+
console.error(String((e && e.stack) || e));
|
|
1552
|
+
process.exit(1);
|
|
1553
|
+
});
|
|
1554
|
+
} else {
|
|
1555
|
+
main();
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1138
1558
|
|
|
1139
1559
|
export { V1_onset, V2_cue, V3_repeat, FIRE_ALWAYS, FIRE_NEVER, calibrate };
|