@cplieger/deadset-ts 8.0.0-dev.17 → 8.0.0-dev.19

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 (86) hide show
  1. package/dist/src/analysis.js +30 -72
  2. package/dist/src/augmentations.js +1 -1
  3. package/dist/src/calls.js +267 -48
  4. package/dist/src/commands/held.js +499 -0
  5. package/dist/src/commands/read.js +16 -10
  6. package/dist/src/commands/shell.js +109 -15
  7. package/dist/src/commands/tools.js +1540 -149
  8. package/dist/src/conformance-record.js +1 -1
  9. package/dist/src/contract/contract.generated.js +9 -2
  10. package/dist/src/entries/conventions.js +7 -6
  11. package/dist/src/entries/test-runners.js +6 -40
  12. package/dist/src/entries/workflow-steps.js +202 -28
  13. package/dist/src/exempt.js +0 -26
  14. package/dist/src/exposure.js +400 -0
  15. package/dist/src/findings/completion.js +4 -17
  16. package/dist/src/findings/emitters.js +22 -33
  17. package/dist/src/findings/intra-function.js +3 -3
  18. package/dist/src/findings/non-code-artifacts.js +11 -5
  19. package/dist/src/findings/reads-and-writes.js +71 -15
  20. package/dist/src/findings/unused-declarations.js +2 -22
  21. package/dist/src/findings/visibility-narrowing.js +14 -20
  22. package/dist/src/inventory.js +18 -0
  23. package/dist/src/{components.js → liveness/components.js} +1 -1
  24. package/dist/src/{graph.js → liveness/graph.js} +38 -21
  25. package/dist/src/liveness/matrix.js +99 -0
  26. package/dist/src/liveness/sweep.js +558 -0
  27. package/dist/src/query.js +46 -0
  28. package/dist/src/references/sink.js +87 -1
  29. package/dist/src/references.js +1 -1
  30. package/dist/src/roots/manifest.js +47 -4
  31. package/dist/src/roots/published-reach.js +4 -4
  32. package/dist/src/roots/root-set.js +14 -16
  33. package/dist/src/text/named-files.js +257 -34
  34. package/dist/src/verbs/analyze.js +16 -1
  35. package/dist/src/verbs/explain.js +73 -152
  36. package/dist/src/verbs/print-retained.js +1 -1
  37. package/jsr.json +1 -1
  38. package/package.json +1 -1
  39. package/src/analysis.ts +54 -108
  40. package/src/augmentations.ts +1 -1
  41. package/src/calls.ts +326 -52
  42. package/src/commands/held.ts +632 -0
  43. package/src/commands/read.ts +26 -12
  44. package/src/commands/shell.ts +117 -15
  45. package/src/commands/tools.ts +1972 -202
  46. package/src/conformance-record.ts +1 -1
  47. package/src/contract/contract.generated.ts +9 -2
  48. package/src/dependencies.ts +1 -1
  49. package/src/entries/conventions.ts +8 -12
  50. package/src/entries/test-runners.ts +12 -45
  51. package/src/entries/workflow-steps.ts +227 -38
  52. package/src/exempt.ts +31 -46
  53. package/src/exposure.ts +492 -0
  54. package/src/file-facts.ts +5 -0
  55. package/src/finding.ts +1 -1
  56. package/src/findings/boundary.ts +7 -0
  57. package/src/findings/completion.ts +5 -19
  58. package/src/findings/emitters.ts +23 -41
  59. package/src/findings/interfaces.ts +3 -3
  60. package/src/findings/intra-function.ts +3 -3
  61. package/src/findings/non-code-artifacts.ts +16 -5
  62. package/src/findings/reads-and-writes.ts +99 -26
  63. package/src/findings/unused-declarations.ts +4 -28
  64. package/src/findings/visibility-narrowing.ts +25 -26
  65. package/src/interface-satisfaction.ts +1 -1
  66. package/src/inventory.ts +19 -0
  67. package/src/{components.ts → liveness/components.ts} +3 -3
  68. package/src/{graph.ts → liveness/graph.ts} +74 -26
  69. package/src/liveness/matrix.ts +162 -0
  70. package/src/liveness/sweep.ts +855 -0
  71. package/src/query.ts +81 -0
  72. package/src/references/sink.ts +101 -0
  73. package/src/references.ts +8 -1
  74. package/src/roots/manifest.ts +53 -8
  75. package/src/roots/published-reach.ts +9 -4
  76. package/src/roots/root-set.ts +18 -16
  77. package/src/stores.ts +2 -1
  78. package/src/tests/file-class.ts +19 -2
  79. package/src/text/named-files.ts +333 -36
  80. package/src/verbs/analyze.ts +16 -1
  81. package/src/verbs/explain.ts +81 -199
  82. package/src/verbs/print-retained.ts +1 -1
  83. package/dist/src/matrix.js +0 -275
  84. package/dist/src/sweep.js +0 -234
  85. package/src/matrix.ts +0 -466
  86. package/src/sweep.ts +0 -384
@@ -0,0 +1,632 @@
1
+ /**
2
+ * The words layer 3 of the command reader reads: each word of a command string as the vendored
3
+ * shell parser reads it, and each token of a value of a command file that is no command, each
4
+ * with whether it may write the file a command runs. `text/named-files.ts` reads the files
5
+ * they name. Comments and keys are never words.
6
+ */
7
+
8
+ import {
9
+ forEachChild,
10
+ parse,
11
+ type CommandNode,
12
+ type ParsedScript,
13
+ type Redirection,
14
+ type SyntaxNode,
15
+ type Word as ShellWord,
16
+ } from "../vendor/unbash/parser.ts";
17
+ import { expressionsAsParts, heldTextsOf, parseLine, PART, wordOf, type Word } from "./shell.ts";
18
+ import { fileOperands, outputOf } from "./tools.ts";
19
+
20
+ /** One text layer 3 reads. */
21
+ export interface HeldText {
22
+ readonly text: string;
23
+ /** Whether a shell runs it: a command string, rather than a value that is no command. */
24
+ readonly shell: boolean;
25
+ /** The key a YAML value is written under. */
26
+ readonly key?: string;
27
+ /** Whether it is a manifest's script, whose `node --test` runs the files tests.1 roots. */
28
+ readonly tested?: boolean;
29
+ }
30
+
31
+ /** One word layer 3 reads, each part only the run knows in it written as {@link PART}. */
32
+ export interface HeldWord {
33
+ readonly text: string;
34
+ /** Whether it may write the file a command runs or a module a runtime loads before it. */
35
+ readonly runs: boolean;
36
+ }
37
+
38
+ /** A `uses:` value that references a repository's action, never a local path (https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsuses). */
39
+ const ACTION_REFERENCE = /^[\w-][\w.-]*\/[\w.-]+(?:\/[\w.-]+)*@[\w./-]+$/u;
40
+
41
+ /** The word of a command whose runtime runs a file no word of it writes, as another program hands it the path. */
42
+ const UNWRITTEN: HeldWord = { text: PART, runs: true };
43
+
44
+ /**
45
+ * What a command's standard input carries, as far as the line shows it: the file a `<` or `<>`
46
+ * names; the text of a here-document or here-string, command lines to a shell and code to a
47
+ * runtime; the lines a command before it in a pipeline writes, as {@link outputOf} reads them;
48
+ * and whether it carries what no row reads, another program's output.
49
+ */
50
+ interface Stdin {
51
+ readonly file?: Word;
52
+ readonly content: readonly string[];
53
+ readonly piped: readonly string[];
54
+ readonly unknown: boolean;
55
+ }
56
+
57
+ const NO_STDIN: Stdin = { content: [], piped: [], unknown: false };
58
+
59
+ const UNKNOWN_STDIN: Stdin = { ...NO_STDIN, unknown: true };
60
+
61
+ /** Whether `redirect` reads into descriptor 0, the default of `<`, `<>`, `<&`, `<<` and `<<<`. */
62
+ function readsInput(redirect: Redirection): boolean {
63
+ const { descriptor } = redirect;
64
+ return (
65
+ (redirect.type !== "Redirect" || ["<", "<>", "<&"].includes(redirect.operator)) &&
66
+ (descriptor === undefined || (descriptor.type === "FileDescriptor" && descriptor.value === 0))
67
+ );
68
+ }
69
+
70
+ /**
71
+ * The standard input `redirects` give a command, the last that reads descriptor 0 deciding as
72
+ * the shell applies them in order, or `inherited` where none does. A `<&` duplicates a
73
+ * descriptor the line does not show.
74
+ */
75
+ function stdinOf(redirects: readonly Redirection[], inherited: Stdin): Stdin {
76
+ const last = redirects.findLast(readsInput);
77
+ if (last === undefined) {
78
+ return inherited;
79
+ }
80
+ switch (last.type) {
81
+ case "HereDoc":
82
+ return { ...NO_STDIN, content: [last.body.text] };
83
+ case "HereString":
84
+ return { ...NO_STDIN, content: last.target === undefined ? [] : heldTextsOf(last.target) };
85
+ default:
86
+ return last.operator === "<&" || last.target === undefined
87
+ ? UNKNOWN_STDIN
88
+ : { ...NO_STDIN, file: wordOf(last.target) };
89
+ }
90
+ }
91
+
92
+ /** The words of a simple command, its program word first. */
93
+ function wordsOf(node: CommandNode): readonly ShellWord[] {
94
+ return node.type === "Command" && node.name !== undefined
95
+ ? [node.name, ...node.suffix.filter((part) => part.type === "Word")]
96
+ : [];
97
+ }
98
+
99
+ /** What the pipeline member `member`, whose own standard input `inputs` holds, writes to the next, as {@link outputOf} reads it. */
100
+ function pipedFrom(member: CommandNode, inputs: ReadonlyMap<SyntaxNode, Stdin>): Stdin {
101
+ const words = wordsOf(member);
102
+ const output = words.length === 0 ? undefined : outputOf(words.map(wordOf));
103
+ if (output === undefined) {
104
+ return UNKNOWN_STDIN;
105
+ }
106
+ const own = output.input ? (inputs.get(member) ?? NO_STDIN) : NO_STDIN;
107
+ const textsAt = (at: number): readonly string[] => {
108
+ const word = words[at];
109
+ return word === undefined ? [] : heldTextsOf(word);
110
+ };
111
+ const lines = [
112
+ ...output.at.flatMap(textsAt),
113
+ ...output.joined.map(([from, to]) =>
114
+ words
115
+ .slice(from, to)
116
+ .flatMap((_, at) => textsAt(from + at))
117
+ .join(" "),
118
+ ),
119
+ ...output.texts,
120
+ ];
121
+ return { ...own, piped: [...own.piped, ...lines] };
122
+ }
123
+
124
+ /** How many times a quoted text, a here-document's body or a `-c` value is read as a command line in turn. */
125
+ const DEPTH = 4;
126
+
127
+ /** A `\\` a path may hold: none that ends a line, nor one before a backquote, which it escapes. */
128
+ const BACKSLASH_PATH = /\\(?![\r\n`])/gu;
129
+
130
+ /** Text a shell may read as more than one word: a blank or an operator. */
131
+ const LINE_SYNTAX = /[\s;&|<>()]/u;
132
+
133
+ /** A shell parameter's name, or a special parameter, after its `$`. */
134
+ const PARAMETER = /[A-Za-z_]\w*|[0-9@*#?!$-]/uy;
135
+
136
+ /** The place after the brackets that open at `at`, nested ones included, or the text's end. */
137
+ function closing(text: string, at: number, open: string, close: string): number {
138
+ let depth = 0;
139
+ for (let next = at; next < text.length; next += 1) {
140
+ depth += text.charAt(next) === open ? 1 : text.charAt(next) === close ? -1 : 0;
141
+ if (depth === 0) {
142
+ return next + 1;
143
+ }
144
+ }
145
+ return text.length;
146
+ }
147
+
148
+ /** The text inside each outermost command substitution `text`, a line that does not parse, writes. */
149
+ function substitutions(text: string): readonly string[] {
150
+ const found: string[] = [];
151
+ for (let at = text.indexOf("$("); at >= 0; at = text.indexOf("$(", at + 1)) {
152
+ const end = closing(text, at + 1, "(", ")");
153
+ found.push(text.slice(at + 2, text.charAt(end - 1) === ")" ? end - 1 : end));
154
+ at = end - 1;
155
+ }
156
+ return found;
157
+ }
158
+
159
+ /**
160
+ * `text`, a line that does not parse, with each expansion it writes as a part: a parameter
161
+ * expansion, a command substitution or an arithmetic expansion to its matching bracket, and
162
+ * a backquoted command to its closing backquote, each to the text's end where none closes it.
163
+ */
164
+ function expansionsAsParts(text: string): string {
165
+ let read = "";
166
+ for (let at = 0; at < text.length; at += 1) {
167
+ const char = text.charAt(at);
168
+ const next = text.charAt(at + 1);
169
+ let end = at;
170
+ if (char === "`") {
171
+ const close = text.indexOf("`", at + 1);
172
+ end = close < 0 ? text.length : close + 1;
173
+ } else if (char === "$" && (next === "{" || next === "(")) {
174
+ end = closing(text, at + 1, next, next === "{" ? "}" : ")");
175
+ } else if (char === "$") {
176
+ PARAMETER.lastIndex = at + 1;
177
+ end = at + 1 + (PARAMETER.exec(text)?.[0].length ?? -1);
178
+ }
179
+ if (end > at) {
180
+ read += PART;
181
+ at = end - 1;
182
+ } else {
183
+ read += char;
184
+ }
185
+ }
186
+ return read;
187
+ }
188
+
189
+ /** A character a shell reads as syntax, a glob or a quote. */
190
+ const METACHARACTER = /[|&;<>()"'`*?[\]{}~\\]/gu;
191
+
192
+ /** An operator, which ends a shell word wherever it stands outside quotes. */
193
+ const OPERATOR = /[;&|<>()]+/u;
194
+
195
+ /**
196
+ * The texts one token stands for as one shell word, an assignment's value for an assignment,
197
+ * or `undefined` where it is not one.
198
+ */
199
+ function tokenTexts(token: string): readonly string[] | undefined {
200
+ const script = parse(token);
201
+ const [statement, ...more] = script.commands;
202
+ const command = statement?.command;
203
+ if ((script.errors ?? []).length > 0 || more.length > 0 || command?.type !== "Command") {
204
+ return undefined;
205
+ }
206
+ const [assignment, ...others] = command.prefix;
207
+ if (command.name === undefined) {
208
+ return others.length === 0 &&
209
+ command.suffix.length === 0 &&
210
+ assignment?.type === "Assignment" &&
211
+ assignment.value.type === "Word"
212
+ ? heldTextsOf(assignment.value)
213
+ : undefined;
214
+ }
215
+ return command.prefix.length === 0 && command.suffix.length === 0
216
+ ? heldTextsOf(command.name)
217
+ : undefined;
218
+ }
219
+
220
+ /**
221
+ * The texts one blank-separated token of a line that does not parse stands for: each
222
+ * expansion in it a part, then each piece of it between operators as the one word it is,
223
+ * else with its quotes removed, else with each other character a shell reads as syntax a part.
224
+ */
225
+ function tokenPieces(token: string): readonly string[] {
226
+ return expansionsAsParts(token)
227
+ .split(OPERATOR)
228
+ .filter((piece) => piece !== "")
229
+ .flatMap(
230
+ (piece) =>
231
+ tokenTexts(piece) ??
232
+ tokenTexts(piece.replaceAll(/["']/gu, "")) ?? [piece.replaceAll(METACHARACTER, PART)],
233
+ );
234
+ }
235
+
236
+ /** A token that ends a simple command, as a `;`, `&`, `|` or parenthesis it ends with does, and one that starts the next. */
237
+ const ENDS_COMMAND = /[;&|()]$/u;
238
+ const STARTS_COMMAND = /^[;&|()]/u;
239
+
240
+ /** The reserved words that may stand before a simple command's program word in a line. */
241
+ const RESERVED: ReadonlySet<string> = new Set(
242
+ ["if", "then", "else", "elif", "fi", "do", "done", "while", "until"].concat([
243
+ "case",
244
+ "esac",
245
+ "{",
246
+ "}",
247
+ "!",
248
+ "time",
249
+ ]),
250
+ );
251
+
252
+ /** A token that redirects: its descriptor, its operator, and its target where the token writes it. */
253
+ const REDIRECT = /^(\d*)(&>>|&>|<<<|<<-|<<|<>|<&|>&|>>|>\||<|>)(.*)$/su;
254
+
255
+ /**
256
+ * The standard input one redirect token of a line that does not parse gives: a file for `<`
257
+ * or `<>`, written as `target` writes it, and for a here-document, a here-string or a `<&`,
258
+ * text this reading does not take apart.
259
+ */
260
+ function tokenStdin(operator: string, target: string): Stdin {
261
+ if (operator !== "<" && operator !== "<>") {
262
+ return UNKNOWN_STDIN;
263
+ }
264
+ const text = target.replace(/[;&|)]+$/u, "").replaceAll(/["']/gu, "");
265
+ return {
266
+ ...NO_STDIN,
267
+ file: { value: text.includes(PART) ? undefined : text, text, written: text },
268
+ };
269
+ }
270
+
271
+ /**
272
+ * {@link fileOperands} of `words`, the tokens `tokens` of a line read without its structure,
273
+ * one simple command from each operator token to the next, past the reserved words it starts
274
+ * with, its redirect tokens read as {@link tokenStdin} reads them, each first word `program`'s.
275
+ * A command after a `|` reads what no row reads, and any other `stdin`, the line's own; a
276
+ * reader of what no word writes there runs a file no word writes.
277
+ */
278
+ function commandsOf(
279
+ tokens: readonly string[],
280
+ words: readonly Word[],
281
+ program: boolean,
282
+ stdin: Stdin,
283
+ ): ReturnType<typeof fileOperands> {
284
+ const places = new Set<number>();
285
+ const lines = new Map<number, string>();
286
+ const replaced = new Map<number, RegExp>();
287
+ const joined: (readonly [number, number])[] = [];
288
+ const texts: string[] = [];
289
+ let unwritten = false;
290
+ let input = false;
291
+ let stdinRead = false;
292
+ for (let from = 0; from < words.length;) {
293
+ let to = from;
294
+ while (to < words.length && !(to > from && STARTS_COMMAND.test(tokens[to] ?? ""))) {
295
+ to += 1;
296
+ if (ENDS_COMMAND.test(tokens[to - 1] ?? "")) {
297
+ break;
298
+ }
299
+ }
300
+ const piped =
301
+ /(?<!\|)\|$/u.test(tokens[from - 1] ?? "") || /^\|(?!\|)/u.test(tokens[from] ?? "");
302
+ let fed = piped ? UNKNOWN_STDIN : stdin;
303
+ const kept: number[] = [];
304
+ let start = from;
305
+ while (start < to && RESERVED.has(tokens[start] ?? "")) {
306
+ start += 1;
307
+ }
308
+ for (let at = start; at < to; at += 1) {
309
+ const [, descriptor, operator = "", rest = ""] = REDIRECT.exec(tokens[at] ?? "") ?? [];
310
+ if (descriptor === undefined) {
311
+ kept.push(at);
312
+ continue;
313
+ }
314
+ const target = rest === "" && at + 1 < to ? (tokens[(at += 1)] ?? "") : rest;
315
+ if ((descriptor === "" || descriptor === "0") && operator.startsWith("<")) {
316
+ fed = tokenStdin(operator, target);
317
+ }
318
+ }
319
+ const one = fileOperands(
320
+ kept.map((at) => words[at] ?? { value: undefined, text: "" }),
321
+ program,
322
+ fed.file,
323
+ );
324
+ const place = (at: number): number => kept[at] ?? to;
325
+ for (const at of one.places) {
326
+ places.add(place(at));
327
+ }
328
+ for (const [at, prefix] of one.lines) {
329
+ lines.set(place(at), prefix);
330
+ }
331
+ for (const [at, pattern] of one.replaced) {
332
+ replaced.set(place(at), pattern);
333
+ }
334
+ joined.push(...one.joined.map(([start, end]) => [place(start), place(end)] as const));
335
+ texts.push(...one.texts);
336
+ unwritten ||=
337
+ one.unwritten ||
338
+ ((one.input || one.stdin) && (fed.unknown || fed.content.length > 0 || fed.piped.length > 0));
339
+ input ||= one.input;
340
+ stdinRead ||= one.stdin;
341
+ from = to;
342
+ }
343
+ return { places, unwritten, lines, input, stdin: stdinRead, replaced, joined, texts };
344
+ }
345
+
346
+ /**
347
+ * The words of one line read without its structure: the whole line, each blank-separated
348
+ * token as {@link tokenPieces} reads it, as written, so the words inside an expansion count,
349
+ * and with each expansion a part, so a blank inside one splits nothing, and the text of each
350
+ * command substitution as a command line. A token may write the file a command runs at the
351
+ * places {@link fileOperands} gives, `program` saying whether the first token is a program word.
352
+ */
353
+ function tokenWords(
354
+ line: string,
355
+ program: boolean,
356
+ depth: number,
357
+ into: HeldWord[],
358
+ stdin = NO_STDIN,
359
+ ): void {
360
+ const read = expressionsAsParts(line);
361
+ into.push({ text: expansionsAsParts(read), runs: false });
362
+ // Past the depth a command line a program runs is read no more.
363
+ const asLine = (text: string, quoted: boolean): void => {
364
+ if (depth > 0) {
365
+ lineWords(text, depth - 1, quoted, into);
366
+ } else {
367
+ into.push(UNWRITTEN);
368
+ }
369
+ };
370
+ for (const inner of substitutions(read)) {
371
+ asLine(inner, true);
372
+ }
373
+ const blanks = (text: string): readonly string[] =>
374
+ text.split(/\s+/u).filter((token) => token !== "");
375
+ for (const token of blanks(read)) {
376
+ for (const text of tokenPieces(token)) {
377
+ into.push({ text, runs: false });
378
+ }
379
+ }
380
+ const tokens = blanks(expansionsAsParts(read));
381
+ const texts = tokens.map((token) => tokenTexts(token) ?? tokenPieces(token));
382
+ const operands = commandsOf(
383
+ tokens,
384
+ texts.map(([only, ...more], at) => ({
385
+ value: more.length === 0 && only !== undefined && !only.includes(PART) ? only : undefined,
386
+ text: tokens[at] ?? "",
387
+ ...(more.length === 0 && only !== undefined ? { written: only } : {}),
388
+ })),
389
+ program,
390
+ stdin,
391
+ );
392
+ const replacedAt = (at: number): readonly string[] =>
393
+ (texts[at] ?? []).map((text) => {
394
+ const replaced = operands.replaced.get(at);
395
+ return replaced === undefined ? text : text.replace(replaced, PART);
396
+ });
397
+ texts.forEach((_, at) => {
398
+ for (const read of replacedAt(at)) {
399
+ const prefix = operands.lines.get(at);
400
+ into.push({ text: read, runs: operands.places.has(at) });
401
+ if (prefix !== undefined) {
402
+ asLine(read.slice(read.startsWith(prefix) ? prefix.length : 0), false);
403
+ }
404
+ }
405
+ });
406
+ for (const [from, to] of operands.joined) {
407
+ asLine(
408
+ texts
409
+ .slice(from, to)
410
+ .flatMap((_, at) => replacedAt(from + at))
411
+ .join(" "),
412
+ false,
413
+ );
414
+ }
415
+ for (const text of operands.texts) {
416
+ asLine(text, false);
417
+ }
418
+ if (operands.unwritten) {
419
+ into.push(UNWRITTEN);
420
+ }
421
+ }
422
+
423
+ /**
424
+ * How a quoted text or a here-document's body is read: `text` as a command line, where it is
425
+ * not known to be one, without the program-word rule of {@link fileOperands}; `line` as one a
426
+ * shell reads, with it; and `read` not again, inside a word already read as a command line.
427
+ */
428
+ type Quoted = "text" | "line" | "read";
429
+
430
+ /**
431
+ * The standard input of each simple command below `node`: its own, as {@link stdinOf} reads
432
+ * it, that of the nearest compound command around it, or `inherited`, what the command that
433
+ * hands the line on reads; each later member of a pipeline reads what the one before writes.
434
+ */
435
+ function inputsOf(node: SyntaxNode, inherited: Stdin, into: Map<SyntaxNode, Stdin>): void {
436
+ const own =
437
+ node.type === "Command" || node.type === "Redirected"
438
+ ? stdinOf(node.redirects, inherited)
439
+ : inherited;
440
+ if (node.type === "Command") {
441
+ into.set(node, own);
442
+ }
443
+ if (node.type === "Pipeline") {
444
+ let carried = own;
445
+ for (const member of node.commands) {
446
+ inputsOf(member, carried, into);
447
+ carried = pipedFrom(member, into);
448
+ }
449
+ return;
450
+ }
451
+ forEachChild(node, (child) => {
452
+ inputsOf(child, own, into);
453
+ });
454
+ }
455
+
456
+ /** The simple commands of `script` the command reader's first layer reads: each member of each pipeline of each and-or list. */
457
+ function topCommands(script: ParsedScript): ReadonlySet<SyntaxNode> {
458
+ return new Set(
459
+ script.commands.flatMap(({ command }) =>
460
+ (command.type === "AndOr" ? command.commands : [command]).flatMap((one) =>
461
+ one.type === "Pipeline" ? one.commands : [one],
462
+ ),
463
+ ),
464
+ );
465
+ }
466
+
467
+ /**
468
+ * Each word one parsed command line writes, anywhere in its structure, into `into`. A quoted
469
+ * text or a here-document's body may be a command line a program it is handed to runs, so it
470
+ * is read as one in turn. Where {@link fileOperands} says a command hands a word, or words
471
+ * joined by blanks, to a shell, the shell reads them whole with the command's standard input,
472
+ * and where it reads its standard input, that input as {@link Stdin} reads it: a here-document
473
+ * is a command line only for a shell, and code for a runtime. `stdin` is what the line's own
474
+ * standard input carries, and `tests` whether the line is a manifest's script.
475
+ */
476
+ function scriptWords(
477
+ script: ParsedScript,
478
+ depth: number,
479
+ quoted: boolean,
480
+ into: HeldWord[],
481
+ stdin: Stdin,
482
+ tests: boolean,
483
+ ): void {
484
+ const runs = new Set<SyntaxNode>();
485
+ const lines = new Map<SyntaxNode, string>();
486
+ const replaced = new Map<SyntaxNode, RegExp>();
487
+ const handed = new Map<SyntaxNode, Stdin>();
488
+ const inputs = new Map<SyntaxNode, Stdin>();
489
+ inputsOf(script, stdin, inputs);
490
+ const top = tests ? topCommands(script) : new Set<SyntaxNode>();
491
+ const asLine = (text: string, how: Quoted, fed = NO_STDIN): void => {
492
+ if (depth > 0 && (how === "line" || (how === "text" && LINE_SYNTAX.test(text)))) {
493
+ lineWords(text, depth - 1, how === "text", into, fed);
494
+ } else if (how === "line") {
495
+ // Past the depth a command line a program runs is read no more.
496
+ into.push(UNWRITTEN);
497
+ }
498
+ };
499
+ const visit = (node: SyntaxNode, how: Quoted): void => {
500
+ let inner = how;
501
+ switch (node.type) {
502
+ case "Command": {
503
+ const fed = inputs.get(node) ?? NO_STDIN;
504
+ const words = wordsOf(node);
505
+ const operands =
506
+ words.length === 0
507
+ ? undefined
508
+ : fileOperands(words.map(wordOf), !quoted, fed.file, top.has(node));
509
+ words.forEach((word, at) => {
510
+ const pattern = operands?.replaced.get(at);
511
+ if (operands?.places.has(at) === true) {
512
+ runs.add(word);
513
+ }
514
+ const prefix = operands?.lines.get(at);
515
+ if (prefix !== undefined) {
516
+ lines.set(word, prefix);
517
+ handed.set(word, fed);
518
+ }
519
+ if (pattern !== undefined) {
520
+ replaced.set(word, pattern);
521
+ }
522
+ });
523
+ const reads = operands?.input === true || operands?.stdin === true;
524
+ if (operands?.unwritten === true || (reads && fed.unknown)) {
525
+ into.push(UNWRITTEN);
526
+ }
527
+ for (const text of reads ? fed.piped : []) {
528
+ asLine(text, "line");
529
+ }
530
+ for (const text of operands?.input === true ? fed.content : []) {
531
+ asLine(text, "line");
532
+ }
533
+ for (const text of operands?.texts ?? []) {
534
+ asLine(text, "line", fed);
535
+ }
536
+ for (const [from, to] of operands?.joined ?? []) {
537
+ const texts = words.slice(from, to).flatMap((word, at) => {
538
+ const pattern = operands?.replaced.get(from + at);
539
+ return heldTextsOf(word).map((one) =>
540
+ pattern === undefined ? one : one.replace(pattern, PART),
541
+ );
542
+ });
543
+ asLine(texts.join(" "), "line", fed);
544
+ }
545
+ break;
546
+ }
547
+ case "Word": {
548
+ const pattern = replaced.get(node);
549
+ const prefix = lines.get(node);
550
+ for (const one of heldTextsOf(node)) {
551
+ const text = pattern === undefined ? one : one.replace(pattern, PART);
552
+ into.push({ text, runs: runs.has(node) });
553
+ if (prefix !== undefined) {
554
+ asLine(
555
+ text.slice(text.startsWith(prefix) ? prefix.length : 0),
556
+ "line",
557
+ handed.get(node),
558
+ );
559
+ }
560
+ }
561
+ inner = lines.has(node) ? "read" : how;
562
+ break;
563
+ }
564
+ case "SingleQuoted":
565
+ case "AnsiCQuoted":
566
+ asLine(node.value, how);
567
+ break;
568
+ case "DoubleQuoted":
569
+ asLine(node.text.slice(1, -1), how);
570
+ break;
571
+ case "HereDocBody":
572
+ asLine(node.text, how);
573
+ break;
574
+ default:
575
+ break;
576
+ }
577
+ forEachChild(node, (child) => {
578
+ visit(child, inner);
579
+ });
580
+ };
581
+ visit(script, "text");
582
+ }
583
+
584
+ /**
585
+ * Each word of one command line, read twice: as POSIX reads it, and with each
586
+ * {@link BACKSLASH_PATH} read as `/`, as PowerShell and cmd read a path. A line that does not
587
+ * parse is read line by line, and a line of it that does not parse either by {@link tokenWords};
588
+ * `stdin` and `tests` are what {@link scriptWords} takes.
589
+ */
590
+ function lineWords(
591
+ source: string,
592
+ depth: number,
593
+ quoted: boolean,
594
+ into: HeldWord[],
595
+ stdin = NO_STDIN,
596
+ tests = false,
597
+ ): void {
598
+ for (const reading of new Set([source, source.replaceAll(BACKSLASH_PATH, "/")])) {
599
+ const script = parseLine(reading);
600
+ if ((script.errors ?? []).length === 0) {
601
+ scriptWords(script, depth, quoted, into, stdin, tests && reading === source);
602
+ continue;
603
+ }
604
+ for (const line of reading.split(/\r?\n/u)) {
605
+ const one = parseLine(line);
606
+ if ((one.errors ?? []).length === 0) {
607
+ scriptWords(one, depth, quoted, into, stdin, false);
608
+ } else {
609
+ tokenWords(line, !quoted, depth, into, stdin);
610
+ }
611
+ }
612
+ }
613
+ }
614
+
615
+ /**
616
+ * The words of `texts`: each command string's, and each line of each other value's, read by
617
+ * {@link tokenWords} as a quoted text is, with no program word, but for a `uses:` value that
618
+ * references another repository's action.
619
+ */
620
+ export function heldWords(texts: readonly HeldText[]): readonly HeldWord[] {
621
+ const found: HeldWord[] = [];
622
+ for (const { text, shell, key, tested } of texts) {
623
+ if (shell) {
624
+ lineWords(text, DEPTH, false, found, NO_STDIN, tested === true);
625
+ } else if (key !== "uses" || !ACTION_REFERENCE.test(text.trim())) {
626
+ for (const line of text.split(/\r?\n/u)) {
627
+ tokenWords(line, false, DEPTH, found);
628
+ }
629
+ }
630
+ }
631
+ return found;
632
+ }