@sleepy_panda_srl/portulan 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/cli/README.md +145 -358
- package/cli/advisory.mjs +354 -0
- package/cli/comments.mjs +932 -0
- package/cli/compile.mjs +1719 -1699
- package/cli/context.mjs +883 -0
- package/cli/control-chars.mjs +12 -301
- package/cli/discover.mjs +17 -574
- package/cli/doctor.mjs +269 -1080
- package/cli/drills.mjs +92 -504
- package/cli/feedback.mjs +16 -237
- package/cli/finish.mjs +437 -0
- package/cli/form.mjs +644 -0
- package/cli/fuzz-shell.mjs +20 -305
- package/cli/gate.mjs +7 -199
- package/cli/goldens.mjs +26 -184
- package/cli/index.mjs +305 -655
- package/cli/init.mjs +243 -566
- package/cli/inside.mjs +3 -28
- package/cli/instructions.mjs +829 -0
- package/cli/ledger.mjs +882 -0
- package/cli/librarian.mjs +135 -599
- package/cli/manifest.mjs +2 -39
- package/cli/mutants.mjs +9 -170
- package/cli/new.mjs +12 -243
- package/cli/pack-identity.mjs +5 -74
- package/cli/pack-version.mjs +13 -255
- package/cli/plugin-lint.mjs +42 -658
- package/cli/portulan.mjs +6 -125
- package/cli/recipe-set.mjs +15 -245
- package/cli/release-eval.mjs +28 -348
- package/cli/review-meter.mjs +29 -265
- package/cli/rule-carriers.mjs +12 -175
- package/cli/sessions.mjs +82 -0
- package/cli/skill-goldens.mjs +10 -176
- package/cli/skills-set.mjs +12 -414
- package/cli/stop-gate.mjs +83 -514
- package/cli/symbols.mjs +1132 -0
- package/cli/telemetry.mjs +82 -505
- package/cli/upgrade.mjs +171 -314
- package/cli/vendor.mjs +146 -476
- package/cli/version-carriers.mjs +8 -86
- package/core/README.md +1 -1
- package/core/engine.md +3 -9
- package/core/operating/autonomy.md +1 -1
- package/core/operating/context.md +258 -0
- package/core/operating/evolution.md +3 -3
- package/core/operating/loop.md +13 -20
- package/core/operating/memory.md +12 -3
- package/core/operating/sessions.md +100 -0
- package/core/personas/README.md +33 -0
- package/core/personas/librarian.md +2 -2
- package/core/skills/clarify/SKILL.md +1 -1
- package/core/skills/consolidate/SKILL.md +5 -3
- package/core/templates/handoff.md +28 -15
- package/core/templates/memory-entry.md +1 -0
- package/evals/README.md +271 -845
- package/evals/releases/0.2.0.json +146 -0
- package/evals/releases/0.2.0.md +67 -0
- package/evals/releases/0.3.0.json +150 -0
- package/evals/releases/0.3.0.md +68 -0
- package/evals/releases/README.md +3 -3
- package/package.json +5 -2
- package/packs/.claude-plugin/plugin.json +2 -1
- package/packs/README.md +7 -4
- package/packs/rituals/checkpoints/README.md +1 -1
- package/packs/tools/README.md +8 -8
- package/packs/tools/github/verify/actions-pinned.sh +10 -55
- package/packs/tools/local-llm/README.md +136 -0
- package/packs/tools/local-llm/pack.json +32 -0
- package/packs/tools/local-llm/personas/local-worker.md +44 -0
- package/packs/tools/local-llm/runner/local-llm.mjs +1665 -0
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +64 -0
- package/packs/tools/local-llm/verify/README.md +18 -0
- package/spec/README.md +113 -6
- package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
- package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
- package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
- package/spec/migrations/0004-changelog-fragments.mjs +53 -0
- package/spec/migrations/0005-session-log-retired.mjs +69 -0
- package/spec/migrations/0006-boot-card.mjs +77 -0
- package/spec/migrations/0007-guidance-compiled.mjs +31 -0
- package/spec/migrations/0008-card-reading.mjs +55 -0
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
- package/spec/migrations/0010-comment-rail.mjs +57 -0
- package/spec/migrations/README.md +45 -10
- package/spec/pack.schema.json +1 -1
- package/spec/slots.md +343 -27
- package/spec/workspace.schema.json +145 -4
package/cli/fuzz-shell.mjs
CHANGED
|
@@ -1,63 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Grammar-aware fuzzing over this repository's two shell segmenters.
|
|
3
3
|
//
|
|
4
|
-
// Milestone 8, clause (b), second half. The first half — mutation testing over both matchers — is
|
|
5
|
-
// ./mutants.mjs, and the two are separate because a surviving mutant and a grammar bypass are
|
|
6
|
-
// different verdicts with different repairs.
|
|
7
|
-
//
|
|
8
|
-
// ## Why a grammar, and not a string mutator
|
|
9
|
-
//
|
|
10
|
-
// A mutator that flips bytes in `git push --force origin main` has no idea whether the string it
|
|
11
|
-
// produced is still a command bash would run. It can therefore report neither a bypass nor a false
|
|
12
|
-
// red — only a difference, which somebody then has to adjudicate by hand. That is the review round
|
|
13
|
-
// this clause exists to replace.
|
|
14
|
-
//
|
|
15
|
-
// This generator instead COMPOSES a command from a grammar, and so knows by construction where the
|
|
16
|
-
// payload it embedded ended up:
|
|
17
|
-
//
|
|
18
|
-
// - a **command position** — bash executes the payload. The matcher must answer `true`.
|
|
19
|
-
// - a **data position** — bash never executes it. The matcher must answer `false`.
|
|
20
|
-
//
|
|
21
|
-
// That is an exact oracle rather than a statistical one, and it is what makes the word
|
|
22
|
-
// *grammar-aware* mean something here.
|
|
23
|
-
//
|
|
24
|
-
// **The declared ground truth is measured, not asserted.** ./fuzz-shell.ground.test.mjs runs every
|
|
25
|
-
// production under real bash with a NEUTRAL payload — `printf ok`, never a gated command — and fails
|
|
26
|
-
// if a production's declared position disagrees with what bash actually did. Every path spelling is
|
|
27
|
-
// verified the same way, by writing to a throwaway file in a temp directory. A hole list is a claim
|
|
28
|
-
// like any other; so is a grammar.
|
|
29
|
-
//
|
|
30
|
-
// ## Two axes, and only one of them is fuzzed
|
|
31
|
-
//
|
|
32
|
-
// **Positions are ENUMERATED and RECORDED.** There are few of them, each has a name a reader can
|
|
33
|
-
// argue with, and each carries the matcher's answer in `EXPECT`. Where that answer is not the one
|
|
34
|
-
// ground truth demands, the entry names the record that documents the divergence — an entry in
|
|
35
|
-
// `../.portulan/gate-map.md`'s honest-holes list, or, for one case, a decision recorded in
|
|
36
|
-
// `./compile.mjs` itself. Both directions are red, exactly as `documented-hole` is in ./goldens.mjs:
|
|
37
|
-
// a recorded escape that starts being caught is good news the record must absorb.
|
|
38
|
-
//
|
|
39
|
-
// **Spellings are FUZZED.** Within one position the generator writes the same semantic command many
|
|
40
|
-
// different ways — quoting form per word, backslash escapes, line continuations, `$'…'`, and for the
|
|
41
|
-
// write payload the path spellings a shell resolves to one file (`./`, `//`, `/./`, `/../`, quoted,
|
|
42
|
-
// escaped). The invariant is exact and it is the one the segmenters exist to provide: **every
|
|
43
|
-
// spelling of one command in one position must get the same answer.** A spelling that answers
|
|
44
|
-
// differently from its cell is the finding, and it is the shape three of #336's ten review rounds
|
|
45
|
-
// had: a reader narrower than a shell word.
|
|
46
|
-
//
|
|
47
|
-
// ## Deterministic, bounded, and reproducible
|
|
48
|
-
//
|
|
49
|
-
// A verify recipe whose verdict moves between runs is not a rail. The generator draws from a seeded
|
|
50
|
-
// PRNG written into this file — no dependency, no `Math.random` — and the recipe pins the seed. The
|
|
51
|
-
// seed is printed on EVERY run, green included, so a green is as reproducible as a red. `--seed` and
|
|
52
|
-
// `--cases` exist for a session hunting deeper than the pinned budget.
|
|
53
|
-
//
|
|
54
|
-
// ## What it never does
|
|
55
|
-
//
|
|
56
|
-
// **It executes nothing.** The payloads are `git push --force origin main` and a constitution write,
|
|
57
|
-
// by design; there is no code path from this module to a subprocess and ./fuzz-shell.test.mjs asserts
|
|
58
|
-
// it imports no process-spawning API. The bash measurement lives in the ground-truth test, uses a
|
|
59
|
-
// neutral payload, and is a separate file for exactly that reason.
|
|
60
|
-
//
|
|
61
4
|
// Exit 0 green · 1 red · 2 could not run.
|
|
62
5
|
|
|
63
6
|
import fs from "node:fs";
|
|
@@ -67,38 +10,19 @@ import { pathToFileURL } from "node:url";
|
|
|
67
10
|
import { CompileError, matchesRule } from "./compile.mjs";
|
|
68
11
|
import { CouldNotRun, matcherPath, yieldedRules } from "./goldens.mjs";
|
|
69
12
|
|
|
70
|
-
/**
|
|
13
|
+
/** Spellings generated per (position × payload) cell. */
|
|
71
14
|
export const DEFAULT_CASES = 48;
|
|
72
15
|
|
|
73
|
-
/** The pinned seed. Changing it changes what is generated, so it is a reviewed edit like any other. */
|
|
74
16
|
export const DEFAULT_SEED = 20260825;
|
|
75
17
|
|
|
76
|
-
/**
|
|
77
|
-
* The payloads: one per matcher, and TWO for the write matcher because it has two recognitions.
|
|
78
|
-
*
|
|
79
|
-
* `./compile.mjs`'s `shellWrites` docblock puts it plainly — *"Two recognitions, both by table: a
|
|
80
|
-
* `>`/`>>` redirection into the path, and a file-writing command that names it"* — and measurement
|
|
81
|
-
* showed the two do not answer alike everywhere: in the `same-line-comment` position the redirection
|
|
82
|
-
* is caught and the named argument is not, because there the segment's head is `ls`. Folded into one
|
|
83
|
-
* payload that cell would answer two ways and the fuzzer's invariant would have to be weakened to
|
|
84
|
-
* admit it. Split, every cell is exact.
|
|
85
|
-
*
|
|
86
|
-
* Every rule named here must exist in the yielded policy or the run refuses — a fuzzer that skipped a
|
|
87
|
-
* payload whose rule it could not find would report a clean sweep of the part it managed to run.
|
|
88
|
-
*/
|
|
18
|
+
/** A payload per recognition: a redirected write and a named one can answer differently in one position. */
|
|
89
19
|
export const PAYLOADS = {
|
|
90
20
|
shell: { rule: "force-push-without-a-lease", tool: "Bash", what: "a gated force-push" },
|
|
91
|
-
"write-redirect": { rule: "
|
|
92
|
-
"write-named": { rule: "
|
|
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" },
|
|
93
23
|
};
|
|
94
24
|
|
|
95
|
-
/**
|
|
96
|
-
* mulberry32 — a small, fast, well-distributed 32-bit PRNG.
|
|
97
|
-
*
|
|
98
|
-
* Written out rather than imported because this repository installs nothing, and reached for rather
|
|
99
|
-
* than `Math.random` because a fuzzer whose corpus changes every run cannot be a required check: a
|
|
100
|
-
* red nobody can reproduce is a red nobody fixes.
|
|
101
|
-
*/
|
|
25
|
+
/** mulberry32, a seeded 32-bit PRNG. */
|
|
102
26
|
export function prng(seed) {
|
|
103
27
|
let a = seed >>> 0;
|
|
104
28
|
return () => {
|
|
@@ -112,18 +36,7 @@ export function prng(seed) {
|
|
|
112
36
|
|
|
113
37
|
const pick = (rand, xs) => xs[Math.floor(rand() * xs.length) % xs.length];
|
|
114
38
|
|
|
115
|
-
/**
|
|
116
|
-
* FNV-1a over a string, for deriving one cell's seed from its full identity.
|
|
117
|
-
*
|
|
118
|
-
* **The first cut mixed in `position.id.length` and `kind.length` instead**, which made every cell
|
|
119
|
-
* whose id happened to be the same length share one stream — most of the grammar did, measured on
|
|
120
|
-
* 2026-08-25 — while the comment beside it claimed each cell had its own. A claim broader than the code
|
|
121
|
-
* it describes is this repository's signature defect and this session had already met it three times
|
|
122
|
-
* in other people's code before writing one of its own. The cells stayed correct (each still drew its
|
|
123
|
-
* full budget) and the spelling space collapsed onto far fewer distinct streams than the budget
|
|
124
|
-
* implies, which is a quiet loss of exactly the diversity this axis exists for. Reported by Copilot,
|
|
125
|
-
* round 1 on #338.
|
|
126
|
-
*/
|
|
39
|
+
/** 32-bit FNV-1a. */
|
|
127
40
|
export function hash(text) {
|
|
128
41
|
let h = 0x811c9dc5;
|
|
129
42
|
for (let i = 0; i < text.length; i += 1) {
|
|
@@ -137,13 +50,7 @@ export function hash(text) {
|
|
|
137
50
|
// The spelling axis — many ways to write one command
|
|
138
51
|
// =============================================================================================
|
|
139
52
|
|
|
140
|
-
/**
|
|
141
|
-
* Quote or escape one word without changing what a shell makes of it.
|
|
142
|
-
*
|
|
143
|
-
* `$'…'` reaches `shellWords` with its `$` dropped and is then read as a single-quoted run, so no
|
|
144
|
-
* ANSI-C escape sequence is generated inside one: `$'\x41'` is `A` to bash and `\x41` to the matcher,
|
|
145
|
-
* which would be a generator that lies about its own ground truth rather than a finding.
|
|
146
|
-
*/
|
|
53
|
+
/** No escapes inside `$'…'`: the matcher reads it as plain `'…'`, where bash would decode them. */
|
|
147
54
|
export function respell(word, rand) {
|
|
148
55
|
switch (pick(rand, ["bare", "double", "single", "ansi-c", "escape-one", "split-quote"])) {
|
|
149
56
|
case "double":
|
|
@@ -153,14 +60,10 @@ export function respell(word, rand) {
|
|
|
153
60
|
case "ansi-c":
|
|
154
61
|
return `$'${word}'`;
|
|
155
62
|
case "escape-one": {
|
|
156
|
-
// A backslash before an ordinary character is that character. Chosen at a position rather
|
|
157
|
-
// than always the first, so the generator explores the word rather than one index of it.
|
|
158
63
|
const i = Math.floor(rand() * word.length);
|
|
159
64
|
return `${word.slice(0, i)}\\${word.slice(i)}`;
|
|
160
65
|
}
|
|
161
66
|
case "split-quote": {
|
|
162
|
-
// `do"cs"/vision.md` — one word, two quoting regimes. Bash concatenates adjacent quoted
|
|
163
|
-
// and unquoted runs into a single word, which is the property being tested.
|
|
164
67
|
const i = 1 + Math.floor(rand() * Math.max(1, word.length - 1));
|
|
165
68
|
return `${word.slice(0, i)}"${word.slice(i)}"`;
|
|
166
69
|
}
|
|
@@ -169,14 +72,7 @@ export function respell(word, rand) {
|
|
|
169
72
|
}
|
|
170
73
|
}
|
|
171
74
|
|
|
172
|
-
/**
|
|
173
|
-
* Spellings of `docs/vision.md` that a shell resolves to the same file.
|
|
174
|
-
*
|
|
175
|
-
* Every one of these must reach the constitution's write gate. They are the "two path spellings" the
|
|
176
|
-
* 2026-07-28 amendment names among the eight bypasses #60 shipped with, generalised: `normalisePath`
|
|
177
|
-
* exists for exactly this list and `./fuzz-shell.ground.test.mjs` writes each one to a throwaway file
|
|
178
|
-
* under a temp directory to prove bash agrees.
|
|
179
|
-
*/
|
|
75
|
+
/** Spellings of `target` that a shell resolves to the same file. */
|
|
180
76
|
export function pathSpellings(target) {
|
|
181
77
|
const parts = target.split("/");
|
|
182
78
|
const head = parts.slice(0, -1).join("/");
|
|
@@ -186,13 +82,7 @@ export function pathSpellings(target) {
|
|
|
186
82
|
`./${target}`,
|
|
187
83
|
`${head}/./${tail}`,
|
|
188
84
|
`${head}//${tail}`,
|
|
189
|
-
//
|
|
190
|
-
// resolves `..` against the real filesystem while `normalisePath` resolves it lexically. That
|
|
191
|
-
// difference is fail-CLOSED and deliberate — the matcher answers `true` for a hop through a
|
|
192
|
-
// directory that is not there, which is a false red on a command bash would refuse anyway —
|
|
193
|
-
// and ./fuzz-shell.ground.test.mjs creates `sibling` before it measures this spelling, so
|
|
194
|
-
// the claim being measured is the one the list actually makes. Found by that measurement:
|
|
195
|
-
// the first spelling here hopped through a directory nothing created and bash refused it.
|
|
85
|
+
// bash resolves `..` against the filesystem, so the ground test creates `sibling` first.
|
|
196
86
|
`${head}/sibling/../${tail}`,
|
|
197
87
|
`./${head}/./${tail}`,
|
|
198
88
|
// A backslash-newline is a line continuation: both characters vanish and the word continues.
|
|
@@ -200,18 +90,6 @@ export function pathSpellings(target) {
|
|
|
200
90
|
];
|
|
201
91
|
}
|
|
202
92
|
|
|
203
|
-
/**
|
|
204
|
-
* The writer shapes, split by which of `shellWrites`' TWO recognitions they reach.
|
|
205
|
-
*
|
|
206
|
-
* **The split is structural, not cosmetic, and measurement is what forced it.** That function's own
|
|
207
|
-
* docblock says *"Two recognitions, both by table: a `>`/`>>` redirection into the path, and a
|
|
208
|
-
* file-writing command that names it"* — and the two do not answer alike in every position, because a
|
|
209
|
-
* redirection is read off the whole segment while a named argument needs the segment's HEAD to be a
|
|
210
|
-
* writer. In the `same-line-comment` position they diverge outright: `ls # echo ok > docs/vision.md`
|
|
211
|
-
* is caught, `ls # cp /tmp/x docs/vision.md` is not, because there the head is `ls`. Keyed as one
|
|
212
|
-
* payload the cell would answer two ways and the invariant would have to be weakened to admit it;
|
|
213
|
-
* keyed as two, each cell is exact and the divergence becomes a recorded fact.
|
|
214
|
-
*/
|
|
215
93
|
export const WRITERS = {
|
|
216
94
|
"write-redirect": [(p) => `echo ok > ${p}`, (p) => `echo ok >> ${p}`, (p) => `cat /tmp/x > ${p}`, (p) => `printf ok 1> ${p}`],
|
|
217
95
|
"write-named": [
|
|
@@ -225,25 +103,14 @@ export const WRITERS = {
|
|
|
225
103
|
],
|
|
226
104
|
};
|
|
227
105
|
|
|
228
|
-
/** One spelling of the write payload: a writer shape, a path spelling, and quoting over both. */
|
|
229
106
|
export function writePayload(rand, kind, target = "docs/vision.md") {
|
|
230
107
|
const spelling = pick(rand, pathSpellings(target));
|
|
231
|
-
//
|
|
232
|
-
// continuation, but inside `'…'` it is two literal characters and the word stops being the path.
|
|
233
|
-
// Generating that would be the generator lying about its own ground truth.
|
|
108
|
+
// Never requote a continuation: inside `'…'` a backslash-newline is two literal characters.
|
|
234
109
|
const quoted = spelling.includes("\\\n") ? spelling : respell(spelling, rand);
|
|
235
110
|
return pick(rand, WRITERS[kind])(quoted);
|
|
236
111
|
}
|
|
237
112
|
|
|
238
|
-
/**
|
|
239
|
-
* One spelling of the shell payload.
|
|
240
|
-
*
|
|
241
|
-
* **The gated prefix stays literal, and that is a property of the ORACLE rather than a convenience.**
|
|
242
|
-
* `git push --force` is a command *prefix* and both the compiled permission rule and this matcher
|
|
243
|
-
* compare it literally, so `"git" push --force …` is a spelling no layer claims to catch — hole 1's
|
|
244
|
-
* "spellings neither layer sees". Generating it would produce a finding the gate map already answers.
|
|
245
|
-
* Only the arguments after the prefix are respelt.
|
|
246
|
-
*/
|
|
113
|
+
/** Only the arguments are respelt: the prefix is matched literally, and the gate map records that hole. */
|
|
247
114
|
export function shellPayload(rand, target = "git push --force") {
|
|
248
115
|
const args = pick(rand, [[], ["origin", "main"], ["origin", "HEAD"], ["--repo", "o/r"], ["origin", "main", "--quiet"]]);
|
|
249
116
|
return [target, ...args.map((a) => respell(a, rand))].join(" ");
|
|
@@ -253,14 +120,7 @@ export function shellPayload(rand, target = "git push --force") {
|
|
|
253
120
|
// The position axis — enumerated, recorded, and measured under bash
|
|
254
121
|
// =============================================================================================
|
|
255
122
|
|
|
256
|
-
/**
|
|
257
|
-
* Where the payload sits, and whether bash runs it there.
|
|
258
|
-
*
|
|
259
|
-
* `bashSafe: false` marks a production ./fuzz-shell.ground.test.mjs must not execute even with a
|
|
260
|
-
* neutral payload — `sudo` would prompt for a password on a developer's machine and hang a test
|
|
261
|
-
* suite. Its ground truth is argued in `why` instead of measured, and saying which of the two a
|
|
262
|
-
* production got is the point of the flag.
|
|
263
|
-
*/
|
|
123
|
+
/** Where the payload sits: bash executes it in a `command` ground and never in a `data` one. */
|
|
264
124
|
export const POSITIONS = [
|
|
265
125
|
{ id: "bare", ground: "command", build: (p) => p },
|
|
266
126
|
{ id: "after-semicolon", ground: "command", build: (p) => `ls; ${p}` },
|
|
@@ -284,69 +144,16 @@ export const POSITIONS = [
|
|
|
284
144
|
build: (p) => `sudo ${p}`,
|
|
285
145
|
},
|
|
286
146
|
{ id: "leading-redirection", ground: "command", build: (p) => `2> /dev/null ${p}` },
|
|
287
|
-
// ------------------------------------------------------------------------------------------
|
|
288
|
-
// **The redirection-TARGET family — session-open adjustment 4, and the class #336 met three
|
|
289
|
-
// times.** A shell word may hold spaces when it is quoted or escaped, and `LEADING_REDIRECTION`
|
|
290
|
-
// must consume the whole word or the command behind it escapes ungated. That defect shipped
|
|
291
|
-
// twice on #336 and was fixed twice at the spelling that was quoted, until the suite stopped
|
|
292
|
-
// asserting spellings and asserted the rule against `shellWords`. These productions put the same
|
|
293
|
-
// rule in front of the generator, so a fourth sibling reds here rather than in a review.
|
|
294
|
-
//
|
|
295
|
-
// Every one is a COMMAND position: bash redirects stderr to a file and then runs the payload.
|
|
296
147
|
{ id: "leading-redirection-quoted-target", ground: "command", build: (p) => `2> "log file.txt" ${p}` },
|
|
297
148
|
{ id: "leading-redirection-single-quoted-target", ground: "command", build: (p) => `2> 'log file.txt' ${p}` },
|
|
298
149
|
{ id: "leading-redirection-escaped-space-target", ground: "command", build: (p) => `2> log\\ file.txt ${p}` },
|
|
299
150
|
{ id: "leading-redirection-escaped-quote-target", ground: "command", build: (p) => `2> "log \\"q\\" file.txt" ${p}` },
|
|
300
|
-
//
|
|
301
|
-
// **A CRLF continuation, which is NOT a continuation.** `shellWords` consumes `\r\n` after a
|
|
302
|
-
// backslash as a pair and joins the word; **five shells do not, and split the command instead** —
|
|
303
|
-
// `bash 3.2.57(1)` (arm64-apple-darwin25), `bash 5.2.15(1)` (aarch64-unknown-linux-gnu) and
|
|
304
|
-
// `bash 5.2.37(1)` (x86_64-pc-linux-gnu), plus `zsh 5.9` and **the measured host's `/bin/sh`**, which
|
|
305
|
-
// on that macOS host is bash 3.2.57 in POSIX mode — `dash` and `busybox`, the common `/bin/sh` on
|
|
306
|
-
// Linux, were NOT measured.
|
|
307
|
-
// The three bash builds are named in full rather than as "bash 5", which would be a claim from two 5.2
|
|
308
|
-
// patchlevels; no bash 4.x was measured, and neither was any Windows-side bash — git-bash, MSYS2,
|
|
309
|
-
// Cygwin or WSL — which is the platform the retired reachability claim named. Widened 2026-08-25
|
|
310
|
-
// from "three shells on this machine", which was the true width until two containers were run.
|
|
311
|
-
//
|
|
312
|
-
// **What follows from that is NOT one answer, and an earlier draft of this very comment said it
|
|
313
|
-
// was.** It read "the matcher answers `true` anyway: a FALSE RED", which is right for one payload
|
|
314
|
-
// and wrong for another:
|
|
315
|
-
//
|
|
316
|
-
// - `shell` — nothing gated runs. The matcher answers `false`. Correct.
|
|
317
|
-
// - `write-named` — the intended write never runs and the target is untouched. The matcher
|
|
318
|
-
// answers `true`. (`cp` ITSELF does run, on the fragment before the split, and fails on a
|
|
319
|
-
// malformed `\r` argument — the sibling of the cell below, corrected in the same pass after
|
|
320
|
-
// shipping the fix to one carrier and not the other. Copilot, round 3 on #342.)
|
|
321
|
-
// A false red, fail-closed, and the only one of the three.
|
|
322
|
-
// - `write-redirect` — a shell applies a redirection BEFORE it looks the command up, so the
|
|
323
|
-
// clobbering redirection on the surviving fragment still fires and TRUNCATES the target to
|
|
324
|
-
// zero bytes. The matcher answering `true` is a **true positive**. Measured.
|
|
325
|
-
//
|
|
326
|
-
// So the ground truth here is a property of the payload rather than of the position, which is why
|
|
327
|
-
// `groundByKind` exists and why this is the only production that uses it. `exitsNonZero` is
|
|
328
|
-
// declared because the fragment left over by the split is run as a command and is not found.
|
|
329
|
-
//
|
|
330
|
-
// _The stale sentence survived the correction of the three cells below it — one carrier fixed and
|
|
331
|
-
// its sibling left, in the block whose subject is that class. Reported by Copilot, round 2 on #341._
|
|
151
|
+
// `shellWords` joins a backslash-CRLF; bash 3.2.57, 5.2.15 and 5.2.37 and zsh 5.9 split there.
|
|
332
152
|
{
|
|
333
153
|
id: "crlf-continuation-in-the-payload",
|
|
334
154
|
ground: "data",
|
|
335
|
-
// **The one production whose ground truth is not a property of the position.** bash splits at
|
|
336
|
-
// the CRLF, so the intended write never runs and a gated command never runs — data. But a
|
|
337
|
-
// CLOBBERING redirection is applied before the command is looked up, so it fires anyway and
|
|
338
|
-
// truncates its target to zero bytes. That is the gated effect occurring, and calling it data
|
|
339
|
-
// would record a destroyed file as an untouched one — which an earlier draft of this entry did,
|
|
340
|
-
// in all three of its carriers, until the pre-commit checkpoint measured it.
|
|
341
155
|
groundByKind: { "write-redirect": "command" },
|
|
342
|
-
// `>>` appends
|
|
343
|
-
// truth differs from its siblings'. Refused rather than folded in: a cell whose ground truth
|
|
344
|
-
// varies with the spelling is a cell whose invariant cannot hold. Measured — `echo \<CRLF>ok >> t`
|
|
345
|
-
// leaves `t` byte-for-byte unchanged while `> t` and `1> t` leave it empty. Re-measured
|
|
346
|
-
// 2026-08-25 on bash 5.2.15 and 5.2.37 as well as 3.2.57: identical on all three, so the
|
|
347
|
-
// refusal is right on **both bash 5.2 builds measured** and not only on the shell it was
|
|
348
|
-
// written against. Named as builds rather than as "bash 5", which would be the series
|
|
349
|
-
// inference this production's own comment refuses eight lines up.
|
|
156
|
+
// `>>` appends rather than truncates, so its ground differs from its siblings' and it is refused.
|
|
350
157
|
carries: (p, kind) => kind !== "write-redirect" || !p.includes(">>"),
|
|
351
158
|
exitsNonZero: true,
|
|
352
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.",
|
|
@@ -360,14 +167,7 @@ export const POSITIONS = [
|
|
|
360
167
|
{
|
|
361
168
|
id: "wrapper-single-quoted",
|
|
362
169
|
ground: "command",
|
|
363
|
-
//
|
|
364
|
-
// here because bash said so. POSIX gives `'…'` no escapes, so `sh -c 'cp /tmp/x $'docs/vision.md''`
|
|
365
|
-
// closes the wrapper at the payload's own quote and the inner shell then reads
|
|
366
|
-
// `cp /tmp/x $docs/vision.md` — which writes `/vision.md`, NOT the constitution. The cell
|
|
367
|
-
// records `true` and bash would not have touched the file: a generator composing a command it
|
|
368
|
-
// no longer has ground truth for, which is the one failure this whole design is arranged
|
|
369
|
-
// against. Measured by ./fuzz-shell.ground.test.mjs's wrapper-respelling case, added at the
|
|
370
|
-
// pre-commit checkpoint after it named this composition as argued-but-unmeasured.
|
|
170
|
+
// POSIX `'…'` has no escapes, so a payload holding `'` would close the wrapper early.
|
|
371
171
|
carries: (p) => !p.includes("'"),
|
|
372
172
|
build: (p) => `sh -c '${p}'`,
|
|
373
173
|
},
|
|
@@ -377,7 +177,6 @@ export const POSITIONS = [
|
|
|
377
177
|
{
|
|
378
178
|
id: "nested-wrapper",
|
|
379
179
|
ground: "command",
|
|
380
|
-
// The same limit, for the same reason: the inner wrapper here is single-quoted too.
|
|
381
180
|
carries: (p) => !p.includes("'"),
|
|
382
181
|
build: (p) => `bash -c "sh -c '${p}'"`,
|
|
383
182
|
},
|
|
@@ -386,29 +185,10 @@ export const POSITIONS = [
|
|
|
386
185
|
{ id: "heredoc-body", ground: "data", build: (p) => `cat <<'EOF'\n${p}\nEOF` },
|
|
387
186
|
{ id: "same-line-comment", ground: "data", build: (p) => `ls # ${p}` },
|
|
388
187
|
{ id: "comment-then-separator", ground: "data", build: (p) => `echo ok #; ${p}` },
|
|
389
|
-
// Added at the pre-commit checkpoint. Its differential of the staged matcher against HEAD's found
|
|
390
|
-
// that the 2026-08-25 widening inherits the `#` decision one level INTO a wrapper — a new false
|
|
391
|
-
// red, and the only new false-red class the repair produced. `comment-then-separator` pins the
|
|
392
|
-
// top-level spelling and could not see the composed one, which is the "two claims that each held
|
|
393
|
-
// and did not compose" shape this whole session is about, met once more in the fix for it.
|
|
394
188
|
{ id: "wrapper-holding-comment", ground: "data", build: (p) => `bash -c "echo ok #; ${p}"` },
|
|
395
189
|
];
|
|
396
190
|
|
|
397
|
-
/**
|
|
398
|
-
* What the matcher answers for each cell, and the record that licenses a disagreement with ground
|
|
399
|
-
* truth.
|
|
400
|
-
*
|
|
401
|
-
* **Every entry here was MEASURED against the shipped matcher before it was written, and one round of
|
|
402
|
-
* writing it from the armchair was thrown away first.** Milestone 8 session 0's founding result is
|
|
403
|
-
* ten hand-written expectations refuted on the corpus's first run; an expectation table is a claim
|
|
404
|
-
* like any other and this one is not exempt.
|
|
405
|
-
*
|
|
406
|
-
* `answer` is what `matchesRule` returns. `record` is required whenever `answer` disagrees with what
|
|
407
|
-
* the production's `ground` demands, and it may cite either the gate map's honest-holes list or a
|
|
408
|
-
* decision recorded in `./compile.mjs` — the false red on `#` is the second kind, argued at
|
|
409
|
-
* `commandSegments` and declined on a Copilot review of #60, and it appears in no hole list because
|
|
410
|
-
* it is a choice rather than a gap.
|
|
411
|
-
*/
|
|
191
|
+
/** The matcher's measured answer per cell; one that differs from ground truth names its `record`. */
|
|
412
192
|
export const EXPECT = {
|
|
413
193
|
"leading-redirection-quoted-target|shell": { answer: true },
|
|
414
194
|
"leading-redirection-quoted-target|write-redirect": { answer: true },
|
|
@@ -616,51 +396,15 @@ export const EXPECT = {
|
|
|
616
396
|
},
|
|
617
397
|
};
|
|
618
398
|
|
|
619
|
-
/** The answer ground truth demands, before any record is consulted. */
|
|
620
399
|
export const correctFor = (ground) => ground === "command";
|
|
621
400
|
|
|
622
|
-
/**
|
|
623
|
-
* What "the payload took effect" MEANS, per payload kind.
|
|
624
|
-
*
|
|
625
|
-
* **A redirection is not a command, and conflating the two hid a true positive as a false red.** A
|
|
626
|
-
* shell applies a redirection BEFORE it looks the command up, so a `>` whose command never runs still
|
|
627
|
-
* truncates its target to zero bytes. For a `write-redirect` payload the gated effect is therefore
|
|
628
|
-
* *the target was written*, not *the command ran* — and measuring it as the latter reports a
|
|
629
|
-
* destroyed file as an untouched one. Measured on bash 3.2.57: a split `printf … 1> target` leaves
|
|
630
|
-
* the target at **0 bytes** where it held content before.
|
|
631
|
-
*/
|
|
401
|
+
/** How a payload shows its effect: a redirection truncates its target even when its command never runs. */
|
|
632
402
|
export const EFFECT = { shell: "ran", "write-redirect": "touched", "write-named": "ran" };
|
|
633
403
|
|
|
634
|
-
/**
|
|
635
|
-
* The ground truth for one cell, which is not always a property of the position alone.
|
|
636
|
-
*
|
|
637
|
-
* Most productions put the payload somewhere bash either runs or does not, whatever the payload is.
|
|
638
|
-
* One does not: a CRLF splits the command, and what survives depends on the payload's SHAPE — a
|
|
639
|
-
* clobbering redirection still fires and destroys the target, while a writer command simply never
|
|
640
|
-
* runs. A single `ground` field cannot say that, so a production may override it per kind. The
|
|
641
|
-
* override is rare by design and every use of it is argued in the production's own `why`.
|
|
642
|
-
*/
|
|
643
404
|
export const groundFor = (position, kind) => position.groundByKind?.[kind] ?? position.ground;
|
|
644
405
|
|
|
645
|
-
/**
|
|
646
|
-
* The ACTION kind a payload kind reaches.
|
|
647
|
-
*
|
|
648
|
-
* Three payload kinds, two action kinds: the write matcher's two recognitions are separate payloads
|
|
649
|
-
* here (see `WRITERS`) and one rule kind there. Anything that hands a payload kind to a consumer
|
|
650
|
-
* expecting an action kind is a bug, and one shipped — see `asCase`.
|
|
651
|
-
*/
|
|
652
406
|
export const ruleKind = (kind) => (kind === "shell" ? "shell" : "write");
|
|
653
407
|
|
|
654
|
-
/**
|
|
655
|
-
* Generate one case: a spelling of one payload in one position.
|
|
656
|
-
*
|
|
657
|
-
* **A position may REFUSE a payload it cannot carry, and the refusal is a redraw rather than a
|
|
658
|
-
* skip.** A single-quoted wrapper holding a `'`-quoted word is not the command it looks like, so
|
|
659
|
-
* emitting one would give the fuzzer an exact answer about a string whose ground truth it no longer
|
|
660
|
-
* knows. Bounded, and a budget that runs out throws rather than returning something unchecked: a
|
|
661
|
-
* generator that quietly gave up would thin the spelling space in exactly the cell whose quoting is
|
|
662
|
-
* hardest, which is the cell that most needs the coverage.
|
|
663
|
-
*/
|
|
664
408
|
export function generate(position, kind, rand) {
|
|
665
409
|
const carries = position.carries ?? (() => true);
|
|
666
410
|
for (let attempt = 0; attempt < 64; attempt += 1) {
|
|
@@ -674,28 +418,12 @@ export function generate(position, kind, rand) {
|
|
|
674
418
|
);
|
|
675
419
|
}
|
|
676
420
|
|
|
677
|
-
/**
|
|
678
|
-
* A finding, rendered as a corpus case ready to paste into evals/goldens/gates/<rule-id>.json.
|
|
679
|
-
*
|
|
680
|
-
* `../evals/README.md` records that clause (a)'s fixture format is clause (b)'s output shape, so a
|
|
681
|
-
* bypass found here becomes a permanent regression pin by paste rather than by retyping. The `expect`
|
|
682
|
-
* written is what the MATCHER ACTUALLY DID — a fixture records behaviour, and the repair belongs in
|
|
683
|
-
* the matcher or in the record, never in a fixture that asserts what somebody wished had happened.
|
|
684
|
-
*/
|
|
421
|
+
/** A finding as a case to paste into `evals/goldens/gates/<rule-id>.json`, expecting what the matcher did. */
|
|
685
422
|
export function asCase(position, kind, ruleId, command, actual, index) {
|
|
686
423
|
return {
|
|
687
424
|
id: `fuzz-${position.id}-${kind}-${index}`,
|
|
688
425
|
class: correctFor(groundFor(position, kind)) === actual ? "holds" : "documented-hole",
|
|
689
426
|
tool: PAYLOADS[kind].tool,
|
|
690
|
-
// **The RULE kind, not the payload kind.** `matcherPath` reads an action kind — `shell` /
|
|
691
|
-
// `write` / `read` — and this passed it `write-redirect` and `write-named`, which it knows
|
|
692
|
-
// nothing about, so it fell through to `no-branch` and two of the three payloads emitted a
|
|
693
|
-
// case `goldens` would refuse as a mislabel. The paste-ready promise this module and
|
|
694
|
-
// `../evals/README.md` both make was therefore FALSE for the write matcher, which is
|
|
695
|
-
// `../.portulan/dod.md` condition 4 rather than a preference. Reported by Copilot on #338;
|
|
696
|
-
// it survived a drill that printed a finding, because the drill exercised the shell payload —
|
|
697
|
-
// a check written alongside a change inheriting the change's blind spot, for the fifth time
|
|
698
|
-
// in two sessions.
|
|
699
427
|
path: matcherPath(ruleKind(kind), PAYLOADS[kind].tool),
|
|
700
428
|
expect: actual,
|
|
701
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.`,
|
|
@@ -740,8 +468,6 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
|
|
|
740
468
|
const flag = argv[i];
|
|
741
469
|
const raw = argv[i + 1];
|
|
742
470
|
i += 1;
|
|
743
|
-
// Refused rather than coerced. `Number("12abc")` is NaN and `Number("")` is 0, and a
|
|
744
|
-
// fuzzer silently running zero cases is the false green this whole file is against.
|
|
745
471
|
if (raw === undefined || !/^[0-9]+$/.test(raw)) throw new CouldNotRun(`${flag} needs a non-negative integer, not ${JSON.stringify(raw)}`);
|
|
746
472
|
const value = Number(raw);
|
|
747
473
|
if (flag === "--cases" && value === 0) throw new CouldNotRun("--cases 0 would generate nothing and report green having tested nothing");
|
|
@@ -766,9 +492,6 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
|
|
|
766
492
|
for (const u of unresolved) say(`pack ${u.name} UNRESOLVED — ${u.why}; its gate fragments are not in this run`);
|
|
767
493
|
const byId = new Map(rules.map((r) => [r.id, r]));
|
|
768
494
|
|
|
769
|
-
// **A payload whose rule is absent is could-not-run, never a skip.** A fuzzer that quietly
|
|
770
|
-
// ran one of its two matchers would print a green about half the subject the clause names —
|
|
771
|
-
// ../.portulan/memory/a-checker-must-refuse-what-it-cannot-check.md.
|
|
772
495
|
for (const [kind, p] of Object.entries(PAYLOADS)) {
|
|
773
496
|
if (!byId.has(p.rule)) {
|
|
774
497
|
throw new CouldNotRun(
|
|
@@ -777,9 +500,6 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
|
|
|
777
500
|
);
|
|
778
501
|
}
|
|
779
502
|
}
|
|
780
|
-
// The table must be total over the grammar, in both directions, and this is checked before a
|
|
781
|
-
// case is generated: a position with no recorded answer would otherwise be silently skipped,
|
|
782
|
-
// and a recorded answer for a position nobody generates is a claim about nothing.
|
|
783
503
|
const keys = new Set(Object.keys(EXPECT));
|
|
784
504
|
const missing = [];
|
|
785
505
|
for (const position of POSITIONS) {
|
|
@@ -812,11 +532,7 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
|
|
|
812
532
|
for (const [kind, p] of Object.entries(PAYLOADS)) {
|
|
813
533
|
const expected = EXPECT[`${position.id}|${kind}`];
|
|
814
534
|
const rule = byId.get(p.rule);
|
|
815
|
-
//
|
|
816
|
-
// one cell's budget cannot shift another cell's stream — a case-count change would
|
|
817
|
-
// otherwise re-roll every later cell and turn one edit into a new corpus — and no two
|
|
818
|
-
// cells share a stream, so the spelling space is explored once per cell rather than
|
|
819
|
-
// once per id length. See `hash` for what the first cut got wrong.
|
|
535
|
+
// Seeded per cell from its full identity, so one cell's budget never re-rolls another's.
|
|
820
536
|
const rand = prng(seed ^ hash(`${position.id}|${kind}`));
|
|
821
537
|
for (let i = 0; i < cases; i += 1) {
|
|
822
538
|
const { command } = generate(position, kind, rand);
|
|
@@ -864,8 +580,7 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
|
|
|
864
580
|
}
|
|
865
581
|
}
|
|
866
582
|
|
|
867
|
-
//
|
|
868
|
-
// copied rather than re-derived.
|
|
583
|
+
// Compared as URLs so a space in the path still matches, and through realpath for a symlinked launch.
|
|
869
584
|
function isMain() {
|
|
870
585
|
const invoked = process.argv[1];
|
|
871
586
|
if (!invoked) return false;
|