skalpel 4.0.45 → 4.0.47
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/catches.mjs +12 -71
- package/codex-normalize.mjs +216 -0
- package/eval-harness.mjs +734 -1
- package/first-run.mjs +5 -2
- package/install.mjs +1 -0
- package/package.json +1 -1
- package/verify-shadow.mjs +494 -76
package/anchor-shadow.mjs
CHANGED
|
@@ -438,6 +438,434 @@ export async function runProbe(rawTarget) {
|
|
|
438
438
|
return { pass: null, evidence: "unknown target kind" };
|
|
439
439
|
}
|
|
440
440
|
|
|
441
|
+
// ===================================================================================================
|
|
442
|
+
// SHIP-STATUS v1 ("Remote-Truth"): STRICT kind↔probe reconstruction + read-only probes.
|
|
443
|
+
//
|
|
444
|
+
// Distinct from the doubt-spiral reconstructTarget/runProbe above (which allow claim-FAMILY fallbacks
|
|
445
|
+
// to resolve a user's re-verify spiral): the ship-status CATCH must be precision-first. So here EACH
|
|
446
|
+
// ship claim kind maps to EXACTLY ONE probe family, with NO cross-kind fallback — a `pushed` claim can
|
|
447
|
+
// NEVER fall back to a PR probe (the cross-kind false-gotcha that fires "PR #n is OPEN" against a TRUE
|
|
448
|
+
// push). No target of the RIGHT kind → null → the caller stays honestly silent (NO_TARGET, never a red).
|
|
449
|
+
//
|
|
450
|
+
// Every probe is READ-ONLY, execFile arg-array (never a shell), args re-validated against strict
|
|
451
|
+
// whitelists, bounded timeout, fail-open (any error → UNKNOWN, never a lie). The `pushed` probe is the
|
|
452
|
+
// new REMOTE-TRUTH read: one `git ls-remote` (the ONLY network read) resolves the REAL remote tip, then
|
|
453
|
+
// a purely-local `git merge-base --is-ancestor` decides reachability — so a push that never left the
|
|
454
|
+
// machine is caught, while a legitimate feature-branch push is never false-accused.
|
|
455
|
+
// ===================================================================================================
|
|
456
|
+
|
|
457
|
+
// The 1:1 map the caller asserts: each ship claim kind → its single allowed probe FAMILY. `live` &
|
|
458
|
+
// `deployed` share the URL probe; `released` & `published` share the registry/tag family; the rest are
|
|
459
|
+
// unique. reconstructShipTargetV1 ALWAYS returns a target whose family is SHIP_KIND_PROBE[claim], or null.
|
|
460
|
+
export const SHIP_KIND_PROBE = {
|
|
461
|
+
pushed: "git-remote",
|
|
462
|
+
merged: "pr",
|
|
463
|
+
live: "url",
|
|
464
|
+
deployed: "url",
|
|
465
|
+
released: "registry",
|
|
466
|
+
published: "registry",
|
|
467
|
+
ci: "gh-checks",
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// shipProbeFamily(targetKind) → the probe FAMILY a concrete target belongs to (npm & git-tag are both
|
|
471
|
+
// the registry/tag family). Used to assert reconstruction never crosses kinds.
|
|
472
|
+
export function shipProbeFamily(kind) {
|
|
473
|
+
if (kind === "npm" || kind === "git-tag") return "registry";
|
|
474
|
+
return kind; // git-remote | pr | url | gh-checks
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Strict arg whitelists (defense in depth — execFile already means no shell).
|
|
478
|
+
const SHIP_SAFE_REF = /^[A-Za-z0-9._@/+-]{1,120}$/; // git remote/branch/tag name
|
|
479
|
+
const SHIP_SHA_RE = /^[0-9a-f]{7,40}$/;
|
|
480
|
+
const SHIP_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
481
|
+
const SHIP_NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
|
|
482
|
+
const SHIP_NET_TIMEOUT_MS = 10000; // network probes (ls-remote / npm view / gh) get a touch more than 5s
|
|
483
|
+
|
|
484
|
+
// findPushBranch(text) → the LAST `git push [<remote>] [<branch>]` seen in context, as {remote, branch}
|
|
485
|
+
// (either may be null). Used ONLY to name the branch/remote whose remote tip the `pushed` probe reads —
|
|
486
|
+
// never executed. A refspec `local:remote` keeps the remote-side ref. Both fields strictly validated.
|
|
487
|
+
function findPushBranch(text) {
|
|
488
|
+
const re = /\bgit\s+push\b([^\n;&|]*)/gi;
|
|
489
|
+
let last = null;
|
|
490
|
+
let m;
|
|
491
|
+
while ((m = re.exec(text))) {
|
|
492
|
+
const pos = m[1]
|
|
493
|
+
.trim()
|
|
494
|
+
.split(/\s+/)
|
|
495
|
+
.filter((t) => t && !t.startsWith("-"));
|
|
496
|
+
if (pos.length >= 2) last = { remote: pos[0], branch: pos[1] };
|
|
497
|
+
else if (pos.length === 1) last = { remote: pos[0], branch: null };
|
|
498
|
+
else last = { remote: null, branch: null };
|
|
499
|
+
}
|
|
500
|
+
if (!last) return null;
|
|
501
|
+
let branch = last.branch;
|
|
502
|
+
if (branch && branch.includes(":")) branch = branch.split(":").pop(); // refspec local:remote → remote
|
|
503
|
+
const remote = last.remote && SHIP_SAFE_REF.test(last.remote) ? last.remote : null;
|
|
504
|
+
branch = branch && SHIP_SAFE_REF.test(branch) ? branch : null;
|
|
505
|
+
return { remote, branch };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// findTag(text) → a release tag (vX.Y.Z or a `git tag <t>` / `git push origin <t>` mention). Validated.
|
|
509
|
+
function findTag(text) {
|
|
510
|
+
const near = /\bgit\s+(?:tag|push[^\n;&|]*?)\s+(v?\d+\.\d+\.\d+[0-9A-Za-z.\-+]*)\b/i.exec(text);
|
|
511
|
+
if (near && SHIP_SAFE_REF.test(near[1])) return near[1];
|
|
512
|
+
const m = /\b(v\d+\.\d+\.\d+[0-9A-Za-z.\-+]*)\b/.exec(text);
|
|
513
|
+
return m && SHIP_SAFE_REF.test(m[1]) ? m[1] : null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// findNpmVersion(text) → a semver version string near a publish/version mention (strict semver). The
|
|
517
|
+
// npm target's NAME is read from the session's own package.json — never from claim/prompt content.
|
|
518
|
+
function findNpmVersion(text) {
|
|
519
|
+
const near =
|
|
520
|
+
/\b(?:publish|version|v)\s*@?\s*(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/i.exec(
|
|
521
|
+
text,
|
|
522
|
+
) || /@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/.exec(text);
|
|
523
|
+
if (near && SHIP_SEMVER_RE.test(near[1])) return near[1];
|
|
524
|
+
const m = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/.exec(text);
|
|
525
|
+
return m && SHIP_SEMVER_RE.test(m[1]) ? m[1] : null;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// readPkgName(repoDir) → the "name" field of the session's own package.json, validated against npm's
|
|
529
|
+
// package-name rules, or null. This is the ONLY source of the published package name (never the claim).
|
|
530
|
+
function readPkgName(repoDir) {
|
|
531
|
+
try {
|
|
532
|
+
if (!repoDir) return null;
|
|
533
|
+
const name = JSON.parse(readFileSync(join(repoDir, "package.json"), "utf8"))?.name;
|
|
534
|
+
return typeof name === "string" && SHIP_NPM_NAME_RE.test(name) ? name : null;
|
|
535
|
+
} catch {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// reconstructShipTargetV1(claim, contextText, payload) → the STRICT, single-family target for a ship
|
|
541
|
+
// claim, or null. NO cross-kind fallback: a kind that can't reconstruct ITS OWN target returns null.
|
|
542
|
+
export function reconstructShipTargetV1(claim, contextText, payload) {
|
|
543
|
+
const text = String(contextText || "");
|
|
544
|
+
const repoDir = repoDirOf(payload);
|
|
545
|
+
const family = SHIP_KIND_PROBE[claim];
|
|
546
|
+
if (!family) return null;
|
|
547
|
+
switch (family) {
|
|
548
|
+
case "git-remote": {
|
|
549
|
+
// pushed → REMOTE-TRUTH. Needs the claimed sha + a repo. The branch (from the session's own
|
|
550
|
+
// `git push`, else resolved at probe time from the upstream) is optional here; ambiguity that
|
|
551
|
+
// can't be resolved becomes UNKNOWN in the probe, never a false red.
|
|
552
|
+
const sha = findSha(text);
|
|
553
|
+
if (!sha || !repoDir) return null;
|
|
554
|
+
const pb = findPushBranch(text) || {};
|
|
555
|
+
return {
|
|
556
|
+
kind: "git-remote",
|
|
557
|
+
sha,
|
|
558
|
+
remote: pb.remote || null,
|
|
559
|
+
branch: pb.branch || null,
|
|
560
|
+
repoDir,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
case "pr": {
|
|
564
|
+
// merged → the PR probe ONLY. No PR number in context → null (never a git/url fallback).
|
|
565
|
+
const n = findPr(text);
|
|
566
|
+
return n != null ? { kind: "pr", n, repoDir } : null;
|
|
567
|
+
}
|
|
568
|
+
case "url": {
|
|
569
|
+
// live/deployed → the URL probe ONLY.
|
|
570
|
+
const url = findUrl(text);
|
|
571
|
+
return url ? { kind: "url", url } : null;
|
|
572
|
+
}
|
|
573
|
+
case "registry": {
|
|
574
|
+
// released/published → the registry/tag family ONLY. Prefer npm when the session actually ran
|
|
575
|
+
// `npm publish` AND both the package name (from package.json) and a semver version reconstruct;
|
|
576
|
+
// else a git tag if one is present. Neither → null.
|
|
577
|
+
if (/\bnpm\s+publish\b/i.test(text)) {
|
|
578
|
+
const name = readPkgName(repoDir);
|
|
579
|
+
const version = findNpmVersion(text);
|
|
580
|
+
if (name && version) return { kind: "npm", name, version };
|
|
581
|
+
}
|
|
582
|
+
const tag = findTag(text);
|
|
583
|
+
if (tag && repoDir) {
|
|
584
|
+
const pb = findPushBranch(text) || {};
|
|
585
|
+
return { kind: "git-tag", tag, remote: pb.remote || "origin", repoDir };
|
|
586
|
+
}
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
case "gh-checks": {
|
|
590
|
+
// "CI green/passing" → gh checks ONLY (a PR's checks, else a commit's runs).
|
|
591
|
+
const n = findPr(text);
|
|
592
|
+
if (n != null) return { kind: "gh-checks", n, repoDir };
|
|
593
|
+
const sha = findSha(text);
|
|
594
|
+
if (sha) return { kind: "gh-checks", sha, repoDir };
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
default:
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// validateShipTargetV1(t) → re-validate every field of a ship-v1 target before it is passed to execFile
|
|
603
|
+
// (defense in depth). Returns a normalized target or null. Never throws.
|
|
604
|
+
function validateShipTargetV1(t) {
|
|
605
|
+
if (!t || typeof t !== "object") return null;
|
|
606
|
+
switch (t.kind) {
|
|
607
|
+
case "url":
|
|
608
|
+
return isSafeUrl(t.url) ? { kind: "url", url: t.url } : null;
|
|
609
|
+
case "pr": {
|
|
610
|
+
const n = Number(t.n);
|
|
611
|
+
if (!Number.isInteger(n) || n <= 0 || n >= 1e7) return null;
|
|
612
|
+
return { kind: "pr", n, repoDir: validDir(t.repoDir) };
|
|
613
|
+
}
|
|
614
|
+
case "git-remote": {
|
|
615
|
+
if (!SHIP_SHA_RE.test(String(t.sha || ""))) return null;
|
|
616
|
+
const repoDir = validDir(t.repoDir);
|
|
617
|
+
if (!repoDir) return null;
|
|
618
|
+
const remote = t.remote && SHIP_SAFE_REF.test(t.remote) ? t.remote : "origin";
|
|
619
|
+
const branch = t.branch && SHIP_SAFE_REF.test(t.branch) ? t.branch : null;
|
|
620
|
+
return { kind: "git-remote", sha: String(t.sha).toLowerCase(), remote, branch, repoDir };
|
|
621
|
+
}
|
|
622
|
+
case "npm": {
|
|
623
|
+
if (
|
|
624
|
+
!SHIP_NPM_NAME_RE.test(String(t.name || "")) ||
|
|
625
|
+
!SHIP_SEMVER_RE.test(String(t.version || ""))
|
|
626
|
+
)
|
|
627
|
+
return null;
|
|
628
|
+
return { kind: "npm", name: t.name, version: t.version };
|
|
629
|
+
}
|
|
630
|
+
case "git-tag": {
|
|
631
|
+
const repoDir = validDir(t.repoDir);
|
|
632
|
+
if (!repoDir || !SHIP_SAFE_REF.test(String(t.tag || ""))) return null;
|
|
633
|
+
const remote = t.remote && SHIP_SAFE_REF.test(t.remote) ? t.remote : "origin";
|
|
634
|
+
return { kind: "git-tag", tag: t.tag, remote, repoDir };
|
|
635
|
+
}
|
|
636
|
+
case "gh-checks": {
|
|
637
|
+
const repoDir = validDir(t.repoDir);
|
|
638
|
+
const n = t.n != null ? Number(t.n) : null;
|
|
639
|
+
const sha = t.sha ? String(t.sha).toLowerCase() : null;
|
|
640
|
+
if (n != null && Number.isInteger(n) && n > 0 && n < 1e7)
|
|
641
|
+
return { kind: "gh-checks", n, repoDir };
|
|
642
|
+
if (sha && SHIP_SHA_RE.test(sha)) return { kind: "gh-checks", sha, repoDir };
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
default:
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// classifyHttpVerdict(code) → the Defect-3 HTTP precision map. A responding server is REACHABLE; only a
|
|
651
|
+
// genuine "gone" (404/410) is a FAIL CANDIDATE (never an immediate red — the caller confirm-reprobes).
|
|
652
|
+
// 401/403/405 (auth-walled), 429 (rate-limit), 5xx (transient), and any other response → UNKNOWN.
|
|
653
|
+
export function classifyHttpVerdict(code) {
|
|
654
|
+
if (!Number.isFinite(code) || code === 0) return "UNKNOWN";
|
|
655
|
+
if (code >= 200 && code < 400) return "PASS";
|
|
656
|
+
if (code === 404 || code === 410) return "FAIL_CANDIDATE";
|
|
657
|
+
return "UNKNOWN";
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// parseLsRemoteTip(stdout, branch) → the 40-hex tip sha for refs/heads/<branch> (or the sole ref line),
|
|
661
|
+
// else null. `git ls-remote <remote> <branch>` prints `<sha>\t<ref>` lines.
|
|
662
|
+
function parseLsRemoteTip(stdout, branch) {
|
|
663
|
+
const lines = String(stdout || "").split("\n");
|
|
664
|
+
for (const line of lines) {
|
|
665
|
+
const mm = line.match(/^([0-9a-f]{40})\s+(\S+)/i);
|
|
666
|
+
if (mm && (mm[2] === `refs/heads/${branch}` || mm[2] === "HEAD")) return mm[1].toLowerCase();
|
|
667
|
+
}
|
|
668
|
+
const first = String(stdout || "").match(/^([0-9a-f]{40})\b/im);
|
|
669
|
+
return first ? first[1].toLowerCase() : null;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// runShipProbeV1(target, exec) → ONE read-only probe for the strict target. Returns
|
|
673
|
+
// { verdict, evidence } where verdict ∈ PASS | FAIL | FAIL_CANDIDATE | UNKNOWN. `exec` is injectable
|
|
674
|
+
// (defaults to the real read-only `run`) purely so the eval harness can drive it hermetically. Never throws.
|
|
675
|
+
export async function runShipProbeV1(rawTarget, exec = run) {
|
|
676
|
+
const t = validateShipTargetV1(rawTarget);
|
|
677
|
+
if (!t) return { verdict: "UNKNOWN", evidence: "rejected: target failed validation" };
|
|
678
|
+
try {
|
|
679
|
+
if (t.kind === "url") {
|
|
680
|
+
// curl -sS -m 5 -o /dev/null -w "%{http_code}" <url> (READ-ONLY). curl exits 0 on ANY HTTP
|
|
681
|
+
// status; a non-zero exit is a transport error (6=NXDOMAIN, 7=refused → fail candidate; else unknown).
|
|
682
|
+
const { err, stdout } = await exec(
|
|
683
|
+
"curl",
|
|
684
|
+
["-sS", "-m", "5", "-o", "/dev/null", "-w", "%{http_code}", t.url],
|
|
685
|
+
{ timeout: SHIP_NET_TIMEOUT_MS },
|
|
686
|
+
);
|
|
687
|
+
if (err) {
|
|
688
|
+
const c = err.code;
|
|
689
|
+
if (c === 6)
|
|
690
|
+
return { verdict: "FAIL_CANDIDATE", evidence: "curl: could not resolve host (NXDOMAIN)" };
|
|
691
|
+
if (c === 7) return { verdict: "FAIL_CANDIDATE", evidence: "curl: connection refused" };
|
|
692
|
+
return {
|
|
693
|
+
verdict: "UNKNOWN",
|
|
694
|
+
evidence: clip(`curl error: ${err.killed ? "timeout" : err.message || c}`),
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
const code = parseInt(stdout, 10);
|
|
698
|
+
const verdict = classifyHttpVerdict(code);
|
|
699
|
+
const evidence =
|
|
700
|
+
Number.isFinite(code) && code !== 0 ? `http_code=${code}` : clip(`no http code: ${stdout}`);
|
|
701
|
+
return { verdict, evidence };
|
|
702
|
+
}
|
|
703
|
+
if (t.kind === "pr") {
|
|
704
|
+
// gh pr view <n> --json state,mergedAt (READ-ONLY, DECISIVE)
|
|
705
|
+
const opts = t.repoDir ? { cwd: t.repoDir } : {};
|
|
706
|
+
const { err, stdout } = await exec(
|
|
707
|
+
"gh",
|
|
708
|
+
["pr", "view", String(t.n), "--json", "state,mergedAt"],
|
|
709
|
+
opts,
|
|
710
|
+
);
|
|
711
|
+
if (err)
|
|
712
|
+
return {
|
|
713
|
+
verdict: "UNKNOWN",
|
|
714
|
+
evidence: clip(`gh error: ${err.killed ? "timeout" : err.message}`),
|
|
715
|
+
};
|
|
716
|
+
let j;
|
|
717
|
+
try {
|
|
718
|
+
j = JSON.parse(stdout);
|
|
719
|
+
} catch {
|
|
720
|
+
return { verdict: "UNKNOWN", evidence: clip(`gh non-json: ${stdout}`) };
|
|
721
|
+
}
|
|
722
|
+
const merged = j.state === "MERGED" || !!j.mergedAt;
|
|
723
|
+
return {
|
|
724
|
+
verdict: merged ? "PASS" : "FAIL",
|
|
725
|
+
evidence: clip(`state=${j.state} mergedAt=${j.mergedAt || "null"}`),
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
if (t.kind === "git-remote") {
|
|
729
|
+
// REMOTE-TRUTH. Resolve the branch (from the push, else the upstream / current branch), read the
|
|
730
|
+
// REAL remote tip with ONE `git ls-remote`, then decide reachability locally with merge-base.
|
|
731
|
+
const remote = t.remote || "origin";
|
|
732
|
+
let branch = t.branch;
|
|
733
|
+
if (!branch) {
|
|
734
|
+
const up = await exec(
|
|
735
|
+
"git",
|
|
736
|
+
["-C", t.repoDir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
|
|
737
|
+
{ timeout: SHIP_NET_TIMEOUT_MS },
|
|
738
|
+
);
|
|
739
|
+
if (!up.err) {
|
|
740
|
+
const ref = String(up.stdout || "").trim(); // e.g. origin/feat-x
|
|
741
|
+
const idx = ref.indexOf("/");
|
|
742
|
+
if (idx > 0) branch = ref.slice(idx + 1);
|
|
743
|
+
}
|
|
744
|
+
if (!branch) {
|
|
745
|
+
const cur = await exec("git", ["-C", t.repoDir, "symbolic-ref", "--short", "HEAD"], {
|
|
746
|
+
timeout: SHIP_NET_TIMEOUT_MS,
|
|
747
|
+
});
|
|
748
|
+
if (!cur.err) branch = String(cur.stdout || "").trim() || null;
|
|
749
|
+
}
|
|
750
|
+
if (!branch || !SHIP_SAFE_REF.test(branch))
|
|
751
|
+
return { verdict: "UNKNOWN", evidence: "git-remote: branch unresolved (ambiguous)" };
|
|
752
|
+
}
|
|
753
|
+
const ls = await exec("git", ["-C", t.repoDir, "ls-remote", remote, branch], {
|
|
754
|
+
timeout: SHIP_NET_TIMEOUT_MS,
|
|
755
|
+
});
|
|
756
|
+
if (ls.err)
|
|
757
|
+
return {
|
|
758
|
+
verdict: "UNKNOWN",
|
|
759
|
+
evidence: clip(`ls-remote error: ${ls.err.killed ? "timeout" : ls.err.message}`),
|
|
760
|
+
};
|
|
761
|
+
const tip = parseLsRemoteTip(ls.stdout, branch);
|
|
762
|
+
if (!tip)
|
|
763
|
+
return { verdict: "UNKNOWN", evidence: clip(`no remote tip for ${remote}/${branch}`) };
|
|
764
|
+
const mb = await exec("git", ["-C", t.repoDir, "merge-base", "--is-ancestor", t.sha, tip], {
|
|
765
|
+
timeout: SHIP_NET_TIMEOUT_MS,
|
|
766
|
+
});
|
|
767
|
+
const evid = `sha=${t.sha.slice(0, 12)} branch=${remote}/${branch} remoteTip=${tip.slice(0, 12)}`;
|
|
768
|
+
// exit 0 → reachable (PASS). exit 1 → both objects known AND not reachable (DECISIVE FAIL — it
|
|
769
|
+
// never left the machine). Any other exit → a commit is unknown locally → UNKNOWN (never a lie).
|
|
770
|
+
if (!mb.err) return { verdict: "PASS", evidence: clip(`${evid} ancestor`) };
|
|
771
|
+
if (mb.err.code === 1) return { verdict: "FAIL", evidence: clip(`${evid} not-ancestor`) };
|
|
772
|
+
return {
|
|
773
|
+
verdict: "UNKNOWN",
|
|
774
|
+
evidence: clip(`${evid} merge-base indeterminate (tip/sha unknown locally)`),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
if (t.kind === "npm") {
|
|
778
|
+
// npm view <name>@<version> version (READ-ONLY registry read, DECISIVE)
|
|
779
|
+
const { err, stdout, stderr } = await exec(
|
|
780
|
+
"npm",
|
|
781
|
+
["view", `${t.name}@${t.version}`, "version"],
|
|
782
|
+
{
|
|
783
|
+
timeout: SHIP_NET_TIMEOUT_MS,
|
|
784
|
+
},
|
|
785
|
+
);
|
|
786
|
+
if (!err) {
|
|
787
|
+
return String(stdout || "").trim()
|
|
788
|
+
? {
|
|
789
|
+
verdict: "PASS",
|
|
790
|
+
evidence: clip(`npm ${t.name}@${t.version} = ${String(stdout).trim()}`),
|
|
791
|
+
}
|
|
792
|
+
: { verdict: "UNKNOWN", evidence: clip(`npm empty for ${t.name}@${t.version}`) };
|
|
793
|
+
}
|
|
794
|
+
const combined = `${stderr || ""} ${stdout || ""}`;
|
|
795
|
+
if (/E404|No match found|is not in (?:this|the)/i.test(combined))
|
|
796
|
+
return { verdict: "FAIL", evidence: clip(`npm: ${t.name}@${t.version} not on registry`) };
|
|
797
|
+
return {
|
|
798
|
+
verdict: "UNKNOWN",
|
|
799
|
+
evidence: clip(`npm error: ${err.killed ? "timeout" : err.message}`),
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
if (t.kind === "git-tag") {
|
|
803
|
+
// git -C <repo> ls-remote --tags <remote> <tag> (READ-ONLY, DECISIVE)
|
|
804
|
+
const { err, stdout } = await exec(
|
|
805
|
+
"git",
|
|
806
|
+
["-C", t.repoDir, "ls-remote", "--tags", t.remote, t.tag],
|
|
807
|
+
{
|
|
808
|
+
timeout: SHIP_NET_TIMEOUT_MS,
|
|
809
|
+
},
|
|
810
|
+
);
|
|
811
|
+
if (err)
|
|
812
|
+
return {
|
|
813
|
+
verdict: "UNKNOWN",
|
|
814
|
+
evidence: clip(`ls-remote --tags error: ${err.killed ? "timeout" : err.message}`),
|
|
815
|
+
};
|
|
816
|
+
const esc = t.tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
817
|
+
const has = new RegExp(`\\brefs/tags/${esc}(?:\\^\\{\\})?$`, "m").test(String(stdout || ""));
|
|
818
|
+
return has
|
|
819
|
+
? { verdict: "PASS", evidence: clip(`tag ${t.remote}/${t.tag} present`) }
|
|
820
|
+
: { verdict: "FAIL", evidence: clip(`tag ${t.tag} not on ${t.remote}`) };
|
|
821
|
+
}
|
|
822
|
+
if (t.kind === "gh-checks") {
|
|
823
|
+
// gh pr checks <n> --json ... / gh run list --commit <sha> --json ... (READ-ONLY). gh prints
|
|
824
|
+
// JSON even on a non-zero exit (failing/pending), so we parse stdout regardless of err.
|
|
825
|
+
const opts = t.repoDir ? { cwd: t.repoDir } : {};
|
|
826
|
+
let err, stdout;
|
|
827
|
+
if (t.n != null) {
|
|
828
|
+
({ err, stdout } = await exec(
|
|
829
|
+
"gh",
|
|
830
|
+
["pr", "checks", String(t.n), "--json", "state,bucket"],
|
|
831
|
+
opts,
|
|
832
|
+
));
|
|
833
|
+
} else {
|
|
834
|
+
({ err, stdout } = await exec(
|
|
835
|
+
"gh",
|
|
836
|
+
["run", "list", "--commit", t.sha, "--json", "conclusion,status"],
|
|
837
|
+
opts,
|
|
838
|
+
));
|
|
839
|
+
}
|
|
840
|
+
const out = String(stdout || "");
|
|
841
|
+
if (err && !out.trim())
|
|
842
|
+
return {
|
|
843
|
+
verdict: "UNKNOWN",
|
|
844
|
+
evidence: clip(`gh checks error: ${err.killed ? "timeout" : err.message}`),
|
|
845
|
+
};
|
|
846
|
+
let arr;
|
|
847
|
+
try {
|
|
848
|
+
arr = JSON.parse(out);
|
|
849
|
+
} catch {
|
|
850
|
+
return { verdict: "UNKNOWN", evidence: clip(`gh checks non-json: ${out}`) };
|
|
851
|
+
}
|
|
852
|
+
if (!Array.isArray(arr) || !arr.length)
|
|
853
|
+
return { verdict: "UNKNOWN", evidence: "gh checks: none reported" };
|
|
854
|
+
const norm = arr.map((c) =>
|
|
855
|
+
String((c && (c.bucket || c.conclusion || c.state || c.status)) || "").toLowerCase(),
|
|
856
|
+
);
|
|
857
|
+
if (norm.some((s) => /fail|error|cancel|timed_out|action_required/.test(s)))
|
|
858
|
+
return { verdict: "FAIL", evidence: clip(`checks: ${norm.join(",")}`) };
|
|
859
|
+
if (norm.some((s) => s === "" || /pending|queued|in_progress|waiting|requested/.test(s)))
|
|
860
|
+
return { verdict: "UNKNOWN", evidence: clip(`checks pending: ${norm.join(",")}`) };
|
|
861
|
+
return { verdict: "PASS", evidence: clip(`checks: ${norm.join(",")}`) };
|
|
862
|
+
}
|
|
863
|
+
} catch (e) {
|
|
864
|
+
return { verdict: "UNKNOWN", evidence: clip(`probe threw: ${e}`) };
|
|
865
|
+
}
|
|
866
|
+
return { verdict: "UNKNOWN", evidence: "unknown ship target kind" };
|
|
867
|
+
}
|
|
868
|
+
|
|
441
869
|
// ---------------------------------------------------------------------------------------------------
|
|
442
870
|
// Logging — the ONLY side effect. Append-only NDJSON at ~/.skalpel/anchor-shadow.log. Never throws.
|
|
443
871
|
// ---------------------------------------------------------------------------------------------------
|
package/catches.mjs
CHANGED
|
@@ -39,6 +39,10 @@ import {
|
|
|
39
39
|
classifyOutcome,
|
|
40
40
|
extractFailCount,
|
|
41
41
|
} from "./verify-shadow.mjs";
|
|
42
|
+
// ONE normalizer, never two: the Codex rollout → Claude-shape translation lives in codex-normalize.mjs and
|
|
43
|
+
// is shared verbatim with the LIVE verify-shadow worker, so the retroactive scan and the live catch can
|
|
44
|
+
// never drift. (This module keeps only the Claude-native loader below.)
|
|
45
|
+
import { loadCodexEntries } from "./codex-normalize.mjs";
|
|
42
46
|
import { recordInsight } from "./insights.mjs";
|
|
43
47
|
|
|
44
48
|
const HOME = homedir();
|
|
@@ -113,76 +117,10 @@ export function loadClaudeEntries(file) {
|
|
|
113
117
|
return entries;
|
|
114
118
|
}
|
|
115
119
|
|
|
116
|
-
// Codex
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const m = s.match(/Process exited with code\s+(-?\d+)/i);
|
|
121
|
-
const exitCode = m ? parseInt(m[1], 10) : null;
|
|
122
|
-
// Prefer the text AFTER "Output:" as the evidence body (drops the Command/Chunk/Wall-time header).
|
|
123
|
-
const oi = s.indexOf("\nOutput:\n");
|
|
124
|
-
const body = oi >= 0 ? s.slice(oi + "\nOutput:\n".length) : s;
|
|
125
|
-
return { exitCode, body };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Normalize one Codex rollout file into Claude-shaped entries. exec_command → a Bash tool_use; its
|
|
129
|
-
// function_call_output → a tool_result carrying the recorded exit code; assistant messages → text.
|
|
130
|
-
export function loadCodexEntries(file) {
|
|
131
|
-
const entries = [];
|
|
132
|
-
for (const line of tailLines(file, SESSION_TAIL_BYTES)) {
|
|
133
|
-
let e;
|
|
134
|
-
try {
|
|
135
|
-
e = JSON.parse(line);
|
|
136
|
-
} catch {
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
if (e.type !== "response_item") continue;
|
|
140
|
-
const p = e.payload || {};
|
|
141
|
-
if (p.type === "message") {
|
|
142
|
-
const role = p.role === "assistant" ? "assistant" : "user";
|
|
143
|
-
const text = Array.isArray(p.content)
|
|
144
|
-
? p.content.map((b) => (b && typeof b.text === "string" ? b.text : "")).join("")
|
|
145
|
-
: "";
|
|
146
|
-
if (text.trim())
|
|
147
|
-
entries.push({ type: role, message: { role, content: [{ type: "text", text }] } });
|
|
148
|
-
} else if (p.type === "function_call" && p.name === "exec_command" && p.call_id) {
|
|
149
|
-
let cmd = "";
|
|
150
|
-
let workdir = null;
|
|
151
|
-
try {
|
|
152
|
-
const a = JSON.parse(p.arguments || "{}");
|
|
153
|
-
cmd = typeof a.cmd === "string" ? a.cmd : Array.isArray(a.cmd) ? a.cmd.join(" ") : "";
|
|
154
|
-
workdir = typeof a.workdir === "string" ? a.workdir : null;
|
|
155
|
-
} catch {
|
|
156
|
-
/* unparseable args — leave cmd empty (no proof) */
|
|
157
|
-
}
|
|
158
|
-
if (cmd)
|
|
159
|
-
entries.push({
|
|
160
|
-
cwd: workdir,
|
|
161
|
-
message: {
|
|
162
|
-
role: "assistant",
|
|
163
|
-
content: [{ type: "tool_use", name: "Bash", id: p.call_id, input: { command: cmd } }],
|
|
164
|
-
},
|
|
165
|
-
});
|
|
166
|
-
} else if (p.type === "function_call_output" && p.call_id) {
|
|
167
|
-
const { exitCode, body } = parseCodexOutput(p.output);
|
|
168
|
-
entries.push({
|
|
169
|
-
message: {
|
|
170
|
-
role: "user",
|
|
171
|
-
content: [
|
|
172
|
-
{
|
|
173
|
-
type: "tool_result",
|
|
174
|
-
tool_use_id: p.call_id,
|
|
175
|
-
is_error: typeof exitCode === "number" ? exitCode !== 0 : false,
|
|
176
|
-
exit_code: exitCode,
|
|
177
|
-
content: body,
|
|
178
|
-
},
|
|
179
|
-
],
|
|
180
|
-
},
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
return entries;
|
|
185
|
-
}
|
|
120
|
+
// Codex rollout normalization (loadCodexEntries / parseCodexOutput) now lives in ./codex-normalize.mjs and
|
|
121
|
+
// is imported above — ONE normalizer shared with the live verify-shadow worker (deleted here to avoid a
|
|
122
|
+
// second, drifting copy). The retroactive scan still reads the RECORDED exit code those entries carry
|
|
123
|
+
// (exit_code / is_error) instead of re-running.
|
|
186
124
|
|
|
187
125
|
// ---------- (3) index the recorded proof results (no re-run) ----------
|
|
188
126
|
function resultTextOf(block) {
|
|
@@ -318,7 +256,10 @@ export function runScan() {
|
|
|
318
256
|
for (const s of sessions) {
|
|
319
257
|
let entries;
|
|
320
258
|
try {
|
|
321
|
-
entries =
|
|
259
|
+
entries =
|
|
260
|
+
s.tool === "codex"
|
|
261
|
+
? loadCodexEntries(s.file, SESSION_TAIL_BYTES)
|
|
262
|
+
: loadClaudeEntries(s.file);
|
|
322
263
|
} catch {
|
|
323
264
|
continue; // unreadable file — skip (read-only, fail-open)
|
|
324
265
|
}
|