@sleepy_panda_srl/portulan 0.1.2 → 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/cli/compile.mjs CHANGED
@@ -70,7 +70,11 @@ export class CompileError extends Error {
70
70
  // Workspace Definition. A fresh reviewer at milestone 6 session 1 sight-read this as a carrier left
71
71
  // stale by the 2.5 → 2.6 bump and had to measure to clear it. It is correctly untouched by any
72
72
  // Workspace Definition bump; renamed to say so, because the next reader may not measure.
73
- const KNOWN_GATE_POLICY_SPECS = new Set(["2.1", "2.2"]);
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"]);
74
78
 
75
79
  const TIERS = new Set(["auto", "propose", "gated", "prohibited"]);
76
80
 
@@ -81,8 +85,12 @@ const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
81
85
  const FILE_PATH = /^[^#?:/]([^#?:]*[^#?:/])?$/;
82
86
 
83
87
  /** The tools that can write a path, and the tools that can read one. */
84
- const WRITE_TOOLS = ["Edit", "Write", "NotebookEdit"];
85
- const READ_TOOLS = ["Read"];
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"];
86
94
 
87
95
  // ===========================================================================================
88
96
  // 1. Parse: policy -> validated, normalised rules. NO backend opinion lives here.
@@ -474,7 +482,10 @@ function stripHeredocs(command) {
474
482
  }
475
483
 
476
484
  /** A shell command split into words, with unquoted operators kept as words of their own. */
477
- function shellWords(command) {
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) {
478
489
  const words = [];
479
490
  let text = "";
480
491
  let open = false; // a word has begun — so an empty `""` argument survives as a word
@@ -535,11 +546,74 @@ function shellWords(command) {
535
546
  // A backslash-newline is a line continuation: both characters vanish, and the word
536
547
  // continues on the next line. Appending the newline instead would have glued it into the
537
548
  // middle of a path, which no target matches.
538
- // `\r\n` is consumed as a PAIR. Skipping one character left the `\n` behind, and a
539
- // newline is an operator here, so a CRLF continuation flushed the word instead of
540
- // continuing it: `cp /tmp/x \\<CRLF>docs/vision.md` stepped aside where the LF spelling
541
- // answers `deny` — the constitution, reachable by editing the file on Windows. Measured
542
- // both ways, 2026-07-28. Found by Copilot review on #60.
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.
543
617
  if (command[i + 1] === "\r" && command[i + 2] === "\n") {
544
618
  i += 2;
545
619
  continue;
@@ -648,6 +722,85 @@ function normalisePath(p) {
648
722
  return (absolute ? "/" : "") + out.join("/");
649
723
  }
650
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
+
651
804
  /**
652
805
  * The directories a target lives under, as exact paths: `a/b/c.md` -> `a`, `a/b`.
653
806
  *
@@ -718,6 +871,8 @@ function commandSegments(raw) {
718
871
  const out = [];
719
872
  let start = 0;
720
873
  let quote = null;
874
+ // The index of the character a backslash most recently turned into data. See the `\\` branch.
875
+ let escaped = -1;
721
876
  for (let i = 0; i < command.length; i += 1) {
722
877
  const c = command[i];
723
878
  if (quote) {
@@ -747,7 +902,28 @@ function commandSegments(raw) {
747
902
  // of one — so this is fixed for the reason the session kept re-learning rather than for a
748
903
  // failing case: one carrier corrected and its sibling left is how the last three defects
749
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.
750
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;
751
927
  continue;
752
928
  }
753
929
  // `#` is NOT treated as starting a comment, and that is a decision rather than an oversight.
@@ -764,13 +940,91 @@ function commandSegments(raw) {
764
940
  //
765
941
  // Both spellings are asserted in the suite so this stays a choice rather than drift.
766
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;
767
957
  if (";|&()\n\r".includes(c)) {
768
958
  out.push(command.slice(start, i));
769
959
  start = i + 1;
770
960
  }
771
961
  }
772
962
  out.push(command.slice(start));
773
- 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
+ }
774
1028
  }
775
1029
 
776
1030
  /**
@@ -873,8 +1127,32 @@ export function matchesRule(rule, tool, input = {}) {
873
1127
  // wrapper and made `bash -c "bash -c 'git push origin HEAD'"` match, contradicting hole 1's
874
1128
  // documented and asserted limit. The suite caught it, which is what that counterexample test
875
1129
  // is for. Reading the raw command in both branches keeps the budget at exactly one unwrap.
876
- const segments = commandSegments(input.command);
877
- return spellings(input.command).some(hit) || segments.some((seg) => spellings(seg).some(hit));
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));
878
1156
  }
879
1157
  if (typeof action.write === "string") {
880
1158
  if (WRITE_TOOLS.includes(tool)) return matchesPath(input.file_path ?? input.notebook_path, action.write);
@@ -970,6 +1248,16 @@ const HOST_TIER_NOT_A_GATE = {
970
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",
971
1249
  };
972
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
+
973
1261
  /**
974
1262
  * Translate the policy into a Claude Code settings object.
975
1263
  *
@@ -1034,8 +1322,8 @@ export function claudeCode(parsed, options = {}) {
1034
1322
  pinned.push({ file, abs });
1035
1323
  return `"${abs}"`;
1036
1324
  };
1037
- const runner = options.runner ?? spell("gate.mjs");
1038
- const stopRunner = options.stopRunner ?? spell("stop-gate.mjs");
1325
+ const runner = options.runner ?? spell(HOOK_RUNNERS[0]);
1326
+ const stopRunner = options.stopRunner ?? spell(HOOK_RUNNERS[1]);
1039
1327
 
1040
1328
  const compiled = [];
1041
1329
  const refused = [];
@@ -1043,6 +1331,8 @@ export function claudeCode(parsed, options = {}) {
1043
1331
  const ask = [];
1044
1332
  const matchers = new Set();
1045
1333
  const shellWriteGates = [];
1334
+ // Write gates whose single emitted permission pattern is `Edit(path)` — see the note they produce.
1335
+ const editCoveredGates = [];
1046
1336
 
1047
1337
  for (const rule of parsed.rules) {
1048
1338
  if (!HOST_GATE_TIERS.has(rule.tier)) {
@@ -1056,6 +1346,60 @@ export function claudeCode(parsed, options = {}) {
1056
1346
  refused.push({ id: rule.id, tier: rule.tier, why: rule.target });
1057
1347
  continue;
1058
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
+ }
1059
1403
  const into = rule.tier === "prohibited" ? deny : ask;
1060
1404
  const emitted = []; // permission patterns — the layer that cannot fail open
1061
1405
  const hookOnly = []; // coverage the permission DSL cannot express, carried by the hook alone
@@ -1063,11 +1407,36 @@ export function claudeCode(parsed, options = {}) {
1063
1407
  emitted.push(`Bash(${rule.target}:*)`);
1064
1408
  matchers.add("Bash");
1065
1409
  } else {
1066
- for (const tool of rule.kind === "write" ? WRITE_TOOLS : READ_TOOLS) {
1067
- emitted.push(pattern(tool, rule.target));
1068
- matchers.add(tool);
1069
- }
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);
1070
1438
  if (rule.kind === "write") {
1439
+ editCoveredGates.push(rule.id);
1071
1440
  // The hook is wired for Bash so that a shell spelling of this write reaches
1072
1441
  // `matchesRule`, which now answers for it. **This line is the load-bearing half.**
1073
1442
  // The matcher alone would be inert in any workspace whose policy declares no shell
@@ -1163,6 +1532,21 @@ export function claudeCode(parsed, options = {}) {
1163
1532
  `project, or pass \`--runner\`/\`--stop-runner\` to name a path you control`,
1164
1533
  );
1165
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
+ }
1166
1550
  if (shellWriteGates.length) {
1167
1551
  notes.push(
1168
1552
  `${shellWriteGates.length} write gate(s) — ${shellWriteGates.join(", ")} — also match a Bash command that writes the ` +
@@ -1487,7 +1871,76 @@ export function matrix(parsed, options = {}) {
1487
1871
  // ===========================================================================================
1488
1872
 
1489
1873
  /**
1490
- * Where a workspace's gate policy lives.
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.
1491
1944
  *
1492
1945
  * Read from the manifest's top-level `gates` key, because that is what the key MEANS —
1493
1946
  * `../spec/slots.md` calls it "a path to a JSON file the enforcement compiler reads", and `doctor`
@@ -1501,26 +1954,73 @@ export function matrix(parsed, options = {}) {
1501
1954
  * refusing it would make the key required, which is a spec change nobody decided.
1502
1955
  */
1503
1956
  export function policyPath(workspaceRoot, workspaceDir = ".portulan") {
1504
- const base = path.join(workspaceRoot, workspaceDir);
1505
- const manifest = path.join(base, "workspace.json");
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;
1506
1996
  try {
1507
- const declared = JSON.parse(fs.readFileSync(manifest, "utf8")).gates;
1508
- // Validated against the schema's `filePath` shape before it is used, and containment is
1509
- // checked after resolution rather than by pattern alone — a `../` chain passes any regex and
1510
- // still escapes. Without this, a malformed or hand-edited manifest turns a hook that runs on
1511
- // EVERY tool call into an arbitrary file read outside the workspace. `doctor` would refuse such
1512
- // a manifest, but this runner must not depend on `doctor` having been run: the two tools have
1513
- // no ordering between them, and the schema is the contract, not the sequence. Found by review.
1514
- if (typeof declared === "string" && declared.trim() && FILE_PATH.test(declared)) {
1515
- const resolved = path.resolve(base, declared);
1516
- const inside = path.relative(base, resolved);
1517
- if (inside && !inside.startsWith("..") && !path.isAbsolute(inside)) return resolved;
1518
- }
1997
+ composed = packContributions(workspaceRoot, workspaceDir, packOptions);
1519
1998
  } catch {
1520
- // No manifest, or unreadable. `doctor` is the tool that judges a manifest; this one only needs
1521
- // to know where the policy is, and the default is where it is when nothing says otherwise.
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.
1522
2003
  }
1523
- return path.join(workspaceRoot, workspaceDir, "gates.json");
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 ");
1524
2024
  }
1525
2025
 
1526
2026
  // ===========================================================================================
@@ -2273,7 +2773,25 @@ export function run(argv, options = {}) {
2273
2773
  // The workspace may be named as a repository root or as the workspace directory itself, and the
2274
2774
  // second is how a feed-side workspace is reachable at all — see `resolveWorkspace`.
2275
2775
  const { workspaceRoot, workspaceDir } = resolveWorkspace(named);
2276
- const policyFile = policyPath(workspaceRoot, workspaceDir);
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
+ }
2277
2795
  const policy = readJson(policyFile, "the gate policy");
2278
2796
  // The cascade's middle layer, composed before the policy is parsed so that a pack's fragment
2279
2797
  // is validated by exactly the code that validates a hand-written rule.