@sleepy_panda_srl/portulan 0.1.1 → 0.1.3
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/README.md +16 -19
- package/cli/README.md +155 -125
- package/cli/compile.mjs +797 -40
- package/cli/doctor.mjs +59 -2
- package/cli/drills.mjs +1541 -0
- package/cli/fuzz-shell.mjs +880 -0
- package/cli/gate.mjs +3 -2
- package/cli/goldens.mjs +469 -0
- package/cli/index.mjs +92 -42
- package/cli/init.mjs +4 -1
- package/cli/inside.mjs +38 -0
- package/cli/mutants.mjs +925 -0
- package/cli/pack-identity.mjs +9 -2
- package/cli/pack-version.mjs +7 -0
- package/cli/recipe-set.mjs +48 -1
- package/cli/release-eval.mjs +1068 -0
- package/cli/review-meter.mjs +741 -0
- package/cli/skill-goldens.mjs +590 -0
- package/cli/skills-set.mjs +166 -4
- package/cli/stop-gate.mjs +301 -28
- package/cli/telemetry.mjs +1047 -0
- package/cli/version-carriers.mjs +224 -0
- package/core/operating/memory.md +7 -8
- package/core/operating/verification.md +4 -12
- package/core/templates/gate-policy.md +27 -8
- package/evals/README.md +910 -0
- package/evals/releases/0.1.3.json +134 -0
- package/evals/releases/0.1.3.md +64 -0
- package/evals/releases/README.md +32 -0
- package/package.json +7 -2
- package/packs/README.md +11 -21
- package/packs/rituals/checkpoints/README.md +8 -1
- package/packs/rituals/checkpoints/pack.json +1 -1
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
- package/packs/tools/README.md +1 -4
- package/spec/README.md +19 -22
- package/spec/migrations/README.md +2 -3
- package/spec/slots.md +30 -11
package/cli/compile.mjs
CHANGED
|
@@ -45,6 +45,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
45
45
|
// Host plugin-cache discovery (#123). The record reader, the config directory and the version refusal
|
|
46
46
|
// all live there, once — see that file's pack-root section for why this half arrived separately.
|
|
47
47
|
import { AUTO, discoverPackRoots, namedWithAuto, resolutionRoots } from "./discover.mjs";
|
|
48
|
+
import { isInside } from "./inside.mjs";
|
|
48
49
|
|
|
49
50
|
/** Raised when `compile` cannot run, or cannot compile honestly. Always exit 2, never 1. */
|
|
50
51
|
export class CompileError extends Error {
|
|
@@ -69,7 +70,11 @@ export class CompileError extends Error {
|
|
|
69
70
|
// Workspace Definition. A fresh reviewer at milestone 6 session 1 sight-read this as a carrier left
|
|
70
71
|
// stale by the 2.5 → 2.6 bump and had to measure to clear it. It is correctly untouched by any
|
|
71
72
|
// Workspace Definition bump; renamed to say so, because the next reader may not measure.
|
|
72
|
-
|
|
73
|
+
// Exported so a test can assert MEMBERSHIP against it rather than restate the literal. The
|
|
74
|
+
// scaffold `new gate-policy` emits must declare a spec THIS set contains (#329), and a rail
|
|
75
|
+
// spelling `"2.2"` on its own side would pass while the two drifted apart — which is the
|
|
76
|
+
// shape #329 was.
|
|
77
|
+
export const KNOWN_GATE_POLICY_SPECS = new Set(["2.1", "2.2"]);
|
|
73
78
|
|
|
74
79
|
const TIERS = new Set(["auto", "propose", "gated", "prohibited"]);
|
|
75
80
|
|
|
@@ -80,8 +85,12 @@ const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
|
80
85
|
const FILE_PATH = /^[^#?:/]([^#?:]*[^#?:/])?$/;
|
|
81
86
|
|
|
82
87
|
/** The tools that can write a path, and the tools that can read one. */
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
// Exported since 2026-08-24 so `./goldens.mjs` can DERIVE which branch of `matchesRule` a fixture
|
|
89
|
+
// exercises instead of a corpus author declaring it from memory. A hand-declared path would be a
|
|
90
|
+
// second carrier of what these two tables already decide, and the fixture format asks for the path on
|
|
91
|
+
// every case — which is exactly the volume at which a hand-copied answer goes wrong.
|
|
92
|
+
export const WRITE_TOOLS = ["Edit", "Write", "NotebookEdit"];
|
|
93
|
+
export const READ_TOOLS = ["Read"];
|
|
85
94
|
|
|
86
95
|
// ===========================================================================================
|
|
87
96
|
// 1. Parse: policy -> validated, normalised rules. NO backend opinion lives here.
|
|
@@ -473,7 +482,10 @@ function stripHeredocs(command) {
|
|
|
473
482
|
}
|
|
474
483
|
|
|
475
484
|
/** A shell command split into words, with unquoted operators kept as words of their own. */
|
|
476
|
-
|
|
485
|
+
// Exported since 2026-08-24 so the suite can hold `REDIRECTION_TARGET` to THIS definition of a word
|
|
486
|
+
// rather than to a reviewer's patience. Three review findings in a row were the same class — the
|
|
487
|
+
// target reader narrower than a shell word — and each was fixed at the instance quoted.
|
|
488
|
+
export function shellWords(command) {
|
|
477
489
|
const words = [];
|
|
478
490
|
let text = "";
|
|
479
491
|
let open = false; // a word has begun — so an empty `""` argument survives as a word
|
|
@@ -534,11 +546,74 @@ function shellWords(command) {
|
|
|
534
546
|
// A backslash-newline is a line continuation: both characters vanish, and the word
|
|
535
547
|
// continues on the next line. Appending the newline instead would have glued it into the
|
|
536
548
|
// middle of a path, which no target matches.
|
|
537
|
-
// `\r\n` is consumed as a PAIR
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
// answers `deny` — the constitution, reachable by
|
|
541
|
-
//
|
|
549
|
+
// `\r\n` is consumed as a PAIR, and **the reachability that argued for it has been
|
|
550
|
+
// retired.** What stood here from 2026-07-28 until 2026-08-25 was: skipping one character
|
|
551
|
+
// left the `\n` behind, a newline is an operator here, so `cp /tmp/x \\<CRLF>docs/vision.md`
|
|
552
|
+
// stepped aside where the LF spelling answers `deny` — "the constitution, reachable by
|
|
553
|
+
// editing the file on Windows". The first half is a true statement about THIS reader. The
|
|
554
|
+
// second is a claim about a shell, and it did not reproduce on any shell measured.
|
|
555
|
+
//
|
|
556
|
+
// **Measured 2026-08-25 on five shells, with a neutral target path — never the
|
|
557
|
+
// constitution.** `bash 3.2.57(1)` (arm64-apple-darwin25, the maintainer's machine),
|
|
558
|
+
// `bash 5.2.15(1)` (aarch64-unknown-linux-gnu) and `bash 5.2.37(1)` (x86_64-pc-linux-gnu),
|
|
559
|
+
// both in containers; `zsh 5.9`; and **the measured host's `/bin/sh`**, which on that macOS host
|
|
560
|
+
// resolves to bash 3.2.57 in POSIX mode — elsewhere `/bin/sh` is commonly `dash` or `busybox`
|
|
561
|
+
// and NEITHER was measured, so this row is a fifth reading of bash and zsh rather than a claim
|
|
562
|
+
// about `/bin/sh` as such.
|
|
563
|
+
// **All five agree, and none of them joins the pair:** the backslash escapes the `\r`, the
|
|
564
|
+
// newline then ends the command, and the fragment after it is run as its own command. The
|
|
565
|
+
// LF spelling in the same harness DID join and DID write the target, so the harness fires
|
|
566
|
+
// rather than reporting nothing everywhere. For the `cp`-shaped payload the retired
|
|
567
|
+
// sentence names, the target is left byte-for-byte unchanged on all five — so this branch
|
|
568
|
+
// is a **false red** there, fail-closed, and worth one prompt.
|
|
569
|
+
//
|
|
570
|
+
// **It is not a false red everywhere, which is why the branch is still here.** A shell
|
|
571
|
+
// applies a redirection BEFORE it looks the command up, so a clobbering `>` or `1>` on the
|
|
572
|
+
// fragment left by the split still fires and truncates its target to zero bytes — measured
|
|
573
|
+
// on all three bash builds. `>>` appends and does not. The carriers of that split verdict
|
|
574
|
+
// are `./fuzz-shell.mjs`'s `crlf-continuation-in-the-payload` EXPECT cells and
|
|
575
|
+
// `./compile.test.mjs`'s paired CRLF assertions. **Not `../.portulan/gate-map.md`**, which
|
|
576
|
+
// an earlier draft of this sentence named and which carries no CRLF entry at all — a
|
|
577
|
+
// citation to a record that does not exist, in the comment repairing exactly that.
|
|
578
|
+
//
|
|
579
|
+
// **Removing the branch is fail-OPEN on a gate matcher, so it is the maintainer's and not
|
|
580
|
+
// this comment's.** Deleting BOTH carriers in a scratch clone and running the recipes moves
|
|
581
|
+
// **four** surfaces, which is the count an accepting change has to carry:
|
|
582
|
+
// 1. `fuzz-shell` — the `crlf-continuation-in-the-payload|write-named` EXPECT cell, whose
|
|
583
|
+
// recorded divergence CLOSES. Good news, and a red until the record absorbs it.
|
|
584
|
+
// 2. `goldens` — the `a-CRLF-continuation` fixture in
|
|
585
|
+
// `../evals/goldens/gates/edit-the-constitution.json` regresses.
|
|
586
|
+
// 3. `./compile.test.mjs` — **two** direct assertions fail, *a CRLF continuation before
|
|
587
|
+
// the path* and *a CRLF continuation after `>`*.
|
|
588
|
+
// 4. `mutants` — exit **2, could-not-run**, downstream of 2: a census over a red corpus
|
|
589
|
+
// measures nothing, so it refuses until the fixture is repaired.
|
|
590
|
+
// **An earlier draft of this comment said "those two and nothing else" and had counted only
|
|
591
|
+
// the first two** — an exhaustive claim that had not run the suite it was exhaustive about,
|
|
592
|
+
// in the comment repairing an unmeasured claim. Reported by Copilot, round 1 on #342.
|
|
593
|
+
// What does NOT move, **and the reason an earlier draft gave for it was false.** That draft
|
|
594
|
+
// said "the redirection is recognised off the raw segment text rather than off a joined
|
|
595
|
+
// word". There is no raw-text path: `shellWrites` iterates `shellSegments`, which is built
|
|
596
|
+
// from `shellWords`. The real reason is narrower and is a property of WHERE the pair sits.
|
|
597
|
+
// With the branch gone the newline splits, and for the fuzzer's shape — pair after the head,
|
|
598
|
+
// `echo \<CRLF>ok > docs/vision.md` — the `> docs/vision.md` lands in a LATER segment whose
|
|
599
|
+
// `redirects` still name the target, so that cell stays `true`. Put the pair AFTER the `>`
|
|
600
|
+
// and the opposite happens: the operator takes the escaped `\r` as its target and the path
|
|
601
|
+
// becomes an ordinary word in the next segment — which is precisely why surface 3's second
|
|
602
|
+
// assertion fails. **So the true positive survives for this production and NOT for every
|
|
603
|
+
// redirect spelling**, and generalising it was the overclaim. Reported by Copilot, rounds 2
|
|
604
|
+
// and 3 on #342. A real LF continuation is untouched either way. Asked at
|
|
605
|
+
// `../.portulan/proposals/0031-a-continuation-no-shell-joins.md`; until that is ruled, the
|
|
606
|
+
// branch stays and this comment states what was measured rather than what was argued.
|
|
607
|
+
//
|
|
608
|
+
// **The honest limit, and it is the platform the retired sentence named: no Windows-side
|
|
609
|
+
// bash was measured** — not git-bash, MSYS2, Cygwin (whose `igncr` exists precisely for
|
|
610
|
+
// this) or WSL. Nor was any bash 4.x. The standing rail is `./fuzz-shell.ground.test.mjs`,
|
|
611
|
+
// which runs this production under **whatever bash the host running the `tests` recipe
|
|
612
|
+
// has** and reds if that bash joins the pair — so a joining shell is caught wherever the
|
|
613
|
+
// recipe runs, CI included, and is caught nowhere the recipe does not run. That boundary is
|
|
614
|
+
// stated because it is the whole of the enforcement: the container measurements above are a
|
|
615
|
+
// record, not a recipe, since a recipe that pulled an image would be a network call in CI.
|
|
616
|
+
// Found by Copilot review on #60; the claim retired by measurement 2026-08-25.
|
|
542
617
|
if (command[i + 1] === "\r" && command[i + 2] === "\n") {
|
|
543
618
|
i += 2;
|
|
544
619
|
continue;
|
|
@@ -647,6 +722,85 @@ function normalisePath(p) {
|
|
|
647
722
|
return (absolute ? "/" : "") + out.join("/");
|
|
648
723
|
}
|
|
649
724
|
|
|
725
|
+
/**
|
|
726
|
+
* Does this target match NO path a host actually submits? `true` means none can — the rule matches
|
|
727
|
+
* nothing, whatever it says.
|
|
728
|
+
*
|
|
729
|
+
* _(That first line asked the inverse question until 2026-09-09 — "can any path fall under this target?
|
|
730
|
+
* `false` means none can" — so it described a return value the function does not have. Eight
|
|
731
|
+
* fresh-context gradings read past it, four of them grading this docblock's accuracy: every one checked
|
|
732
|
+
* whether the predicate was right and none checked whether its opening sentence matched its own
|
|
733
|
+
* `return`. Found by Copilot on #408.)_
|
|
734
|
+
*
|
|
735
|
+
* **The predicate is a comparison between two forms of the target, and that is what makes it the
|
|
736
|
+
* class rather than a list.** `matchesPath` compares a *tail* — it strips a leading `./` and any
|
|
737
|
+
* leading `/` and then asks whether the candidate ends with, or contains, what is left. A host is
|
|
738
|
+
* **expected** to hand over a normalised absolute path — no `.` segment, no empty segment, no
|
|
739
|
+
* backslash — and that is an assumption rather than something measured here, argued below. So the
|
|
740
|
+
* target can match only if its comparison form is already normalised. Where the two forms differ, the comparison is against
|
|
741
|
+
* a spelling no candidate can carry, and the rule matches nothing whatever it says.
|
|
742
|
+
*
|
|
743
|
+
* That covers three families at once, which is why it is written as a comparison and not as a list of
|
|
744
|
+
* spellings a reader would have to keep in step:
|
|
745
|
+
*
|
|
746
|
+
* - **The target that reduces to nothing**, which compiles to `Edit(./)`, `Edit(./.)`, or — for one
|
|
747
|
+
* spelling — the real subtree glob `Edit(././**)`.
|
|
748
|
+
* - **The target with an interior `.` or empty segment**, each compiling to a named surface that reads
|
|
749
|
+
* like a real gate on a plausible path, and matches nothing.
|
|
750
|
+
* - **The target carrying a backslash**, and this one is why the argument above needs its second half
|
|
751
|
+
* stated: `matchesPath` normalises the **candidate**'s backslashes to `/` and never the target's, so
|
|
752
|
+
* a backslash in a target is compared against a character no candidate can still contain, **on every
|
|
753
|
+
* platform**. Unlike the first two families this one has no conditional reading at all.
|
|
754
|
+
*
|
|
755
|
+
* **The host assumption, named because it is one.** *No `.` segment, no empty segment, no backslash*
|
|
756
|
+
* describes what a host is expected to send, and this repository does not control that and has not
|
|
757
|
+
* measured an unnormalised `file_path` arriving. The refusal does not rest on it: a rule matching only
|
|
758
|
+
* `/repo/x/.` gates nothing anybody meant to gate whichever way the assumption falls.
|
|
759
|
+
*
|
|
760
|
+
* **Four cuts, three of them incomplete, always in the same direction — and that arc is why this is a
|
|
761
|
+
* comparison rather than a list.** Cut 1 lifted `matchesPath`'s own strip and caught two of the first
|
|
762
|
+
* family's five. Cut 2 asked whether the target normalised to **empty**, catching that family alone
|
|
763
|
+
* while three carriers of its prose claimed the class. Cut 3 added the interior family. Cut 4 added the
|
|
764
|
+
* backslashes. Every miss was found by putting neighbouring spellings through the function at a
|
|
765
|
+
* checkpoint, never by re-reading the sentence. A predicate defined by the instances it was written for
|
|
766
|
+
* is ../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md's shape, and **this
|
|
767
|
+
* docblock is the one carrier of that arc** — the records cite it rather than recounting it.
|
|
768
|
+
*
|
|
769
|
+
* The thirteen spellings and the controls that must stay matchable are enumerated **and measured** in
|
|
770
|
+
* `./compile.test.mjs`'s `NEVER` and `CONTROLS` — the only carrier that measures them. Four records
|
|
771
|
+
* enumerate them for a reader; this docblock cites the suite rather than joining them.
|
|
772
|
+
*
|
|
773
|
+
* **`matchesPath` is left byte-identical on purpose**, and that is a decision rather than an omission:
|
|
774
|
+
* its answers are asserted by twelve `documented-hole` cases in ../evals/goldens/gates/ and by
|
|
775
|
+
* `mutants` operators anchored on its text, so moving its strip would move two census anchors and exit
|
|
776
|
+
* the census 2 — could-not-run wearing a pass's clothes. This function re-spells that strip rather than
|
|
777
|
+
* importing it, which is one carrier more than ideal and is pinned by a suite case comparing the two on
|
|
778
|
+
* every target below.
|
|
779
|
+
*
|
|
780
|
+
* The subtree marker is **meaning, not spelling**: exactly one trailing `/` says *the subtree* and is
|
|
781
|
+
* removed before the comparison, which is why `docs/` is a real target and `docs//` is not.
|
|
782
|
+
*
|
|
783
|
+
* What this does NOT answer is what `./` should MEAN as a policy target. That is
|
|
784
|
+
* https://github.com/sleepy-panda-srl/portulan/issues/337's reserved question, and this predicate
|
|
785
|
+
* answers only whether a target can *never* match.
|
|
786
|
+
*/
|
|
787
|
+
export function neverMatches(target) {
|
|
788
|
+
const clean = String(target ?? "").replace(/^\.\//, "").replace(/^\/+/, "");
|
|
789
|
+
// `clean === "/"` is UNREACHABLE, and so is the `body === ""` arm below — the strip above removes
|
|
790
|
+
// every leading slash, so `"/"` arrives here as `""`. Measured, not assumed. They are kept anyway
|
|
791
|
+
// because `matchesPath` carries the identical pair one screen down and this predicate is meant to be
|
|
792
|
+
// read beside it: deleting them here would make two functions that answer the same question about
|
|
793
|
+
// the same string look different, to save two comparisons nobody runs. Copilot raised it on #408;
|
|
794
|
+
// the answer is that the redundancy is deliberate and now says so, which is the half it was missing.
|
|
795
|
+
if (clean === "" || clean === "/") return true;
|
|
796
|
+
const body = clean.endsWith("/") ? clean.slice(0, -1) : clean;
|
|
797
|
+
// The backslash arm is separate because it is about the CANDIDATE's normalisation rather than the
|
|
798
|
+
// target's: `matchesPath` rewrites `\\` to `/` in what the host hands over, so a target keeping one
|
|
799
|
+
// is compared against a character the comparison has already removed from every candidate.
|
|
800
|
+
if (body.includes("\\")) return true;
|
|
801
|
+
return body === "" || normalisePath(body) !== body;
|
|
802
|
+
}
|
|
803
|
+
|
|
650
804
|
/**
|
|
651
805
|
* The directories a target lives under, as exact paths: `a/b/c.md` -> `a`, `a/b`.
|
|
652
806
|
*
|
|
@@ -717,6 +871,8 @@ function commandSegments(raw) {
|
|
|
717
871
|
const out = [];
|
|
718
872
|
let start = 0;
|
|
719
873
|
let quote = null;
|
|
874
|
+
// The index of the character a backslash most recently turned into data. See the `\\` branch.
|
|
875
|
+
let escaped = -1;
|
|
720
876
|
for (let i = 0; i < command.length; i += 1) {
|
|
721
877
|
const c = command[i];
|
|
722
878
|
if (quote) {
|
|
@@ -746,7 +902,28 @@ function commandSegments(raw) {
|
|
|
746
902
|
// of one — so this is fixed for the reason the session kept re-learning rather than for a
|
|
747
903
|
// failing case: one carrier corrected and its sibling left is how the last three defects
|
|
748
904
|
// on this branch happened.
|
|
905
|
+
// **The pair's two carriers are this line and `shellWords`, and the measurement retiring
|
|
906
|
+
// the reachability claim is written up there once.** No shell measured joins the pair, so
|
|
907
|
+
// consuming it here is unfaithful to a shell in the same direction — **but this carrier
|
|
908
|
+
// records no false red of its own, and an earlier draft of this comment said it did.** On
|
|
909
|
+
// the shell path its `crlf-continuation-in-the-payload|shell` cell answers `false`, the
|
|
910
|
+
// correct negative: nothing gated runs there. On the write path it is an UNWRAPPER feeding
|
|
911
|
+
// `shellWrites`, reached through a short-circuiting `||` after the un-peeled spellings, so
|
|
912
|
+
// the recorded false red — `write-named` — is produced by `shellWords` and not here. **The
|
|
913
|
+
// reason to keep the two readers in step is synchronisation, not a shared verdict**: a
|
|
914
|
+
// carrier corrected while its sibling stands is how the last several defects on this line
|
|
915
|
+
// happened. Reported by Copilot, round 2 on #342. That is why
|
|
916
|
+
// `../.portulan/proposals/0031-a-continuation-no-shell-joins.md` names both carriers rather
|
|
917
|
+
// than the one whose comment argued for it. Reported by Copilot, round 1 on #342.
|
|
749
918
|
i += command[i + 1] === "\r" && command[i + 2] === "\n" ? 2 : 1;
|
|
919
|
+
// **Remember WHICH character this backslash turned into data.** The redirection-operator
|
|
920
|
+
// check below reads one raw neighbour, and a raw read cannot tell `>` the operator from
|
|
921
|
+
// `\>` the literal. Without this, `echo \>| git push --force origin main` — a REAL pipe,
|
|
922
|
+
// measured in bash delivering bytes downstream — stopped splitting at the `|`, and the
|
|
923
|
+
// force-push after it went invisible to every matcher. `\>&` did the same across a real
|
|
924
|
+
// background separator. Both were caught at HEAD and were narrowed by the #71 fix itself;
|
|
925
|
+
// found by the pre-commit checkpoint, which attacked the change rather than reading it.
|
|
926
|
+
escaped = i;
|
|
750
927
|
continue;
|
|
751
928
|
}
|
|
752
929
|
// `#` is NOT treated as starting a comment, and that is a decision rather than an oversight.
|
|
@@ -763,13 +940,91 @@ function commandSegments(raw) {
|
|
|
763
940
|
//
|
|
764
941
|
// Both spellings are asserted in the suite so this stays a choice rather than drift.
|
|
765
942
|
// Reported by Copilot review on #60 and declined on those grounds.
|
|
943
|
+
// An `&` or `|` bound into a redirection OPERATOR is not a separator, and splitting there is
|
|
944
|
+
// what made the strip below unreachable for three of the four spellings. `2>&1 git push
|
|
945
|
+
// --force …` split at the `&` into `2>` and `1 git push --force …`, whose head is `1` — the
|
|
946
|
+
// redirection was already in pieces by the time anything could strip it, and `>| f git push
|
|
947
|
+
// --force …` broke the same way at the `|`. Both operators are closed forms: `&` preceded by
|
|
948
|
+
// `>`/`<` or followed by `>`, and `|` preceded by `>`. `a && b`, `a & b` and `a | b` are
|
|
949
|
+
// untouched — none of them puts a redirection character next to the separator.
|
|
950
|
+
// `i - 1 !== escaped` on both arms: a `>` or `<` that a backslash turned into DATA is not an
|
|
951
|
+
// operator, and treating it as one un-splits a real separator. The `command[i + 1]` arm needs
|
|
952
|
+
// no such guard — an escaped character is always preceded by its own backslash, so the
|
|
953
|
+
// character after `&` can never be an escaped `>`.
|
|
954
|
+
const opBefore = i - 1 !== escaped && (command[i - 1] === ">" || command[i - 1] === "<");
|
|
955
|
+
if (c === "&" && (opBefore || command[i + 1] === ">")) continue;
|
|
956
|
+
if (c === "|" && i - 1 !== escaped && command[i - 1] === ">") continue;
|
|
766
957
|
if (";|&()\n\r".includes(c)) {
|
|
767
958
|
out.push(command.slice(start, i));
|
|
768
959
|
start = i + 1;
|
|
769
960
|
}
|
|
770
961
|
}
|
|
771
962
|
out.push(command.slice(start));
|
|
772
|
-
return out.map((s) => s.trim()).filter(Boolean);
|
|
963
|
+
return out.map((s) => stripLeadingRedirections(s.trim())).filter(Boolean);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* A redirection sitting where the command goes: an optional file descriptor, one of the operators
|
|
968
|
+
* below, and a word.
|
|
969
|
+
*
|
|
970
|
+
* **This is the one leader that is stripped, and the asymmetry is the point** (#71). The other
|
|
971
|
+
* leaders `commandSegments` leaves alone — `env`, `sudo`, `FOO=bar`, `then`, `do`, a brace group —
|
|
972
|
+
* are refused for a reason that does not apply here: a table of command prefixes has no natural edge
|
|
973
|
+
* (`nice`, `time`, `nohup`, `timeout`, `command`, `stdbuf`, `doas`), and one missing entry buys
|
|
974
|
+
* exactly the false confidence a hole list exists to deny. A redirection's grammar is CLOSED, so it
|
|
975
|
+
* can be stripped completely with an edge a reader can check. #60 left it open deliberately, because
|
|
976
|
+
* closing it would have been a matcher change on the same commit that stopped hole 2 overclaiming.
|
|
977
|
+
*
|
|
978
|
+
* **Do not extend this to a named table of command prefixes.** That is the change the paragraph
|
|
979
|
+
* above refuses, and the two look similar enough to be proposed together.
|
|
980
|
+
*
|
|
981
|
+
* Applied repeatedly, because redirections stack: `> /tmp/out 2>&1 git push --force …` carries two.
|
|
982
|
+
*/
|
|
983
|
+
/**
|
|
984
|
+
* The redirection TARGET, as one word.
|
|
985
|
+
*
|
|
986
|
+
* `[^\s]+` was wrong and shipped for exactly one review round: a redirection target may contain
|
|
987
|
+
* spaces when it is quoted or escaped, and `> "foo bar" git push --force …` then stripped only
|
|
988
|
+
* `> "foo`, leaving `bar" git push --force …` — head `bar"`, no gate, and the segment text corrupted
|
|
989
|
+
* into the bargain. Measured escaping on five spellings (both quote styles, an escaped space, `2>` and
|
|
990
|
+
* `>|`), and bash confirmed to run the command after each. **A fix that closes a hole by a width its
|
|
991
|
+
* own test never probed**, found by Copilot round 1 — which named the double-quoted case; the other
|
|
992
|
+
* four are its siblings and are closed in the same stroke rather than left for the next round.
|
|
993
|
+
*
|
|
994
|
+
* A word is therefore a run of quoted spans, backslash-escaped characters, and ordinary non-space
|
|
995
|
+
* characters — the same three things `shellWords` recognises, kept to a regex here because this reader
|
|
996
|
+
* needs the target's EXTENT and never its value.
|
|
997
|
+
*
|
|
998
|
+
* **Inside a double-quoted span a backslash escapes the next character, and inside a single-quoted one
|
|
999
|
+
* it does not.** POSIX gives `'…'` no escapes at all, so honouring one there would invent a hole —
|
|
1000
|
+
* which is the same asymmetry `commandSegments`'s own quote loop already carries, and the same one
|
|
1001
|
+
* `shellWords` carries at its quoted-run branch.
|
|
1002
|
+
*
|
|
1003
|
+
* **This is the THIRD review finding of one class, and the class is what the suite now pins.** Round 1
|
|
1004
|
+
* found `[^\s]+` too narrow for a quoted target; round 3 found `"[^"]*"` too narrow for an escaped
|
|
1005
|
+
* quote *inside* one — `> "foo \"bar baz\"" git push --force …`, measured escaping while bash was
|
|
1006
|
+
* measured creating the file and running the command. Both times the fix landed at the spelling that
|
|
1007
|
+
* was quoted. So `compile.test.mjs` now asserts this pattern against `shellWords` itself: every
|
|
1008
|
+
* spelling that reader calls ONE word must be consumed whole here. A fourth sibling cannot be found by
|
|
1009
|
+
* a reviewer first.
|
|
1010
|
+
*
|
|
1011
|
+
* Linear rather than backtracking-prone despite the alternation: the pattern is anchored, the trailing
|
|
1012
|
+
* `\s*` matches empty, and the `+` is greedy over a match that always succeeds once an operator has
|
|
1013
|
+
* been seen — so there is no failure to backtrack into. Probed to 200KB.
|
|
1014
|
+
*/
|
|
1015
|
+
const REDIRECTION_TARGET = String.raw`(?:"(?:\\[\s\S]|[^"\\])*"|'[^']*'|\\[\s\S]|[^\s])+`;
|
|
1016
|
+
|
|
1017
|
+
const LEADING_REDIRECTION = new RegExp(String.raw`^\d*(?:&>>|&>|>>|>&|>\||<&|<>|<|>)\s*${REDIRECTION_TARGET}\s*`);
|
|
1018
|
+
|
|
1019
|
+
function stripLeadingRedirections(segment) {
|
|
1020
|
+
let text = segment;
|
|
1021
|
+
for (;;) {
|
|
1022
|
+
const shorter = text.replace(LEADING_REDIRECTION, "");
|
|
1023
|
+
// A segment that is ONLY a redirection strips to the empty string and is dropped by the
|
|
1024
|
+
// caller's `filter(Boolean)`, which is correct: a redirection is not a command.
|
|
1025
|
+
if (shorter === text) return text;
|
|
1026
|
+
text = shorter;
|
|
1027
|
+
}
|
|
773
1028
|
}
|
|
774
1029
|
|
|
775
1030
|
/**
|
|
@@ -872,8 +1127,32 @@ export function matchesRule(rule, tool, input = {}) {
|
|
|
872
1127
|
// wrapper and made `bash -c "bash -c 'git push origin HEAD'"` match, contradicting hole 1's
|
|
873
1128
|
// documented and asserted limit. The suite caught it, which is what that counterexample test
|
|
874
1129
|
// is for. Reading the raw command in both branches keeps the budget at exactly one unwrap.
|
|
875
|
-
|
|
876
|
-
|
|
1130
|
+
//
|
|
1131
|
+
// **A spelling is SEGMENTED as well as tested whole, and that half was missing until
|
|
1132
|
+
// 2026-08-25.** The two calls above unwrap the raw command and unwrap each of its segments,
|
|
1133
|
+
// which closes a wrapper before a separator — `ls && bash -c "git push --force origin main"`
|
|
1134
|
+
// — and leaves a separator inside a wrapper wide open: `bash -c "ls; git push --force origin
|
|
1135
|
+
// main"` answered **false**, and so did every other Gated shell action behind one wrapper and
|
|
1136
|
+
// one separator. Bash runs it; the gate did not see it.
|
|
1137
|
+
//
|
|
1138
|
+
// **The write branch below never had this gap, and the reason is why the fix is shaped like
|
|
1139
|
+
// this.** Its `writes` callback is `shellWrites`, which segments AGAIN internally with
|
|
1140
|
+
// `shellSegments` — so the write half got a third segmentation for free while the shell half,
|
|
1141
|
+
// whose `hit` is a plain prefix test, got none. One fix landing in one carrier and not its
|
|
1142
|
+
// sibling, between two branches of one function, for the second time
|
|
1143
|
+
// (`proposals/0020`). Found by ./fuzz-shell.mjs, which
|
|
1144
|
+
// generates the grammar rather than reading it.
|
|
1145
|
+
//
|
|
1146
|
+
// `reach` is used on BOTH arms deliberately: without it on the second,
|
|
1147
|
+
// `ls && bash -c "x; git push --force …"` — a separator outside AND inside — still escaped.
|
|
1148
|
+
// Fixing only the spelling that was quoted is the class this repository keeps meeting.
|
|
1149
|
+
//
|
|
1150
|
+
// **The unwrap budget is still exactly one level**, which is hole 1's documented and asserted
|
|
1151
|
+
// limit. `spellings` is called once on each path here and `commandSegments` peels nothing, so
|
|
1152
|
+
// `bash -c "sh -c 'git push origin HEAD'"` stays false — asserted, because an earlier draft of
|
|
1153
|
+
// the segment composition did peel twice and the suite is what caught it.
|
|
1154
|
+
const reach = (s) => hit(s) || commandSegments(s).some(hit);
|
|
1155
|
+
return spellings(input.command).some(reach) || commandSegments(input.command).some((seg) => spellings(seg).some(reach));
|
|
877
1156
|
}
|
|
878
1157
|
if (typeof action.write === "string") {
|
|
879
1158
|
if (WRITE_TOOLS.includes(tool)) return matchesPath(input.file_path ?? input.notebook_path, action.write);
|
|
@@ -969,6 +1248,16 @@ const HOST_TIER_NOT_A_GATE = {
|
|
|
969
1248
|
propose: "tier `propose` is enforced by the platform floor — pull requests, required checks, review — not by a tool-level permission rule on this machine",
|
|
970
1249
|
};
|
|
971
1250
|
|
|
1251
|
+
/**
|
|
1252
|
+
* The compiled-hook runners, in the order `claudeCode` spells them: the PreToolUse gate, then the Stop
|
|
1253
|
+
* gate. **This is their one carrier.** They are invoked by generated host configuration rather than
|
|
1254
|
+
* imported by anything, so no import graph can find them and every other roster that needs to know
|
|
1255
|
+
* which `cli/` modules are runners has to ask here — `./payload.mjs` does. A third runner added below
|
|
1256
|
+
* without a name added here would classify as unreachable in that rail, which is the direction that
|
|
1257
|
+
* fails loudly rather than the one that ships something unnoticed.
|
|
1258
|
+
*/
|
|
1259
|
+
export const HOOK_RUNNERS = ["gate.mjs", "stop-gate.mjs"];
|
|
1260
|
+
|
|
972
1261
|
/**
|
|
973
1262
|
* Translate the policy into a Claude Code settings object.
|
|
974
1263
|
*
|
|
@@ -1033,8 +1322,8 @@ export function claudeCode(parsed, options = {}) {
|
|
|
1033
1322
|
pinned.push({ file, abs });
|
|
1034
1323
|
return `"${abs}"`;
|
|
1035
1324
|
};
|
|
1036
|
-
const runner = options.runner ?? spell(
|
|
1037
|
-
const stopRunner = options.stopRunner ?? spell(
|
|
1325
|
+
const runner = options.runner ?? spell(HOOK_RUNNERS[0]);
|
|
1326
|
+
const stopRunner = options.stopRunner ?? spell(HOOK_RUNNERS[1]);
|
|
1038
1327
|
|
|
1039
1328
|
const compiled = [];
|
|
1040
1329
|
const refused = [];
|
|
@@ -1042,6 +1331,8 @@ export function claudeCode(parsed, options = {}) {
|
|
|
1042
1331
|
const ask = [];
|
|
1043
1332
|
const matchers = new Set();
|
|
1044
1333
|
const shellWriteGates = [];
|
|
1334
|
+
// Write gates whose single emitted permission pattern is `Edit(path)` — see the note they produce.
|
|
1335
|
+
const editCoveredGates = [];
|
|
1045
1336
|
|
|
1046
1337
|
for (const rule of parsed.rules) {
|
|
1047
1338
|
if (!HOST_GATE_TIERS.has(rule.tier)) {
|
|
@@ -1055,6 +1346,60 @@ export function claudeCode(parsed, options = {}) {
|
|
|
1055
1346
|
refused.push({ id: rule.id, tier: rule.tier, why: rule.target });
|
|
1056
1347
|
continue;
|
|
1057
1348
|
}
|
|
1349
|
+
// ---- a path target no path can match, refused HERE and not at `parse`
|
|
1350
|
+
//
|
|
1351
|
+
// **Hole 8 of ../.portulan/gate-map.md, closed at the tier that asks the question.** A
|
|
1352
|
+
// `gated` or `prohibited` rule whose path target can never match — see `neverMatches` for the
|
|
1353
|
+
// three families, of which *reduces to nothing* is only the first — compiles to a named
|
|
1354
|
+
// permission surface (`Edit(./)`, `Edit(./.)`, `Edit(././**)`, `Edit(./docs/./vision.md)`,
|
|
1355
|
+
// `Edit(./docs\\vision.md)`) while `matchesRule` answers `false` for every path a host submits. The compiler then reports the rule COMPILED, and
|
|
1356
|
+
// `doctor` counts it as covered. That is a hollow gate that reads from outside exactly like a
|
|
1357
|
+
// whole one: hole 3's failure mode reached by a different road.
|
|
1358
|
+
//
|
|
1359
|
+
// **Why a throw and not a `refused` row.** `refused` is the accounting for a rule a backend
|
|
1360
|
+
// legitimately declines — an `auto` tier, a `none` kind — and it writes the artifact without
|
|
1361
|
+
// the rule and exits 0. Filing this there would leave `doctor` reporting a gate the policy
|
|
1362
|
+
// declares and nothing enforces as ordinary non-coverage, which is precisely the laundering
|
|
1363
|
+
// `parse`'s own target refusals (absolute, `..`, whitespace) already decline to do. A policy
|
|
1364
|
+
// that cannot be enforced as written is malformed, and malformed is could-not-run.
|
|
1365
|
+
//
|
|
1366
|
+
// **Why here and not at `parse`.** That stage holds no tier partition, by a decision recorded
|
|
1367
|
+
// in its own docblock: *"A partition that is one backend's opinion must live in that backend,
|
|
1368
|
+
// or the second one is structurally incapable of disagreeing."* A session once put the
|
|
1369
|
+
// `auto`/`propose` refusals there and the floor backend then needed the rules it had thrown
|
|
1370
|
+
// away. This is that same shape — the hazard exists only for the tiers THIS backend enforces —
|
|
1371
|
+
// so it lives with `HOST_GATE_TIERS` above it, and the two `auto` rules in
|
|
1372
|
+
// ../.portulan/gates.json — `edit-on-a-working-branch` (`write: "./"`) and
|
|
1373
|
+
// `read-anything-in-the-repository` (`read: "./"`), one of each path kind — keep their spelling
|
|
1374
|
+
// and are refused one line earlier, for their tier, exactly as before.
|
|
1375
|
+
//
|
|
1376
|
+
// **One site, and the predicate exported rather than inlined.** The floor backend compiles no
|
|
1377
|
+
// path target at all (see `floorRefusal`), so there is no second site to sweep today and
|
|
1378
|
+
// saying "the same treatment there" would be a claim about code that does not exist.
|
|
1379
|
+
// `neverMatches` is exported for the backend that does compile one, and for the tests.
|
|
1380
|
+
//
|
|
1381
|
+
// **What this does NOT reach, said here rather than left to be discovered.** ./gate.mjs reads
|
|
1382
|
+
// the policy through `parse`, which this change does not touch, so a workspace that has
|
|
1383
|
+
// committed such a rule still loads it at the hook, `matchesRule` answers `false`, and the
|
|
1384
|
+
// hook steps aside because nothing matched. The rule is refused at COMPILE time — which is
|
|
1385
|
+
// when the artifact that gates the host is written — and the residue is recorded in hole 8.
|
|
1386
|
+
//
|
|
1387
|
+
// Found by the milestone-8 gate corpus on its first run (#336) and put as three options in
|
|
1388
|
+
// #337; this is the third, the narrowest, and it deliberately leaves open what `./` should
|
|
1389
|
+
// MEAN as a policy target.
|
|
1390
|
+
if ((rule.kind === "write" || rule.kind === "read") && neverMatches(rule.target)) {
|
|
1391
|
+
throw new CompileError(
|
|
1392
|
+
`rule \`${rule.id}\` is ${rule.tier} and its ${rule.kind} target ${JSON.stringify(rule.target)} matches no path a host ` +
|
|
1393
|
+
`can submit: the matcher compares a tail, and this target's comparison form is one no candidate can carry. ` +
|
|
1394
|
+
`This backend would emit ${pattern(rule.kind === "write" ? "Edit" : READ_TOOLS[0], rule.target)} ` +
|
|
1395
|
+
`and report the rule compiled, while the runtime matcher answered false for every input: a gate that reads as whole ` +
|
|
1396
|
+
`from outside and enforces nothing. Name the path the rule protects in its normalised spelling — no ` +
|
|
1397
|
+
`\`.\` or empty segment, no backslash — or drop the rule; and where this ` +
|
|
1398
|
+
`rule came from a pack, it is the pack that carries it, not the workspace composing it. ` +
|
|
1399
|
+
`(A never-matching target is harmless at \`auto\`, which this backend refuses one step earlier; it is a hollow gate only ` +
|
|
1400
|
+
`at the tiers that enforce. See .portulan/gate-map.md, honest holes, entry 8.)`,
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1058
1403
|
const into = rule.tier === "prohibited" ? deny : ask;
|
|
1059
1404
|
const emitted = []; // permission patterns — the layer that cannot fail open
|
|
1060
1405
|
const hookOnly = []; // coverage the permission DSL cannot express, carried by the hook alone
|
|
@@ -1062,11 +1407,36 @@ export function claudeCode(parsed, options = {}) {
|
|
|
1062
1407
|
emitted.push(`Bash(${rule.target}:*)`);
|
|
1063
1408
|
matchers.add("Bash");
|
|
1064
1409
|
} else {
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1410
|
+
// **The PERMISSION pattern narrows to `Edit`; the HOOK matchers keep all three.** These two
|
|
1411
|
+
// lines used to be one loop, and that emitted `Write(path)` and `NotebookEdit(path)` beside
|
|
1412
|
+
// `Edit(path)` — patterns the host **discards**. Measured on Claude Code 2.1.240, printed on
|
|
1413
|
+
// every start of this repository:
|
|
1414
|
+
//
|
|
1415
|
+
// Permission deny rule (.claude/settings.json): Write(./docs/vision.md) is not matched by
|
|
1416
|
+
// file permission checks — only Edit(path) rules are. Use Edit(./docs/vision.md) instead
|
|
1417
|
+
// (Edit rules cover all file-editing tools).
|
|
1418
|
+
//
|
|
1419
|
+
// and the same for the `ask` tier, so this was never a `deny`-only defect. The gate held
|
|
1420
|
+
// throughout — `Edit(path)` is matched for every file-editing tool — so what was wrong was
|
|
1421
|
+
// the compiler's **accounting**: three rules reported compiled where one enforces, plus a
|
|
1422
|
+
// permanent warning, which is `../.portulan/memory/a-stated-enforcer-must-be-the-real-one.md`.
|
|
1423
|
+
//
|
|
1424
|
+
// **`matchers` is a different consumer and must not narrow with it.** It drives the
|
|
1425
|
+
// `PreToolUse` hook, where all three tool names genuinely arrive and `matchesRule` above
|
|
1426
|
+
// answers for each; narrowing it would open a real hole where today there is none. That is
|
|
1427
|
+
// why the repair is not narrowing `WRITE_TOOLS` — two consumers of one constant, one of
|
|
1428
|
+
// which changes. A suite case pins the matcher set for exactly this reason.
|
|
1429
|
+
//
|
|
1430
|
+
// **The dropped patterns are NOT `hookOnly`.** Measured with the pattern alone in `deny` and
|
|
1431
|
+
// `Write` allowed: the host refused the write. So their coverage is carried by the
|
|
1432
|
+
// permission layer, not by the hook, and filing them under a channel documented as *carried
|
|
1433
|
+
// by the hook alone* would be a false claim in the weakening direction. The fact goes on
|
|
1434
|
+
// `notes` instead, where it names its host version.
|
|
1435
|
+
const permissionTools = rule.kind === "write" ? ["Edit"] : READ_TOOLS;
|
|
1436
|
+
for (const tool of permissionTools) emitted.push(pattern(tool, rule.target));
|
|
1437
|
+
for (const tool of rule.kind === "write" ? WRITE_TOOLS : READ_TOOLS) matchers.add(tool);
|
|
1069
1438
|
if (rule.kind === "write") {
|
|
1439
|
+
editCoveredGates.push(rule.id);
|
|
1070
1440
|
// The hook is wired for Bash so that a shell spelling of this write reaches
|
|
1071
1441
|
// `matchesRule`, which now answers for it. **This line is the load-bearing half.**
|
|
1072
1442
|
// The matcher alone would be inert in any workspace whose policy declares no shell
|
|
@@ -1107,10 +1477,24 @@ export function claudeCode(parsed, options = {}) {
|
|
|
1107
1477
|
// The generation header. An emitted artifact that does not say what generated it invites the
|
|
1108
1478
|
// one edit this whole rail exists to catch — a hand-fix that survives until the next compile
|
|
1109
1479
|
// silently reverts it.
|
|
1480
|
+
// **What compiled this, recorded beside what generated it** (#264). An unpinned run WOULD read
|
|
1481
|
+
// the host's plugin cache while the rail reads the tree — it now refuses that arrangement outright
|
|
1482
|
+
// (#316) — and until this field the artifact said nothing about which answered, so a drift RED named a difference no reader could find in the repository, because the
|
|
1483
|
+
// deciding input was a directory outside it.
|
|
1484
|
+
//
|
|
1485
|
+
// **Origin and version, never the ROOT PATH.** The discovered root is an absolute path under
|
|
1486
|
+
// somebody's home directory; recording it would make a tracked artifact machine-dependent and red
|
|
1487
|
+
// `verify/compile.sh` for every developer and CI — trading a silent hazard for a permanent false
|
|
1488
|
+
// one. `recordedOrigin` collapses the resolver's three tags to two for the same reason: the pinned
|
|
1489
|
+
// rail and a bare run must agree byte for byte about an identical world.
|
|
1490
|
+
const packs = (options.packProvenance ?? [])
|
|
1491
|
+
.map((c) => ({ pack: c.pack, origin: c.origin, version: c.version ?? null }))
|
|
1492
|
+
.sort((a, b) => a.pack.localeCompare(b.pack));
|
|
1110
1493
|
const value = {
|
|
1111
1494
|
$portulan: {
|
|
1112
1495
|
generated: "cli/compile.mjs",
|
|
1113
1496
|
source,
|
|
1497
|
+
...(packs.length ? { packs } : {}),
|
|
1114
1498
|
warning: `Generated file. Edit ${source} and recompile; \`verify/compile.sh\` fails on drift.`,
|
|
1115
1499
|
},
|
|
1116
1500
|
permissions: { deny, ask, allow: [] },
|
|
@@ -1148,6 +1532,21 @@ export function claudeCode(parsed, options = {}) {
|
|
|
1148
1532
|
`project, or pass \`--runner\`/\`--stop-runner\` to name a path you control`,
|
|
1149
1533
|
);
|
|
1150
1534
|
}
|
|
1535
|
+
if (editCoveredGates.length) {
|
|
1536
|
+
notes.push(
|
|
1537
|
+
`${editCoveredGates.length} write gate(s) — ${editCoveredGates.join(", ")} — emit \`Edit(path)\` as their only ` +
|
|
1538
|
+
`permission pattern, and that ONE pattern is matched for every file-editing tool. Measured on Claude Code ` +
|
|
1539
|
+
`2.1.240: with \`Edit(path)\` alone in the list and \`Write\` allowed, the host refused the write — and a ` +
|
|
1540
|
+
`\`Write\` to a DIFFERENT path succeeded in the same session, the control that tells *refused* from ` +
|
|
1541
|
+
`*refuses everything*. \`Write(path)\` ` +
|
|
1542
|
+
`and \`NotebookEdit(path)\` are NOT emitted because the host discards them — it says so on every start — and a ` +
|
|
1543
|
+
`compiler that emitted them would report three rules where one enforces. **Re-measure on a host change in EITHER ` +
|
|
1544
|
+
`direction:** this is a fact about one CLI version. If a later host stops treating \`Edit\` as tool-general, ` +
|
|
1545
|
+
`this gate narrows to one tool at the permission layer with nothing here going red. And no EARLIER host was ` +
|
|
1546
|
+
`re-measured — on one that honoured all three patterns and did not treat \`Edit\` as tool-general, this ` +
|
|
1547
|
+
`emission removes two working rules. The hook covers all three tool names on any host.`,
|
|
1548
|
+
);
|
|
1549
|
+
}
|
|
1151
1550
|
if (shellWriteGates.length) {
|
|
1152
1551
|
notes.push(
|
|
1153
1552
|
`${shellWriteGates.length} write gate(s) — ${shellWriteGates.join(", ")} — also match a Bash command that writes the ` +
|
|
@@ -1438,6 +1837,11 @@ function artifactPaths(workspaceDir) {
|
|
|
1438
1837
|
|
|
1439
1838
|
/** Every backend, run against one parsed policy. The order is the order the matrix prints in. */
|
|
1440
1839
|
export function backends(parsed, options = {}) {
|
|
1840
|
+
// **Provenance rides in the Claude Code artifact only, and that is an honest limit rather than an
|
|
1841
|
+
// oversight.** `githubRuleset` emits to a fixed external schema that has nowhere to carry a
|
|
1842
|
+
// `$portulan` block; its provenance is the one line it can carry, in `name`. Both backends compile
|
|
1843
|
+
// from ONE root plan per run, so the recorded origins describe the ruleset's inputs too — they are
|
|
1844
|
+
// simply not readable in its file. Stated here and in `../.portulan/compile/README.md` (#264).
|
|
1441
1845
|
return [claudeCode(parsed, options), githubRuleset(parsed, options)];
|
|
1442
1846
|
}
|
|
1443
1847
|
|
|
@@ -1467,7 +1871,76 @@ export function matrix(parsed, options = {}) {
|
|
|
1467
1871
|
// ===========================================================================================
|
|
1468
1872
|
|
|
1469
1873
|
/**
|
|
1470
|
-
*
|
|
1874
|
+
* The same answer as `policyPath`, plus WHICH ARM produced it — and, where the manifest named
|
|
1875
|
+
* something unusable, WHY.
|
|
1876
|
+
*
|
|
1877
|
+
* `policyPath` returns a path either way, which is all a reader of the policy needs and is exactly
|
|
1878
|
+
* what made the caller unable to tell two different situations apart: a workspace that NAMES a policy
|
|
1879
|
+
* file which is missing (a genuine error — something is declared and absent), and a workspace that
|
|
1880
|
+
* names none at all (a legitimate shape, per this function's own note below). Both arrived at
|
|
1881
|
+
* `readJson` as `ENOENT` and were reported identically, so the second — documented here as legitimate
|
|
1882
|
+
* — surfaced as `cannot read the gate policy at …/gates.json`, which reads like a corrupt or deleted
|
|
1883
|
+
* file and sends the reader to look for one that was never supposed to exist.
|
|
1884
|
+
*
|
|
1885
|
+
* Found 2026-08-22 while booting the `sleepy-panda` workspace, which declares no `gates` key and
|
|
1886
|
+
* composes a pack contributing two gate rules: the compiler refused with an ENOENT that mentioned
|
|
1887
|
+
* neither fact. Reporting declaredness is what lets the caller say which of the two it is.
|
|
1888
|
+
*
|
|
1889
|
+
* **`declared: false` is four situations, not one, so it carries a `reason`.** The first cut reported
|
|
1890
|
+
* every one of them as "`workspace.json` has no top-level `gates` key", which is false for a manifest
|
|
1891
|
+
* that is absent and false for a `gates` value this function refused — the same two-situations-one-
|
|
1892
|
+
* sentence defect this change exists to fix, reproduced one level down. `reason` is `no-key`,
|
|
1893
|
+
* `no-manifest`, `refused`, or `declared`. Found by Copilot on this pull request.
|
|
1894
|
+
*/
|
|
1895
|
+
export function policyDeclaration(workspaceRoot, workspaceDir = ".portulan") {
|
|
1896
|
+
const base = path.join(workspaceRoot, workspaceDir);
|
|
1897
|
+
const manifest = path.join(base, "workspace.json");
|
|
1898
|
+
const fallback = (reason) => ({ file: path.join(workspaceRoot, workspaceDir, "gates.json"), declared: false, reason });
|
|
1899
|
+
let declared;
|
|
1900
|
+
try {
|
|
1901
|
+
declared = JSON.parse(fs.readFileSync(manifest, "utf8")).gates;
|
|
1902
|
+
} catch {
|
|
1903
|
+
// No manifest, or unreadable. `doctor` is the tool that judges a manifest; this one only needs
|
|
1904
|
+
// to know where the policy is, and the default is where it is when nothing says otherwise.
|
|
1905
|
+
return fallback("no-manifest");
|
|
1906
|
+
}
|
|
1907
|
+
if (declared === undefined) return fallback("no-key");
|
|
1908
|
+
// Validated against the schema's `filePath` shape before it is used, and containment is
|
|
1909
|
+
// checked after resolution rather than by pattern alone — a `../` chain passes any regex and
|
|
1910
|
+
// still escapes. Without this, a malformed or hand-edited manifest turns a hook that runs on
|
|
1911
|
+
// EVERY tool call into an arbitrary file read outside the workspace. `doctor` would refuse such
|
|
1912
|
+
// a manifest, but this runner must not depend on `doctor` having been run: the two tools have
|
|
1913
|
+
// no ordering between them, and the schema is the contract, not the sequence. Found by review.
|
|
1914
|
+
//
|
|
1915
|
+
// **`isInside`, not another hand-rolled spelling.** The containment test read
|
|
1916
|
+
// `!path.relative(base, resolved).startsWith("..")` until 2026-08-22 — the spelling
|
|
1917
|
+
// `./inside.mjs` exists to replace, which calls an ordinary directory named `..foo` an escape and
|
|
1918
|
+
// falls back for a policy that was inside all along. This module already imports `isInside` and
|
|
1919
|
+
// already carries the same warning at `recordedOrigin`, so the rule was in scope and unread.
|
|
1920
|
+
// Pre-existing, and surfaced by Copilot on the pull request that touched this line.
|
|
1921
|
+
//
|
|
1922
|
+
// **No count is asserted here.** A first draft called this "the fifth copy"; the pre-commit
|
|
1923
|
+
// checkpoint could not reproduce that figure and a census then found more, including one in THIS
|
|
1924
|
+
// module at `resolveWorkspace`. The census is #331 rather than this commit, because the remaining
|
|
1925
|
+
// sites do not share semantics — they disagree on the empty relative path and on absolute paths —
|
|
1926
|
+
// so each needs its own red and green. An unreproducible number is worse than none.
|
|
1927
|
+
if (typeof declared === "string" && declared.trim() && FILE_PATH.test(declared)) {
|
|
1928
|
+
const resolved = path.resolve(base, declared);
|
|
1929
|
+
if (resolved !== base && isInside(base, resolved)) return { file: resolved, declared: true, reason: "declared" };
|
|
1930
|
+
}
|
|
1931
|
+
// The manifest named something, and nothing it named is usable — a wrong type, an empty string, a
|
|
1932
|
+
// shape the schema refuses, or a path that escapes the workspace. The compiler is on the
|
|
1933
|
+
// conventional path, so the fallback owns the diagnostic; but this is NOT the state of a manifest
|
|
1934
|
+
// that named nothing, and a message conflating the two is the defect this change is about.
|
|
1935
|
+
return fallback("refused");
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
/**
|
|
1939
|
+
* Where a workspace's gate policy lives — the path alone.
|
|
1940
|
+
*
|
|
1941
|
+
* Kept as the narrow answer because most callers want exactly that, and because every existing caller
|
|
1942
|
+
* and test was written against a string. `policyDeclaration` is the one that also says whether the
|
|
1943
|
+
* workspace named it, and why when it did not.
|
|
1471
1944
|
*
|
|
1472
1945
|
* Read from the manifest's top-level `gates` key, because that is what the key MEANS —
|
|
1473
1946
|
* `../spec/slots.md` calls it "a path to a JSON file the enforcement compiler reads", and `doctor`
|
|
@@ -1481,26 +1954,73 @@ export function matrix(parsed, options = {}) {
|
|
|
1481
1954
|
* refusing it would make the key required, which is a spec change nobody decided.
|
|
1482
1955
|
*/
|
|
1483
1956
|
export function policyPath(workspaceRoot, workspaceDir = ".portulan") {
|
|
1484
|
-
|
|
1485
|
-
|
|
1957
|
+
return policyDeclaration(workspaceRoot, workspaceDir).file;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
/**
|
|
1961
|
+
* What to say when a workspace declares no gate policy and none is there by convention.
|
|
1962
|
+
*
|
|
1963
|
+
* This is a STATE, not a failure to read a file, and the difference is the whole point: `policyPath`'s
|
|
1964
|
+
* note calls an absent `gates` key "a legitimate shape, and refusing it would make the key required,
|
|
1965
|
+
* which is a spec change nobody decided" — and then the reader refused it anyway, with `ENOENT` on a
|
|
1966
|
+
* file the workspace never claimed to have.
|
|
1967
|
+
*
|
|
1968
|
+
* It stays a refusal (exit 2, nothing written), because nothing was compiled and a compiler that
|
|
1969
|
+
* reports success having emitted nothing is the failure this repository keeps writing checks against.
|
|
1970
|
+
* What changes is that the refusal names the actual state and what it costs.
|
|
1971
|
+
*
|
|
1972
|
+
* **The pack count is the load-bearing half.** A workspace in this shape may still compose packs
|
|
1973
|
+
* contributing gate fragments — `sleepy-panda` composes `rituals/checkpoints`, which contributes two
|
|
1974
|
+
* — and those rules silently reach nothing, because a fragment tightens a policy and there is none to
|
|
1975
|
+
* tighten. That is the fact a reader needs and the one the old message could not carry: it threw
|
|
1976
|
+
* before `packContributions` was ever called.
|
|
1977
|
+
*/
|
|
1978
|
+
function undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, reason = "no-key") {
|
|
1979
|
+
const manifest = path.join(workspaceRoot, workspaceDir, "workspace.json");
|
|
1980
|
+
// **One opening per arm, because the arm is the reader's first question.** `no-key` is the shape
|
|
1981
|
+
// this whole change defends as legitimate; `no-manifest` is a workspace that has not been
|
|
1982
|
+
// authored yet; `refused` is a manifest that DID name a policy and named one this compiler will
|
|
1983
|
+
// not read, which is neither of the other two and must not be told it has no `gates` key.
|
|
1984
|
+
const opening =
|
|
1985
|
+
reason === "refused"
|
|
1986
|
+
? `\`workspace.json\` names a gate policy this compiler will not read — its top-level ` +
|
|
1987
|
+
`\`gates\` key is not a relative path to a file inside the workspace — and there is no ` +
|
|
1988
|
+
`\`gates.json\` at ${policyFile} either.`
|
|
1989
|
+
: reason === "no-manifest"
|
|
1990
|
+
? `this workspace declares no gate policy — there is no readable \`workspace.json\` at ` +
|
|
1991
|
+
`${manifest}, and there is no \`gates.json\` at ${policyFile}.`
|
|
1992
|
+
: `this workspace declares no gate policy — \`workspace.json\` has no top-level \`gates\` key, ` +
|
|
1993
|
+
`and there is no \`gates.json\` at ${policyFile}.`;
|
|
1994
|
+
const lines = [`${opening} Nothing was compiled and nothing was written.`];
|
|
1995
|
+
let composed = null;
|
|
1486
1996
|
try {
|
|
1487
|
-
|
|
1488
|
-
// Validated against the schema's `filePath` shape before it is used, and containment is
|
|
1489
|
-
// checked after resolution rather than by pattern alone — a `../` chain passes any regex and
|
|
1490
|
-
// still escapes. Without this, a malformed or hand-edited manifest turns a hook that runs on
|
|
1491
|
-
// EVERY tool call into an arbitrary file read outside the workspace. `doctor` would refuse such
|
|
1492
|
-
// a manifest, but this runner must not depend on `doctor` having been run: the two tools have
|
|
1493
|
-
// no ordering between them, and the schema is the contract, not the sequence. Found by review.
|
|
1494
|
-
if (typeof declared === "string" && declared.trim() && FILE_PATH.test(declared)) {
|
|
1495
|
-
const resolved = path.resolve(base, declared);
|
|
1496
|
-
const inside = path.relative(base, resolved);
|
|
1497
|
-
if (inside && !inside.startsWith("..") && !path.isAbsolute(inside)) return resolved;
|
|
1498
|
-
}
|
|
1997
|
+
composed = packContributions(workspaceRoot, workspaceDir, packOptions);
|
|
1499
1998
|
} catch {
|
|
1500
|
-
//
|
|
1501
|
-
//
|
|
1999
|
+
// The pack layer has refusals of its own — a shadowed pack, a malformed pack manifest, an
|
|
2000
|
+
// unresolvable root. Swallowed HERE and nowhere else: this message is about the missing policy,
|
|
2001
|
+
// and a pack refusal raised from inside a diagnostic would replace the answer with a different
|
|
2002
|
+
// question. Declare a policy and the very next run surfaces it on its own terms.
|
|
1502
2003
|
}
|
|
1503
|
-
|
|
2004
|
+
const contributions = composed?.contributions ?? [];
|
|
2005
|
+
const rules = contributions.reduce((n, c) => n + (c.fragments?.length ?? 0), 0);
|
|
2006
|
+
if (rules > 0) {
|
|
2007
|
+
const packs = contributions
|
|
2008
|
+
.filter((c) => (c.fragments?.length ?? 0) > 0)
|
|
2009
|
+
.map((c) => `\`${c.pack}\``)
|
|
2010
|
+
.join(", ");
|
|
2011
|
+
lines.push(
|
|
2012
|
+
`${rules} pack-contributed gate rule(s) from ${packs} are therefore NOT compiled: ` +
|
|
2013
|
+
`a fragment tightens a policy, and there is none here to tighten.`,
|
|
2014
|
+
);
|
|
2015
|
+
}
|
|
2016
|
+
lines.push(
|
|
2017
|
+
reason === "refused"
|
|
2018
|
+
? "Give `gates` a relative path to a file inside the workspace directory, or remove the key " +
|
|
2019
|
+
"and let `gates.json` be found by convention."
|
|
2020
|
+
: "Declare one with `portulan new gate-policy`, or leave it undeclared deliberately — " +
|
|
2021
|
+
"a workspace with no gate policy is a legitimate shape, and this is a state rather than a fault.",
|
|
2022
|
+
);
|
|
2023
|
+
return lines.join("\n ");
|
|
1504
2024
|
}
|
|
1505
2025
|
|
|
1506
2026
|
// ===========================================================================================
|
|
@@ -1626,6 +2146,103 @@ export function rootPlan(workspaceDir, manifest, { named = [], namedGiven = null
|
|
|
1626
2146
|
* them. What it depends on instead is `parse`, which validates every composed rule exactly as it
|
|
1627
2147
|
* validates a hand-written one, so a malformed fragment is refused by the same code either way.
|
|
1628
2148
|
*/
|
|
2149
|
+
/**
|
|
2150
|
+
* How a resolved pack's ORIGIN is recorded in an emitted artifact — a deliberate collapse of the
|
|
2151
|
+
* resolver's three tags into two, and the collapse is the load-bearing part.
|
|
2152
|
+
*
|
|
2153
|
+
* `resolutionRoots` tags roots `named | derived | discovered`. Recording that raw would be wrong in a
|
|
2154
|
+
* way that turns this whole feature into a liability: the PINNED rail spells its root
|
|
2155
|
+
* (`--pack-root packs`, tagged `named`) while a bare run derives the same directory (tagged
|
|
2156
|
+
* `derived`). Two documented-correct spellings of the same world would emit different bytes, and
|
|
2157
|
+
* `verify/compile.sh` would go red on a tree nothing was wrong with — a per-machine false red, which
|
|
2158
|
+
* is worse than the silent hazard #264 is about.
|
|
2159
|
+
*
|
|
2160
|
+
* So: `discovered` iff the answering root came from discovery — **which is a statement about where the
|
|
2161
|
+
* root was FOUND, not about where it sits.** Discovery reads a record under `CLAUDE_CONFIG_DIR`, and a
|
|
2162
|
+
* hermetic run can point that inside the tree, so a discovered root is not necessarily an external one;
|
|
2163
|
+
* the docblock said "a directory outside this repository" until review caught the conflation, and the
|
|
2164
|
+
* distinction is this field's whole subject rather than a quibble — it is why `outside-tree` exists as
|
|
2165
|
+
* a separate answer below. Otherwise `tree`, when the root is one the repository itself yields. A NAMED root that is not under the workspace root is neither, and is recorded as
|
|
2166
|
+
* `outside-tree` rather than flattened into `tree`, because calling somebody's `--pack-root /elsewhere`
|
|
2167
|
+
* "the tree" would be this field's first lie.
|
|
2168
|
+
*/
|
|
2169
|
+
export function recordedOrigin(root, plan, workspaceRoot) {
|
|
2170
|
+
const same = (a, b) => path.resolve(a) === path.resolve(b);
|
|
2171
|
+
const tagged = (plan?.origins ?? []).find((o) => same(o.root, root));
|
|
2172
|
+
if (tagged?.origin === "discovered") return "discovered";
|
|
2173
|
+
// **`derived` and `named` take the SAME inside-the-repository test**, and the first draft trusted
|
|
2174
|
+
// `derived` on its tag alone. It is derived from the manifest's `tree`, which is a string a human
|
|
2175
|
+
// edits: `tree: "../../elsewhere"` resolves a derived root of `/elsewhere/packs` — measured — and
|
|
2176
|
+
// that would have been recorded as `tree`. A tag says where a root CAME FROM; only the path says
|
|
2177
|
+
// whether it is in this repository, and this field's whole job is the second question. Copilot.
|
|
2178
|
+
//
|
|
2179
|
+
// Inside INCLUDES being the repository itself, which the first draft called `outside-tree` on a
|
|
2180
|
+
// `rel` of `""`. By this function's own standard that was the field's first lie.
|
|
2181
|
+
//
|
|
2182
|
+
// **Both sides resolved through `realpathSync`**, because a lexical comparison makes two spellings
|
|
2183
|
+
// of one directory disagree: `--workspace <alias> --pack-root <realpath>` recorded `outside-tree`
|
|
2184
|
+
// for a root plainly inside the tree, which would emit a different artifact for an identical world
|
|
2185
|
+
// — the per-machine false red this whole design refuses. Measured. It is the same trap #220's
|
|
2186
|
+
// second arm repaired in the Stop-gate's divergence sentence, and it is worth stating twice
|
|
2187
|
+
// because the two sites were written a day apart and neither inherits the other's guard.
|
|
2188
|
+
const real = (dir) => {
|
|
2189
|
+
try {
|
|
2190
|
+
return fs.realpathSync(path.resolve(dir));
|
|
2191
|
+
} catch {
|
|
2192
|
+
return path.resolve(dir);
|
|
2193
|
+
}
|
|
2194
|
+
};
|
|
2195
|
+
// **`isInside`, not a fourth hand-rolled spelling.** `!rel.startsWith("..")` calls a directory
|
|
2196
|
+
// literally named `..foo` outside the tree — and this repository has already paid for that exact
|
|
2197
|
+
// reasoning once: `./index.mjs`'s docblock records it as the ninth fail-open found in this
|
|
2198
|
+
// scaffolding, and *the first one written by the change that cites the class*. This was the second.
|
|
2199
|
+
// The helper is exported precisely so a third copy cannot drift into it. Copilot.
|
|
2200
|
+
return isInside(real(workspaceRoot), real(root)) ? "tree" : "outside-tree";
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
/**
|
|
2204
|
+
* Two copies of one pack answered, and this says what differs between them.
|
|
2205
|
+
*
|
|
2206
|
+
* **One carrier, because the comparison has already been wrong twice.** `doctor` grew this first and
|
|
2207
|
+
* its history is the argument for not writing a fourth: a first spelling projected `[id, tier, action]`
|
|
2208
|
+
* and so read a copy differing in `reason` as agreeing — while `composeFragments` pushes the WHOLE
|
|
2209
|
+
* fragment, so those differences do reach the compiled policy; a second compared the whole fragment
|
|
2210
|
+
* but claimed *byte-identical*, which `JSON.stringify` of a parsed value cannot promise. This is that
|
|
2211
|
+
* third, correct version, moved here so `doctor` and `compile` cannot drift apart about what a shadow
|
|
2212
|
+
* IS — proposal `0020`'s rule, applied to a function rather than to a sentence.
|
|
2213
|
+
*
|
|
2214
|
+
* Parsed and key-order-normalised is the RIGHT comparison: reformatting a `pack.json` changes nothing
|
|
2215
|
+
* about what the pack contributes, so reporting it would be noise.
|
|
2216
|
+
*/
|
|
2217
|
+
export function packDifferences(mine, other) {
|
|
2218
|
+
const canonical = (v) =>
|
|
2219
|
+
Array.isArray(v)
|
|
2220
|
+
? v.map(canonical)
|
|
2221
|
+
: v && typeof v === "object"
|
|
2222
|
+
? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical(v[k])]))
|
|
2223
|
+
: v;
|
|
2224
|
+
const frag = (m) => JSON.stringify(canonical(m?.contributes?.gates ?? []));
|
|
2225
|
+
const mineV = mine?.portulan?.version ?? "no version";
|
|
2226
|
+
const otherV = other?.portulan?.version ?? "no version";
|
|
2227
|
+
const differs = [];
|
|
2228
|
+
if (mineV !== otherV) differs.push(`version ${mineV} against the tree's ${otherV}`);
|
|
2229
|
+
if (frag(mine) !== frag(other)) differs.push("gate fragments that differ once parsed");
|
|
2230
|
+
return differs;
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
/**
|
|
2234
|
+
* The non-discovered copy of a pack that a discovered root answered for — or `null` when there is no
|
|
2235
|
+
* shadow. `originOf` maps a root to its tag; pass `plan.origins`' lookup.
|
|
2236
|
+
*
|
|
2237
|
+
* Only a **discovered** answer can be shadowed in the sense that matters: a named root replaces the
|
|
2238
|
+
* derived one, so nothing is behind it, and a derived answer is the tree's own copy already.
|
|
2239
|
+
*/
|
|
2240
|
+
export function shadowedCopy(name, answeringOrigin, roots, originOf) {
|
|
2241
|
+
if (answeringOrigin !== "discovered") return null;
|
|
2242
|
+
const behind = resolvePack(name, roots.filter((r) => originOf(r) !== "discovered"));
|
|
2243
|
+
return behind.dir ? behind : null;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
1629
2246
|
export function packContributions(workspaceRoot, workspaceDir = ".portulan", options = {}) {
|
|
1630
2247
|
const base = path.join(workspaceRoot, workspaceDir);
|
|
1631
2248
|
let manifest;
|
|
@@ -1666,6 +2283,83 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
|
|
|
1666
2283
|
continue;
|
|
1667
2284
|
}
|
|
1668
2285
|
const packManifest = readJson(found.manifest, `the pack manifest for \`${found.name}\``);
|
|
2286
|
+
// **A shadowed pack is a could-not-run, not a resolution** (#316). Two roots answered for one
|
|
2287
|
+
// declared name and the invocation does not say which the caller meant, so this compiler has
|
|
2288
|
+
// no honest way to pick — and unlike `doctor`, which reports and never grades because an
|
|
2289
|
+
// install state is a fact about a machine rather than a verdict about the repository, this one
|
|
2290
|
+
// does not report: it EMITS. Picking silently wrote the discovered copy's fragments into the
|
|
2291
|
+
// artifact while `verify/compile.sh` read the tree's, which on this project's own host meant a
|
|
2292
|
+
// `git commit --no-verify` matcher that the tree had deliberately removed as false coverage.
|
|
2293
|
+
//
|
|
2294
|
+
// **Refused at RESOLUTION, so `--check` and the write path inherit one rule.** Refusing only
|
|
2295
|
+
// on write would make one tool answer one ambiguity two ways: the check would go on adopting
|
|
2296
|
+
// the discovered world and exit 1 — asserting that the *repository* drifted when it had not.
|
|
2297
|
+
// An exit code is the machine-read API, and a false 1 is a false verdict about the tree.
|
|
2298
|
+
//
|
|
2299
|
+
// **Every unasked shadow refuses, including one whose manifests agree**, which reads as
|
|
2300
|
+
// over-strict until you follow the bytes: `recordedOrigin` tags the answering root into
|
|
2301
|
+
// `$portulan.packs[].origin`, so a discovered answer emits `discovered` where the rail's
|
|
2302
|
+
// artifact says `tree`. Agreement in the manifests is therefore not agreement in the artifact,
|
|
2303
|
+
// and a carve-out for it would ship a compile that still reds the recipe it was meant to
|
|
2304
|
+
// reconcile with. The message distinguishes the two cases; the refusal does not.
|
|
2305
|
+
//
|
|
2306
|
+
// **Only where the caller did not choose.** `--pack-root auto` is discovery ELECTED, and
|
|
2307
|
+
// electing it is the choice this refusal exists to ask for. Both spellings are offered in the
|
|
2308
|
+
// message, because naming only the tree would make the refusal decide the question it claims
|
|
2309
|
+
// to be handing back.
|
|
2310
|
+
//
|
|
2311
|
+
// **The `named` half of this guard is defence in depth, and that was measured rather than
|
|
2312
|
+
// assumed.** Forcing the condition to `true` — refuse unconditionally — leaves the named-root
|
|
2313
|
+
// case still passing: a named root REPLACES the derived one, so the plan carries no discovered
|
|
2314
|
+
// root, `shadowedCopy` finds nothing behind, and there is no shadow to refuse. The protection
|
|
2315
|
+
// is structural. The clause stays because it states the intent at the site where a future
|
|
2316
|
+
// change to `resolutionRoots` could quietly make it load-bearing.
|
|
2317
|
+
if (!(options.forced ?? false) && plan.source !== "named") {
|
|
2318
|
+
const behind = shadowedCopy(found.name, recordedOrigin(found.root, plan, workspaceRoot), roots, (r) =>
|
|
2319
|
+
(plan.origins ?? []).find((o) => path.resolve(o.root) === path.resolve(r))?.origin,
|
|
2320
|
+
);
|
|
2321
|
+
if (behind) {
|
|
2322
|
+
let other = null;
|
|
2323
|
+
try {
|
|
2324
|
+
other = JSON.parse(fs.readFileSync(behind.manifest, "utf8"));
|
|
2325
|
+
} catch (cause) {
|
|
2326
|
+
throw new CompileError(
|
|
2327
|
+
`\`${found.name}\` resolved under the root ${found.root} while the root ` +
|
|
2328
|
+
`${path.relative(workspaceRoot, behind.root)} also carries it, ` +
|
|
2329
|
+
`and that second copy could not be read (${cause.message}) — so which one this would compile from could not be established. ` +
|
|
2330
|
+
"Name the root: `--pack-root packs` compiles from the tree, `--pack-root auto` from the installed copy.",
|
|
2331
|
+
);
|
|
2332
|
+
}
|
|
2333
|
+
const differs = packDifferences(packManifest, other);
|
|
2334
|
+
// **Both ROOTS by path — the roots, not the pack directories.** `resolvePack` returns
|
|
2335
|
+
// both, and a first cut printed `dir` (`<root>/<category>/<pack>`) while calling it a
|
|
2336
|
+
// root: a diagnostic that mislabels the thing it names, in a message whose own claim is
|
|
2337
|
+
// to name both roots. The root is also the more useful of the two, because it is what a
|
|
2338
|
+
// reader types back into `--pack-root`. A refusal naming only one hands back a choice
|
|
2339
|
+
// while withholding half of what it is between. Printing the absolute path is safe in a
|
|
2340
|
+
// way it is not in `$portulan.packs`: this is stderr, read once by a human, never a
|
|
2341
|
+
// tracked artifact, so #264's origins-never-paths rule (which exists to stop a
|
|
2342
|
+
// machine-dependent path being committed) does not reach it. `doctor` already prints it
|
|
2343
|
+
// in its resolves-from note, so the two tools now name the same two directories.
|
|
2344
|
+
//
|
|
2345
|
+
// **"Discovered on this host", never "outside this repository".** Discovery reads a
|
|
2346
|
+
// record under `CLAUDE_CONFIG_DIR`, which a hermetic run can point INSIDE the tree — so
|
|
2347
|
+
// a discovered root is not necessarily an external one, and the message must not assert
|
|
2348
|
+
// a location it has not tested. `recordedOrigin` keeps `discovered` and `outside-tree`
|
|
2349
|
+
// as separate answers for exactly this reason: where a root came from and where it sits
|
|
2350
|
+
// are different questions, and the path is printed right there for a reader who wants
|
|
2351
|
+
// the second one. Caught by review after the first cut conflated them.
|
|
2352
|
+
throw new CompileError(
|
|
2353
|
+
`\`${found.name}\` is SHADOWED — it resolved under ${found.root}, a root discovered on this ` +
|
|
2354
|
+
`host, while the root ${path.relative(workspaceRoot, behind.root)} also carries it. ` +
|
|
2355
|
+
(differs.length
|
|
2356
|
+
? `They differ by ${differs.join(" and ")}, so the two roots compile to different policies. `
|
|
2357
|
+
: "Their manifests agree, but the emitted artifact still records which root answered, so the two roots compile to different bytes. ") +
|
|
2358
|
+
"Refusing to pick: name the root instead — `--pack-root packs` compiles from the tree, which is what " +
|
|
2359
|
+
"`verify/compile.sh` checks, and `--pack-root auto` compiles from the installed copy.",
|
|
2360
|
+
);
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
1669
2363
|
const fragments = packManifest?.contributes?.gates;
|
|
1670
2364
|
// Checked here rather than left to `composeFragments` iterating it: a manifest is a file a
|
|
1671
2365
|
// human edits, `doctor` may not have run (the two tools have no ordering between them), and a
|
|
@@ -1678,7 +2372,18 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
|
|
|
1678
2372
|
`Refusing to compose it — run \`doctor\` to validate the pack against the Pack Definition.`,
|
|
1679
2373
|
);
|
|
1680
2374
|
}
|
|
1681
|
-
contributions.push({
|
|
2375
|
+
contributions.push({
|
|
2376
|
+
pack: found.name,
|
|
2377
|
+
dir: found.dir,
|
|
2378
|
+
fragments: fragments ?? [],
|
|
2379
|
+
// **Provenance, recorded per pack**, so the artifact this compiles into can say what
|
|
2380
|
+
// compiled it (#264). `version` comes from `portulan.version` — the pack manifest's own
|
|
2381
|
+
// location for it, not a top-level `version` — and its ABSENCE is recorded as such rather
|
|
2382
|
+
// than as a blank, because "this pack declares no version" and "I did not look" are
|
|
2383
|
+
// different facts and only one of them is the pack's.
|
|
2384
|
+
origin: recordedOrigin(found.root, plan, workspaceRoot),
|
|
2385
|
+
version: typeof packManifest?.portulan?.version === "string" ? packManifest.portulan.version : null,
|
|
2386
|
+
});
|
|
1682
2387
|
}
|
|
1683
2388
|
// `plan` travels with the result because every consumer prints it: which source won, and the path
|
|
1684
2389
|
// it won with, is what tells a feed resolution from a local one.
|
|
@@ -2068,7 +2773,25 @@ export function run(argv, options = {}) {
|
|
|
2068
2773
|
// The workspace may be named as a repository root or as the workspace directory itself, and the
|
|
2069
2774
|
// second is how a feed-side workspace is reachable at all — see `resolveWorkspace`.
|
|
2070
2775
|
const { workspaceRoot, workspaceDir } = resolveWorkspace(named);
|
|
2071
|
-
const policyFile =
|
|
2776
|
+
const { file: policyFile, declared: policyDeclared, reason: policyReason } = policyDeclaration(workspaceRoot, workspaceDir);
|
|
2777
|
+
// **Declared-and-missing and never-declared are different answers.** Only the first is a
|
|
2778
|
+
// failure to read something this workspace claimed to have; the second is a shape `policyPath`
|
|
2779
|
+
// documents as legitimate, and reporting it as `ENOENT` sent readers hunting for a deleted
|
|
2780
|
+
// file. Checked here rather than inside the reader because only the caller knows to name what
|
|
2781
|
+
// the absence costs — see `undeclaredPolicyMessage`. The fallback arm is itself FOUR states,
|
|
2782
|
+
// and `policyReason` is what lets the message name the one it is in rather than assert the
|
|
2783
|
+
// commonest: this comment read "two different answers" while the code below had four.
|
|
2784
|
+
if (!policyDeclared && !fs.existsSync(policyFile)) {
|
|
2785
|
+
throw new CompileError(
|
|
2786
|
+
undeclaredPolicyMessage(
|
|
2787
|
+
policyFile,
|
|
2788
|
+
workspaceRoot,
|
|
2789
|
+
workspaceDir,
|
|
2790
|
+
{ named: namedRoots, discovery: () => discoverPackRoots(), forced },
|
|
2791
|
+
policyReason,
|
|
2792
|
+
),
|
|
2793
|
+
);
|
|
2794
|
+
}
|
|
2072
2795
|
const policy = readJson(policyFile, "the gate policy");
|
|
2073
2796
|
// The cascade's middle layer, composed before the policy is parsed so that a pack's fragment
|
|
2074
2797
|
// is validated by exactly the code that validates a hand-written rule.
|
|
@@ -2106,7 +2829,14 @@ export function run(argv, options = {}) {
|
|
|
2106
2829
|
// fails open, silently. That is the exact defect class this whole change exists to close,
|
|
2107
2830
|
// reintroduced through the cross-compile path. Found by the pre-commit checkpoint, which
|
|
2108
2831
|
// demonstrated it rather than reasoning about it.
|
|
2109
|
-
const columns = backends(parsed, {
|
|
2832
|
+
const columns = backends(parsed, {
|
|
2833
|
+
source,
|
|
2834
|
+
root: path.resolve(workspaceRoot),
|
|
2835
|
+
workspaceDir,
|
|
2836
|
+
// What resolved each pack, carried to the artifact so the file the rail compares says
|
|
2837
|
+
// which world compiled it (#264).
|
|
2838
|
+
packProvenance: contributions,
|
|
2839
|
+
});
|
|
2110
2840
|
|
|
2111
2841
|
// Printed before the backends, because a rule's provenance changes how its compiled line reads
|
|
2112
2842
|
// — and an unresolvable pack is a declaration the workspace believes it composed.
|
|
@@ -2162,7 +2892,34 @@ export function run(argv, options = {}) {
|
|
|
2162
2892
|
continue;
|
|
2163
2893
|
}
|
|
2164
2894
|
if (current !== column.artifact.text) {
|
|
2165
|
-
|
|
2895
|
+
// **Name the origin difference AT THE MOMENT OF FAILURE** (#264). A drift caused by
|
|
2896
|
+
// a shadowed pack was previously reported as a diff nobody could reproduce from the
|
|
2897
|
+
// files in front of them, because the deciding input was a directory outside the
|
|
2898
|
+
// repository — and the remedy the sentence prescribed, "Recompile", is the very
|
|
2899
|
+
// unpinned act that caused it. Read DEFENSIVELY: the artifact on disk is a file a
|
|
2900
|
+
// human may have edited, so anything unparseable leaves the plain sentence standing
|
|
2901
|
+
// rather than turning a drift report into a crash.
|
|
2902
|
+
let why = "";
|
|
2903
|
+
try {
|
|
2904
|
+
const onDisk = JSON.parse(current)?.$portulan?.packs;
|
|
2905
|
+
const mine = JSON.parse(column.artifact.text)?.$portulan?.packs;
|
|
2906
|
+
if (Array.isArray(onDisk) && Array.isArray(mine)) {
|
|
2907
|
+
const key = (p) => `${p.origin ?? "?"} ${p.version ?? "no version"}`;
|
|
2908
|
+
const byName = new Map(mine.map((p) => [p.pack, p]));
|
|
2909
|
+
const moved = onDisk
|
|
2910
|
+
.filter((p) => byName.has(p.pack) && key(byName.get(p.pack)) !== key(p))
|
|
2911
|
+
.map((p) => `\`${p.pack}\` was compiled from the ${key(p)} copy; this check reads the ${key(byName.get(p.pack))} one`);
|
|
2912
|
+
if (moved.length) {
|
|
2913
|
+
why =
|
|
2914
|
+
`\n ${moved.join("\n ")}` +
|
|
2915
|
+
"\n That is a different world, not a stale file: recompile with the root this check uses" +
|
|
2916
|
+
`\n — \`node cli/compile.mjs --workspace . --pack-root packs\` — rather than bare, or the drift returns.`;
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
} catch {
|
|
2920
|
+
// Unparseable or hand-edited. The plain sentence below is still true.
|
|
2921
|
+
}
|
|
2922
|
+
say(`RED — ${file} has drifted from ${policyFile}. Recompile.${why}`);
|
|
2166
2923
|
drifted += 1;
|
|
2167
2924
|
}
|
|
2168
2925
|
}
|