@junghanacs/entwurf 0.34.1 → 0.35.0
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/CHANGELOG.md +217 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +11 -7
- package/VERIFY.md +11 -1
- package/demo/README.md +109 -175
- package/demo/demo-baseline.sh +5 -1
- package/demo/demo.sh +6 -2
- package/demo/herdr-pi-cc-20261011.provenance.json +64 -0
- package/docs/assets/entwurf-demo.gif +0 -0
- package/docs/setup-clean-host.md +2 -2
- package/package.json +4 -2
- package/run.sh +39 -0
- package/scripts/check-gate-qualification.ts +3 -2
- package/scripts/check-herdr-activation.ts +80 -0
- package/scripts/check-meta-doctor-oracle.sh +86 -1
- package/scripts/check-release-gate-outcomes.ts +159 -1
- package/scripts/ci-qualify-decide.sh +114 -17
- package/scripts/herdr-activation.mjs +50 -1
- package/scripts/herdr-doctor-mode.mjs +26 -0
- package/scripts/meta-bridge-doctor.sh +79 -20
- package/scripts/mutants/demo-reproducibility.json +155 -0
- package/scripts/mutants/herdr-activation.json +71 -0
- package/scripts/mutants/release-gate.json +28 -2
- package/scripts/tsconfig.json +1 -0
|
@@ -183,6 +183,7 @@ interface Mod {
|
|
|
183
183
|
resolveEntwurfDataRoot: (env: NodeJS.ProcessEnv) => string;
|
|
184
184
|
LEDGER_PHASES: string[];
|
|
185
185
|
COMPONENT_STATES: string[];
|
|
186
|
+
resolveDoctorPluginRuntime: (env: NodeJS.ProcessEnv, opts: { repo: string; override?: string }) => string | null;
|
|
186
187
|
}
|
|
187
188
|
|
|
188
189
|
const mod = (await import(pathToFileURL(ACTIVATION).href)) as Mod;
|
|
@@ -204,6 +205,7 @@ const {
|
|
|
204
205
|
resolveEntwurfDataRoot,
|
|
205
206
|
LEDGER_PHASES,
|
|
206
207
|
COMPONENT_STATES,
|
|
208
|
+
resolveDoctorPluginRuntime,
|
|
207
209
|
} = mod;
|
|
208
210
|
|
|
209
211
|
/** The pi pin package.json declares (an exact production dependency since #130) — the SSOT
|
|
@@ -1484,4 +1486,82 @@ function ledgerWith(
|
|
|
1484
1486
|
);
|
|
1485
1487
|
}
|
|
1486
1488
|
|
|
1489
|
+
// ── 29. the doctor's expectation mode comes from the activation ledger (#136) ────
|
|
1490
|
+
// `doctor-meta-bridge` must compute the plugin activation's ABSOLUTE expectations exactly when the
|
|
1491
|
+
// ledger authorised that wiring, and never adopt it from a checkout. The oracle for the returned
|
|
1492
|
+
// root is `runtimeRootOf`, computed here independently of the resolver.
|
|
1493
|
+
{
|
|
1494
|
+
const env = world("doctor-mode");
|
|
1495
|
+
seedRuntime(env);
|
|
1496
|
+
const active = runtimeRootOf(env);
|
|
1497
|
+
const runtimeRepo = path.join(active, "node_modules", "@junghanacs", "entwurf");
|
|
1498
|
+
const layout = resolveActivationLayout(env);
|
|
1499
|
+
const mode = (opts: { repo: string; override?: string }): string | null => {
|
|
1500
|
+
try {
|
|
1501
|
+
return resolveDoctorPluginRuntime(env, opts);
|
|
1502
|
+
} catch (err) {
|
|
1503
|
+
return `refused:${(err as { code?: string }).code ?? String(err)}`;
|
|
1504
|
+
}
|
|
1505
|
+
};
|
|
1506
|
+
// D2 — no ledger: a doctor running from the runtime package is simply not in plugin mode.
|
|
1507
|
+
const absent = mode({ repo: runtimeRepo });
|
|
1508
|
+
writeLedger(layout, ledgerFor(env, ["pi", "claude-code"]));
|
|
1509
|
+
// D1 — runtime package + an active ledger that activated claude-code at THIS stable root.
|
|
1510
|
+
const derived = mode({ repo: runtimeRepo });
|
|
1511
|
+
ok(
|
|
1512
|
+
"[QK:DOCTOR-MODE-DERIVED] a doctor running from the plugin runtime's package derives plugin-runtime mode from " +
|
|
1513
|
+
`an active ledger that activated claude-code at this host's stable root, and from nothing else (no-ledger=${absent} derived=${derived})`,
|
|
1514
|
+
absent === null && derived === active,
|
|
1515
|
+
);
|
|
1516
|
+
// D3 — the checkout never derives, and never even opens the ledger: an uncertified body is
|
|
1517
|
+
// planted first, so reading it would throw instead of returning null.
|
|
1518
|
+
fs.writeFileSync(layout.ledgerPath, "not json\n");
|
|
1519
|
+
const checkout = mode({ repo: REPO });
|
|
1520
|
+
ok(
|
|
1521
|
+
"[QK:DOCTOR-MODE-CHECKOUT-NEVER-DERIVES] a doctor whose repo is not the runtime package returns no plugin mode " +
|
|
1522
|
+
`without opening the ledger, so a checkout never adopts plugin wiring (checkout=${checkout})`,
|
|
1523
|
+
checkout === null,
|
|
1524
|
+
);
|
|
1525
|
+
// D5 — on a reading path an uncertified ledger dies with the reader's own refusal, never null.
|
|
1526
|
+
fs.unlinkSync(layout.ledgerPath);
|
|
1527
|
+
fs.symlinkSync(path.join(env.HOME as string, "nowhere.json"), layout.ledgerPath);
|
|
1528
|
+
const viaRuntime = mode({ repo: runtimeRepo });
|
|
1529
|
+
const viaOverride = mode({ repo: REPO, override: active });
|
|
1530
|
+
fs.unlinkSync(layout.ledgerPath);
|
|
1531
|
+
ok(
|
|
1532
|
+
"[QK:DOCTOR-MODE-UNCERTIFIED-DIES] an uncertified ledger on either reading path (runtime-repo derivation, explicit " +
|
|
1533
|
+
`override) refuses with the reader's own code, never 'not plugin mode' (runtime=${viaRuntime} override=${viaOverride})`,
|
|
1534
|
+
viaRuntime === "refused:activation-ledger-uncertified" && viaOverride === "refused:activation-ledger-uncertified",
|
|
1535
|
+
);
|
|
1536
|
+
// D4 — the admission clauses on a reading path, each on a CERTIFIED ledger so only the clause can
|
|
1537
|
+
// refuse: a phase other than active, a ledger that never activated claude-code, and a runtimeRoot
|
|
1538
|
+
// that is not this host's stable root. Each is refused by name, never read as 'not plugin mode'.
|
|
1539
|
+
writeLedger(layout, ledgerWith(env, "activating", { pi: "active", "claude-code": "pending" }));
|
|
1540
|
+
const notActive = mode({ repo: runtimeRepo });
|
|
1541
|
+
writeLedger(layout, ledgerFor(env, ["pi"]));
|
|
1542
|
+
const piOnly = mode({ repo: runtimeRepo });
|
|
1543
|
+
writeLedger(layout, {
|
|
1544
|
+
...ledgerFor(env, ["pi", "claude-code"]),
|
|
1545
|
+
runtimeRoot: path.join(env.HOME as string, "another-runtime", "active"),
|
|
1546
|
+
});
|
|
1547
|
+
const foreignRoot = mode({ repo: runtimeRepo });
|
|
1548
|
+
ok(
|
|
1549
|
+
"[QK:DOCTOR-MODE-ADMISSION-CLAUSES] on the runtime repo a certified ledger still admits plugin mode only when " +
|
|
1550
|
+
"phase is active, claude-code was activated and its runtimeRoot is this host's stable root — each other case " +
|
|
1551
|
+
`is refused by name (activating=${notActive} pi-only=${piOnly} foreign-root=${foreignRoot})`,
|
|
1552
|
+
notActive === "refused:doctor-plugin-runtime-unadmitted" &&
|
|
1553
|
+
piOnly === "refused:doctor-plugin-runtime-unadmitted" &&
|
|
1554
|
+
foreignRoot === "refused:doctor-plugin-runtime-unadmitted",
|
|
1555
|
+
);
|
|
1556
|
+
// D6 — the override must agree with the ledger's root; agreeing, it is admitted from a checkout.
|
|
1557
|
+
writeLedger(layout, ledgerFor(env, ["pi", "claude-code"]));
|
|
1558
|
+
const elsewhere = mode({ repo: REPO, override: path.join(env.HOME as string, "elsewhere") });
|
|
1559
|
+
const agreeing = mode({ repo: REPO, override: active });
|
|
1560
|
+
ok(
|
|
1561
|
+
"[QK:DOCTOR-MODE-OVERRIDE-AGREES] an explicit --plugin-runtime is admitted only when it names the ledger's own " +
|
|
1562
|
+
`stable root (elsewhere=${elsewhere} agreeing=${agreeing})`,
|
|
1563
|
+
elsewhere === "refused:doctor-plugin-runtime-unadmitted" && agreeing === active,
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1487
1567
|
console.log(`\ncheck-herdr-activation: ${passed} assertions passed`);
|
|
@@ -227,6 +227,8 @@ ok "fake live entwurf MCP bridge running as a child of the stand-in Claude pid (
|
|
|
227
227
|
# --- doctor driver -----------------------------------------------------------
|
|
228
228
|
DOC_OUT=""
|
|
229
229
|
DOC_RC=0
|
|
230
|
+
# #136: arguments for the plugin-runtime block at the end; empty for every clone cell.
|
|
231
|
+
DOC_ARGS=()
|
|
230
232
|
run_doctor() {
|
|
231
233
|
set +e
|
|
232
234
|
# Deliberate cross-harness poison. Before the doctor scrubbed these carriers, the
|
|
@@ -237,7 +239,7 @@ run_doctor() {
|
|
|
237
239
|
DOC_OUT="$(PI_SESSION_ID=ambient-pi-session-must-not-win \
|
|
238
240
|
PI_AGENT_ID=ambient-pi-agent/must-not-win \
|
|
239
241
|
ENTWURF_META_SENDER_MARKER="$TMP/ambient-sender-marker-must-not-win.json" \
|
|
240
|
-
bash "$REPO/scripts/meta-bridge-doctor.sh" 2>&1)"
|
|
242
|
+
bash "$REPO/scripts/meta-bridge-doctor.sh" ${DOC_ARGS[@]+"${DOC_ARGS[@]}"} 2>&1)"
|
|
241
243
|
DOC_RC=$?
|
|
242
244
|
set -e
|
|
243
245
|
}
|
|
@@ -713,5 +715,88 @@ else
|
|
|
713
715
|
bad "doctor stayed red after reverting every mutation (a restore is incomplete):"$'\n'"$(printf '%s\n' "$DOC_OUT" | grep -E '^ FAIL' | sed 's/^/ /')"
|
|
714
716
|
fi
|
|
715
717
|
|
|
718
|
+
# --- 6. #136: a Herdr plugin activation's ABSOLUTE wiring ---------------------
|
|
719
|
+
# The plugin activation runs the same installer with ENTWURF_PLUGIN_RUNTIME, so Claude's MCP and
|
|
720
|
+
# statusLine name absolute bins under the stable runtime. The doctor must learn that mode from the
|
|
721
|
+
# activation ledger and certify the wiring, not call it drift. FIXTURE HONESTY: the two runtime
|
|
722
|
+
# bins are wrappers onto this checkout's own launcher and statusline, and the ledger is written by
|
|
723
|
+
# the shipped writer with a fixture artifact identity; no npm runtime is installed here.
|
|
724
|
+
echo "[plugin-runtime] the installer's absolute wiring, certified through the activation ledger"
|
|
725
|
+
ACTIVE="$XDG_DATA_HOME/entwurf/herdr-plugin/runtime/active"
|
|
726
|
+
mkdir -p "$ACTIVE/node_modules/.bin" "$ACTIVE/node_modules/@junghanacs/entwurf"
|
|
727
|
+
printf '#!/usr/bin/env bash\nexec bash "%s/mcp/entwurf-bridge/start.sh" "$@"\n' "$REPO" > "$ACTIVE/node_modules/.bin/entwurf-bridge"
|
|
728
|
+
printf '#!/usr/bin/env bash\nexec "%s/scripts/meta-bridge-statusline.sh" "$@"\n' "$REPO" > "$ACTIVE/node_modules/.bin/entwurf-statusline"
|
|
729
|
+
chmod +x "$ACTIVE/node_modules/.bin/entwurf-bridge" "$ACTIVE/node_modules/.bin/entwurf-statusline"
|
|
730
|
+
if ENTWURF_PLUGIN_RUNTIME="$ACTIVE" bash "$REPO/scripts/meta-bridge-install.sh" >"$TMP/install-plugin.log" 2>&1; then
|
|
731
|
+
ok "real meta-bridge-install.sh wires the plugin-runtime mode (absolute bins under the sandbox stable root)"
|
|
732
|
+
else
|
|
733
|
+
bad "plugin-runtime install-meta-bridge failed in the sandbox:"$'\n'"$(sed 's/^/ install| /' "$TMP/install-plugin.log")"
|
|
734
|
+
fi
|
|
735
|
+
rm -rf "$PLANTED"
|
|
736
|
+
mkdir -p "$PLANTED"
|
|
737
|
+
cp -r "$ASM/$PLUGIN/." "$PLANTED/"
|
|
738
|
+
write_ledger() {
|
|
739
|
+
node --input-type=module - "$REPO/scripts/herdr-activation.mjs" "$ACTIVE" "$1" <<'JS'
|
|
740
|
+
import { pathToFileURL } from "node:url";
|
|
741
|
+
const [modPath, active, backends] = process.argv.slice(2);
|
|
742
|
+
const m = await import(pathToFileURL(modPath).href);
|
|
743
|
+
const states = Object.fromEntries(backends.split(",").map((b) => [b, "active"]));
|
|
744
|
+
m.writeLedger(m.resolveActivationLayout(process.env), m.ledgerBody({
|
|
745
|
+
phase: "active",
|
|
746
|
+
runtimeRoot: active,
|
|
747
|
+
artifactIdentity: { kind: "npm", name: "@junghanacs/entwurf", version: "0.34.1", expectedIntegrity: "sha512-fixture", observedDigest: `sha256-${"c".repeat(64)}` },
|
|
748
|
+
roots: m.resolveComponentRoots(process.env),
|
|
749
|
+
states,
|
|
750
|
+
}));
|
|
751
|
+
JS
|
|
752
|
+
}
|
|
753
|
+
write_ledger "pi,claude-code"
|
|
754
|
+
DOC_ARGS=(--plugin-runtime "$ACTIVE")
|
|
755
|
+
|
|
756
|
+
# P1 — the plugin wiring the installer wrote is certified: PASS, and the doctor names its mode.
|
|
757
|
+
# The [QK:] token lives on the `bad` line only (see M9b).
|
|
758
|
+
run_doctor
|
|
759
|
+
if [ "$DOC_RC" -eq 0 ] && printf '%s\n' "$DOC_OUT" | grep -q 'meta-bridge doctor: PASS' \
|
|
760
|
+
&& printf '%s\n' "$DOC_OUT" | grep -qF "mode: plugin-runtime $ACTIVE (activation ledger)" \
|
|
761
|
+
&& printf '%s\n' "$DOC_OUT" | grep -qF 'live mcpServers.entwurf-bridge equals desired_mcp()'; then
|
|
762
|
+
ok "plugin-runtime wiring reaches doctor PASS through the activation ledger, and the doctor names that mode"
|
|
763
|
+
else
|
|
764
|
+
bad "[QK:DOCTOR-PLUGIN-MODE-GREEN] the plugin activation's absolute wiring did NOT reach PASS (exit $DOC_RC):"$'\n'"$(printf '%s\n' "$DOC_OUT" | grep -E '^ (FAIL|WARN)|mode:' | sed 's/^/ /')"
|
|
765
|
+
fi
|
|
766
|
+
|
|
767
|
+
# P2 — genuine drift stays red in plugin mode: an absolute statusLine under the same root, but not ours.
|
|
768
|
+
cp "$CLAUDE_CONFIG_DIR/settings.json" "$TMP/settings-p2.bak"
|
|
769
|
+
python3 - "$CLAUDE_CONFIG_DIR/settings.json" "$ACTIVE/node_modules/.bin/evil" <<'PY'
|
|
770
|
+
import json, sys
|
|
771
|
+
p = sys.argv[1]
|
|
772
|
+
d = json.load(open(p, encoding="utf-8"))
|
|
773
|
+
d["statusLine"]["command"] = sys.argv[2]
|
|
774
|
+
json.dump(d, open(p, "w", encoding="utf-8"), indent=2)
|
|
775
|
+
PY
|
|
776
|
+
expect_red "plugin-mode statusLine repointed under the same runtime root" "statusLine.command drifted"
|
|
777
|
+
cp "$TMP/settings-p2.bak" "$CLAUDE_CONFIG_DIR/settings.json"
|
|
778
|
+
|
|
779
|
+
# P3 — a ledger that did not activate claude-code admits no plugin mode. The refusal is named,
|
|
780
|
+
# and it is NOT an early exit: the store and live-owner sections still run (Hard Rule 13).
|
|
781
|
+
write_ledger "pi"
|
|
782
|
+
expect_red "override against a ledger that never activated claude-code" "doctor-plugin-runtime-unadmitted"
|
|
783
|
+
# Each of the three expectation sites says UNKNOWN for that reason (never a bare-bin fallback),
|
|
784
|
+
# and the runtime axes still run: the live command still DELIVERS, the store and owner join report.
|
|
785
|
+
for claim in \
|
|
786
|
+
'managed settings/MCP keyset is UNKNOWN: the expectation mode was not established' \
|
|
787
|
+
'statusLine expectation is UNKNOWN: the expectation mode was not established' \
|
|
788
|
+
'live-vs-desired MCP ownership is UNKNOWN: the expectation mode was not established' \
|
|
789
|
+
'live bridge command DELIVERS' \
|
|
790
|
+
'[meta-record store]' \
|
|
791
|
+
'[live Claude MCP owner join]'
|
|
792
|
+
do
|
|
793
|
+
if printf '%s\n' "$DOC_OUT" | sed -n '/expectation mode refused: doctor-plugin-runtime-unadmitted/,$p' | grep -qF "$claim"; then
|
|
794
|
+
ok "after the mode refusal the doctor still reports: $claim"
|
|
795
|
+
else
|
|
796
|
+
bad "after the mode refusal the doctor never reported: $claim"
|
|
797
|
+
fi
|
|
798
|
+
done
|
|
799
|
+
write_ledger "pi,claude-code"
|
|
800
|
+
|
|
716
801
|
echo
|
|
717
802
|
if [ "$fail" -eq 0 ]; then echo "check-meta-doctor-oracle: PASS"; else echo "check-meta-doctor-oracle: FAIL (see above)"; exit 1; fi
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
// Pure + subprocess, no network/model — IN pnpm run check:full.
|
|
44
44
|
|
|
45
45
|
import { strict as assert } from "node:assert";
|
|
46
|
-
import { execFileSync } from "node:child_process";
|
|
46
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
47
47
|
import { copyFileSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
48
48
|
import { tmpdir } from "node:os";
|
|
49
49
|
import { basename, join } from "node:path";
|
|
@@ -650,6 +650,164 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
|
|
|
650
650
|
}
|
|
651
651
|
}
|
|
652
652
|
|
|
653
|
+
// ===========================================================================
|
|
654
|
+
// 8g. #137: TOML data-subject hunks, not path identity; Markdown/JSON data and
|
|
655
|
+
// code/signatures/machinery remain file-level. Hermetic like 8f: real history is NOT
|
|
656
|
+
// in a qualification snapshot.
|
|
657
|
+
// ==========================================================================
|
|
658
|
+
{
|
|
659
|
+
const tmp = mkdtempSync(join(tmpdir(), "entwurf-qualify-hunks-"));
|
|
660
|
+
const git = (...args: string[]) =>
|
|
661
|
+
execFileSync("git", ["-c", "core.hooksPath=/dev/null", "-c", "user.email=g@e", "-c", "user.name=g", ...args], {
|
|
662
|
+
cwd: tmp,
|
|
663
|
+
stdio: "ignore",
|
|
664
|
+
});
|
|
665
|
+
const head = () => execFileSync("git", ["rev-parse", "HEAD"], { cwd: tmp, encoding: "utf8" }).trim();
|
|
666
|
+
const data = 'version = "1"\n[[panes]]\ncommand = ["node", "build.mjs"]\n# outside\n';
|
|
667
|
+
const wrong: string[] = [];
|
|
668
|
+
try {
|
|
669
|
+
git("init", "-q", "-b", "main");
|
|
670
|
+
mkdirSync(join(tmp, "scripts/mutants"), { recursive: true });
|
|
671
|
+
mkdirSync(join(tmp, "fixture"));
|
|
672
|
+
mkdirSync(join(tmp, "scripts/fixtures"));
|
|
673
|
+
copyFileSync(join(REPO_DIR, "scripts/ci-qualify-decide.sh"), join(tmp, "scripts/ci-qualify-decide.sh"));
|
|
674
|
+
const manifest = JSON.stringify({
|
|
675
|
+
mutants: [
|
|
676
|
+
{ subject: "fixture/data.toml", find: ["[[panes]]"] },
|
|
677
|
+
{ subject: "fixture/data.toml", find: ['command = ["node", "build.mjs"]'] },
|
|
678
|
+
{ subject: "fixture/data.toml", find: ["[[panes]]", 'command = ["node", "build.mjs"]'] },
|
|
679
|
+
{ subject: "fixture/code.ts", find: ["return 1"], signatureSource: "fixture/signature.md" },
|
|
680
|
+
{ subject: "fixture/signature.md", find: ["signature"] },
|
|
681
|
+
{ subject: "fixture/joined.toml", find: ["alpha = 1", "beta = 2"] },
|
|
682
|
+
{ subject: "fixture/doc.md", find: ["Legacy rows are documented: kept for history."] },
|
|
683
|
+
{ subject: "fixture/reg.json", find: ['"mode": "self-fetch",'] },
|
|
684
|
+
{ subject: "scripts/fixtures/pin.json", find: ['"pinned": 1'] },
|
|
685
|
+
{ subject: "scripts/ci-qualify-decide.sh", find: ["set -euo pipefail"] },
|
|
686
|
+
],
|
|
687
|
+
});
|
|
688
|
+
writeFileSync(join(tmp, "scripts/mutants/fixture.json"), manifest);
|
|
689
|
+
writeFileSync(join(tmp, "fixture/data.toml"), data);
|
|
690
|
+
writeFileSync(join(tmp, "fixture/code.ts"), "// outside\nfunction f() { return 1; }\n");
|
|
691
|
+
writeFileSync(join(tmp, "fixture/signature.md"), "signature\n# outside\n");
|
|
692
|
+
writeFileSync(join(tmp, "fixture/joined.toml"), "version = 1\nalpha = 1\nbeta = 2\n");
|
|
693
|
+
const doc = "# Doc\nLegacy rows are documented: kept for history.\n# outside\n";
|
|
694
|
+
const reg = '{\n"copilot": {\n"mode": "self-fetch",\n"level": 6\n}\n}\n';
|
|
695
|
+
writeFileSync(join(tmp, "fixture/doc.md"), doc);
|
|
696
|
+
writeFileSync(join(tmp, "fixture/reg.json"), reg);
|
|
697
|
+
writeFileSync(join(tmp, "scripts/fixtures/pin.json"), '{\n"version": 1,\n"pinned": 1\n}\n');
|
|
698
|
+
git("add", "-A");
|
|
699
|
+
git("commit", "-qm", "seed");
|
|
700
|
+
const base = head();
|
|
701
|
+
const nonToml: string[] = [];
|
|
702
|
+
const check = (name: string, expected: boolean, reason?: string, before = base, into = wrong) => {
|
|
703
|
+
const r = spawnSync("bash", [join(tmp, "scripts/ci-qualify-decide.sh"), before, head()], {
|
|
704
|
+
cwd: tmp,
|
|
705
|
+
encoding: "utf8",
|
|
706
|
+
env: { ...process.env, CI_EVENT_NAME: "push", CI_FORCED: "false" },
|
|
707
|
+
});
|
|
708
|
+
if (r.status !== 0 || r.stdout.trim() !== `run_body=${expected}` || (reason && !r.stderr.includes(reason)))
|
|
709
|
+
into.push(`${name}: status=${r.status}, out=${r.stdout.trim()}, err=${r.stderr.trim()}`);
|
|
710
|
+
};
|
|
711
|
+
const commit = () => {
|
|
712
|
+
git("add", "-A");
|
|
713
|
+
git("commit", "-qm", "cell");
|
|
714
|
+
};
|
|
715
|
+
const reset = () => git("reset", "--hard", base); // ONLY this test-owned throwaway repository.
|
|
716
|
+
writeFileSync(join(tmp, "fixture/data.toml"), data.replace('version = "1"', 'version = "2"'));
|
|
717
|
+
commit();
|
|
718
|
+
check("outside", false, "fixture/data.toml: no find range touched");
|
|
719
|
+
// A replay must read HEAD's manifest, not an unrelated dirty worktree's find.
|
|
720
|
+
writeFileSync(
|
|
721
|
+
join(tmp, "scripts/mutants/fixture.json"),
|
|
722
|
+
'{"mutants":[{"subject":"fixture/data.toml","find":["version"]}]}',
|
|
723
|
+
);
|
|
724
|
+
check("HEAD manifest, not dirty worktree", false);
|
|
725
|
+
reset();
|
|
726
|
+
for (const [name, content] of [
|
|
727
|
+
["panes line", data.replace("[[panes]]", "[[panes]] # changed")],
|
|
728
|
+
["command line", data.replace('command = ["node", "build.mjs"]', 'command = ["node", "build.mjs"] # changed')],
|
|
729
|
+
["moved", `# inserted above\n${data}`],
|
|
730
|
+
["ambiguous", `${data}[[panes]]\n`],
|
|
731
|
+
["missing find", data.replace("[[panes]]", "[[other]]")],
|
|
732
|
+
] as const) {
|
|
733
|
+
writeFileSync(join(tmp, "fixture/data.toml"), content);
|
|
734
|
+
commit();
|
|
735
|
+
check(name, true);
|
|
736
|
+
reset();
|
|
737
|
+
}
|
|
738
|
+
git("rm", "fixture/data.toml");
|
|
739
|
+
commit();
|
|
740
|
+
check("deleted subject", true);
|
|
741
|
+
reset();
|
|
742
|
+
git("mv", "fixture/data.toml", "fixture/renamed.toml");
|
|
743
|
+
commit();
|
|
744
|
+
check("renamed subject", true);
|
|
745
|
+
reset();
|
|
746
|
+
writeFileSync(join(tmp, "fixture/code.ts"), "// outside changed\nfunction f() { return 1; }\n");
|
|
747
|
+
commit();
|
|
748
|
+
check("code outside find remains file-level", true);
|
|
749
|
+
reset();
|
|
750
|
+
writeFileSync(join(tmp, "fixture/signature.md"), "signature\n# outside changed\n");
|
|
751
|
+
commit();
|
|
752
|
+
check("signature also data subject remains file-level", true);
|
|
753
|
+
reset();
|
|
754
|
+
writeFileSync(join(tmp, "fixture/joined.toml"), "version = 1\nalpha = 1\nbeta = 2 # changed\n");
|
|
755
|
+
commit();
|
|
756
|
+
check("only multiline find, second line touched", true);
|
|
757
|
+
reset();
|
|
758
|
+
writeFileSync(join(tmp, "scripts/fixtures/pin.json"), '{\n"version": 2,\n"pinned": 1\n}\n');
|
|
759
|
+
commit();
|
|
760
|
+
check("machinery also data subject remains file-level", true);
|
|
761
|
+
reset();
|
|
762
|
+
writeFileSync(
|
|
763
|
+
join(tmp, "scripts/ci-qualify-decide.sh"),
|
|
764
|
+
`${readFileSync(join(tmp, "scripts/ci-qualify-decide.sh"), "utf8")}\n# outside find\n`,
|
|
765
|
+
);
|
|
766
|
+
commit();
|
|
767
|
+
check("machinery also subject remains file-level", true);
|
|
768
|
+
reset();
|
|
769
|
+
// Outside an untouched exact-once span, a shorter presence literal (measured on DELIVERY.md)
|
|
770
|
+
// or a later JSON duplicate key (JSON.parse: last wins) can re-satisfy the oracle.
|
|
771
|
+
writeFileSync(join(tmp, "fixture/doc.md"), `${doc}Legacy rows are documented\n`);
|
|
772
|
+
commit();
|
|
773
|
+
check("markdown restatement outside find", true, "conservative:", base, nonToml);
|
|
774
|
+
reset();
|
|
775
|
+
writeFileSync(
|
|
776
|
+
join(tmp, "fixture/reg.json"),
|
|
777
|
+
reg.replace("6\n}\n}\n", '6\n},\n"copilot": { "level": 6, "mode": "self-fetch" }\n}\n'),
|
|
778
|
+
);
|
|
779
|
+
commit();
|
|
780
|
+
check("json duplicate key outside find", true, "conservative:", base, nonToml);
|
|
781
|
+
reset();
|
|
782
|
+
// The first quoted name must still fail open after a >pipe-buffer list:
|
|
783
|
+
// printf | grep -q can lose its match to SIGPIPE under pipefail.
|
|
784
|
+
writeFileSync(join(tmp, '000"quoted'), "x\n");
|
|
785
|
+
for (let i = 0; i < 768; i++) writeFileSync(join(tmp, "fixture", `${i}_${"x".repeat(180)}.txt`), "x\n");
|
|
786
|
+
commit();
|
|
787
|
+
check("large quoted changed-name list", true, "quoted changed path cannot be read honestly");
|
|
788
|
+
reset();
|
|
789
|
+
check("unreadable range", true, "fail-open 4", "f".repeat(40));
|
|
790
|
+
git("rm", "scripts/mutants/fixture.json");
|
|
791
|
+
commit();
|
|
792
|
+
check("missing manifests", true);
|
|
793
|
+
assert.ok(
|
|
794
|
+
wrong.length === 0,
|
|
795
|
+
"[QK:QUALIFY-FILTER-SUBJECT-HUNKS] only untouched, unmoved, exact-once TOML find ranges may skip; " +
|
|
796
|
+
"unknown ranges run, and code/signature/machinery retain file-level protection. " +
|
|
797
|
+
wrong.join("; "),
|
|
798
|
+
);
|
|
799
|
+
assert.ok(
|
|
800
|
+
nonToml.length === 0,
|
|
801
|
+
"[QK:QUALIFY-FILTER-TOML-ONLY-REFINEMENT] Markdown and JSON subjects stay file-level: an untouched exact-once " +
|
|
802
|
+
"find does not pin kill-power when a shorter presence literal or a later duplicate JSON key outside it can " +
|
|
803
|
+
"re-satisfy the oracle. " +
|
|
804
|
+
nonToml.join("; "),
|
|
805
|
+
);
|
|
806
|
+
} finally {
|
|
807
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
653
811
|
// ===========================================================================
|
|
654
812
|
// 9. Every gate a mutant manifest NAMES runs inside `pnpm run check:full`
|
|
655
813
|
//
|
|
@@ -28,37 +28,117 @@ fail_open() {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
collect_paths_from_manifests() {
|
|
31
|
-
python3 - "$REPO_DIR" <<'PY'
|
|
31
|
+
python3 - "$REPO_DIR" "${1:-}" <<'PY'
|
|
32
32
|
import glob
|
|
33
33
|
import json
|
|
34
34
|
import os
|
|
35
35
|
import sys
|
|
36
|
+
import subprocess
|
|
36
37
|
|
|
37
|
-
repo = sys.argv[1]
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
repo, rev = sys.argv[1:]
|
|
39
|
+
def git(*args):
|
|
40
|
+
return subprocess.check_output(["git", "-C", repo, *args])
|
|
41
|
+
paths = (git("ls-tree", "-r", "--name-only", rev, "scripts/mutants").decode().splitlines()
|
|
42
|
+
if rev else sorted(glob.glob(os.path.join(repo, "scripts/mutants/*.json"))))
|
|
43
|
+
paths = [p for p in paths if p.endswith(".json")]
|
|
44
|
+
if not paths:
|
|
45
|
+
raise ValueError("no mutant manifests")
|
|
46
|
+
out, signatures, subjects = set(), set(), {}
|
|
47
|
+
for path in paths:
|
|
48
|
+
if rev:
|
|
49
|
+
manifest = json.loads(git("show", f"{rev}:{path}"))
|
|
50
|
+
else:
|
|
51
|
+
with open(path, encoding="utf-8") as handle:
|
|
52
|
+
manifest = json.load(handle)
|
|
42
53
|
for mutant in manifest.get("mutants", []):
|
|
43
54
|
subject = mutant.get("subject")
|
|
44
55
|
if subject:
|
|
45
56
|
out.add(subject)
|
|
57
|
+
find = mutant.get("find")
|
|
58
|
+
# The runner joins a find ARRAY into ONE multiline occurrence.
|
|
59
|
+
needle = "\n".join(find) if isinstance(find, list) and all(isinstance(s, str) for s in find) else None
|
|
60
|
+
subjects.setdefault(subject, []).append(needle)
|
|
46
61
|
signature_source = mutant.get("signatureSource")
|
|
47
62
|
if signature_source:
|
|
48
63
|
out.add(signature_source)
|
|
49
|
-
|
|
50
|
-
|
|
64
|
+
signatures.add(signature_source)
|
|
65
|
+
if not out or any(any(c in p for c in '\n\r\t"\\') for p in out):
|
|
66
|
+
raise ValueError("empty or unrepresentable manifest paths")
|
|
67
|
+
print(json.dumps({"paths": sorted(out), "signatures": sorted(signatures), "subjects": subjects}))
|
|
51
68
|
PY
|
|
52
69
|
}
|
|
53
70
|
|
|
54
|
-
|
|
55
|
-
|
|
71
|
+
load_manifest_surface() {
|
|
72
|
+
MANIFEST_DATA="$(collect_paths_from_manifests "${1:-}")" || fail_open 6 "missing/unreadable mutant manifests"
|
|
73
|
+
MANIFEST_PATHS="$(printf '%s' "$MANIFEST_DATA" | python3 -c 'import json,sys;print("\n".join(json.load(sys.stdin)["paths"]))')" || fail_open 6 "unreadable manifest paths"
|
|
74
|
+
SIGNATURE_PATHS="$(printf '%s' "$MANIFEST_DATA" | python3 -c 'import json,sys;print("\n".join(json.load(sys.stdin)["signatures"]))')" || fail_open 6 "unreadable signature paths"
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Return success ONLY when every exact-once TOML find span is demonstrably
|
|
78
|
+
# untouched and unmoved on BOTH blobs. Any uncertainty leaves the body armed.
|
|
79
|
+
subject_find_untouched() {
|
|
80
|
+
python3 - "$REPO_DIR" "$BEFORE" "$HEAD_SHA" "$1" 3<<< "$MANIFEST_DATA" <<'PY'
|
|
81
|
+
import json
|
|
82
|
+
import os
|
|
83
|
+
import re
|
|
84
|
+
import subprocess
|
|
85
|
+
import sys
|
|
86
|
+
|
|
87
|
+
repo, before, head, path = sys.argv[1:]
|
|
88
|
+
def git(*args):
|
|
89
|
+
return subprocess.check_output(["git", "-C", repo, *args], stderr=subprocess.DEVNULL).decode("utf-8")
|
|
90
|
+
def span(source, needle):
|
|
91
|
+
if not needle or source.count(needle) != 1:
|
|
92
|
+
raise ValueError("find absent or ambiguous")
|
|
93
|
+
start = source[:source.index(needle)].count("\n") + 1
|
|
94
|
+
return start, start + needle.count("\n") - int(needle.endswith("\n"))
|
|
95
|
+
try:
|
|
96
|
+
finds = json.load(os.fdopen(3))["subjects"].get(path)
|
|
97
|
+
if not finds or any(not needle for needle in finds):
|
|
98
|
+
raise ValueError("missing find spans")
|
|
99
|
+
old, new = git("show", f"{before}:{path}"), git("show", f"{head}:{path}")
|
|
100
|
+
if "\0" in old or "\0" in new:
|
|
101
|
+
raise ValueError("binary subject")
|
|
102
|
+
diff = git("diff", "--no-renames", "--no-ext-diff", "--no-textconv", "--no-color", "-U0", before, head, "--", path)
|
|
103
|
+
if "Binary files " in diff or "GIT binary patch" in diff:
|
|
104
|
+
raise ValueError("binary diff")
|
|
105
|
+
hunks = []
|
|
106
|
+
for line in diff.splitlines():
|
|
107
|
+
if not line.startswith("@@"):
|
|
108
|
+
continue
|
|
109
|
+
match = re.fullmatch(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?", line)
|
|
110
|
+
if not match:
|
|
111
|
+
raise ValueError("unreadable hunk")
|
|
112
|
+
a, b, c, d = match.groups()
|
|
113
|
+
hunks.append((int(a), int(b or 1), int(c), int(d or 1)))
|
|
114
|
+
if not hunks:
|
|
115
|
+
raise ValueError("no readable text hunks")
|
|
116
|
+
for needle in set(finds):
|
|
117
|
+
os_, oe = span(old, needle)
|
|
118
|
+
ns, ne = span(new, needle)
|
|
119
|
+
if (os_, oe) != (ns, ne):
|
|
120
|
+
raise ValueError("find range moved")
|
|
121
|
+
for a, b, c, d in hunks:
|
|
122
|
+
if (b > 0 and a <= oe and a + b - 1 >= os_) or (d > 0 and c <= ne and c + d - 1 >= ns):
|
|
123
|
+
raise ValueError("find range touched")
|
|
124
|
+
except (ValueError, KeyError, TypeError, OSError, subprocess.SubprocessError) as error:
|
|
125
|
+
print(f"ci-qualify-decide: fail-open 7 {path}: {error} -> run_body=true", file=sys.stderr)
|
|
126
|
+
sys.exit(1)
|
|
127
|
+
print(f"ci-qualify-decide: {path}: no find range touched (all exact-once spans unmoved)", file=sys.stderr)
|
|
128
|
+
PY
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# Signature sources and machinery MUST precede subjects: many paths are BOTH.
|
|
132
|
+
# Code/unknown subjects retain file-level protection; only TOML data gets hunk
|
|
133
|
+
# refinement (#137 non-goal: no skipping code/gate/mutant/matrix changes). TOML
|
|
134
|
+
# refuses a redefined key, but a shorter Markdown presence literal or a later JSON
|
|
135
|
+
# duplicate key outside an untouched find can still re-satisfy an oracle.
|
|
56
136
|
matches_qualification_surface() {
|
|
57
137
|
local file="$1"
|
|
58
138
|
local subject
|
|
59
139
|
while IFS= read -r subject; do
|
|
60
|
-
[ "$file" = "$subject" ] && { echo "manifest-
|
|
61
|
-
done <<< "$
|
|
140
|
+
[ "$file" = "$subject" ] && { echo "manifest-signature-source"; return 0; }
|
|
141
|
+
done <<< "$SIGNATURE_PATHS"
|
|
62
142
|
case "$file" in
|
|
63
143
|
scripts/mutants/*) echo "glob:scripts/mutants/**"; return 0 ;;
|
|
64
144
|
scripts/check-*) echo "glob:scripts/check-*"; return 0 ;;
|
|
@@ -73,6 +153,20 @@ matches_qualification_surface() {
|
|
|
73
153
|
# that stopped covering something cannot hide behind its own change.
|
|
74
154
|
scripts/ci-qualify-decide.sh) echo "exact:scripts/ci-qualify-decide.sh"; return 0 ;;
|
|
75
155
|
esac
|
|
156
|
+
while IFS= read -r subject; do
|
|
157
|
+
if [ "$file" = "$subject" ]; then
|
|
158
|
+
if [ "${MATCH_MODE:-files}" = range ]; then
|
|
159
|
+
case "$file" in
|
|
160
|
+
*.toml) ;;
|
|
161
|
+
*) echo "conservative:non-toml-subject"; return 0 ;;
|
|
162
|
+
esac
|
|
163
|
+
if subject_find_untouched "$file"; then
|
|
164
|
+
return 1
|
|
165
|
+
fi
|
|
166
|
+
fi
|
|
167
|
+
echo "manifest-subject"; return 0
|
|
168
|
+
fi
|
|
169
|
+
done <<< "$MANIFEST_PATHS"
|
|
76
170
|
return 1
|
|
77
171
|
}
|
|
78
172
|
|
|
@@ -98,13 +192,12 @@ decide_from_files() {
|
|
|
98
192
|
fi
|
|
99
193
|
}
|
|
100
194
|
|
|
101
|
-
MANIFEST_PATHS="$(collect_paths_from_manifests)"
|
|
102
|
-
[ -n "$MANIFEST_PATHS" ] || { echo "ABORT: no mutant manifests read from $REPO_DIR/scripts/mutants" >&2; exit 1; }
|
|
103
|
-
|
|
104
195
|
if [ "${1:-}" = "--files-from" ]; then
|
|
196
|
+
MATCH_MODE=files
|
|
197
|
+
load_manifest_surface
|
|
105
198
|
SRC="${2:-}"
|
|
106
199
|
[ -n "$SRC" ] || { echo "ABORT: --files-from needs a path or -" >&2; exit 1; }
|
|
107
|
-
if [ "$SRC" = "-" ]; then FILES="$(cat)"; else FILES="$(cat "$SRC")"; fi
|
|
200
|
+
if [ "$SRC" = "-" ]; then FILES="$(cat)"; else FILES="$(cat "$SRC")"; fi || fail_open 4 "unreadable files-from input"
|
|
108
201
|
decide_from_files "$FILES"
|
|
109
202
|
exit 0
|
|
110
203
|
fi
|
|
@@ -154,6 +247,10 @@ esac
|
|
|
154
247
|
git -C "$REPO_DIR" cat-file -e "$BEFORE^{commit}" 2>/dev/null || fail_open 4 "before=$BEFORE is not a commit object in this checkout"
|
|
155
248
|
git -C "$REPO_DIR" cat-file -e "$HEAD_SHA^{commit}" 2>/dev/null || fail_open 4 "head=$HEAD_SHA is not a commit object in this checkout"
|
|
156
249
|
|
|
157
|
-
|
|
250
|
+
MATCH_MODE=range
|
|
251
|
+
load_manifest_surface "$HEAD_SHA"
|
|
252
|
+
CHANGED="$(git -C "$REPO_DIR" diff --no-renames --no-ext-diff --no-textconv --name-only "$BEFORE".."$HEAD_SHA" 2>/dev/null)" || fail_open 4 "git diff $BEFORE..$HEAD_SHA failed"
|
|
253
|
+
# Git quotes unrepresentable changed names; never interpret those as absent paths.
|
|
254
|
+
if grep -q '^"' <<< "$CHANGED"; then fail_open 4 "quoted changed path cannot be read honestly"; fi
|
|
158
255
|
|
|
159
256
|
decide_from_files "$CHANGED"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
import fs from "node:fs";
|
|
30
30
|
import path from "node:path";
|
|
31
|
-
import { artifactStage, certifyArtifactIdentity, sameArtifactRequest } from "./herdr-runtime.mjs";
|
|
31
|
+
import { artifactStage, certifyArtifactIdentity, resolveRuntimeLayout, sameArtifactRequest } from "./herdr-runtime.mjs";
|
|
32
32
|
|
|
33
33
|
/** Ledger format. Bump only with a reader that understands both. */
|
|
34
34
|
export const ACTIVATION_SCHEMA_VERSION = 2;
|
|
@@ -502,6 +502,55 @@ export function piStatePaths(env) {
|
|
|
502
502
|
});
|
|
503
503
|
}
|
|
504
504
|
|
|
505
|
+
/**
|
|
506
|
+
* Which expectations `doctor-meta-bridge` must compute (#136): the stable active root when this
|
|
507
|
+
* host's Claude wiring was written by a plugin activation, otherwise null (clone / installed).
|
|
508
|
+
*
|
|
509
|
+
* The plugin activation writes Claude's MCP and statusLine as ABSOLUTE commands under the stable
|
|
510
|
+
* runtime. A doctor that does not know that expects bare bins and calls correct wiring drifted. The
|
|
511
|
+
* mode is read from the record that authorised the wiring, the certified activation ledger, never
|
|
512
|
+
* from settings, environment or a path guess:
|
|
513
|
+
* - phase `active`, `claude-code` activated, and the ledger's runtimeRoot is THIS host's stable root;
|
|
514
|
+
* - without an override, only a doctor running FROM that runtime's package derives the mode. A
|
|
515
|
+
* checkout's doctor returns null without opening the ledger, so it never adopts plugin wiring;
|
|
516
|
+
* - an explicit override must agree with the ledger, or it is refused.
|
|
517
|
+
* An absent ledger is "not plugin mode"; an uncertified one throws the reader's own refusal.
|
|
518
|
+
*/
|
|
519
|
+
export function resolveDoctorPluginRuntime(env, { repo, override } = {}) {
|
|
520
|
+
const activeDir = resolveRuntimeLayout(env).activeDir;
|
|
521
|
+
if (override === undefined) {
|
|
522
|
+
const runtimePackage = path.join(activeDir, "node_modules", "@junghanacs", "entwurf");
|
|
523
|
+
let same = false;
|
|
524
|
+
try {
|
|
525
|
+
same = fs.realpathSync(repo) === fs.realpathSync(runtimePackage);
|
|
526
|
+
} catch {
|
|
527
|
+
// Bounded probe: no runtime package at the stable address means this repo is not it.
|
|
528
|
+
same = false;
|
|
529
|
+
}
|
|
530
|
+
if (!same) return null;
|
|
531
|
+
}
|
|
532
|
+
const ledger = readCertifiedLedger(resolveActivationLayout(env));
|
|
533
|
+
if (ledger === null) {
|
|
534
|
+
if (override === undefined) return null;
|
|
535
|
+
throw new ActivationError(
|
|
536
|
+
"doctor-plugin-runtime-unadmitted",
|
|
537
|
+
"--plugin-runtime was given but no activation ledger exists",
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
const refuse = (clause) => {
|
|
541
|
+
throw new ActivationError("doctor-plugin-runtime-unadmitted", clause);
|
|
542
|
+
};
|
|
543
|
+
if (ledger.phase !== "active") refuse(`activation ledger phase is ${ledger.phase}, not active`);
|
|
544
|
+
if (!ledger.activatedBackends.includes("claude-code")) refuse("the activation ledger did not activate claude-code");
|
|
545
|
+
if (ledger.runtimeRoot !== activeDir) {
|
|
546
|
+
refuse(`the ledger's runtimeRoot ${ledger.runtimeRoot} is not this host's stable runtime root ${activeDir}`);
|
|
547
|
+
}
|
|
548
|
+
if (override !== undefined && override !== activeDir) {
|
|
549
|
+
refuse(`--plugin-runtime ${override} does not name the ledger's runtime root ${activeDir}`);
|
|
550
|
+
}
|
|
551
|
+
return activeDir;
|
|
552
|
+
}
|
|
553
|
+
|
|
505
554
|
/** A component row, in the one shape the certifier accepts. */
|
|
506
555
|
export function componentRow(backend, state) {
|
|
507
556
|
return Object.freeze({ backend, state });
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* herdr-doctor-mode — the one call `meta-bridge-doctor.sh` makes to learn its expectation mode (#136).
|
|
4
|
+
*
|
|
5
|
+
* node herdr-doctor-mode.mjs <repo> [<plugin-runtime override>]
|
|
6
|
+
*
|
|
7
|
+
* Prints the stable active root (plugin-runtime mode) or nothing (clone / installed), exit 0.
|
|
8
|
+
* A refusal prints `code: detail` to stderr and exits 3. Any other exit is a crash the doctor
|
|
9
|
+
* reports as UNKNOWN. All judgement lives in `resolveDoctorPluginRuntime`; nothing is decided here.
|
|
10
|
+
*/
|
|
11
|
+
import { resolveDoctorPluginRuntime } from "./herdr-activation.mjs";
|
|
12
|
+
|
|
13
|
+
const [repo, override] = process.argv.slice(2);
|
|
14
|
+
if (!repo) {
|
|
15
|
+
process.stderr.write("usage: herdr-doctor-mode.mjs <repo> [<plugin-runtime>]\n");
|
|
16
|
+
process.exit(2);
|
|
17
|
+
}
|
|
18
|
+
let root;
|
|
19
|
+
try {
|
|
20
|
+
root = resolveDoctorPluginRuntime(process.env, { repo, override });
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (err?.name !== "ActivationError") throw err;
|
|
23
|
+
process.stderr.write(`${err.message}\n`);
|
|
24
|
+
process.exit(3);
|
|
25
|
+
}
|
|
26
|
+
if (root !== null) process.stdout.write(`${root}\n`);
|