polydeukes 0.6.0 → 0.7.1

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 (117) hide show
  1. package/README.ko.md +30 -70
  2. package/README.md +31 -84
  3. package/dist/baseline.d.ts +37 -1
  4. package/dist/baseline.js +68 -1
  5. package/dist/bin.d.ts +3 -4
  6. package/dist/bin.js +79 -93
  7. package/dist/covenant/bash-line.d.ts +130 -0
  8. package/dist/covenant/bash-line.js +566 -0
  9. package/dist/covenant/declaration-engine.d.ts +108 -0
  10. package/dist/covenant/declaration-engine.js +415 -0
  11. package/dist/covenant/discipline.d.ts +103 -0
  12. package/dist/covenant/discipline.js +667 -0
  13. package/dist/covenant/dispatch.d.ts +153 -0
  14. package/dist/covenant/dispatch.js +237 -0
  15. package/dist/covenant/extract-steps.d.ts +122 -0
  16. package/dist/covenant/extract-steps.js +0 -0
  17. package/dist/covenant/mention.d.ts +117 -0
  18. package/dist/covenant/mention.js +209 -0
  19. package/dist/covenant/module.d.ts +25 -0
  20. package/dist/covenant/module.js +23 -0
  21. package/dist/covenant/mutation-rules.d.ts +40 -0
  22. package/dist/covenant/mutation-rules.js +146 -0
  23. package/dist/covenant/relations.d.ts +46 -0
  24. package/dist/covenant/relations.js +68 -0
  25. package/dist/covenant/run-covenant.d.ts +95 -0
  26. package/dist/covenant/run-covenant.js +164 -0
  27. package/dist/covenant/self-mod.d.ts +55 -0
  28. package/dist/covenant/self-mod.js +84 -0
  29. package/dist/covenant/shell-evidence.d.ts +38 -0
  30. package/dist/covenant/shell-evidence.js +247 -0
  31. package/dist/covenant/shell-mod.d.ts +84 -0
  32. package/dist/covenant/shell-mod.js +213 -0
  33. package/dist/covenant/supply.d.ts +54 -0
  34. package/dist/covenant/supply.js +50 -0
  35. package/dist/covenant/transcript-mod.d.ts +60 -0
  36. package/dist/covenant/transcript-mod.js +254 -0
  37. package/dist/covenant/ttl-witness.d.ts +47 -0
  38. package/dist/covenant/ttl-witness.js +80 -0
  39. package/dist/covenant-check.d.ts +72 -39
  40. package/dist/covenant-check.js +260 -121
  41. package/dist/diff-ir.d.ts +28 -0
  42. package/dist/diff-ir.js +310 -0
  43. package/dist/docs/README.ko.md +60 -0
  44. package/dist/docs/README.md +64 -0
  45. package/dist/docs/catalog.json +480 -0
  46. package/dist/docs/concepts/judgment.ko.md +113 -0
  47. package/dist/docs/concepts/judgment.md +113 -0
  48. package/dist/docs/how-to/configure-project.ko.md +99 -0
  49. package/dist/docs/how-to/configure-project.md +98 -0
  50. package/dist/docs/how-to/connect-surfaces.ko.md +120 -0
  51. package/dist/docs/how-to/connect-surfaces.md +121 -0
  52. package/dist/docs/how-to/write-disciplines.ko.md +179 -0
  53. package/dist/docs/how-to/write-disciplines.md +181 -0
  54. package/dist/docs/index.json +2280 -0
  55. package/dist/docs/reference/cli/covenant-check.ko.md +148 -0
  56. package/dist/docs/reference/cli/covenant-check.md +153 -0
  57. package/dist/docs/reference/cli/docs.ko.md +97 -0
  58. package/dist/docs/reference/cli/docs.md +95 -0
  59. package/dist/docs/reference/cli/explain.ko.md +84 -0
  60. package/dist/docs/reference/cli/explain.md +89 -0
  61. package/dist/docs/reference/cli/init.ko.md +119 -0
  62. package/dist/docs/reference/cli/init.md +119 -0
  63. package/dist/docs/reference/configuration/index.ko.md +553 -0
  64. package/dist/docs/reference/{configuration.md → configuration/index.md} +215 -90
  65. package/dist/docs/reference/packages/adapter-claude-code.ko.md +110 -0
  66. package/dist/docs/reference/{adapter-claude-code.md → packages/adapter-claude-code.md} +47 -19
  67. package/dist/docs/reference/packages/adapter-grok.ko.md +77 -0
  68. package/dist/docs/reference/packages/adapter-grok.md +79 -0
  69. package/dist/docs/reference/packages/core.ko.md +174 -0
  70. package/dist/docs/reference/{core.md → packages/core.md} +74 -12
  71. package/dist/docs/reference/packages/polydeukes.ko.md +195 -0
  72. package/dist/docs/reference/packages/polydeukes.md +204 -0
  73. package/dist/docs/reference/packages/sdk-ts.ko.md +161 -0
  74. package/dist/docs/reference/packages/sdk-ts.md +164 -0
  75. package/dist/docs/troubleshooting.ko.md +144 -0
  76. package/dist/docs/troubleshooting.md +103 -151
  77. package/dist/docs/tutorials/first-judgment.ko.md +82 -0
  78. package/dist/docs/tutorials/first-judgment.md +81 -0
  79. package/dist/docs-catalog.d.ts +25 -0
  80. package/dist/docs-catalog.js +450 -0
  81. package/dist/docs-library.d.ts +23 -0
  82. package/dist/docs-library.js +347 -0
  83. package/dist/docs-markdown.d.ts +32 -0
  84. package/dist/docs-markdown.js +150 -0
  85. package/dist/docs-query.d.ts +11 -40
  86. package/dist/docs-query.js +28 -122
  87. package/dist/docs-types.d.ts +105 -0
  88. package/dist/docs-types.js +2 -0
  89. package/dist/explain.d.ts +1 -1
  90. package/dist/explain.js +37 -22
  91. package/dist/load-config.d.ts +1 -1
  92. package/dist/load-config.js +1 -1
  93. package/dist/pre-state-reader.d.ts +1 -1
  94. package/dist/pre-state-reader.js +1 -1
  95. package/dist/scaffold-project.d.ts +2 -2
  96. package/dist/scaffold-project.js +9 -3
  97. package/dist/schema/polydeukes.schema.json +12 -0
  98. package/dist/worktree-reader.d.ts +19 -0
  99. package/dist/worktree-reader.js +30 -0
  100. package/package.json +3 -17
  101. package/dist/claude-code-hook.d.ts +0 -79
  102. package/dist/claude-code-hook.js +0 -372
  103. package/dist/claude-code.d.ts +0 -6
  104. package/dist/claude-code.js +0 -6
  105. package/dist/covenant-module.d.ts +0 -25
  106. package/dist/covenant-module.js +0 -42
  107. package/dist/docs/configuration.md +0 -103
  108. package/dist/docs/installation.md +0 -241
  109. package/dist/docs/reference/adapter-git.md +0 -100
  110. package/dist/docs/reference/covenant.md +0 -116
  111. package/dist/docs/reference/polydeukes.md +0 -315
  112. package/dist/index.d.ts +0 -22
  113. package/dist/index.js +0 -21
  114. package/dist/init-claude-code.d.ts +0 -52
  115. package/dist/init-claude-code.js +0 -469
  116. package/dist/init-grok.d.ts +0 -51
  117. package/dist/init-grok.js +0 -242
@@ -0,0 +1,566 @@
1
+ /**
2
+ * Bash command-line tokenizer + mutation-target extraction core.
3
+ *
4
+ * Pure functions only — zero I/O, spawn, or logging. A hand-rolled single-pass character
5
+ * scanner recognizes quote state (`'`, `"`, `$'…'`, `\`), control operators
6
+ * (`;` `&&` `||` `|` `&`), redirect operators (`>` `>>` `>|` `<` `2>` `&>`, attached `>f`),
7
+ * and marks tokens opaque when their static value is unknowable (command substitution,
8
+ * parameter expansion, globs).
9
+ *
10
+ * Fail-closed: no input ever throws. A construct the scanner cannot finish reading yields a
11
+ * partial result — the commands it did read, plus one `unread` span per failure; in
12
+ * {@link extractMutations} each span becomes one indeterminate entry. Block/allow
13
+ * decisions, read-only allowlists, and detection rules live in the judges that consume this.
14
+ */
15
+ // Reinterpretation-boundary declaration, NOT a blocklist: a command whose first word is
16
+ // one of these re-parses its string arguments in a nested shell, so the tokenizer honestly
17
+ // reports indeterminate rather than parsing into it. Deliberately a small explicit set —
18
+ // residual vectors such as indirect path computation are telemetry's concern in the judges,
19
+ // not something this set should grow to chase.
20
+ const NESTED_SHELL_COMMANDS = new Set(['eval', 'bash', 'sh', 'zsh']);
21
+ /**
22
+ * True when `commandName` (a command word's basename) re-parses its string arguments in a
23
+ * nested shell — the reinterpretation boundary the tokenizer refuses to parse into. A judge
24
+ * can consult this to refuse to treat such a command as provably read-only.
25
+ */
26
+ export function isNestedShellCommand(commandName) {
27
+ return NESTED_SHELL_COMMANDS.has(commandName);
28
+ }
29
+ // A `NAME=VALUE` assignment word, the prefix bash allows (any number of them) before the
30
+ // command name.
31
+ const ASSIGNMENT_WORD = /^[A-Za-z_][A-Za-z0-9_]*=/;
32
+ /**
33
+ * The word that names the command, skipping any leading assignments — undefined when the
34
+ * command is nothing but assignments.
35
+ *
36
+ * Read at the nested-shell boundary only. There an unskipped assignment hides `bash` behind
37
+ * `FOO=1` and the line passes with confidence; for the read-only allowlist and precedent
38
+ * evidence the same miss is the conservative direction, so those keep reading `words[0]`.
39
+ */
40
+ export function commandNameWord(command) {
41
+ let i = 0;
42
+ while (i < command.words.length && ASSIGNMENT_WORD.test(command.words[i].text))
43
+ i += 1;
44
+ return command.words[i];
45
+ }
46
+ /**
47
+ * True if a double-quoted fragment carries a dynamic construct whose value is unknowable.
48
+ * Only expansion and command substitution run inside double quotes — bash does not glob
49
+ * there, so `*` and `?` are opacity grounds outside quotes only.
50
+ */
51
+ function quotedFragmentIsOpaque(fragment) {
52
+ return fragment.includes('$') || fragment.includes('`');
53
+ }
54
+ // Inside double quotes bash removes a backslash only before these characters; before
55
+ // anything else it stays literal content (`"a\|b"` is the four bytes `a\|b`). The same
56
+ // set decides the pairing, so an escaped `"` never closes the string.
57
+ const DOUBLE_QUOTE_ESCAPES = new Set(['$', '`', '"', '\\']);
58
+ /**
59
+ * Scan a double-quoted string whose opening `"` sits at `open`. Returns the content bash
60
+ * would pass and the index just past the closing quote; a string that never closes is read
61
+ * to the end of input and reported `closed: false`, never discarded.
62
+ */
63
+ function scanDoubleQuoted(line, open) {
64
+ let text = '';
65
+ let i = open + 1;
66
+ while (i < line.length) {
67
+ const ch = line[i];
68
+ if (ch === '\\') {
69
+ const next = line[i + 1];
70
+ // `\`+newline is a line continuation inside double quotes too — both characters go.
71
+ if (next === '\n') {
72
+ i += 2;
73
+ continue;
74
+ }
75
+ if (next !== undefined && DOUBLE_QUOTE_ESCAPES.has(next)) {
76
+ text += next;
77
+ i += 2;
78
+ continue;
79
+ }
80
+ text += ch;
81
+ i += 1;
82
+ continue;
83
+ }
84
+ // Always `decoded`: the escape set above IS bash's whole rule inside double quotes, so
85
+ // the text is the bytes bash passes with nothing left untranslated.
86
+ if (ch === '"')
87
+ return { text, next: i + 1, closed: true, decoded: true };
88
+ text += ch;
89
+ i += 1;
90
+ }
91
+ return { text, next: line.length, closed: false, decoded: true };
92
+ }
93
+ // The ANSI-C escapes decoded inside `$'…'`. Deliberately not the whole table: an escape
94
+ // that is not listed keeps its backslash, which is bash's own answer for one it does not
95
+ // recognize (measured: `$'\q'` is the two bytes `\q`).
96
+ const ANSI_C_ESCAPES = { n: '\n', t: '\t', "'": "'", '\\': '\\' };
97
+ /**
98
+ * Scan an ANSI-C quoted string (`$'…'`) whose opening `'` sits at `open`. Returns the
99
+ * DECODED bytes and the index just past the closing quote; a string that never closes is
100
+ * read to the end of input and reported `closed: false`. Decoding is not cosmetic: the word
101
+ * text becomes written-content evidence downstream, so handing back the source spelling
102
+ * would record bytes bash never writes.
103
+ *
104
+ * An escape the table does not carry sets `decoded: false`, and the caller turns that into
105
+ * opacity. Keeping the source spelling and calling it decided would assert bytes bash never
106
+ * writes AS CONFIDENT EVIDENCE: `$'\x64ist'` would be filed as the literal `\x64ist` while
107
+ * bash writes `dist`, so a judge reading written content compares a string that never
108
+ * existed and upholds with no unjudgeable row. Completing the table is NOT the fix — the
109
+ * next unlisted escape reproduces it. Declining to claim knowledge is.
110
+ */
111
+ function scanAnsiCQuoted(line, open) {
112
+ let text = '';
113
+ let decoded = true;
114
+ let i = open + 1;
115
+ while (i < line.length) {
116
+ const ch = line[i];
117
+ if (ch === '\\') {
118
+ const next = line[i + 1];
119
+ if (next === undefined)
120
+ break;
121
+ const replacement = ANSI_C_ESCAPES[next];
122
+ if (replacement === undefined)
123
+ decoded = false;
124
+ text += replacement ?? `\\${next}`;
125
+ i += 2;
126
+ continue;
127
+ }
128
+ if (ch === "'")
129
+ return { text, next: i + 1, closed: true, decoded };
130
+ text += ch;
131
+ i += 1;
132
+ }
133
+ return { text, next: line.length, closed: false, decoded };
134
+ }
135
+ /**
136
+ * Close a word on an unterminated quote whose opening character sits at `open`: the rest of
137
+ * the input is consumed, the word is opaque (its value depends on bytes the shell never
138
+ * received), and the raw span is reported. All three quote forms and the backtick branch of
139
+ * {@link scanWord} share this treatment.
140
+ */
141
+ function unreadFrom(line, open, text) {
142
+ return {
143
+ word: { text, opaque: true },
144
+ next: line.length,
145
+ unread: { text: line.slice(open), reason: 'unclosed quote' },
146
+ };
147
+ }
148
+ /**
149
+ * Scan one word starting at `i`, honoring quotes and escapes. Returns the assembled word
150
+ * and the index just past it; an unclosed quote also returns the span it could not read.
151
+ */
152
+ function scanWord(line, start) {
153
+ let text = '';
154
+ let opaque = false;
155
+ let i = start;
156
+ while (i < line.length) {
157
+ const ch = line[i];
158
+ // Whitespace and control/redirect operators terminate a word (outside quotes).
159
+ // Newlines count too: they separate commands, like `;`. Inside quotes they remain
160
+ // word content — the quote branches below consume across them.
161
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r')
162
+ break;
163
+ if (ch === ';' || ch === '|' || ch === '&' || ch === '<' || ch === '>')
164
+ break;
165
+ if (ch === '\\') {
166
+ // Backslash escape: the next character is literal, never a separator or expansion.
167
+ const nextCh = line[i + 1];
168
+ if (nextCh === '\n') {
169
+ // `\`+newline is a shell line continuation: elide both characters (the word
170
+ // continues on the next physical line) rather than inserting a literal newline.
171
+ i += 2;
172
+ continue;
173
+ }
174
+ if (nextCh === '\r' && line[i + 2] === '\n') {
175
+ i += 3;
176
+ continue;
177
+ }
178
+ if (nextCh !== undefined) {
179
+ text += nextCh;
180
+ i += 2;
181
+ continue;
182
+ }
183
+ text += ch;
184
+ i += 1;
185
+ continue;
186
+ }
187
+ if (ch === "'") {
188
+ // Single quotes: literal content, no expansion — never contributes opacity.
189
+ const close = line.indexOf("'", i + 1);
190
+ // The dequoted content JOINS the word in progress rather than starting a new one:
191
+ // `pack'ages/…` is one shell word, and splitting it leaves two halves that each
192
+ // match nothing.
193
+ if (close === -1)
194
+ return unreadFrom(line, i, text + line.slice(i + 1));
195
+ text += line.slice(i + 1, close);
196
+ i = close + 1;
197
+ continue;
198
+ }
199
+ if (ch === '"') {
200
+ // Double quotes: expansions still apply, so scan for opacity within.
201
+ const quoted = scanDoubleQuoted(line, i);
202
+ if (!quoted.closed)
203
+ return unreadFrom(line, i, text + quoted.text);
204
+ if (quotedFragmentIsOpaque(quoted.text))
205
+ opaque = true;
206
+ text += quoted.text;
207
+ i = quoted.next;
208
+ continue;
209
+ }
210
+ if (ch === '$' && line[i + 1] === "'") {
211
+ // ANSI-C quoting: bash decodes the escapes and passes a string constant, so a fully
212
+ // decoded one is decided — an expansion it is not, and marking it opaque would be
213
+ // wrong. An escape this scanner cannot translate is the opposite case: the text is
214
+ // source spelling, not the bytes bash passes, so the word's value is NOT known and
215
+ // saying otherwise files evidence for a string that never existed.
216
+ const quoted = scanAnsiCQuoted(line, i + 1);
217
+ if (!quoted.closed)
218
+ return unreadFrom(line, i, text + quoted.text);
219
+ if (!quoted.decoded)
220
+ opaque = true;
221
+ text += quoted.text;
222
+ i = quoted.next;
223
+ continue;
224
+ }
225
+ if (ch === '$' && line[i + 1] === '(') {
226
+ // Command substitution `$(…)` with nesting — consume to the matching close paren. A
227
+ // substitution that never closes has swallowed the rest of the line into this one
228
+ // opaque word, so it reports an unread span exactly like an unterminated quote does:
229
+ // without it the line would answer "fully read" and file no unjudgeable entry.
230
+ const scan = matchParen(line, i + 1);
231
+ const chunk = line.slice(i, scan.end);
232
+ if (!scan.closed)
233
+ return unreadFrom(line, i, text + chunk);
234
+ text += chunk;
235
+ opaque = true;
236
+ i = scan.end;
237
+ continue;
238
+ }
239
+ if (ch === '`') {
240
+ // Backtick command substitution — consume to the closing backtick.
241
+ const close = line.indexOf('`', i + 1);
242
+ const end = close === -1 ? line.length : close + 1;
243
+ text += line.slice(i, end);
244
+ opaque = true;
245
+ i = end;
246
+ continue;
247
+ }
248
+ // Ordinary character. Mark opacity for parameter expansion / globs.
249
+ if (ch === '$' || ch === '*' || ch === '?')
250
+ opaque = true;
251
+ text += ch;
252
+ i += 1;
253
+ }
254
+ return { word: { text, opaque }, next: i };
255
+ }
256
+ function matchParen(line, open) {
257
+ let depth = 0;
258
+ for (let i = open; i < line.length; i++) {
259
+ const ch = line[i];
260
+ // Quoting suspends the count: a `)` inside `"[^)]+"` is regex content, not a close paren.
261
+ // Counting it ends the substitution early and every quote after it pairs one position off.
262
+ if (ch === '\\') {
263
+ i += 1;
264
+ continue;
265
+ }
266
+ if (ch === "'") {
267
+ const close = line.indexOf("'", i + 1);
268
+ if (close === -1)
269
+ return { end: line.length, closed: false };
270
+ i = close;
271
+ continue;
272
+ }
273
+ if (ch === '"') {
274
+ const quoted = scanDoubleQuoted(line, i);
275
+ if (!quoted.closed)
276
+ return { end: line.length, closed: false };
277
+ i = quoted.next - 1;
278
+ continue;
279
+ }
280
+ if (ch === '(')
281
+ depth += 1;
282
+ else if (ch === ')') {
283
+ depth -= 1;
284
+ if (depth === 0)
285
+ return { end: i + 1, closed: true };
286
+ }
287
+ }
288
+ // Ran off the end with the nesting still open — the same unread condition as an
289
+ // unterminated quote above, reported the same way.
290
+ return { end: line.length, closed: false };
291
+ }
292
+ /**
293
+ * Recognize a redirect operator at `i`; returns the operator text and its length, or null.
294
+ * Longest forms first within each family, and a single-digit fd prefix is folded into the
295
+ * operator (`2>`, `2>>`, `2>&`) — it is only an fd when it starts a token, matching bash.
296
+ */
297
+ function scanRedirect(line, i) {
298
+ const three = line.slice(i, i + 3);
299
+ const two = three.slice(0, 2);
300
+ const ch = three[0];
301
+ // Fd prefix: bash folds ANY all-digit run immediately before `>` into the redirect
302
+ // (`12> f` sends fd 12 to f; tee receives no "12" operand), so the scan must too.
303
+ let digitEnd = i;
304
+ while (digitEnd < line.length && line[digitEnd] >= '0' && line[digitEnd] <= '9')
305
+ digitEnd += 1;
306
+ if (digitEnd > i && line[digitEnd] === '>') {
307
+ // `|` joins the tail set for `N>|`, which bash accepts as a write (`&>|` does not
308
+ // exist — a syntax error — so the `&>` family below is left alone).
309
+ const tail = line[digitEnd + 1];
310
+ const end = tail === '>' || tail === '&' || tail === '|' ? digitEnd + 2 : digitEnd + 1;
311
+ return { operator: line.slice(i, end), length: end - i };
312
+ }
313
+ if (three === '&>>')
314
+ return { operator: '&>>', length: 3 };
315
+ // `>|` truncates past `noclobber` — a write like `>`, and graded as one by the `>` it
316
+ // contains. `<&` is the read-direction twin of `>&` and closes an fd as `<&-`; without it
317
+ // the `-` never reads as a target and a valid line dies.
318
+ if (two === '>>' || two === '>|' || two === '&>' || two === '>&' || two === '<&') {
319
+ return { operator: two, length: 2 };
320
+ }
321
+ if (ch === '>')
322
+ return { operator: '>', length: 1 };
323
+ // Heredoc family, longest match first so `<<EOF` is never a lone `<` with an empty
324
+ // (fail-closing) target — `<<<` herestring, `<<-` tab-stripping heredoc, `<<`.
325
+ if (three === '<<<' || three === '<<-')
326
+ return { operator: three, length: 3 };
327
+ if (two === '<<')
328
+ return { operator: '<<', length: 2 };
329
+ if (ch === '<')
330
+ return { operator: '<', length: 1 };
331
+ return null;
332
+ }
333
+ /** Recognize a control operator at `i`; returns its text, or null. */
334
+ function scanControl(line, i) {
335
+ const two = line.slice(i, i + 2);
336
+ if (two === '&&')
337
+ return '&&';
338
+ if (two === '||')
339
+ return '||';
340
+ const ch = line[i];
341
+ if (ch === ';' || ch === '|' || ch === '&')
342
+ return ch;
343
+ return null;
344
+ }
345
+ /**
346
+ * Consume queued heredoc bodies starting at `start` (just past the opening newline), in
347
+ * queue order. Body lines are data — never parsed as commands — until a line equals the
348
+ * delimiter (`<<-` allows leading tabs), or end of input (bash ends at EOF too). Each body
349
+ * is recorded on the command that declared it, in the bytes bash would write (tabs
350
+ * stripped under `<<-`, the `\r` of CRLF dropped), and its span in the original line is
351
+ * appended to `data`. Returns the index just past the last consumed body.
352
+ */
353
+ function consumeHeredocBodies(line, start, pending, data) {
354
+ let i = start;
355
+ for (const heredoc of pending) {
356
+ let body = '';
357
+ const bodyStart = i;
358
+ // Where the body ends in the ORIGINAL line: the start of the delimiter line, or end of
359
+ // input when the delimiter never arrives. The delimiter line itself is not data.
360
+ let bodyEnd = i;
361
+ while (i < line.length) {
362
+ let end = line.indexOf('\n', i);
363
+ if (end === -1)
364
+ end = line.length;
365
+ let bodyLine = line.slice(i, end);
366
+ if (bodyLine.endsWith('\r'))
367
+ bodyLine = bodyLine.slice(0, -1);
368
+ const lineStart = i;
369
+ i = end + 1;
370
+ const stripped = heredoc.stripTabs ? bodyLine.replace(/^\t+/, '') : bodyLine;
371
+ if (stripped === heredoc.delimiter) {
372
+ bodyEnd = lineStart;
373
+ break;
374
+ }
375
+ body += `${stripped}\n`;
376
+ bodyEnd = Math.min(i, line.length);
377
+ }
378
+ // An unquoted delimiter leaves the body subject to expansion, so a `$(…)` or backtick
379
+ // in it is text bash executes — that body stays on the command line. An empty body
380
+ // has no bytes to delete and reports no span.
381
+ const expands = !heredoc.literal && /[$`]/.test(body);
382
+ if (bodyEnd > bodyStart && !expands)
383
+ data.push({ start: bodyStart, end: bodyEnd });
384
+ heredoc.owner.heredocs = [
385
+ ...(heredoc.owner.heredocs ?? []),
386
+ { body, literal: heredoc.literal },
387
+ ];
388
+ }
389
+ return i;
390
+ }
391
+ /**
392
+ * Tokenize one shell line into simple commands. Never throws, and never discards what it
393
+ * read: a construct it cannot finish reading is recorded as an `unread` span and the scan
394
+ * carries on.
395
+ */
396
+ export function tokenizeCommandLine(line) {
397
+ const commands = [];
398
+ const unread = [];
399
+ const data = [];
400
+ let current = { words: [], redirects: [] };
401
+ // Heredoc delimiters queued on the current line, consumed in order at the next newline.
402
+ let pendingHeredocs = [];
403
+ let i = 0;
404
+ while (i < line.length) {
405
+ const ch = line[i];
406
+ if (ch === ' ' || ch === '\t') {
407
+ i += 1;
408
+ continue;
409
+ }
410
+ // Newline (or CRLF, or a lone CR — every scanWord terminator needs a consuming
411
+ // branch here, or the loop stalls) separates commands like `;`, then feeds any
412
+ // queued heredocs their body lines.
413
+ if (ch === '\n' || ch === '\r') {
414
+ commands.push(current);
415
+ current = { words: [], redirects: [] };
416
+ i += ch === '\r' && line[i + 1] === '\n' ? 2 : 1;
417
+ i = consumeHeredocBodies(line, i, pendingHeredocs, data);
418
+ pendingHeredocs = [];
419
+ continue;
420
+ }
421
+ // Process substitution `<(…)` / `>(…)` is a word-level filename token bash EXECUTES,
422
+ // not a redirect. Consume the whole `(…)` (matching nested parens) as one opaque word,
423
+ // scanned before the redirect operators would split on the leading `<`/`>` — otherwise
424
+ // the inner command's args leak as top-level words and a first-word allowlist could
425
+ // absolve them while the inner write runs.
426
+ if ((ch === '<' || ch === '>') && line[i + 1] === '(') {
427
+ const scan = matchParen(line, i + 1);
428
+ current.words.push({ text: line.slice(i, scan.end), opaque: true });
429
+ // A substitution that never closes swallowed the rest of the line into that one word,
430
+ // so the span is recorded rather than left to read as fully parsed.
431
+ if (!scan.closed)
432
+ unread.push({ text: line.slice(i), reason: 'unclosed quote' });
433
+ i = scan.end;
434
+ continue;
435
+ }
436
+ // Redirects are scanned before control operators so `&>` is not mistaken for a lone `&`.
437
+ const redirect = scanRedirect(line, i);
438
+ if (redirect !== null) {
439
+ let j = i + redirect.length;
440
+ while (line[j] === ' ' || line[j] === '\t')
441
+ j += 1;
442
+ // A spaced process substitution is the redirect's target (`echo x > >(wc -c)` is
443
+ // valid bash): consume the whole `(…)` as ONE opaque target, exactly as the attached
444
+ // word-level branch above does — scanning it as an ordinary word would leak the inner
445
+ // command's arguments to top level, where a first-word allowlist could absolve them.
446
+ if ((line[j] === '<' || line[j] === '>') && line[j + 1] === '(') {
447
+ const scan = matchParen(line, j + 1);
448
+ current.redirects.push({
449
+ operator: redirect.operator,
450
+ target: { text: line.slice(j, scan.end), opaque: true },
451
+ });
452
+ if (!scan.closed)
453
+ unread.push({ text: line.slice(j), reason: 'unclosed quote' });
454
+ i = scan.end;
455
+ continue;
456
+ }
457
+ const scanned = scanWord(line, j);
458
+ if (scanned.unread !== undefined) {
459
+ // The target position ran into an unterminated quote: record the span and drop the
460
+ // redirect rather than claim a target nobody could read.
461
+ unread.push(scanned.unread);
462
+ i = scanned.next;
463
+ continue;
464
+ }
465
+ // A redirect with no target is a bash syntax error, but a LOCAL one: everything past
466
+ // the operator is still readable, so the span is recorded and the scan resumes there
467
+ // instead of throwing the line away.
468
+ if (scanned.word.text === '') {
469
+ unread.push({ text: line.slice(i, j), reason: 'missing redirect target' });
470
+ i = j;
471
+ continue;
472
+ }
473
+ if (redirect.operator === '<<' || redirect.operator === '<<-') {
474
+ // bash never expands a heredoc delimiter, so the body's end is decidable from the
475
+ // literal text even for `<<$D` — no fail-closed branch here.
476
+ pendingHeredocs.push({
477
+ delimiter: scanned.word.text,
478
+ stripTabs: redirect.operator === '<<-',
479
+ // A quoting character ANYWHERE in the delimiter makes the body literal
480
+ // (`<<E"O"F` and `<<\EOF` both stop expansion), and `scanWord` has already
481
+ // removed those characters — so the raw span is the only place to read it.
482
+ literal: /['"\\]/.test(line.slice(j, scanned.next)),
483
+ owner: current,
484
+ });
485
+ i = scanned.next;
486
+ continue;
487
+ }
488
+ // Process substitution (`>(…)`/`<(…)`): the real path lives inside the substitution
489
+ // and is not statically knowable — an opaque target, never a confident path.
490
+ const target = scanned.word.text.startsWith('(')
491
+ ? { ...scanned.word, opaque: true }
492
+ : scanned.word;
493
+ // A herestring's target word is stdin data, not a command: bash passes it as written,
494
+ // quotes included, so the span covers the raw word. An opaque word (`$(…)`, a
495
+ // backtick, a bare `(`) is text bash expands first, so it stays on the command line.
496
+ if (redirect.operator === '<<<' && !target.opaque) {
497
+ data.push({ start: j, end: scanned.next });
498
+ }
499
+ current.redirects.push({ operator: redirect.operator, target });
500
+ i = scanned.next;
501
+ continue;
502
+ }
503
+ const control = scanControl(line, i);
504
+ if (control !== null) {
505
+ commands.push(current);
506
+ current = { words: [], redirects: [] };
507
+ i += control.length;
508
+ continue;
509
+ }
510
+ const scanned = scanWord(line, i);
511
+ if (scanned.unread !== undefined)
512
+ unread.push(scanned.unread);
513
+ current.words.push(scanned.word);
514
+ i = scanned.next;
515
+ }
516
+ commands.push(current);
517
+ // Drop empty commands produced by leading/trailing/adjacent operators (e.g. ";;").
518
+ const nonEmpty = commands.filter((c) => c.words.length > 0 || c.redirects.length > 0);
519
+ return { commands: nonEmpty, unread, data };
520
+ }
521
+ /**
522
+ * The executed text of a command line: the line with every `data` span deleted and nothing
523
+ * put in its place, so the words bash runs keep their spelling and line structure. Bytes
524
+ * the scanner could not finish reading are not data spans and stay as written.
525
+ */
526
+ export function executedText(line) {
527
+ const { data } = tokenizeCommandLine(line);
528
+ let text = '';
529
+ let cut = 0;
530
+ for (const span of data) {
531
+ text += line.slice(cut, span.start);
532
+ cut = span.end;
533
+ }
534
+ return text + line.slice(cut);
535
+ }
536
+ /**
537
+ * Extract mutation targets from a shell line via injected rules. A simple command
538
+ * contributes an indeterminate entry when it is a nested-shell call OR contains any opaque
539
+ * word (in which case its rules are still applied, but an undecidable structure is present);
540
+ * each unread span yields one more. Never throws.
541
+ */
542
+ export function extractMutations(line, rules) {
543
+ const result = tokenizeCommandLine(line);
544
+ const mutations = [];
545
+ const indeterminate = result.unread.map((span) => ({ reason: span.reason }));
546
+ for (const command of result.commands) {
547
+ // Nested shell = reinterpretation boundary: report indeterminate, do not parse inside.
548
+ // Matched by command basename (`/bin/sh` → `sh`), the same boundary shell-mod's
549
+ // allowlist clause uses, so a leading-path nested shell is not missed by a raw compare.
550
+ const name = commandNameWord(command);
551
+ const nameBasename = name !== undefined ? name.text.slice(name.text.lastIndexOf('/') + 1) : '';
552
+ if (name !== undefined && isNestedShellCommand(nameBasename)) {
553
+ indeterminate.push({ reason: `nested shell execution: ${name.text}` });
554
+ continue;
555
+ }
556
+ // An opaque word or redirect target (command substitution, parameter expansion, glob)
557
+ // has an unknowable value — honestly indeterminate rather than a confident pass.
558
+ if (command.words.some((w) => w.opaque) || command.redirects.some((r) => r.target.opaque)) {
559
+ indeterminate.push({ reason: 'opaque token' });
560
+ }
561
+ for (const rule of rules) {
562
+ mutations.push(...rule.detect(command));
563
+ }
564
+ }
565
+ return { mutations, indeterminate };
566
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The declaration engine — `judge = relate ∘ extract` as a pure function of one `World`.
3
+ *
4
+ * `compileDeclaration` resolves a shape-validated declaration against the extract registry
5
+ * and answers either something judgeable or a config fault naming where the declaration is
6
+ * wrong; it never throws. `judgeDeclaration` then runs the extraction pipelines over a
7
+ * world value and evaluates the relations, answering a verdict. `witnessOpens` evaluates
8
+ * the witness block on its own, so a caller can let a satisfied condition stand after the
9
+ * verdict without re-reading it.
10
+ *
11
+ * The engine knows nothing but its two arguments: no files, no tools, no session. Whatever
12
+ * a host can express as named values in a `World` it can judge here.
13
+ */
14
+ import type { AlgebraDeclaration, BinaryStep, ExtractStep, RelateEntry } from '@polydeukes/core';
15
+ import { type ConfigFault, type World } from './extract-steps.ts';
16
+ import { type Witness } from './relations.ts';
17
+ export type { ConfigFault, Items, SessionSnapshot, World } from './extract-steps.ts';
18
+ export { EXTRACT_STEPS, UNARY_STEP_NAMES } from './extract-steps.ts';
19
+ export type { Witness } from './relations.ts';
20
+ /** One entry's failure: the text a human reads, and the elements that produced it. */
21
+ export type Break = {
22
+ readonly id: string;
23
+ readonly message: string;
24
+ readonly witnesses: readonly Witness[];
25
+ };
26
+ /**
27
+ * What one judgment answers. `not-applicable` separates the two ways a call goes unjudged —
28
+ * outside the declaration's scope, or missing a source the declaration lets pass.
29
+ */
30
+ export type DeclarationVerdict = {
31
+ readonly kind: 'pass';
32
+ } | {
33
+ readonly kind: 'broken';
34
+ readonly breaks: readonly Break[];
35
+ } | {
36
+ readonly kind: 'not-applicable';
37
+ readonly reason: 'scope' | 'supply-pass';
38
+ readonly source?: string;
39
+ } | {
40
+ readonly kind: 'supply-error';
41
+ readonly source: string;
42
+ readonly reason: string;
43
+ };
44
+ type CompiledPipeline = {
45
+ readonly name: string;
46
+ readonly paired: boolean;
47
+ readonly references: readonly string[];
48
+ readonly combinator?: BinaryStep;
49
+ readonly steps: readonly ExtractStep[];
50
+ };
51
+ type CompiledScope = {
52
+ readonly source: string;
53
+ readonly include: readonly RegExp[];
54
+ readonly exclude: readonly RegExp[];
55
+ };
56
+ /** The opaque result of compiling one declaration; only this module reads inside it. */
57
+ export type CompiledDeclaration = {
58
+ readonly pipelines: ReadonlyMap<string, CompiledPipeline>;
59
+ readonly relate: readonly RelateEntry[];
60
+ readonly supply: Readonly<Record<string, 'error' | 'pass' | 'empty'>>;
61
+ readonly scope?: CompiledScope;
62
+ readonly witness?: {
63
+ readonly pipelines: ReadonlyMap<string, CompiledPipeline>;
64
+ readonly relate: readonly RelateEntry[];
65
+ };
66
+ };
67
+ /** `compileDeclaration` input — the shape-validated declaration to resolve. */
68
+ export type CompileDeclarationSpec = {
69
+ declaration: AlgebraDeclaration;
70
+ };
71
+ /**
72
+ * Resolve a shape-validated declaration into something judgeable, or answer the first thing
73
+ * wrong with it: a step outside the registry, an argument outside a step's closed keys, an
74
+ * uncompilable expression, or a paired extraction where a single one belongs.
75
+ */
76
+ export declare function compileDeclaration(spec: CompileDeclarationSpec): CompiledDeclaration | ConfigFault;
77
+ /**
78
+ * Whether the declaration's scope admits this world — the routing question, asked without
79
+ * judging. A surface routes on the same predicate the judgment starts with, so a world it
80
+ * sends to the body is never one the body answers `not-applicable` for.
81
+ */
82
+ export declare function scopeAdmits(compiled: CompiledDeclaration, world: World): boolean;
83
+ /** `judgeDeclaration` input — one compiled declaration and the world it is judged against. */
84
+ export type JudgeDeclarationSpec = {
85
+ compiled: CompiledDeclaration;
86
+ world: World;
87
+ };
88
+ /**
89
+ * Judge one compiled declaration against one world.
90
+ *
91
+ * The scope decides first, so a declaration that does not apply never reads a source it
92
+ * would have failed on. Then every relate entry is evaluated in declaration order, one
93
+ * break per entry that does not hold. The first supply failure ends the judgment: a
94
+ * partially read world produces no partial verdict.
95
+ */
96
+ export declare function judgeDeclaration(spec: JudgeDeclarationSpec): DeclarationVerdict;
97
+ /** `witnessOpens` input — one compiled declaration and the world its witness is asked about. */
98
+ export type WitnessOpensSpec = {
99
+ compiled: CompiledDeclaration;
100
+ world: World;
101
+ };
102
+ /**
103
+ * Whether the declaration's witness condition holds for this world — the valve that stands
104
+ * after the verdict. It is closed unless every witness entry holds: a declaration with no
105
+ * witness block, a world outside the scope, and a source the witness cannot read all leave
106
+ * it shut, so the valve opens only on a condition that was actually met.
107
+ */
108
+ export declare function witnessOpens(spec: WitnessOpensSpec): boolean;