@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.
- package/README.md +7 -6
- package/cli/README.md +142 -291
- package/cli/advisory.mjs +354 -0
- package/cli/comments.mjs +932 -0
- package/cli/compile.mjs +1443 -1210
- package/cli/context.mjs +883 -0
- package/cli/control-chars.mjs +12 -301
- package/cli/discover.mjs +17 -574
- package/cli/doctor.mjs +225 -1071
- package/cli/drills.mjs +1107 -0
- package/cli/feedback.mjs +16 -237
- package/cli/finish.mjs +437 -0
- package/cli/form.mjs +644 -0
- package/cli/fuzz-shell.mjs +595 -0
- package/cli/gate.mjs +7 -198
- package/cli/goldens.mjs +311 -0
- package/cli/index.mjs +305 -655
- package/cli/init.mjs +244 -564
- package/cli/inside.mjs +3 -28
- package/cli/instructions.mjs +829 -0
- package/cli/ledger.mjs +778 -0
- package/cli/librarian.mjs +135 -599
- package/cli/manifest.mjs +2 -39
- package/cli/mutants.mjs +764 -0
- package/cli/new.mjs +12 -243
- package/cli/pack-identity.mjs +6 -68
- package/cli/pack-version.mjs +13 -248
- package/cli/plugin-lint.mjs +42 -658
- package/cli/portulan.mjs +5 -124
- package/cli/recipe-set.mjs +16 -240
- package/cli/release-eval.mjs +748 -0
- package/cli/review-meter.mjs +500 -0
- package/cli/rule-carriers.mjs +12 -175
- package/cli/sessions.mjs +82 -0
- package/cli/skill-goldens.mjs +424 -0
- package/cli/skills-set.mjs +12 -414
- package/cli/stop-gate.mjs +126 -454
- package/cli/symbols.mjs +1132 -0
- package/cli/telemetry.mjs +582 -0
- package/cli/upgrade.mjs +171 -314
- package/cli/vendor.mjs +146 -476
- package/cli/version-carriers.mjs +9 -74
- package/core/README.md +1 -1
- package/core/engine.md +3 -9
- package/core/operating/autonomy.md +1 -1
- package/core/operating/context.md +183 -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/librarian.md +2 -2
- package/core/skills/consolidate/SKILL.md +4 -2
- package/core/templates/gate-policy.md +27 -8
- package/core/templates/handoff.md +28 -15
- package/core/templates/memory-entry.md +1 -0
- package/evals/README.md +320 -0
- package/evals/releases/0.1.3.json +134 -0
- package/evals/releases/0.1.3.md +64 -0
- package/evals/releases/0.2.0.json +146 -0
- package/evals/releases/0.2.0.md +67 -0
- package/evals/releases/README.md +32 -0
- package/package.json +9 -2
- package/packs/rituals/checkpoints/README.md +9 -2
- package/packs/rituals/checkpoints/pack.json +1 -1
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
- package/packs/tools/github/verify/actions-pinned.sh +10 -55
- package/spec/README.md +112 -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 +359 -23
- package/spec/workspace.schema.json +145 -4
package/cli/mutants.mjs
ADDED
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Mutation-test this repository's two gate matchers against the corpus that claims to cover them.
|
|
3
|
+
//
|
|
4
|
+
// Exit 0 green · 1 red · 2 could not run.
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
10
|
+
|
|
11
|
+
import { CompileError } from "./compile.mjs";
|
|
12
|
+
import { CouldNotRun, partition, readCorpus, yieldedRules } from "./goldens.mjs";
|
|
13
|
+
|
|
14
|
+
export const SUBJECT = "cli/compile.mjs";
|
|
15
|
+
|
|
16
|
+
/** Every function and table `matchesRule` can reach, listed by hand rather than derived. */
|
|
17
|
+
export const REGION = [
|
|
18
|
+
"matchesRule",
|
|
19
|
+
"matchesPath",
|
|
20
|
+
"shellWrites",
|
|
21
|
+
"shellSegments",
|
|
22
|
+
"commandSegments",
|
|
23
|
+
"shellWords",
|
|
24
|
+
"spellings",
|
|
25
|
+
"stripHeredocs",
|
|
26
|
+
"stripLeadingRedirections",
|
|
27
|
+
"REDIRECTION_TARGET",
|
|
28
|
+
"LEADING_REDIRECTION",
|
|
29
|
+
"namesTarget",
|
|
30
|
+
"normalisePath",
|
|
31
|
+
"ancestors",
|
|
32
|
+
"writesWhatItNames",
|
|
33
|
+
"FILE_WRITERS",
|
|
34
|
+
"IN_PLACE_EDITORS",
|
|
35
|
+
"COMMAND_PREFIXES",
|
|
36
|
+
"SEGMENT_LEADERS",
|
|
37
|
+
"OPERATOR",
|
|
38
|
+
"ASSIGNMENT",
|
|
39
|
+
"WRITE_TOOLS",
|
|
40
|
+
"READ_TOOLS",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export const OUTCOMES = ["killed", "survives"];
|
|
44
|
+
|
|
45
|
+
/** Record `survives` only for a mutant equivalent outright or under the yielded policy: a fixture can kill any other. */
|
|
46
|
+
export const OPERATORS = [
|
|
47
|
+
// ---------------------------------------------------------------------------------- matchesRule
|
|
48
|
+
{
|
|
49
|
+
id: "matchesRule-shell-drops-exact-match",
|
|
50
|
+
member: "matchesRule",
|
|
51
|
+
find: "action.shell.endsWith(\"/\") ? s.startsWith(action.shell) : s === action.shell || s.startsWith(`${action.shell} `)",
|
|
52
|
+
replace: "action.shell.endsWith(\"/\") ? s.startsWith(action.shell) : s.startsWith(`${action.shell} `)",
|
|
53
|
+
outcome: "killed",
|
|
54
|
+
why: "A command spelled EXACTLY as its target — `git commit` with no arguments — stops matching.",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "matchesRule-shell-drops-word-boundary",
|
|
58
|
+
member: "matchesRule",
|
|
59
|
+
find: "s === action.shell || s.startsWith(`${action.shell} `)",
|
|
60
|
+
replace: "s === action.shell || s.startsWith(action.shell)",
|
|
61
|
+
outcome: "killed",
|
|
62
|
+
why: "The whitespace boundary is what stops `git push` covering `git pushall`. Removing it is a false red on an unrelated command.",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "matchesRule-shell-drops-segment-arm",
|
|
66
|
+
member: "matchesRule",
|
|
67
|
+
find: "return spellings(input.command).some(reach) || commandSegments(input.command).some((seg) => spellings(seg).some(reach));",
|
|
68
|
+
replace: "return spellings(input.command).some(reach);",
|
|
69
|
+
outcome: "killed",
|
|
70
|
+
why: "Reopens hole 2 wholesale: a gated command behind any separator reaches nothing.",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "matchesRule-inserts-a-no-op",
|
|
74
|
+
member: "matchesRule",
|
|
75
|
+
find: "const action = rule?.action ?? {};",
|
|
76
|
+
replace: "const action = rule?.action ?? {};\n void action;",
|
|
77
|
+
outcome: "survives",
|
|
78
|
+
why: "EQUIVALENT: a no-op statement. Present as the table's own control — an operator that changes the source and not the behaviour must survive, or the harness is reporting kills it did not make.",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "matchesRule-shell-stops-segmenting-a-spelling",
|
|
82
|
+
member: "matchesRule",
|
|
83
|
+
find: "const reach = (s) => hit(s) || commandSegments(s).some(hit);",
|
|
84
|
+
replace: "const reach = (s) => hit(s);",
|
|
85
|
+
outcome: "killed",
|
|
86
|
+
why: "Reverts the 2026-08-25 repair and reopens the live bypass it closed: `bash -c \"ls; git push --force origin main\"` — one wrapper, one separator, every Gated shell action.",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: "matchesRule-write-drops-bash-branch",
|
|
90
|
+
member: "matchesRule",
|
|
91
|
+
find: " if (tool === \"Bash\") {\n // Whole line and per segment, exactly as the `shell` branch above",
|
|
92
|
+
replace: " if (false && tool === \"Bash\") {\n // Whole line and per segment, exactly as the `shell` branch above",
|
|
93
|
+
outcome: "killed",
|
|
94
|
+
why: "Reopens the constitution's shell half entirely — `echo x >> docs/vision.md` gated by neither layer.",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "matchesRule-write-drops-segment-arm",
|
|
98
|
+
member: "matchesRule",
|
|
99
|
+
find: " spellings(input.command).some(writes) ||\n commandSegments(input.command).some((seg) => spellings(seg).some(writes))",
|
|
100
|
+
replace: " spellings(input.command).some(writes)",
|
|
101
|
+
outcome: "killed",
|
|
102
|
+
why: "The four write-gate holes #71 closed reopen; each is reachable only through this second arm.",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "matchesRule-read-accepts-any-tool",
|
|
106
|
+
member: "matchesRule",
|
|
107
|
+
find: "if (typeof action.read === \"string\" && READ_TOOLS.includes(tool)) {",
|
|
108
|
+
replace: "if (typeof action.read === \"string\") {",
|
|
109
|
+
outcome: "survives",
|
|
110
|
+
why: "EQUIVALENT UNDER THE YIELDED POLICY: the one `read:` rule declared is `read-anything-in-the-repository`, whose target is `./` — hole 8, `matchesPath` answers false for every input whatever the tool. No fixture can distinguish this, and none should be invented to; the mutant becomes killable the day a `read:` rule with a real target is declared.",
|
|
111
|
+
},
|
|
112
|
+
// ---------------------------------------------------------------------------------- matchesPath
|
|
113
|
+
{
|
|
114
|
+
id: "matchesPath-subtree-becomes-tail",
|
|
115
|
+
member: "matchesPath",
|
|
116
|
+
find: "return clean.endsWith(\"/\") ? normalised.includes(`/${clean}`) : normalised.endsWith(`/${clean}`);",
|
|
117
|
+
replace: "return normalised.endsWith(`/${clean}`);",
|
|
118
|
+
outcome: "killed",
|
|
119
|
+
why: "A subtree target stops covering its subtree: `core/` no longer matches `core/engine.md`.",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: "matchesPath-tail-becomes-substring",
|
|
123
|
+
member: "matchesPath",
|
|
124
|
+
find: "clean.endsWith(\"/\") ? normalised.includes(`/${clean}`) : normalised.endsWith(`/${clean}`)",
|
|
125
|
+
replace: "normalised.includes(`/${clean}`)",
|
|
126
|
+
outcome: "killed",
|
|
127
|
+
why: "An exact target starts matching any path CONTAINING it — `docs/vision.md.bak` becomes the constitution.",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: "matchesPath-admits-the-empty-target",
|
|
131
|
+
member: "matchesPath",
|
|
132
|
+
find: "if (clean === \"\" || clean === \"/\") return false;",
|
|
133
|
+
replace: "if (clean === \"/\") return false;",
|
|
134
|
+
outcome: "killed",
|
|
135
|
+
why: "CLOSES hole 8 — a `./` target would start matching. The documented-hole cases must go red, which is the good-news direction the corpus exists to catch.",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: "matchesPath-drops-the-empty-candidate-guard",
|
|
139
|
+
member: "matchesPath",
|
|
140
|
+
find: "if (typeof candidate !== \"string\" || candidate === \"\") return false;",
|
|
141
|
+
replace: "if (typeof candidate !== \"string\") return false;",
|
|
142
|
+
outcome: "survives",
|
|
143
|
+
why: "EQUIVALENT: with `candidate` empty, `\"\".endsWith(`/${clean}`)` is false for every non-empty `clean`, and `clean` is guarded non-empty two lines down. The guard is a readability rail, not a behavioural one.",
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: "matchesPath-stops-stripping-a-leading-dot-slash",
|
|
147
|
+
member: "matchesPath",
|
|
148
|
+
// The second line keeps the anchor unique: `neverMatches` repeats the first.
|
|
149
|
+
find: "const clean = String(target ?? \"\").replace(/^\\.\\//, \"\").replace(/^\\/+/, \"\");\n if (clean === \"\" || clean === \"/\") return false;",
|
|
150
|
+
replace: "const clean = String(target ?? \"\").replace(/^\\/+/, \"\");\n if (clean === \"\" || clean === \"/\") return false;",
|
|
151
|
+
outcome: "killed",
|
|
152
|
+
why: "A target written `./docs/vision.md` stops matching; and `./` stops reducing to empty, which moves hole 8.",
|
|
153
|
+
},
|
|
154
|
+
// ---------------------------------------------------------------------------------- shellWrites
|
|
155
|
+
{
|
|
156
|
+
id: "shellWrites-drops-redirection-recognition",
|
|
157
|
+
member: "shellWrites",
|
|
158
|
+
find: "if (segment.redirects.some((word) => namesTarget(word, target))) return true;",
|
|
159
|
+
replace: "",
|
|
160
|
+
outcome: "killed",
|
|
161
|
+
why: "`echo x > docs/vision.md` stops being a write. The plainest spelling the shell half exists for.",
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: "shellWrites-drops-the-writer-table",
|
|
165
|
+
member: "shellWrites",
|
|
166
|
+
find: "if (writesWhatItNames(segment) && segment.args.some((word) => namesTarget(word, target, true))) return true;",
|
|
167
|
+
replace: "",
|
|
168
|
+
outcome: "killed",
|
|
169
|
+
why: "`cp /tmp/x docs/vision.md` stops being a write.",
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
id: "shellWrites-drops-ancestors",
|
|
173
|
+
member: "shellWrites",
|
|
174
|
+
find: "segment.args.some((word) => namesTarget(word, target, true))",
|
|
175
|
+
replace: "segment.args.some((word) => namesTarget(word, target, false))",
|
|
176
|
+
outcome: "killed",
|
|
177
|
+
why: "`rm -rf docs` stops reaching a rule protecting `docs/vision.md` — destroying the container stops counting.",
|
|
178
|
+
},
|
|
179
|
+
// -------------------------------------------------------------------------------- shellSegments
|
|
180
|
+
{
|
|
181
|
+
id: "shellSegments-forgets-write-redirections",
|
|
182
|
+
member: "shellSegments",
|
|
183
|
+
find: " if (word.text.includes(\">\")) pending = \"written\";",
|
|
184
|
+
replace: " if (false) pending = \"written\";",
|
|
185
|
+
outcome: "killed",
|
|
186
|
+
why: "Redirection targets stop being collected, so the first arm of `shellWrites` sees nothing.",
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
id: "shellSegments-treats-read-redirection-as-a-separator",
|
|
190
|
+
member: "shellSegments",
|
|
191
|
+
find: " else if (word.text.includes(\"<\")) pending = \"read\";",
|
|
192
|
+
replace: " else if (false) pending = \"read\";",
|
|
193
|
+
outcome: "killed",
|
|
194
|
+
why: "`tee < /tmp/in docs/vision.md` loses its head and its argument — the case that docblock says goes red if the branch is removed.",
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
id: "shellSegments-stops-skipping-segment-leaders",
|
|
198
|
+
member: "shellSegments",
|
|
199
|
+
find: "if (ASSIGNMENT.test(word.text) || COMMAND_PREFIXES.has(bare) || SEGMENT_LEADERS.has(bare)) continue;",
|
|
200
|
+
replace: "if (ASSIGNMENT.test(word.text) || COMMAND_PREFIXES.has(bare)) continue;",
|
|
201
|
+
outcome: "killed",
|
|
202
|
+
why: "`{ cp … ; }` and `if …; then cp …` hide the writer behind the leader — hole 2's write half reopens.",
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
id: "shellSegments-stops-skipping-command-prefixes",
|
|
206
|
+
member: "shellSegments",
|
|
207
|
+
find: "if (ASSIGNMENT.test(word.text) || COMMAND_PREFIXES.has(bare) || SEGMENT_LEADERS.has(bare)) continue;\n current.head = bare;",
|
|
208
|
+
replace: "if (ASSIGNMENT.test(word.text) || SEGMENT_LEADERS.has(bare)) continue;\n current.head = bare;",
|
|
209
|
+
outcome: "killed",
|
|
210
|
+
why: "`sudo cp /tmp/x docs/vision.md` takes `sudo` as its head, which is not a writer.",
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
id: "shellSegments-stops-skipping-assignments",
|
|
214
|
+
member: "shellSegments",
|
|
215
|
+
find: "if (ASSIGNMENT.test(word.text) || COMMAND_PREFIXES.has(bare) || SEGMENT_LEADERS.has(bare)) continue;\n current.head = bare;\n continue;",
|
|
216
|
+
replace: "if (COMMAND_PREFIXES.has(bare) || SEGMENT_LEADERS.has(bare)) continue;\n current.head = bare;\n continue;",
|
|
217
|
+
outcome: "killed",
|
|
218
|
+
why: "`FOO=bar cp /tmp/x docs/vision.md` takes `FOO=bar` as its head.",
|
|
219
|
+
},
|
|
220
|
+
// ------------------------------------------------------------------------------- commandSegments
|
|
221
|
+
{
|
|
222
|
+
id: "commandSegments-forgets-quoting",
|
|
223
|
+
member: "commandSegments",
|
|
224
|
+
find: " if (quote) {",
|
|
225
|
+
replace: " if (false && quote) {",
|
|
226
|
+
outcome: "killed",
|
|
227
|
+
why: "A separator inside a quoted string starts splitting, so `echo \"git push --force\"` becomes a gated command — a false red on data.",
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: "commandSegments-forgets-escapes-inside-quotes",
|
|
231
|
+
member: "commandSegments",
|
|
232
|
+
find: " escaped = i;\n continue;",
|
|
233
|
+
replace: " escaped = -1;\n continue;",
|
|
234
|
+
outcome: "killed",
|
|
235
|
+
why: "The escape-aware split the pre-commit checkpoint added on #336 reverts: `echo \\>| git push --force origin main` stops splitting at a real pipe.",
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
id: "commandSegments-splits-redirection-operators",
|
|
239
|
+
member: "commandSegments",
|
|
240
|
+
find: "if (c === \"&\" && (opBefore || command[i + 1] === \">\")) continue;",
|
|
241
|
+
replace: "if (false) continue;",
|
|
242
|
+
outcome: "killed",
|
|
243
|
+
why: "`2>&1 git push --force …` breaks into pieces at the `&` before the redirection can be stripped — the #71 regression.",
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
id: "commandSegments-drops-heredoc-stripping",
|
|
247
|
+
member: "commandSegments",
|
|
248
|
+
find: "const command = stripHeredocs(String(raw ?? \"\"));",
|
|
249
|
+
replace: "const command = String(raw ?? \"\");",
|
|
250
|
+
outcome: "killed",
|
|
251
|
+
why: "A heredoc BODY is read as command text again, so its lines become segments — the false red that made this repository's own commit message ungatable.",
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
id: "commandSegments-declares-a-dead-local",
|
|
255
|
+
member: "commandSegments",
|
|
256
|
+
find: "let quote = null;",
|
|
257
|
+
replace: "let quote = null;\n let unusedByDesign = 0;\n void unusedByDesign;",
|
|
258
|
+
outcome: "survives",
|
|
259
|
+
why: "EQUIVALENT: an unread local, declared and immediately voided. A second control alongside `matchesRule-inserts-a-no-op`, at a different member, because a harness that only ever reports kills is indistinguishable from one that reports kills wrongly. _(This `why` described a `String(x).toString()` substitution until the pre-commit checkpoint read it — the text of an EARLIER draft of this operator, left behind when the anchor moved. A `survives` record whose proof describes a different mutant is a wrong record even where the verdict is right, and it is this session's own signature defect in its own record layer.)_",
|
|
260
|
+
},
|
|
261
|
+
// ----------------------------------------------------------------------------------- shellWords
|
|
262
|
+
{
|
|
263
|
+
id: "shellWords-forgets-ansi-c-quoting",
|
|
264
|
+
member: "shellWords",
|
|
265
|
+
find: "if (c === \"$\" && (command[i + 1] === \"'\" || command[i + 1] === '\"')) continue;",
|
|
266
|
+
replace: "if (false && (command[i + 1] === \"'\" || command[i + 1] === '\"')) continue;",
|
|
267
|
+
outcome: "killed",
|
|
268
|
+
why: "`cp /tmp/x $'docs/vision.md'` glues the `$` onto the word and the path stops being recognised — the Copilot finding from #60.",
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
id: "shellWords-stops-emitting-operator-words",
|
|
272
|
+
member: "shellWords",
|
|
273
|
+
find: "words.push({ text: run, op: true });",
|
|
274
|
+
replace: "words.push({ text: run, op: false });",
|
|
275
|
+
outcome: "killed",
|
|
276
|
+
why: "Operators become ordinary words, so redirection targets are never collected and segments never close.",
|
|
277
|
+
},
|
|
278
|
+
// ------------------------------------------------------------------------------------ spellings
|
|
279
|
+
{
|
|
280
|
+
id: "spellings-stops-unwrapping",
|
|
281
|
+
member: "spellings",
|
|
282
|
+
find: " if (wrapper) {",
|
|
283
|
+
replace: " if (false && wrapper) {",
|
|
284
|
+
outcome: "killed",
|
|
285
|
+
why: "`sh -c \"git push --force origin main\"` reaches nothing — the one level of unwrapping that is the hook's whole reach past a literal prefix.",
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
id: "spellings-stops-stripping-the-wrapper-quotes",
|
|
289
|
+
member: "spellings",
|
|
290
|
+
find: " if ((quote === '\"' || quote === \"'\") && inner.endsWith(quote) && inner.length > 1) {",
|
|
291
|
+
replace: " if (false && inner.endsWith(quote) && inner.length > 1) {",
|
|
292
|
+
outcome: "killed",
|
|
293
|
+
why: "The unwrapped command keeps its quotes, so the prefix compare fails on the leading quote character.",
|
|
294
|
+
},
|
|
295
|
+
// --------------------------------------------------------------------------------- stripHeredocs
|
|
296
|
+
{
|
|
297
|
+
id: "stripHeredocs-swallows-an-unterminated-opener",
|
|
298
|
+
member: "stripHeredocs",
|
|
299
|
+
find: " if (delimiter !== null) out.push(...held);",
|
|
300
|
+
replace: "",
|
|
301
|
+
outcome: "killed",
|
|
302
|
+
why: "The fail-open Copilot found on #60 returns: `echo \"not a heredoc <<EOF\"` swallows every later line, hiding a gated command on any of them.",
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
id: "stripHeredocs-forgets-the-quoted-delimiter-form",
|
|
306
|
+
member: "stripHeredocs",
|
|
307
|
+
find: "const opened = /<<-?\\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\\1/.exec(line);",
|
|
308
|
+
replace: "const opened = /<<-?\\s*([A-Za-z_][A-Za-z0-9_]*)/.exec(line);",
|
|
309
|
+
outcome: "killed",
|
|
310
|
+
why: "`<<'EOF'` stops being recognised as an opener — and with capture group 2 now undefined the delimiter never matches, so the body is read as commands.",
|
|
311
|
+
},
|
|
312
|
+
// --------------------------------------------------------------- stripLeadingRedirections + regexes
|
|
313
|
+
{
|
|
314
|
+
id: "stripLeadingRedirections-strips-only-once",
|
|
315
|
+
member: "stripLeadingRedirections",
|
|
316
|
+
find: " for (;;) {\n const shorter = text.replace(LEADING_REDIRECTION, \"\");",
|
|
317
|
+
replace: " for (let once = 0; once < 1; once += 1) {\n const shorter = text.replace(LEADING_REDIRECTION, \"\");",
|
|
318
|
+
outcome: "killed",
|
|
319
|
+
why: "Two stacked redirections — `> a 2> b git push --force …` — leave the second in front of the command.",
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
id: "REDIRECTION_TARGET-narrows-to-non-whitespace",
|
|
323
|
+
member: "REDIRECTION_TARGET",
|
|
324
|
+
find: "const REDIRECTION_TARGET = String.raw`(?:\"(?:\\\\[\\s\\S]|[^\"\\\\])*\"|'[^']*'|\\\\[\\s\\S]|[^\\s])+`;",
|
|
325
|
+
replace: "const REDIRECTION_TARGET = String.raw`[^\\s]+`;",
|
|
326
|
+
outcome: "killed",
|
|
327
|
+
why: "The exact round-1 defect on #336: `> \"foo bar\" git push --force …` strips `> \"foo` and leaves the command behind it ungated.",
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
id: "LEADING_REDIRECTION-forgets-the-clobber-form",
|
|
331
|
+
member: "LEADING_REDIRECTION",
|
|
332
|
+
find: "^\\d*(?:&>>|&>|>>|>&|>\\||<&|<>|<|>)\\s*",
|
|
333
|
+
replace: "^\\d*(?:&>>|&>|>>|>&|<&|<>|<|>)\\s*",
|
|
334
|
+
outcome: "killed",
|
|
335
|
+
why: "`>| f git push --force …` stops stripping — one of the two spellings #71's own table never named.",
|
|
336
|
+
},
|
|
337
|
+
// ---------------------------------------------------------------------------------- namesTarget
|
|
338
|
+
{
|
|
339
|
+
id: "namesTarget-forgets-the-equals-form",
|
|
340
|
+
member: "namesTarget",
|
|
341
|
+
find: "const candidates = eq > 0 ? [word, word.slice(eq + 1)] : [word];",
|
|
342
|
+
replace: "const candidates = [word];",
|
|
343
|
+
outcome: "killed",
|
|
344
|
+
why: "`dd of=docs/vision.md` stops naming the target.",
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
id: "namesTarget-stops-rooting-a-relative-word",
|
|
348
|
+
member: "namesTarget",
|
|
349
|
+
find: "const rooted = clean.startsWith(\"/\") ? clean : `/${clean}`;",
|
|
350
|
+
replace: "const rooted = clean;",
|
|
351
|
+
outcome: "killed",
|
|
352
|
+
why: "A relative word loses the leading separator the tail comparison needs, so `cp /tmp/x docs/vision.md` stops matching.",
|
|
353
|
+
},
|
|
354
|
+
// --------------------------------------------------------------------------------- normalisePath
|
|
355
|
+
{
|
|
356
|
+
id: "normalisePath-keeps-dot-segments",
|
|
357
|
+
member: "normalisePath",
|
|
358
|
+
find: " if (part === \"\" || part === \".\") continue;",
|
|
359
|
+
replace: " if (part === \"\") continue;",
|
|
360
|
+
outcome: "killed",
|
|
361
|
+
why: "`docs/./vision.md` stops being the constitution — a live escape for one review round.",
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
id: "normalisePath-keeps-empty-segments",
|
|
365
|
+
member: "normalisePath",
|
|
366
|
+
find: "if (part === \"\" || part === \".\") continue;\n if (part === \"..\"",
|
|
367
|
+
replace: "if (part === \".\") continue;\n if (part === \"..\"",
|
|
368
|
+
outcome: "killed",
|
|
369
|
+
why: "`docs//vision.md` stops being the constitution — the other half of the same escape.",
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
id: "normalisePath-stops-resolving-dotdot",
|
|
373
|
+
member: "normalisePath",
|
|
374
|
+
find: " if (part === \"..\" && out.length && out[out.length - 1] !== \"..\") out.pop();\n else out.push(part);",
|
|
375
|
+
replace: " out.push(part);",
|
|
376
|
+
outcome: "killed",
|
|
377
|
+
why: "`foo/../docs/vision.md` stops being the constitution.",
|
|
378
|
+
},
|
|
379
|
+
// ------------------------------------------------------------------------------------ ancestors
|
|
380
|
+
{
|
|
381
|
+
id: "ancestors-includes-the-file-itself",
|
|
382
|
+
member: "ancestors",
|
|
383
|
+
find: "return parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join(\"/\"));",
|
|
384
|
+
replace: "return parts.map((_, i) => parts.slice(0, i + 1).join(\"/\"));",
|
|
385
|
+
outcome: "survives",
|
|
386
|
+
why: "EQUIVALENT AT THE CALL SITE: the extra entry is the target's own full path, and `namesTarget` already tests that path in the branch above the ancestor one — so the added candidate can only ever repeat a `true` the first branch has already returned. Recorded rather than deleted because the equivalence is a property of the CALLER, not of `ancestors`, and a reader of this function alone would expect a kill.",
|
|
387
|
+
},
|
|
388
|
+
// ----------------------------------------------------------------------------- writesWhatItNames
|
|
389
|
+
{
|
|
390
|
+
id: "writesWhatItNames-drops-the-writer-table",
|
|
391
|
+
member: "writesWhatItNames",
|
|
392
|
+
find: " if (FILE_WRITERS.has(head)) return true;",
|
|
393
|
+
replace: "",
|
|
394
|
+
outcome: "killed",
|
|
395
|
+
why: "Nothing in the writer table writes any more.",
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
id: "writesWhatItNames-drops-the-in-place-flag-check",
|
|
399
|
+
member: "writesWhatItNames",
|
|
400
|
+
find: "return IN_PLACE_EDITORS.has(head) && args.some((a) => /^--in-place(=|$)/.test(a) || /^-[a-zA-Z]*i/.test(a));",
|
|
401
|
+
replace: "return IN_PLACE_EDITORS.has(head);",
|
|
402
|
+
outcome: "killed",
|
|
403
|
+
why: "`sed -n '1,5p' docs/vision.md` — a READ this policy declares Auto — starts being gated as a write.",
|
|
404
|
+
},
|
|
405
|
+
// --------------------------------------------------------------------------------- the tables
|
|
406
|
+
{
|
|
407
|
+
id: "FILE_WRITERS-loses-cp",
|
|
408
|
+
member: "FILE_WRITERS",
|
|
409
|
+
find: "new Set([\"cp\", \"mv\", \"ln\", \"rm\", \"tee\", \"dd\", \"install\", \"truncate\", \"shred\", \"patch\"])",
|
|
410
|
+
replace: "new Set([\"mv\", \"ln\", \"rm\", \"tee\", \"dd\", \"install\", \"truncate\", \"shred\", \"patch\"])",
|
|
411
|
+
outcome: "killed",
|
|
412
|
+
why: "One entry removed from a recognition table. If the corpus does not notice, the table is prose.",
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
id: "IN_PLACE_EDITORS-loses-sed",
|
|
416
|
+
member: "IN_PLACE_EDITORS",
|
|
417
|
+
find: "export const IN_PLACE_EDITORS = new Set([\"sed\", \"gsed\", \"perl\", \"ruby\"]);",
|
|
418
|
+
replace: "export const IN_PLACE_EDITORS = new Set([\"gsed\", \"perl\", \"ruby\"]);",
|
|
419
|
+
outcome: "killed",
|
|
420
|
+
why: "`sed -i … docs/vision.md` stops being a write.",
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
id: "COMMAND_PREFIXES-loses-sudo",
|
|
424
|
+
member: "COMMAND_PREFIXES",
|
|
425
|
+
find: "new Set([\"sudo\", \"env\", \"command\", \"builtin\", \"exec\", \"nohup\", \"nice\", \"time\"])",
|
|
426
|
+
replace: "new Set([\"env\", \"command\", \"builtin\", \"exec\", \"nohup\", \"nice\", \"time\"])",
|
|
427
|
+
outcome: "killed",
|
|
428
|
+
why: "`sudo cp /tmp/x docs/vision.md` takes `sudo` as its head.",
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
id: "SEGMENT_LEADERS-loses-then",
|
|
432
|
+
member: "SEGMENT_LEADERS",
|
|
433
|
+
find: "new Set([\"{\", \"}\", \"!\", \"then\", \"else\", \"elif\", \"do\", \"done\", \"fi\", \"esac\"])",
|
|
434
|
+
replace: "new Set([\"{\", \"}\", \"!\", \"else\", \"elif\", \"do\", \"done\", \"fi\", \"esac\"])",
|
|
435
|
+
outcome: "killed",
|
|
436
|
+
why: "`if true; then cp /tmp/x docs/vision.md; fi` takes `then` as its head — and this is the asymmetry the corpus records between the two segmenters, so it must be the WRITE path that notices.",
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
id: "OPERATOR-loses-the-newline",
|
|
440
|
+
member: "OPERATOR",
|
|
441
|
+
find: "const OPERATOR = /[|&;<>()\\n\\r]/;",
|
|
442
|
+
replace: "const OPERATOR = /[|&;<>()\\r]/;",
|
|
443
|
+
outcome: "killed",
|
|
444
|
+
why: "The plainest spelling there is, and the one the first hole list missed: a two-line script folds into one segment whose head is the first command.",
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
id: "WRITE_TOOLS-loses-Write",
|
|
448
|
+
member: "WRITE_TOOLS",
|
|
449
|
+
find: 'export const WRITE_TOOLS = ["Edit", "Write", "NotebookEdit"];',
|
|
450
|
+
replace: 'export const WRITE_TOOLS = ["Edit", "NotebookEdit"];',
|
|
451
|
+
outcome: "killed",
|
|
452
|
+
why: "The `Write` tool stops reaching any `write:` rule, so the constitution is editable through the plainest tool there is.",
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
id: "WRITE_TOOLS-loses-NotebookEdit",
|
|
456
|
+
member: "WRITE_TOOLS",
|
|
457
|
+
find: 'export const WRITE_TOOLS = ["Edit", "Write", "NotebookEdit"];\nexport const READ_TOOLS = ["Read"];',
|
|
458
|
+
replace: 'export const WRITE_TOOLS = ["Edit", "Write"];\nexport const READ_TOOLS = ["Read"];',
|
|
459
|
+
outcome: "killed",
|
|
460
|
+
why: "The third write tool, which the corpus did not exercise AT ALL until this operator was added — twenty-one write-rule fixtures across five files and not one `NotebookEdit` among them. Found by widening REGION at the pre-commit checkpoint, which is the coverage floor earning its place.",
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
id: "matchesRule-write-drops-the-notebook-path-fallback",
|
|
464
|
+
member: "matchesRule",
|
|
465
|
+
find: "return matchesPath(input.file_path ?? input.notebook_path, action.write);",
|
|
466
|
+
replace: "return matchesPath(input.file_path, action.write);",
|
|
467
|
+
outcome: "killed",
|
|
468
|
+
why: "`NotebookEdit` carries `notebook_path` rather than `file_path`, so without the fallback the tool is in the table and its payload reaches nothing — a gate that looks whole from the outside. Untested until the same widening.",
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
id: "READ_TOOLS-loses-Read",
|
|
472
|
+
member: "READ_TOOLS",
|
|
473
|
+
find: 'export const READ_TOOLS = ["Read"];',
|
|
474
|
+
replace: "export const READ_TOOLS = [];",
|
|
475
|
+
outcome: "survives",
|
|
476
|
+
why: "EQUIVALENT UNDER THE YIELDED POLICY, by the same argument as `matchesRule-read-accepts-any-tool`: the one `read:` rule declared is `read-anything-in-the-repository`, whose `./` target reduces to the empty string and is refused — hole 8 — so the branch answers false whether it is reached or not. No fixture can distinguish this and none should be invented to; it becomes killable the day a `read:` rule with a real target is declared.",
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
id: "ASSIGNMENT-requires-two-characters",
|
|
480
|
+
member: "ASSIGNMENT",
|
|
481
|
+
find: "const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;",
|
|
482
|
+
replace: "const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]+=/;",
|
|
483
|
+
outcome: "killed",
|
|
484
|
+
why: "A single-letter assignment — `A=1 cp /tmp/x docs/vision.md` — stops being recognised as one.",
|
|
485
|
+
},
|
|
486
|
+
];
|
|
487
|
+
|
|
488
|
+
export function mutate(source, op) {
|
|
489
|
+
let count = 0;
|
|
490
|
+
let at = 0;
|
|
491
|
+
for (;;) {
|
|
492
|
+
const i = source.indexOf(op.find, at);
|
|
493
|
+
if (i === -1) break;
|
|
494
|
+
count += 1;
|
|
495
|
+
at = i + 1;
|
|
496
|
+
}
|
|
497
|
+
if (count === 0) {
|
|
498
|
+
throw new CouldNotRun(
|
|
499
|
+
`operator \`${op.id}\` does not place: its anchor is absent from ${SUBJECT}. The subject moved and this ` +
|
|
500
|
+
`operator did not — re-anchor it or retire it, but do not let it be skipped`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
if (count > 1) {
|
|
504
|
+
throw new CouldNotRun(
|
|
505
|
+
`operator \`${op.id}\` places ${count} times in ${SUBJECT} and an operator must be exactly one edit. ` +
|
|
506
|
+
`Lengthen its anchor until it is unique`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
return source.replace(op.find, () => op.replace);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Only `from "./….mjs"` imports are rewritten, and a mutant's `import.meta.url` names its temp directory. */
|
|
513
|
+
export function absolutiseImports(source, cliDir) {
|
|
514
|
+
return source.replace(/from "\.\/([A-Za-z0-9._-]+\.mjs)"/g, (_, name) => `from "${pathToFileURL(path.join(cliDir, name)).href}"`);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function runCorpus(matchesRule, byId, corpus) {
|
|
518
|
+
for (const { where, doc } of corpus) {
|
|
519
|
+
const rule = byId.get(doc.rule);
|
|
520
|
+
// `run` refuses such a corpus first; this arm answers a direct caller.
|
|
521
|
+
if (!rule) {
|
|
522
|
+
throw new CouldNotRun(
|
|
523
|
+
`${where} attacks \`${doc.rule}\`, which the yielded policy does not declare — its cases cannot be ` +
|
|
524
|
+
`graded, and a census that skipped them would report on a kill-set smaller than the one it names`,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
for (const c of doc.cases) {
|
|
528
|
+
let actual;
|
|
529
|
+
try {
|
|
530
|
+
actual = matchesRule(rule, c.tool, c.input);
|
|
531
|
+
} catch (error) {
|
|
532
|
+
// `matchesRule` never throws, so a mutant that throws is killed.
|
|
533
|
+
return { agreed: false, at: `${where} → ${c.id}`, how: `threw ${error?.name ?? "an error"}` };
|
|
534
|
+
}
|
|
535
|
+
if (actual !== c.expect) {
|
|
536
|
+
return { agreed: false, at: `${where} → ${c.id}`, how: `answered ${actual}, corpus records ${c.expect}` };
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return { agreed: true };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** A fresh directory per mutant: ESM caches a module by its URL and never forgets one. */
|
|
544
|
+
async function importMutant(source, cliDir, op) {
|
|
545
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "portulan-mutant-"));
|
|
546
|
+
const file = path.join(dir, "compile.mjs");
|
|
547
|
+
try {
|
|
548
|
+
fs.writeFileSync(file, absolutiseImports(source, cliDir), "utf8");
|
|
549
|
+
try {
|
|
550
|
+
return { module: await import(pathToFileURL(file).href), dir };
|
|
551
|
+
} catch (cause) {
|
|
552
|
+
throw new CouldNotRun(
|
|
553
|
+
`operator \`${op.id}\` produced a module that will not import — ${cause?.message ?? cause}. ` +
|
|
554
|
+
`A mutant that cannot load tests nothing; fix the substitution`,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
} catch (error) {
|
|
558
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function census(operators = OPERATORS) {
|
|
564
|
+
const counts = new Map(REGION.map((m) => [m, 0]));
|
|
565
|
+
const stray = [];
|
|
566
|
+
for (const op of operators) {
|
|
567
|
+
if (!counts.has(op.member)) stray.push(op);
|
|
568
|
+
else counts.set(op.member, counts.get(op.member) + 1);
|
|
569
|
+
}
|
|
570
|
+
return { counts, stray, uncovered: REGION.filter((m) => counts.get(m) === 0) };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function usage() {
|
|
574
|
+
return [
|
|
575
|
+
"usage: node cli/mutants.mjs [--check] [--workspace <dir>] [--pack-root <dir>] [--only <operator-id>]",
|
|
576
|
+
"",
|
|
577
|
+
" Mutates cli/compile.mjs's matcher region, one declared operator at a time, and grades each",
|
|
578
|
+
" mutant against evals/goldens/gates/ — the corpus that claims to cover those matchers.",
|
|
579
|
+
"",
|
|
580
|
+
" --check is the default and the only mode; the flag is accepted so the recipe reads",
|
|
581
|
+
" like its siblings. --only narrows to one operator, for a session hunting a single survivor.",
|
|
582
|
+
"",
|
|
583
|
+
" Exit 0 green · 1 red · 2 could not run.",
|
|
584
|
+
].join("\n");
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export async function run(argv = [], { stdout = process.stdout, stderr = process.stderr, cwd = process.cwd() } = {}) {
|
|
588
|
+
const say = (line = "") => stdout.write(`${line}\n`);
|
|
589
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
590
|
+
say(usage());
|
|
591
|
+
return 0;
|
|
592
|
+
}
|
|
593
|
+
let named = cwd;
|
|
594
|
+
let packRoots = null;
|
|
595
|
+
let only = null;
|
|
596
|
+
const dirs = [];
|
|
597
|
+
try {
|
|
598
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
599
|
+
if (argv[i] === "--check") continue;
|
|
600
|
+
if (argv[i] === "--workspace") {
|
|
601
|
+
named = argv[i + 1];
|
|
602
|
+
i += 1;
|
|
603
|
+
if (named === undefined) throw new CouldNotRun("--workspace needs a directory");
|
|
604
|
+
} else if (argv[i] === "--only") {
|
|
605
|
+
only = argv[i + 1];
|
|
606
|
+
i += 1;
|
|
607
|
+
if (only === undefined || only.startsWith("-")) throw new CouldNotRun("--only needs an operator id");
|
|
608
|
+
} else if (argv[i] === "--pack-root") {
|
|
609
|
+
const root = argv[i + 1];
|
|
610
|
+
i += 1;
|
|
611
|
+
if (root === undefined || root.startsWith("-")) throw new CouldNotRun("--pack-root needs a directory");
|
|
612
|
+
let stat = null;
|
|
613
|
+
try {
|
|
614
|
+
stat = fs.statSync(root);
|
|
615
|
+
} catch (cause) {
|
|
616
|
+
throw new CouldNotRun(`--pack-root ${root} cannot be read — ${cause.code ?? cause.message}`);
|
|
617
|
+
}
|
|
618
|
+
if (!stat.isDirectory()) throw new CouldNotRun(`--pack-root ${root} is not a directory`);
|
|
619
|
+
(packRoots ??= []).push(path.resolve(root));
|
|
620
|
+
} else throw new CouldNotRun(`unknown argument ${JSON.stringify(argv[i])}`);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const { counts, stray, uncovered } = census();
|
|
624
|
+
if (stray.length) {
|
|
625
|
+
throw new CouldNotRun(
|
|
626
|
+
`operator(s) ${stray.map((o) => `\`${o.id}\``).join(", ")} name a region member that REGION does not list. ` +
|
|
627
|
+
`Add the member to REGION or correct the operator — an operator outside the census is coverage nobody counts`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
// Every operator, even under `--only`: it narrows what runs, not what must be well formed.
|
|
631
|
+
for (const op of OPERATORS) {
|
|
632
|
+
if (!OUTCOMES.includes(op.outcome)) {
|
|
633
|
+
throw new CouldNotRun(`operator \`${op.id}\` records outcome ${JSON.stringify(op.outcome)} — one of ${OUTCOMES.join(" / ")}`);
|
|
634
|
+
}
|
|
635
|
+
if (typeof op.why !== "string" || op.why.trim() === "") {
|
|
636
|
+
throw new CouldNotRun(`operator \`${op.id}\` carries no \`why\` — a mutation nobody can read is not reviewable`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const { workspaceRoot, rules, unresolved } = yieldedRules(named, { packRoots });
|
|
641
|
+
const corpus = readCorpus(workspaceRoot);
|
|
642
|
+
for (const u of unresolved) say(`pack ${u.name} UNRESOLVED — ${u.why}; its gate fragments are not in this census`);
|
|
643
|
+
|
|
644
|
+
const cliDir = path.join(workspaceRoot, "cli");
|
|
645
|
+
const subject = path.join(workspaceRoot, SUBJECT);
|
|
646
|
+
let source;
|
|
647
|
+
try {
|
|
648
|
+
source = fs.readFileSync(subject, "utf8");
|
|
649
|
+
} catch (cause) {
|
|
650
|
+
throw new CouldNotRun(`${SUBJECT} cannot be read at ${subject} — ${cause.code ?? cause.message}`);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const { matchable, exempt } = partition(rules);
|
|
654
|
+
const byId = new Map(matchable.map((r) => [r.id, r]));
|
|
655
|
+
|
|
656
|
+
const ungradable = corpus
|
|
657
|
+
.filter(({ doc }) => !byId.has(doc.rule))
|
|
658
|
+
.map(({ where, doc }) => {
|
|
659
|
+
const why = exempt.some((e) => e.id === doc.rule)
|
|
660
|
+
? "declares no matchable action, so there is nothing for a mutant to change"
|
|
661
|
+
: "is not declared by the yielded policy at all";
|
|
662
|
+
return `${where} attacks \`${doc.rule}\`, which ${why}`;
|
|
663
|
+
});
|
|
664
|
+
if (ungradable.length) {
|
|
665
|
+
throw new CouldNotRun(
|
|
666
|
+
`${ungradable.length} fixture file(s) cannot be graded, so the kill-set is smaller than it looks:\n ` +
|
|
667
|
+
`${ungradable.join("\n ")}\n ` +
|
|
668
|
+
`Fix the corpus (the \`goldens\` recipe grades that) before reading a census over it`,
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const { matchesRule } = await import(pathToFileURL(subject).href);
|
|
673
|
+
const baseline = runCorpus(matchesRule, byId, corpus);
|
|
674
|
+
if (!baseline.agreed) {
|
|
675
|
+
throw new CouldNotRun(
|
|
676
|
+
`the corpus does not agree with the UNMUTATED matcher at ${baseline.at} (${baseline.how}). ` +
|
|
677
|
+
`A mutation census over a red corpus measures nothing — run the \`goldens\` recipe and fix that first`,
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const selected = only === null ? OPERATORS : OPERATORS.filter((op) => op.id === only);
|
|
682
|
+
if (only !== null && selected.length === 0) {
|
|
683
|
+
throw new CouldNotRun(`no operator with id \`${only}\` — see the OPERATORS table in cli/mutants.mjs`);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const findings = [];
|
|
687
|
+
let killed = 0;
|
|
688
|
+
let survived = 0;
|
|
689
|
+
for (const op of selected) {
|
|
690
|
+
const mutated = mutate(source, op);
|
|
691
|
+
if (mutated === source) {
|
|
692
|
+
throw new CouldNotRun(
|
|
693
|
+
`operator \`${op.id}\` placed but changed nothing — its \`find\` and \`replace\` are the same text. ` +
|
|
694
|
+
`A no-op operator is an operator that reports coverage it does not provide`,
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
const { module, dir } = await importMutant(mutated, cliDir, op);
|
|
698
|
+
dirs.push(dir);
|
|
699
|
+
const result = runCorpus(module.matchesRule, byId, corpus);
|
|
700
|
+
const actual = result.agreed ? "survives" : "killed";
|
|
701
|
+
if (actual === "killed") killed += 1;
|
|
702
|
+
else survived += 1;
|
|
703
|
+
if (actual === op.outcome) continue;
|
|
704
|
+
findings.push({
|
|
705
|
+
where: op.id,
|
|
706
|
+
what:
|
|
707
|
+
op.outcome === "killed"
|
|
708
|
+
? `is recorded KILLED and SURVIVED. The corpus no longer notices this mutation, so its coverage of ` +
|
|
709
|
+
`\`${op.member}\` has weakened. ${op.why} Add the case that kills it to evals/goldens/gates/ — ` +
|
|
710
|
+
`\`matchesRule\` is a pure function of (rule, tool, input) and a fixture is exactly that triple, ` +
|
|
711
|
+
`so a non-equivalent mutant is always killable by one`
|
|
712
|
+
: `is recorded SURVIVES and was KILLED at ${result.at} (${result.how}). That is good news and the ` +
|
|
713
|
+
`record must absorb it: the equivalence claimed for this operator no longer holds — either the ` +
|
|
714
|
+
`matcher changed or the claim was wrong. Re-read the \`why\` and change the outcome to \`killed\`. ` +
|
|
715
|
+
`A record that still describes a gap somebody closed is as wrong as one that hides a gap somebody opened`,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
say(`mutants: ${selected.length} operator(s) over ${corpus.length} fixture file(s) — ${killed} killed, ${survived} survived`);
|
|
720
|
+
say(`mutants: by matcher-region member —`);
|
|
721
|
+
for (const member of REGION) say(` ${String(counts.get(member)).padStart(2)} ${member}`);
|
|
722
|
+
if (uncovered.length) {
|
|
723
|
+
findings.push({
|
|
724
|
+
where: "the operator table",
|
|
725
|
+
what:
|
|
726
|
+
`leaves ${uncovered.length} matcher-region member(s) with no operator at all — ` +
|
|
727
|
+
`${uncovered.map((m) => `\`${m}\``).join(", ")}. Every member either carries an operator or REGION ` +
|
|
728
|
+
`should not list it; an unmutated member is a part of the matcher this census makes no claim about`,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (findings.length) {
|
|
733
|
+
for (const f of findings) stderr.write(`mutants: ${f.where}\n ${f.what}\n`);
|
|
734
|
+
stderr.write(`RED — ${findings.length} finding(s) in the mutation census\n`);
|
|
735
|
+
return 1;
|
|
736
|
+
}
|
|
737
|
+
say("GREEN — every mutant met its recorded outcome, and every matcher-region member carries one");
|
|
738
|
+
say("mutants: this measures whether the corpus NOTICES a broken matcher — not whether its attacks are realistic");
|
|
739
|
+
return 0;
|
|
740
|
+
} catch (error) {
|
|
741
|
+
if (error instanceof CouldNotRun || error instanceof CompileError) {
|
|
742
|
+
stderr.write(`mutants: ${error.message}\n`);
|
|
743
|
+
return 2;
|
|
744
|
+
}
|
|
745
|
+
stderr.write(`mutants: could not finish the census — ${error?.stack ?? error}\n`);
|
|
746
|
+
return 2;
|
|
747
|
+
} finally {
|
|
748
|
+
for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// URLs on both sides, since `import.meta.url` percent-encodes; the realpath covers a symlinked `bin`.
|
|
753
|
+
function isMain() {
|
|
754
|
+
const invoked = process.argv[1];
|
|
755
|
+
if (!invoked) return false;
|
|
756
|
+
if (import.meta.url === pathToFileURL(invoked).href) return true;
|
|
757
|
+
try {
|
|
758
|
+
return import.meta.url === pathToFileURL(fs.realpathSync(invoked)).href;
|
|
759
|
+
} catch {
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (isMain()) process.exitCode = await run(process.argv.slice(2));
|