@sleepy_panda_srl/portulan 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +7 -6
  2. package/cli/README.md +142 -291
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1443 -1210
  6. package/cli/context.mjs +883 -0
  7. package/cli/control-chars.mjs +12 -301
  8. package/cli/discover.mjs +17 -574
  9. package/cli/doctor.mjs +225 -1071
  10. package/cli/drills.mjs +1107 -0
  11. package/cli/feedback.mjs +16 -237
  12. package/cli/finish.mjs +437 -0
  13. package/cli/form.mjs +644 -0
  14. package/cli/fuzz-shell.mjs +595 -0
  15. package/cli/gate.mjs +7 -198
  16. package/cli/goldens.mjs +311 -0
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +244 -564
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +778 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +764 -0
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +6 -68
  27. package/cli/pack-version.mjs +13 -248
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +5 -124
  30. package/cli/recipe-set.mjs +16 -240
  31. package/cli/release-eval.mjs +748 -0
  32. package/cli/review-meter.mjs +500 -0
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +424 -0
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +126 -454
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +582 -0
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +9 -74
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +183 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/librarian.md +2 -2
  52. package/core/skills/consolidate/SKILL.md +4 -2
  53. package/core/templates/gate-policy.md +27 -8
  54. package/core/templates/handoff.md +28 -15
  55. package/core/templates/memory-entry.md +1 -0
  56. package/evals/README.md +320 -0
  57. package/evals/releases/0.1.3.json +134 -0
  58. package/evals/releases/0.1.3.md +64 -0
  59. package/evals/releases/0.2.0.json +146 -0
  60. package/evals/releases/0.2.0.md +67 -0
  61. package/evals/releases/README.md +32 -0
  62. package/package.json +9 -2
  63. package/packs/rituals/checkpoints/README.md +9 -2
  64. package/packs/rituals/checkpoints/pack.json +1 -1
  65. package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
  66. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  67. package/spec/README.md +112 -6
  68. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  69. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  70. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  71. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  72. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  73. package/spec/migrations/0006-boot-card.mjs +77 -0
  74. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  75. package/spec/migrations/0008-card-reading.mjs +55 -0
  76. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  77. package/spec/migrations/0010-comment-rail.mjs +57 -0
  78. package/spec/migrations/README.md +45 -10
  79. package/spec/pack.schema.json +1 -1
  80. package/spec/slots.md +359 -23
  81. package/spec/workspace.schema.json +145 -4
@@ -0,0 +1,595 @@
1
+ #!/usr/bin/env node
2
+ // Grammar-aware fuzzing over this repository's two shell segmenters.
3
+ //
4
+ // Exit 0 green · 1 red · 2 could not run.
5
+
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { pathToFileURL } from "node:url";
9
+
10
+ import { CompileError, matchesRule } from "./compile.mjs";
11
+ import { CouldNotRun, matcherPath, yieldedRules } from "./goldens.mjs";
12
+
13
+ /** Spellings generated per (position × payload) cell. */
14
+ export const DEFAULT_CASES = 48;
15
+
16
+ export const DEFAULT_SEED = 20260825;
17
+
18
+ /** A payload per recognition: a redirected write and a named one can answer differently in one position. */
19
+ export const PAYLOADS = {
20
+ shell: { rule: "force-push-without-a-lease", tool: "Bash", what: "a gated force-push" },
21
+ "write-redirect": { rule: "change-the-constitution", tool: "Bash", what: "a redirection into the constitution" },
22
+ "write-named": { rule: "change-the-constitution", tool: "Bash", what: "a writer NAMING the constitution" },
23
+ };
24
+
25
+ /** mulberry32, a seeded 32-bit PRNG. */
26
+ export function prng(seed) {
27
+ let a = seed >>> 0;
28
+ return () => {
29
+ a = (a + 0x6d2b79f5) >>> 0;
30
+ let t = a;
31
+ t = Math.imul(t ^ (t >>> 15), t | 1);
32
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
33
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
34
+ };
35
+ }
36
+
37
+ const pick = (rand, xs) => xs[Math.floor(rand() * xs.length) % xs.length];
38
+
39
+ /** 32-bit FNV-1a. */
40
+ export function hash(text) {
41
+ let h = 0x811c9dc5;
42
+ for (let i = 0; i < text.length; i += 1) {
43
+ h ^= text.charCodeAt(i);
44
+ h = Math.imul(h, 0x01000193);
45
+ }
46
+ return h >>> 0;
47
+ }
48
+
49
+ // =============================================================================================
50
+ // The spelling axis — many ways to write one command
51
+ // =============================================================================================
52
+
53
+ /** No escapes inside `$'…'`: the matcher reads it as plain `'…'`, where bash would decode them. */
54
+ export function respell(word, rand) {
55
+ switch (pick(rand, ["bare", "double", "single", "ansi-c", "escape-one", "split-quote"])) {
56
+ case "double":
57
+ return `"${word}"`;
58
+ case "single":
59
+ return `'${word}'`;
60
+ case "ansi-c":
61
+ return `$'${word}'`;
62
+ case "escape-one": {
63
+ const i = Math.floor(rand() * word.length);
64
+ return `${word.slice(0, i)}\\${word.slice(i)}`;
65
+ }
66
+ case "split-quote": {
67
+ const i = 1 + Math.floor(rand() * Math.max(1, word.length - 1));
68
+ return `${word.slice(0, i)}"${word.slice(i)}"`;
69
+ }
70
+ default:
71
+ return word;
72
+ }
73
+ }
74
+
75
+ /** Spellings of `target` that a shell resolves to the same file. */
76
+ export function pathSpellings(target) {
77
+ const parts = target.split("/");
78
+ const head = parts.slice(0, -1).join("/");
79
+ const tail = parts[parts.length - 1];
80
+ return [
81
+ target,
82
+ `./${target}`,
83
+ `${head}/./${tail}`,
84
+ `${head}//${tail}`,
85
+ // bash resolves `..` against the filesystem, so the ground test creates `sibling` first.
86
+ `${head}/sibling/../${tail}`,
87
+ `./${head}/./${tail}`,
88
+ // A backslash-newline is a line continuation: both characters vanish and the word continues.
89
+ `${head}/\\\n${tail}`,
90
+ ];
91
+ }
92
+
93
+ export const WRITERS = {
94
+ "write-redirect": [(p) => `echo ok > ${p}`, (p) => `echo ok >> ${p}`, (p) => `cat /tmp/x > ${p}`, (p) => `printf ok 1> ${p}`],
95
+ "write-named": [
96
+ (p) => `cp /tmp/x ${p}`,
97
+ (p) => `mv /tmp/x ${p}`,
98
+ (p) => `tee ${p}`,
99
+ (p) => `dd if=/tmp/x of=${p}`,
100
+ (p) => `sed -i s/a/b/ ${p}`,
101
+ (p) => `install -m 644 /tmp/x ${p}`,
102
+ (p) => `truncate -s 0 ${p}`,
103
+ ],
104
+ };
105
+
106
+ export function writePayload(rand, kind, target = "docs/vision.md") {
107
+ const spelling = pick(rand, pathSpellings(target));
108
+ // Never requote a continuation: inside `'…'` a backslash-newline is two literal characters.
109
+ const quoted = spelling.includes("\\\n") ? spelling : respell(spelling, rand);
110
+ return pick(rand, WRITERS[kind])(quoted);
111
+ }
112
+
113
+ /** Only the arguments are respelt: the prefix is matched literally, and the gate map records that hole. */
114
+ export function shellPayload(rand, target = "git push --force") {
115
+ const args = pick(rand, [[], ["origin", "main"], ["origin", "HEAD"], ["--repo", "o/r"], ["origin", "main", "--quiet"]]);
116
+ return [target, ...args.map((a) => respell(a, rand))].join(" ");
117
+ }
118
+
119
+ // =============================================================================================
120
+ // The position axis — enumerated, recorded, and measured under bash
121
+ // =============================================================================================
122
+
123
+ /** Where the payload sits: bash executes it in a `command` ground and never in a `data` one. */
124
+ export const POSITIONS = [
125
+ { id: "bare", ground: "command", build: (p) => p },
126
+ { id: "after-semicolon", ground: "command", build: (p) => `ls; ${p}` },
127
+ { id: "after-andand", ground: "command", build: (p) => `ls && ${p}` },
128
+ { id: "after-oror", ground: "command", build: (p) => `false || ${p}` },
129
+ { id: "after-amp", ground: "command", build: (p) => `ls & ${p}` },
130
+ { id: "after-newline", ground: "command", build: (p) => `ls\n${p}` },
131
+ { id: "before-semicolon", ground: "command", build: (p) => `${p}; ls` },
132
+ { id: "brace-group", ground: "command", build: (p) => `{ ${p}; }` },
133
+ { id: "subshell", ground: "command", build: (p) => `( ${p} )` },
134
+ { id: "then-branch", ground: "command", build: (p) => `if true; then ${p}; fi` },
135
+ { id: "do-branch", ground: "command", build: (p) => `for i in 1; do ${p}; done` },
136
+ { id: "assignment-prefix", ground: "command", build: (p) => `FOO=bar ${p}` },
137
+ { id: "env-prefix", ground: "command", build: (p) => `env ${p}` },
138
+ { id: "nice-prefix", ground: "command", build: (p) => `nice ${p}` },
139
+ {
140
+ id: "sudo-prefix",
141
+ ground: "command",
142
+ bashSafe: false,
143
+ why: "sudo would prompt for a password and hang the suite. Its ground truth is the definition of the command: sudo runs its argument list.",
144
+ build: (p) => `sudo ${p}`,
145
+ },
146
+ { id: "leading-redirection", ground: "command", build: (p) => `2> /dev/null ${p}` },
147
+ { id: "leading-redirection-quoted-target", ground: "command", build: (p) => `2> "log file.txt" ${p}` },
148
+ { id: "leading-redirection-single-quoted-target", ground: "command", build: (p) => `2> 'log file.txt' ${p}` },
149
+ { id: "leading-redirection-escaped-space-target", ground: "command", build: (p) => `2> log\\ file.txt ${p}` },
150
+ { id: "leading-redirection-escaped-quote-target", ground: "command", build: (p) => `2> "log \\"q\\" file.txt" ${p}` },
151
+ // `shellWords` joins a backslash-CRLF; bash 3.2.57, 5.2.15 and 5.2.37 and zsh 5.9 split there.
152
+ {
153
+ id: "crlf-continuation-in-the-payload",
154
+ ground: "data",
155
+ groundByKind: { "write-redirect": "command" },
156
+ // `>>` appends rather than truncates, so its ground differs from its siblings' and it is refused.
157
+ carries: (p, kind) => kind !== "write-redirect" || !p.includes(">>"),
158
+ exitsNonZero: true,
159
+ why: "bash splits at the CRLF, so the fragment after it is run as a command and is not found — a non-zero exit is the measurement rather than a failure of it. A clobbering redirection on that fragment still fires, which is why `write-redirect` overrides the ground.",
160
+ build: (p) => p.replace(" ", " \\\r\n"),
161
+ },
162
+ { id: "after-heredoc", ground: "command", build: (p) => `cat <<'EOF'\nbody\nEOF\n${p}` },
163
+ { id: "after-comment-line", ground: "command", build: (p) => `# a note\n${p}` },
164
+ { id: "command-substitution", ground: "command", build: (p) => `echo $(${p})` },
165
+ { id: "quoted-command-substitution", ground: "command", build: (p) => `echo "$(${p})"` },
166
+ { id: "wrapper", ground: "command", build: (p) => `bash -c "${p}"` },
167
+ {
168
+ id: "wrapper-single-quoted",
169
+ ground: "command",
170
+ // POSIX `'…'` has no escapes, so a payload holding `'` would close the wrapper early.
171
+ carries: (p) => !p.includes("'"),
172
+ build: (p) => `sh -c '${p}'`,
173
+ },
174
+ { id: "wrapper-after-separator", ground: "command", build: (p) => `ls && bash -c "${p}"` },
175
+ { id: "wrapper-holding-separator", ground: "command", build: (p) => `bash -c "ls; ${p}"` },
176
+ { id: "wrapper-holding-separator-after-separator", ground: "command", build: (p) => `ls && bash -c "x; ${p}"` },
177
+ {
178
+ id: "nested-wrapper",
179
+ ground: "command",
180
+ carries: (p) => !p.includes("'"),
181
+ build: (p) => `bash -c "sh -c '${p}'"`,
182
+ },
183
+ { id: "single-quoted-echo", ground: "data", build: (p) => `echo '${p}'` },
184
+ { id: "double-quoted-echo", ground: "data", build: (p) => `echo "${p}"` },
185
+ { id: "heredoc-body", ground: "data", build: (p) => `cat <<'EOF'\n${p}\nEOF` },
186
+ { id: "same-line-comment", ground: "data", build: (p) => `ls # ${p}` },
187
+ { id: "comment-then-separator", ground: "data", build: (p) => `echo ok #; ${p}` },
188
+ { id: "wrapper-holding-comment", ground: "data", build: (p) => `bash -c "echo ok #; ${p}"` },
189
+ ];
190
+
191
+ /** The matcher's measured answer per cell; one that differs from ground truth names its `record`. */
192
+ export const EXPECT = {
193
+ "leading-redirection-quoted-target|shell": { answer: true },
194
+ "leading-redirection-quoted-target|write-redirect": { answer: true },
195
+ "leading-redirection-quoted-target|write-named": { answer: true },
196
+ "leading-redirection-single-quoted-target|shell": { answer: true },
197
+ "leading-redirection-single-quoted-target|write-redirect": { answer: true },
198
+ "leading-redirection-single-quoted-target|write-named": { answer: true },
199
+ "leading-redirection-escaped-space-target|shell": { answer: true },
200
+ "leading-redirection-escaped-space-target|write-redirect": { answer: true },
201
+ "leading-redirection-escaped-space-target|write-named": { answer: true },
202
+ "leading-redirection-escaped-quote-target|shell": { answer: true },
203
+ "leading-redirection-escaped-quote-target|write-redirect": { answer: true },
204
+ "leading-redirection-escaped-quote-target|write-named": { answer: true },
205
+ "crlf-continuation-in-the-payload|shell": {
206
+ answer: false,
207
+ why: "Correct, and NOT by the mechanism an earlier draft of this entry claimed. That draft said `commandSegments` \"splits at the newline, which is what bash does too\" — measured false: `commandSegments` consumes the pair exactly as `shellWords` does (compile.mjs, `The same CRLF pair, in the other reader`) and does not split. What actually happens is that the segment keeps its RAW source text, so the literal prefix compare meets `git \\\\<CRLF>push --force …` and fails. Right answer, wrong reason, which `a-stated-enforcer-must-be-the-real-one` counts as the same defect one size down. Found by the pre-commit checkpoint.",
208
+ },
209
+ "crlf-continuation-in-the-payload|write-redirect": {
210
+ answer: true,
211
+ why: "A TRUE POSITIVE, and an earlier draft of this entry recorded it as a false red in all three of its carriers. A shell applies a redirection BEFORE it looks the command up, so although bash splits at the CRLF and the command never runs, the clobbering redirection on the surviving fragment still fires and truncates the target to ZERO BYTES. First measured on bash 3.2.57 and **re-measured 2026-08-25 on bash 5.2.15 and 5.2.37 as well**: a file holding 6 bytes before is 0 bytes after, on all three, for `echo \\<CRLF>ok > t`, `printf \\<CRLF>ok 1> t` and `cat \\<CRLF>src > t` alike. Widened here in the same pass as its `write-named` sibling, because one cell widened beside a narrow sibling is the class this production's own comment records. So the gated effect occurs, the matcher denying it is right, and `groundByKind` says so. The append shape is refused by `carries`, since `>>` does not truncate. Unlike its sibling this cell is NOT affected by `0031`: deleting BOTH carriers in a scratch clone leaves this answer `true` — **but the reason is narrower than an earlier draft claimed, and that draft was wrong about the mechanism.** It said the redirection is read off raw segment text; there is no raw-text path, `shellWrites` iterates `shellSegments` built from `shellWords`. What actually saves this cell is WHERE this production puts the pair: after the head, so the split leaves `> t` in a LATER segment whose `redirects` still name the target. **A CRLF after the `>` loses its target instead** — the operator takes the escaped `\r` and the path becomes an ordinary word — which is exactly why one of `./compile.test.mjs`'s two assertions fails on removal. So this survives for THIS production, not for every redirect spelling. Reported by Copilot, rounds 2 and 3 on #342. The deletion moves four surfaces, none of them this one: the `write-named` cell here, the `a-CRLF-continuation` goldens fixture, two assertions in `./compile.test.mjs`, and `mutants` refusing with exit 2 over the now-reddened corpus.",
212
+ },
213
+ "crlf-continuation-in-the-payload|write-named": {
214
+ answer: true,
215
+ record: "cli/compile.mjs, `shellWords` — a `\\r\\n` after a backslash is consumed as a PAIR, a decision taken 2026-07-28",
216
+ why: "A FALSE RED, and the only one of the three. `shellWrites` reaches `shellSegments`, which reaches `shellWords`, which joins the pair — so the matcher sees a clean `cp /tmp/x docs/vision.md` and denies. bash splits, and the target is left byte-for-byte unchanged. **`cp` itself DOES run, and an earlier draft of this entry said it never does.** This production's `build` inserts the pair after the head, so the first fragment is `cp` carrying a malformed `\r` argument: it runs and fails (`missing destination file operand`). What never runs is the intended write — the target-bearing fragment is a separate command, and it is not found. Reported by Copilot, round 1 on #342. Fail-closed and worth one prompt. **The reachability claim is RETIRED**: `compile.mjs`'s comment used to say this spelling made the constitution 'reachable by editing the file on Windows', and it no longer does, and that comment now carries the measurement instead. Re-measured 2026-08-25 on bash 3.2.57, 5.2.15 and 5.2.37, plus zsh 5.9 and the measured host's `/bin/sh` (bash 3.2.57 in POSIX mode there — NOT the `dash`/`busybox` families, which were not measured), with a neutral target: none joins the pair, and the `cp` shape leaves its target byte-for-byte unchanged on all five. Two things bound that: no Windows-side bash was measured — git-bash, MSYS2, Cygwin, WSL — and no bash 4.x. **Still not repaired here, and for a narrower reason than the earlier draft gave.** That draft said only bash 3.2.57 was available, which stopped being true; what stands is that the repair direction is fail-OPEN on a gate matcher, so it is asked at `../.portulan/proposals/0031-a-continuation-no-shell-joins.md` and not taken.",
217
+ },
218
+ // ============================== shell
219
+ "bare|shell": { answer: true },
220
+ "after-semicolon|shell": { answer: true },
221
+ "after-andand|shell": { answer: true },
222
+ "after-oror|shell": { answer: true },
223
+ "after-amp|shell": { answer: true },
224
+ "after-newline|shell": { answer: true },
225
+ "before-semicolon|shell": { answer: true },
226
+ "brace-group|shell": {
227
+ answer: false,
228
+ record: ".portulan/gate-map.md honest holes, entry 2 — leaders",
229
+ why: "A brace group. `commandSegments` reads a list of separators, not a grammar, so `{` occupies the head position. Caught on both write payloads, which reach `shellSegments` and its SEGMENT_LEADERS table — the two-segmenter asymmetry the gate corpus already records.",
230
+ },
231
+ "subshell|shell": { answer: true },
232
+ "then-branch|shell": {
233
+ answer: false,
234
+ record: ".portulan/gate-map.md honest holes, entry 2 — leaders",
235
+ why: "The `then` leader, on the segmenter with no leader table.",
236
+ },
237
+ "do-branch|shell": {
238
+ answer: false,
239
+ record: ".portulan/gate-map.md honest holes, entry 2 — leaders",
240
+ why: "The `do` leader, same half of the same hole.",
241
+ },
242
+ "assignment-prefix|shell": {
243
+ answer: false,
244
+ record: ".portulan/gate-map.md honest holes, entry 2 — leaders",
245
+ why: "An assignment occupies the head position. `commandSegments` does not read ASSIGNMENT; `shellSegments` does.",
246
+ },
247
+ "env-prefix|shell": {
248
+ answer: false,
249
+ record: ".portulan/gate-map.md honest holes, entry 2 — leaders",
250
+ why: "A command prefix, open BY DECISION on the shell matcher: a named table of prefixes has no natural edge — `nice`, `time`, `nohup`, `timeout`, `command`, `stdbuf`, `doas` — and one omission buys the false confidence a hole list exists to deny.",
251
+ },
252
+ "nice-prefix|shell": {
253
+ answer: false,
254
+ record: ".portulan/gate-map.md honest holes, entry 2 — leaders",
255
+ why: "The same decision at a second prefix, so the record is not read as being about `env` alone.",
256
+ },
257
+ "sudo-prefix|shell": {
258
+ answer: false,
259
+ record: ".portulan/gate-map.md honest holes, entry 2 — leaders",
260
+ why: "The same decision at the prefix a reader is likeliest to reach for.",
261
+ },
262
+ "leading-redirection|shell": { answer: true },
263
+ "after-heredoc|shell": { answer: true },
264
+ "after-comment-line|shell": { answer: true },
265
+ "command-substitution|shell": { answer: true },
266
+ "quoted-command-substitution|shell": {
267
+ answer: false,
268
+ record: ".portulan/gate-map.md honest holes, entry 1 — spellings neither layer sees",
269
+ why: "`\"$(…)\"` is a command bash runs, and `commandSegments` steps over the parentheses inside a quoted run so nothing splits. The BARE `$(…)` form is CAUGHT, because `(` and `)` are in OPERATOR — one concept, two spellings, opposite answers, which is why this table is keyed on the spelling and never on the idea.",
270
+ },
271
+ "wrapper|shell": { answer: true },
272
+ "wrapper-single-quoted|shell": { answer: true },
273
+ "wrapper-after-separator|shell": { answer: true },
274
+ "wrapper-holding-separator|shell": { answer: true },
275
+ "wrapper-holding-separator-after-separator|shell": { answer: true },
276
+ "nested-wrapper|shell": {
277
+ answer: false,
278
+ record: ".portulan/gate-map.md honest holes, entry 1 — the hook peels ONE wrapper",
279
+ why: "Two wrappers. The documented and asserted limit of the unwrap budget, held deliberately: an earlier draft of the segment composition peeled twice and the suite caught it.",
280
+ },
281
+ "single-quoted-echo|shell": { answer: false },
282
+ "double-quoted-echo|shell": { answer: false },
283
+ "heredoc-body|shell": { answer: false },
284
+ "wrapper-holding-comment|shell": {
285
+ answer: true,
286
+ record: "cli/compile.mjs, `commandSegments` — `#` does not start a comment, a decision declined on Copilot review of #60",
287
+ why: "A FALSE RED on data, and one the 2026-08-25 segment repair INHERITED rather than introduced: reading a spelling's segments carries the declined `#` decision one level into a wrapper. The pre-commit checkpoint's differential of the staged matcher against HEAD's measured this as the only new false-red class the repair produced, and `comment-then-separator` could not see it — that entry pins the top-level spelling alone.",
288
+ },
289
+ "same-line-comment|shell": { answer: false },
290
+ "comment-then-separator|shell": {
291
+ answer: true,
292
+ record: "cli/compile.mjs, `commandSegments` — `#` does not start a comment, a decision declined on Copilot review of #60",
293
+ why: "A FALSE RED on data. `#` is not read as a comment, so the `;` behind it splits and the segment after it is a gated command. Taken deliberately: deciding where a comment BEGINS is the part that goes wrong, and getting it wrong turns `echo \"a#b\"; git push --force …` — a real gated command — into a false GREEN.",
294
+ },
295
+ // ============================== write-redirect
296
+ "bare|write-redirect": { answer: true },
297
+ "after-semicolon|write-redirect": { answer: true },
298
+ "after-andand|write-redirect": { answer: true },
299
+ "after-oror|write-redirect": { answer: true },
300
+ "after-amp|write-redirect": { answer: true },
301
+ "after-newline|write-redirect": { answer: true },
302
+ "before-semicolon|write-redirect": { answer: true },
303
+ "brace-group|write-redirect": { answer: true },
304
+ "subshell|write-redirect": { answer: true },
305
+ "then-branch|write-redirect": { answer: true },
306
+ "do-branch|write-redirect": { answer: true },
307
+ "assignment-prefix|write-redirect": { answer: true },
308
+ "env-prefix|write-redirect": { answer: true },
309
+ "nice-prefix|write-redirect": { answer: true },
310
+ "sudo-prefix|write-redirect": { answer: true },
311
+ "leading-redirection|write-redirect": { answer: true },
312
+ "after-heredoc|write-redirect": { answer: true },
313
+ "after-comment-line|write-redirect": { answer: true },
314
+ "command-substitution|write-redirect": { answer: true },
315
+ "quoted-command-substitution|write-redirect": {
316
+ answer: false,
317
+ record: ".portulan/gate-map.md honest holes, entry 1 — spellings neither layer sees",
318
+ why: "The same quote-loop step-over, reached through the write branch.",
319
+ },
320
+ "wrapper|write-redirect": { answer: true },
321
+ "wrapper-single-quoted|write-redirect": { answer: true },
322
+ "wrapper-after-separator|write-redirect": { answer: true },
323
+ "wrapper-holding-separator|write-redirect": { answer: true },
324
+ "wrapper-holding-separator-after-separator|write-redirect": { answer: true },
325
+ "nested-wrapper|write-redirect": {
326
+ answer: false,
327
+ record: ".portulan/gate-map.md honest holes, entry 1 — the hook peels ONE wrapper",
328
+ why: "The same limit, reached through the write branch.",
329
+ },
330
+ "single-quoted-echo|write-redirect": { answer: false },
331
+ "double-quoted-echo|write-redirect": { answer: false },
332
+ "heredoc-body|write-redirect": { answer: false },
333
+ "wrapper-holding-comment|write-redirect": {
334
+ answer: true,
335
+ record: "cli/compile.mjs, `commandSegments` — `#` does not start a comment, a decision declined on Copilot review of #60",
336
+ why: "The same inherited false red, reached through the redirection recognition.",
337
+ },
338
+ "same-line-comment|write-redirect": {
339
+ answer: true,
340
+ record: "cli/compile.mjs, `commandSegments` — `#` does not start a comment, a decision declined on Copilot review of #60",
341
+ why: "A FALSE RED on data, and only on this payload: a redirection is read off the whole segment, so `ls # echo ok > docs/vision.md` is caught while `ls # cp /tmp/x docs/vision.md` is not — there the head is `ls`, which writes nothing. The divergence between the write matchers two recognitions, measured rather than reasoned.",
342
+ },
343
+ "comment-then-separator|write-redirect": {
344
+ answer: true,
345
+ record: "cli/compile.mjs, `commandSegments` — `#` does not start a comment, a decision declined on Copilot review of #60",
346
+ why: "The same declined decision, reached through the redirection recognition.",
347
+ },
348
+ // ============================== write-named
349
+ "bare|write-named": { answer: true },
350
+ "after-semicolon|write-named": { answer: true },
351
+ "after-andand|write-named": { answer: true },
352
+ "after-oror|write-named": { answer: true },
353
+ "after-amp|write-named": { answer: true },
354
+ "after-newline|write-named": { answer: true },
355
+ "before-semicolon|write-named": { answer: true },
356
+ "brace-group|write-named": { answer: true },
357
+ "subshell|write-named": { answer: true },
358
+ "then-branch|write-named": { answer: true },
359
+ "do-branch|write-named": { answer: true },
360
+ "assignment-prefix|write-named": { answer: true },
361
+ "env-prefix|write-named": { answer: true },
362
+ "nice-prefix|write-named": { answer: true },
363
+ "sudo-prefix|write-named": { answer: true },
364
+ "leading-redirection|write-named": { answer: true },
365
+ "after-heredoc|write-named": { answer: true },
366
+ "after-comment-line|write-named": { answer: true },
367
+ "command-substitution|write-named": { answer: true },
368
+ "quoted-command-substitution|write-named": {
369
+ answer: false,
370
+ record: ".portulan/gate-map.md honest holes, entry 1 — spellings neither layer sees",
371
+ why: "The same step-over, at the other write recognition.",
372
+ },
373
+ "wrapper|write-named": { answer: true },
374
+ "wrapper-single-quoted|write-named": { answer: true },
375
+ "wrapper-after-separator|write-named": { answer: true },
376
+ "wrapper-holding-separator|write-named": { answer: true },
377
+ "wrapper-holding-separator-after-separator|write-named": { answer: true },
378
+ "nested-wrapper|write-named": {
379
+ answer: false,
380
+ record: ".portulan/gate-map.md honest holes, entry 1 — the hook peels ONE wrapper",
381
+ why: "The same limit, at the other write recognition.",
382
+ },
383
+ "single-quoted-echo|write-named": { answer: false },
384
+ "double-quoted-echo|write-named": { answer: false },
385
+ "heredoc-body|write-named": { answer: false },
386
+ "wrapper-holding-comment|write-named": {
387
+ answer: true,
388
+ record: "cli/compile.mjs, `commandSegments` — `#` does not start a comment, a decision declined on Copilot review of #60",
389
+ why: "The same inherited false red, reached through the named-argument recognition. Caught here where the uncomposed `same-line-comment` case is NOT, because inside the wrapper the payload leads its own segment and its head is the writer rather than `ls`.",
390
+ },
391
+ "same-line-comment|write-named": { answer: false },
392
+ "comment-then-separator|write-named": {
393
+ answer: true,
394
+ record: "cli/compile.mjs, `commandSegments` — `#` does not start a comment, a decision declined on Copilot review of #60",
395
+ why: "The same declined decision, reached through the named-argument recognition.",
396
+ },
397
+ };
398
+
399
+ export const correctFor = (ground) => ground === "command";
400
+
401
+ /** How a payload shows its effect: a redirection truncates its target even when its command never runs. */
402
+ export const EFFECT = { shell: "ran", "write-redirect": "touched", "write-named": "ran" };
403
+
404
+ export const groundFor = (position, kind) => position.groundByKind?.[kind] ?? position.ground;
405
+
406
+ export const ruleKind = (kind) => (kind === "shell" ? "shell" : "write");
407
+
408
+ export function generate(position, kind, rand) {
409
+ const carries = position.carries ?? (() => true);
410
+ for (let attempt = 0; attempt < 64; attempt += 1) {
411
+ const payload = kind === "shell" ? shellPayload(rand) : writePayload(rand, kind);
412
+ if (carries(payload, kind)) return { command: position.build(payload), payload };
413
+ }
414
+ throw new CouldNotRun(
415
+ `position \`${position.id}\` refused 64 consecutive ${kind} payloads. Its \`carries\` predicate and the ` +
416
+ `spelling generator disagree about what it can hold — one of the two is wrong, and a thinned cell is ` +
417
+ `not an answer`,
418
+ );
419
+ }
420
+
421
+ /** A finding as a case to paste into `evals/goldens/gates/<rule-id>.json`, expecting what the matcher did. */
422
+ export function asCase(position, kind, ruleId, command, actual, index) {
423
+ return {
424
+ id: `fuzz-${position.id}-${kind}-${index}`,
425
+ class: correctFor(groundFor(position, kind)) === actual ? "holds" : "documented-hole",
426
+ tool: PAYLOADS[kind].tool,
427
+ path: matcherPath(ruleKind(kind), PAYLOADS[kind].tool),
428
+ expect: actual,
429
+ why: `Generated by cli/fuzz-shell.mjs: ${PAYLOADS[kind].what} in the \`${position.id}\` position, which is a ${groundFor(position, kind)} position for this payload. REVIEW THIS BEFORE COMMITTING IT — a generated case records what the matcher did, not what it should do.`,
430
+ ...(correctFor(groundFor(position, kind)) === actual ? {} : { hole: "UNRECORDED — name the record this belongs to, or repair the matcher" }),
431
+ input: { command },
432
+ _rule: ruleId,
433
+ };
434
+ }
435
+
436
+ function usage() {
437
+ return [
438
+ "usage: node cli/fuzz-shell.mjs [--check] [--workspace <dir>] [--pack-root <dir>] [--seed <n>] [--cases <n>]",
439
+ "",
440
+ " Generates spellings of one gated command and one constitution write in every position the",
441
+ " grammar knows, and holds both segmenters to the answer recorded for that position.",
442
+ "",
443
+ ` --seed defaults to ${DEFAULT_SEED} and --cases to ${DEFAULT_CASES} spellings per cell.`,
444
+ " Both are printed on every run, green included, so a green is as reproducible as a red.",
445
+ "",
446
+ " Exit 0 green · 1 red · 2 could not run.",
447
+ ].join("\n");
448
+ }
449
+
450
+ export function run(argv = [], { stdout = process.stdout, stderr = process.stderr, cwd = process.cwd() } = {}) {
451
+ const say = (line = "") => stdout.write(`${line}\n`);
452
+ if (argv.includes("--help") || argv.includes("-h")) {
453
+ say(usage());
454
+ return 0;
455
+ }
456
+ let named = cwd;
457
+ let packRoots = null;
458
+ let seed = DEFAULT_SEED;
459
+ let cases = DEFAULT_CASES;
460
+ try {
461
+ for (let i = 0; i < argv.length; i += 1) {
462
+ if (argv[i] === "--check") continue;
463
+ if (argv[i] === "--workspace") {
464
+ named = argv[i + 1];
465
+ i += 1;
466
+ if (named === undefined) throw new CouldNotRun("--workspace needs a directory");
467
+ } else if (argv[i] === "--seed" || argv[i] === "--cases") {
468
+ const flag = argv[i];
469
+ const raw = argv[i + 1];
470
+ i += 1;
471
+ if (raw === undefined || !/^[0-9]+$/.test(raw)) throw new CouldNotRun(`${flag} needs a non-negative integer, not ${JSON.stringify(raw)}`);
472
+ const value = Number(raw);
473
+ if (flag === "--cases" && value === 0) throw new CouldNotRun("--cases 0 would generate nothing and report green having tested nothing");
474
+ if (flag === "--seed") seed = value;
475
+ else cases = value;
476
+ } else if (argv[i] === "--pack-root") {
477
+ const root = argv[i + 1];
478
+ i += 1;
479
+ if (root === undefined || root.startsWith("-")) throw new CouldNotRun("--pack-root needs a directory");
480
+ let stat = null;
481
+ try {
482
+ stat = fs.statSync(root);
483
+ } catch (cause) {
484
+ throw new CouldNotRun(`--pack-root ${root} cannot be read — ${cause.code ?? cause.message}`);
485
+ }
486
+ if (!stat.isDirectory()) throw new CouldNotRun(`--pack-root ${root} is not a directory`);
487
+ (packRoots ??= []).push(path.resolve(root));
488
+ } else throw new CouldNotRun(`unknown argument ${JSON.stringify(argv[i])}`);
489
+ }
490
+
491
+ const { rules, unresolved } = yieldedRules(named, { packRoots });
492
+ for (const u of unresolved) say(`pack ${u.name} UNRESOLVED — ${u.why}; its gate fragments are not in this run`);
493
+ const byId = new Map(rules.map((r) => [r.id, r]));
494
+
495
+ for (const [kind, p] of Object.entries(PAYLOADS)) {
496
+ if (!byId.has(p.rule)) {
497
+ throw new CouldNotRun(
498
+ `the ${kind} payload targets \`${p.rule}\`, which the yielded policy does not declare. ` +
499
+ `Either the rule was renamed and this file was not, or half this fuzzer has nothing to attack`,
500
+ );
501
+ }
502
+ }
503
+ const keys = new Set(Object.keys(EXPECT));
504
+ const missing = [];
505
+ for (const position of POSITIONS) {
506
+ for (const kind of Object.keys(PAYLOADS)) {
507
+ const key = `${position.id}|${kind}`;
508
+ if (!keys.has(key)) missing.push(key);
509
+ keys.delete(key);
510
+ }
511
+ }
512
+ if (missing.length) throw new CouldNotRun(`EXPECT records no answer for ${missing.join(", ")} — every position × payload cell must be recorded`);
513
+ if (keys.size) throw new CouldNotRun(`EXPECT records ${[...keys].join(", ")}, which POSITIONS does not generate — a recorded answer for a cell nobody produces is a claim about nothing`);
514
+ for (const [key, e] of Object.entries(EXPECT)) {
515
+ const position = POSITIONS.find((p) => p.id === key.split("|")[0]);
516
+ const kind = key.split("|")[1];
517
+ const ground = groundFor(position, kind);
518
+ if (e.answer !== correctFor(ground) && (typeof e.record !== "string" || e.record.trim() === "")) {
519
+ throw new CouldNotRun(
520
+ `EXPECT[${key}] records ${e.answer} where a ${ground} position demands ${correctFor(ground)} for this payload, ` +
521
+ `and names no \`record\`. A divergence nobody documented is a hole nobody knows about`,
522
+ );
523
+ }
524
+ }
525
+
526
+ say(`fuzz-shell: seed ${seed} · ${cases} spelling(s) per cell · ${POSITIONS.length} position(s) × ${Object.keys(PAYLOADS).length} payload(s)`);
527
+
528
+ const findings = [];
529
+ let generated = 0;
530
+ const drift = new Map();
531
+ for (const position of POSITIONS) {
532
+ for (const [kind, p] of Object.entries(PAYLOADS)) {
533
+ const expected = EXPECT[`${position.id}|${kind}`];
534
+ const rule = byId.get(p.rule);
535
+ // Seeded per cell from its full identity, so one cell's budget never re-rolls another's.
536
+ const rand = prng(seed ^ hash(`${position.id}|${kind}`));
537
+ for (let i = 0; i < cases; i += 1) {
538
+ const { command } = generate(position, kind, rand);
539
+ generated += 1;
540
+ const actual = matchesRule(rule, p.tool, { command });
541
+ if (actual === expected.answer) continue;
542
+ const key = `${position.id}|${kind}`;
543
+ if (drift.has(key)) continue; // one finding per cell; the rest are the same defect
544
+ drift.set(key, true);
545
+ findings.push({ position, kind, rule: p.rule, command, actual, expected: expected.answer, index: i });
546
+ }
547
+ }
548
+ }
549
+
550
+ say(`fuzz-shell: ${generated} generated command(s) over ${Object.keys(EXPECT).length} recorded cell(s)`);
551
+
552
+ if (findings.length) {
553
+ for (const f of findings) {
554
+ const groundWanted = correctFor(groundFor(f.position, f.kind));
555
+ const headline =
556
+ f.actual === groundWanted
557
+ ? `the recorded divergence at \`${f.position.id}\` (${f.kind}) has CLOSED — the matcher now answers ${f.actual}, which is what a ${groundFor(f.position, f.kind)} position demands for this payload. That is good news and the record must absorb it: update EXPECT, and update the record it cites`
558
+ : f.actual
559
+ ? `FALSE RED: a spelling in the \`${f.position.id}\` (${f.kind}) cell answers true where the cell records ${f.expected}. Bash does not run the payload here`
560
+ : `GATE BYPASS: a spelling in the \`${f.position.id}\` (${f.kind}) cell answers false where the cell records ${f.expected}. Bash DOES run the payload here`;
561
+ stderr.write(`fuzz-shell: ${headline}\n`);
562
+ stderr.write(` seed ${seed}, case ${f.index}\n`);
563
+ stderr.write(` as a corpus case for evals/goldens/gates/${f.rule}.json:\n`);
564
+ const { _rule, ...body } = asCase(f.position, f.kind, f.rule, f.command, f.actual, f.index);
565
+ for (const line of JSON.stringify(body, null, 2).split("\n")) stderr.write(` ${line}\n`);
566
+ }
567
+ stderr.write(`RED — ${findings.length} cell(s) disagree with the recorded grammar\n`);
568
+ return 1;
569
+ }
570
+ say("GREEN — every generated spelling answered as its position records, on both matchers");
571
+ say("fuzz-shell: this holds the SEGMENTERS to a grammar; what a gate should cover is a policy question, not this rail's");
572
+ return 0;
573
+ } catch (error) {
574
+ if (error instanceof CouldNotRun || error instanceof CompileError) {
575
+ stderr.write(`fuzz-shell: ${error.message}\n`);
576
+ return 2;
577
+ }
578
+ stderr.write(`fuzz-shell: could not finish fuzzing — ${error?.stack ?? error}\n`);
579
+ return 2;
580
+ }
581
+ }
582
+
583
+ // Compared as URLs so a space in the path still matches, and through realpath for a symlinked launch.
584
+ function isMain() {
585
+ const invoked = process.argv[1];
586
+ if (!invoked) return false;
587
+ if (import.meta.url === pathToFileURL(invoked).href) return true;
588
+ try {
589
+ return import.meta.url === pathToFileURL(fs.realpathSync(invoked)).href;
590
+ } catch {
591
+ return false;
592
+ }
593
+ }
594
+
595
+ if (isMain()) process.exitCode = run(process.argv.slice(2));