@cplieger/deadset-ts 8.0.0-dev.12 → 8.0.0-dev.14

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 (73) hide show
  1. package/README.md +2 -0
  2. package/THIRD_PARTY_NOTICES.md +27 -0
  3. package/dist/src/build-scripts.js +6 -13
  4. package/dist/src/commands/read.js +156 -0
  5. package/dist/src/commands/shell.js +75 -0
  6. package/dist/src/commands/tools.js +460 -0
  7. package/dist/src/configuration-files.js +11 -25
  8. package/dist/src/conformance-record.js +1 -1
  9. package/dist/src/dependencies.js +67 -115
  10. package/dist/src/load/synthesize.js +12 -5
  11. package/dist/src/manifest.js +27 -14
  12. package/dist/src/roots.js +18 -11
  13. package/dist/src/text/named-files.js +24 -3
  14. package/dist/src/text/yaml.js +464 -173
  15. package/dist/src/vendor/unbash/ansi-c.js +132 -0
  16. package/dist/src/vendor/unbash/arithmetic-parts.js +37 -0
  17. package/dist/src/vendor/unbash/arithmetic.js +591 -0
  18. package/dist/src/vendor/unbash/assignment.js +131 -0
  19. package/dist/src/vendor/unbash/chars.js +48 -0
  20. package/dist/src/vendor/unbash/command.js +41 -0
  21. package/dist/src/vendor/unbash/expansion.js +80 -0
  22. package/dist/src/vendor/unbash/glob.js +268 -0
  23. package/dist/src/vendor/unbash/heredoc.js +27 -0
  24. package/dist/src/vendor/unbash/internal-types.js +1 -0
  25. package/dist/src/vendor/unbash/lexer.js +4274 -0
  26. package/dist/src/vendor/unbash/limits.js +3 -0
  27. package/dist/src/vendor/unbash/parser.js +1366 -0
  28. package/dist/src/vendor/unbash/parts.js +57 -0
  29. package/dist/src/vendor/unbash/substitution-context.js +8 -0
  30. package/dist/src/vendor/unbash/traversal.js +212 -0
  31. package/dist/src/vendor/unbash/types.js +1 -0
  32. package/dist/src/vendor/unbash/word-value.js +61 -0
  33. package/dist/src/vendor/unbash/word.js +40 -0
  34. package/dist/src/workflow-steps.js +111 -133
  35. package/jsr.json +2 -1
  36. package/package.json +4 -2
  37. package/src/build-scripts.ts +6 -13
  38. package/src/commands/read.ts +230 -0
  39. package/src/commands/shell.ts +126 -0
  40. package/src/commands/tools.ts +618 -0
  41. package/src/configuration-files.ts +10 -26
  42. package/src/conformance-record.ts +1 -1
  43. package/src/dependencies.ts +99 -123
  44. package/src/load/synthesize.ts +18 -5
  45. package/src/manifest.ts +43 -25
  46. package/src/roots.ts +20 -13
  47. package/src/text/named-files.ts +26 -3
  48. package/src/text/yaml.ts +503 -179
  49. package/src/vendor/unbash/LICENSE +15 -0
  50. package/src/vendor/unbash/VENDORED.md +16 -0
  51. package/src/vendor/unbash/ansi-c.ts +132 -0
  52. package/src/vendor/unbash/arithmetic-parts.ts +53 -0
  53. package/src/vendor/unbash/arithmetic.ts +641 -0
  54. package/src/vendor/unbash/assignment.ts +163 -0
  55. package/src/vendor/unbash/chars.ts +48 -0
  56. package/src/vendor/unbash/command.ts +47 -0
  57. package/src/vendor/unbash/expansion.ts +144 -0
  58. package/src/vendor/unbash/glob.ts +284 -0
  59. package/src/vendor/unbash/heredoc.ts +34 -0
  60. package/src/vendor/unbash/internal-types.ts +94 -0
  61. package/src/vendor/unbash/lexer.ts +4352 -0
  62. package/src/vendor/unbash/limits.ts +3 -0
  63. package/src/vendor/unbash/parser.ts +1612 -0
  64. package/src/vendor/unbash/parts.ts +96 -0
  65. package/src/vendor/unbash/substitution-context.ts +20 -0
  66. package/src/vendor/unbash/traversal.ts +182 -0
  67. package/src/vendor/unbash/types.ts +660 -0
  68. package/src/vendor/unbash/word-value.ts +64 -0
  69. package/src/vendor/unbash/word.ts +50 -0
  70. package/src/workflow-steps.ts +153 -154
  71. package/dist/src/text/shell.js +0 -159
  72. package/src/text/named-files.inputs.json +0 -1522
  73. package/src/text/shell.ts +0 -161
@@ -1,36 +1,29 @@
1
1
  /**
2
- * The source files a CI workflow's `run:` steps and the repository's git hooks name by their
3
- * path from the target root. A workflow or hook file is read as text, line by line, with no
4
- * YAML parsed.
2
+ * The command strings beside a target root's packages: a CI workflow's `run:` steps, the git
3
+ * hooks, the hook managers' commands and the makefile's recipe lines, each with the directory
4
+ * it runs in. A workflow or hook manager file is read by `text/yaml.ts`, and one it does not
5
+ * read gives no command; each command is read by `commands/read.ts`.
5
6
  */
6
- import { joinPath, relativePath, resolvePath } from "./paths.js";
7
+ import { pathsNamed, readCommands } from "./commands/read.js";
8
+ import { isRecord } from "./json-document.js";
9
+ import { joinPath, resolvePath } from "./paths.js";
7
10
  import { filesIn } from "./fs/tree.js";
8
- import { commandsOf, scriptTokens, unquoted, withoutComment } from "./text/shell.js";
11
+ import { yamlDocument } from "./text/yaml.js";
9
12
  const WORKFLOWS = ".github/workflows";
10
- /** A line holding a run command: indentation and one sequence marker before `run:`. */
11
- const RUN_LINE = /^(\s*(?:- )?)run:(.*)$/u;
12
13
  /** The directories whose every file is a git hook script, run as the shell runs it. */
13
14
  const HOOK_DIRS = [".husky", ".githooks"];
14
- /** A line setting the directory a workflow step, or each step a `defaults.run` covers, runs in. */
15
- const WORKING_DIRECTORY = /^(\s*(?:- )?)working-directory:(.*)$/u;
16
- const LEFTHOOK_FILES = ["lefthook.yml", "lefthook.yaml", ".lefthook.yml"];
17
- /** A line setting the directory a lefthook command runs in. */
18
- const LEFTHOOK_ROOT = /^(\s*(?:- )?)root:(.*)$/u;
19
- /** The hook manager files whose commands are written under a key, with that key. */
15
+ /** The key setting the directory a workflow step, or each step a `defaults.run` covers, runs in. */
16
+ const WORKING_DIRECTORY = "working-directory";
17
+ /**
18
+ * The hook manager files, each with the key its commands are written under and the key of their
19
+ * directory: lefthook's `root`, https://lefthook.dev/configuration/root/
20
+ */
20
21
  const HOOK_MANAGERS = [
21
- [".pre-commit-config.yaml", /^(\s*(?:- )?)entry:(.*)$/u],
22
- ...LEFTHOOK_FILES.map((name) => [name, RUN_LINE]),
22
+ [".pre-commit-config.yaml", "entry", undefined],
23
+ ...["lefthook.yml", "lefthook.yaml", ".lefthook.yml"].map((name) => [name, "run", "root"]),
23
24
  ];
24
25
  /** The names GNU make reads a makefile under, in the order it tries them. */
25
26
  const MAKEFILES = ["GNUmakefile", "makefile", "Makefile"];
26
- const BLOCK_SCALAR = /^[|>][+-]?$/u;
27
- /** A token holding one of these is computed or a pattern, so it names no file. */
28
- const COMPUTED = /[${*?[]/u;
29
- const INDENT = /^\s*/u;
30
- /** The files directly in one directory whose name `accepts`, by path in name order. */
31
- function pathsIn(host, dir, accepts) {
32
- return filesIn(host, dir, accepts).map((name) => joinPath(dir, name));
33
- }
34
27
  function readText(host, path) {
35
28
  try {
36
29
  return host.readFile(path);
@@ -39,144 +32,129 @@ function readText(host, path) {
39
32
  return undefined;
40
33
  }
41
34
  }
42
- /** Every line one workflow file's `run:` steps hold, or every line of the values written under `key`. */
43
- export function runCommands(text, key = RUN_LINE) {
44
- const lines = text.split(/\r?\n/u);
35
+ /** The text a YAML scalar's value stands for, or `undefined` for a collection or a null. */
36
+ function scalarText(value) {
37
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
38
+ ? String(value)
39
+ : undefined;
40
+ }
41
+ /** The `defaults.run.working-directory` one mapping writes, as YAML decodes it. */
42
+ function defaultDirectory(mapping) {
43
+ const defaults = mapping["defaults"];
44
+ const run = isRecord(defaults) ? defaults["run"] : undefined;
45
+ return isRecord(run) ? scalarText(run[WORKING_DIRECTORY]) : undefined;
46
+ }
47
+ /**
48
+ * Every command one workflow or hook manager file writes under `key`, in every mapping of the
49
+ * document, each with the directory it runs in: its mapping's `dirKey` value, else, in a workflow
50
+ * file, the innermost `defaults.run.working-directory` of a mapping holding it, else `root`. A
51
+ * file `yamlDocument` does not read gives none.
52
+ */
53
+ function keyedCommands(text, root, key, dirKey) {
45
54
  const found = [];
46
- for (let at = 0; at < lines.length; at += 1) {
47
- const match = key.exec(lines[at] ?? "");
48
- if (match === null) {
49
- continue;
55
+ const visit = (node, inherited) => {
56
+ if (Array.isArray(node)) {
57
+ for (const item of node) {
58
+ visit(item, inherited);
59
+ }
60
+ return;
50
61
  }
51
- const column = (match[1] ?? "").length;
52
- const value = unquoted((match[2] ?? "").trim());
53
- if (!BLOCK_SCALAR.test(value)) {
54
- found.push(value);
55
- continue;
62
+ if (!isRecord(node)) {
63
+ return;
56
64
  }
57
- for (let next = at + 1; next < lines.length; next += 1) {
58
- const line = lines[next] ?? "";
59
- if (line.trim() !== "" && (INDENT.exec(line)?.[0].length ?? 0) <= column) {
60
- break;
61
- }
62
- found.push(line);
65
+ const here = (dirKey === WORKING_DIRECTORY ? defaultDirectory(node) : undefined) ?? inherited;
66
+ const command = scalarText(node[key]);
67
+ if (command !== undefined && command.trim() !== "") {
68
+ const written = (dirKey === undefined ? undefined : scalarText(node[dirKey])) ?? here;
69
+ found.push({
70
+ text: command,
71
+ dir: written === undefined
72
+ ? root
73
+ : written.includes("${{")
74
+ ? undefined
75
+ : resolvePath(root, written),
76
+ writtenDirectory: written,
77
+ });
63
78
  }
64
- }
79
+ for (const value of Object.values(node)) {
80
+ visit(value, here);
81
+ }
82
+ };
83
+ visit(yamlDocument(text), undefined);
65
84
  return found;
66
85
  }
86
+ /** The path of each git hook script at `root`, every file in {@link HOOK_DIRS}. */
87
+ function hookScripts(host, root) {
88
+ return HOOK_DIRS.flatMap((dir) => filesIn(host, joinPath(root, dir), () => true).map((name) => joinPath(root, dir, name)));
89
+ }
90
+ /** The makefile make reads at `root`, the first of {@link MAKEFILES} there. */
91
+ function makefileAt(host, root) {
92
+ return MAKEFILES.map((name) => joinPath(root, name)).find((candidate) => host.kindOf(candidate) === "file");
93
+ }
67
94
  /**
68
- * The commands of the recipe lines of the target root's makefile, each line's in order: a
69
- * recipe line is one that starts with a tab, joined with the lines a trailing `\`
70
- * continues it onto.
95
+ * Each recipe line of the target root's makefile: a line that starts with a tab, joined with
96
+ * the lines a trailing `\` continues it onto, less the `@`, `-` and `+` make reads before it.
97
+ * Make runs each line in a shell of its own.
71
98
  */
72
99
  function recipeLines(host, root) {
73
- const path = MAKEFILES.map((name) => joinPath(root, name)).find((candidate) => host.kindOf(candidate) === "file");
100
+ const path = makefileAt(host, root);
74
101
  const text = path === undefined ? undefined : readText(host, path);
75
- if (text === undefined) {
76
- return [];
77
- }
78
- return text
102
+ return (text ?? "")
79
103
  .replace(/\\\r?\n/gu, " ")
80
104
  .split(/\r?\n/u)
81
105
  .filter((line) => line.startsWith("\t"))
82
- .map((line) => commandsOf(withoutComment(line.slice(1).replace(/^[@+-]+/u, ""))));
106
+ .map((line) => ({
107
+ text: line.slice(1).replace(/^[@+-]+/u, ""),
108
+ dir: root,
109
+ writtenDirectory: undefined,
110
+ }));
83
111
  }
84
112
  /**
85
- * Every token the recipe lines of the target root's makefile hold, each with the directory
86
- * below the root it is read against: make runs each line in a shell of its own, so a `cd`
87
- * moves the directory of the later commands of its line alone.
88
- */
89
- function recipeTokens(host, root) {
90
- const found = [];
91
- for (const line of recipeLines(host, root)) {
92
- let dir = root;
93
- for (const command of line) {
94
- const tokens = scriptTokens(command);
95
- const [first, target, ...rest] = tokens;
96
- if (first === "cd" && target !== undefined && rest.length === 0 && !COMPUTED.test(target)) {
97
- dir = resolvePath(dir, target);
98
- continue;
99
- }
100
- for (const token of tokens) {
101
- found.push({ token, dir: relativePath(root, dir) ?? "" });
102
- }
103
- }
104
- }
105
- return found;
106
- }
107
- /**
108
- * Every command the target root's workflow steps, git hooks and makefile run: the `run:`
109
- * steps of the workflow files in `.github/workflows`, every line of each file in `.husky`
110
- * and `.githooks`, the `entry:` commands of `.pre-commit-config.yaml`, the `run:` commands
111
- * of a lefthook file and the makefile's recipe lines.
113
+ * Every command the target root's workflow steps, git hooks, hook managers and makefile run:
114
+ * the `run:` steps of the workflow files in `.github/workflows`, each file in `.husky` and
115
+ * `.githooks` as one shell script, the `entry:` commands of `.pre-commit-config.yaml`, the
116
+ * `run:` commands of a lefthook file and the makefile's recipe lines.
112
117
  */
113
118
  export function workflowCommands(host, targetRoot) {
114
119
  const root = resolvePath(host.workingDirectory(), targetRoot);
115
- return [...stepCommands(host, root), ...recipeLines(host, root).flat()];
116
- }
117
- /**
118
- * Every directory the target root's workflow steps and lefthook commands are set to run in, as
119
- * written: each `working-directory` of a workflow file and each `root` of a lefthook file.
120
- */
121
- export function commandDirectories(host, targetRoot) {
122
- const root = resolvePath(host.workingDirectory(), targetRoot);
120
+ const workflows = filesIn(host, joinPath(root, WORKFLOWS), (name) => /\.ya?ml$/u.test(name));
123
121
  return [
124
- ...workflowFiles(host, root).flatMap((path) => runCommands(readText(host, path) ?? "", WORKING_DIRECTORY)),
125
- ...LEFTHOOK_FILES.flatMap((name) => runCommands(readText(host, joinPath(root, name)) ?? "", LEFTHOOK_ROOT)),
122
+ ...workflows.flatMap((name) => keyedCommands(readText(host, joinPath(root, WORKFLOWS, name)) ?? "", root, "run", WORKING_DIRECTORY)),
123
+ ...hookScripts(host, root).map((path) => ({
124
+ text: readText(host, path) ?? "",
125
+ dir: root,
126
+ writtenDirectory: undefined,
127
+ })),
128
+ ...HOOK_MANAGERS.flatMap(([name, key, dirKey]) => keyedCommands(readText(host, joinPath(root, name)) ?? "", root, key, dirKey)),
129
+ ...recipeLines(host, root),
126
130
  ];
127
131
  }
128
- /** The workflow files in `.github/workflows` at one root, by path in name order. */
129
- function workflowFiles(host, root) {
130
- return pathsIn(host, joinPath(root, WORKFLOWS), (name) => /\.ya?ml$/u.test(name));
131
- }
132
- /** Every token the commands {@link workflowCommands} reads hold. */
133
- export function workflowTokens(host, targetRoot) {
132
+ /**
133
+ * The whole text of each file {@link workflowCommands} reads a command from, each workflow file,
134
+ * git hook, hook manager file and makefile at the target root. Layer 3 holds it whole, so a part
135
+ * no command is read from, a comment included, or one the reading of the structure misses, still
136
+ * holds every file it names.
137
+ */
138
+ export function commandFileTexts(host, targetRoot) {
134
139
  const root = resolvePath(host.workingDirectory(), targetRoot);
135
140
  return [
136
- ...stepCommands(host, root).flatMap((command) => scriptTokens(command)),
137
- ...recipeTokens(host, root).map(({ token }) => token),
138
- ];
139
- }
140
- /** The commands of the workflow steps, git hooks and hook managers at one root. */
141
- function stepCommands(host, root) {
142
- const commands = [];
143
- for (const path of workflowFiles(host, root)) {
144
- commands.push(...runCommands(readText(host, path) ?? ""));
145
- }
146
- for (const dir of HOOK_DIRS) {
147
- for (const path of pathsIn(host, joinPath(root, dir), () => true)) {
148
- commands.push(...(readText(host, path) ?? "").split(/\r?\n/u).map(withoutComment));
149
- }
150
- }
151
- for (const [name, key] of HOOK_MANAGERS) {
152
- commands.push(...runCommands(readText(host, joinPath(root, name)) ?? "", key));
153
- }
154
- return commands;
141
+ ...filesIn(host, joinPath(root, WORKFLOWS), (name) => /\.ya?ml$/u.test(name)).map((name) => joinPath(root, WORKFLOWS, name)),
142
+ ...hookScripts(host, root),
143
+ ...HOOK_MANAGERS.map(([name]) => joinPath(root, name)),
144
+ ...[makefileAt(host, root) ?? []].flat(),
145
+ ].flatMap((path) => readText(host, path) ?? []);
155
146
  }
156
147
  /**
157
- * The own files the target root's workflow steps and git hooks name, as
158
- * {@link workflowTokens} reads them: a token equal to a file's path below the target root,
159
- * with or without a leading `./`, whatever directory the step runs in.
148
+ * The own files the target root's workflow steps, git hooks and recipes run or name, as
149
+ * `pathsNamed` reads each command.
160
150
  */
161
151
  export function workflowEntries(host, targetRoot, files) {
162
152
  const found = [];
163
- for (const token of workflowTokens(host, targetRoot)) {
164
- if (COMPUTED.test(token)) {
165
- continue;
166
- }
167
- const file = files.byPath.get(token.startsWith("./") ? token.slice(2) : token);
168
- if (file !== undefined) {
169
- found.push({ file, source: token });
170
- }
171
- }
172
- const root = resolvePath(host.workingDirectory(), targetRoot);
173
- for (const { token, dir } of recipeTokens(host, root)) {
174
- const below = COMPUTED.test(token) || dir === ""
175
- ? undefined
176
- : relativePath(root, resolvePath(joinPath(root, dir), token));
177
- const file = below === undefined ? undefined : files.byPath.get(below);
178
- if (file !== undefined) {
179
- found.push({ file, source: token });
153
+ for (const { text, dir } of workflowCommands(host, targetRoot)) {
154
+ for (const { path, written } of pathsNamed(readCommands(text, dir))) {
155
+ for (const file of files.named(path)) {
156
+ found.push({ file, source: written });
157
+ }
180
158
  }
181
159
  }
182
160
  return found;
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/deadset-ts",
3
- "version": "8.0.0-dev.12",
3
+ "version": "8.0.0-dev.14",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -9,6 +9,7 @@
9
9
  "include": [
10
10
  "LICENSE",
11
11
  "NOTICE",
12
+ "THIRD_PARTY_NOTICES.md",
12
13
  "README.md",
13
14
  "bin/**",
14
15
  "src/**",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/deadset-ts",
3
- "version": "8.0.0-dev.12",
3
+ "version": "8.0.0-dev.14",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Deterministic dead-code analysis for TypeScript, class and type members included",
@@ -20,6 +20,7 @@
20
20
  "scripts": {
21
21
  "build": "tsc -p tsconfig.build.json",
22
22
  "gen": "node scripts/gen-contract.ts",
23
+ "vendor": "node scripts/vendor-unbash.ts",
23
24
  "prepack": "npm run build",
24
25
  "typecheck": "tsc -p tsconfig.json",
25
26
  "typecheck:tests": "tsc -p tsconfig.test.json",
@@ -46,7 +47,7 @@
46
47
  "fast-check": "4.10.2",
47
48
  "prettier": "3.9.10",
48
49
  "typescript": "npm:@typescript/typescript6@6.0.2",
49
- "typescript-eslint": "8.71.2",
50
+ "typescript-eslint": "8.72.1",
50
51
  "vitest": "5.0.5"
51
52
  },
52
53
  "publishConfig": {
@@ -72,6 +73,7 @@
72
73
  "files": [
73
74
  "LICENSE",
74
75
  "NOTICE",
76
+ "THIRD_PARTY_NOTICES.md",
75
77
  "dist/",
76
78
  "src/",
77
79
  "!src/**/*.test.ts",
@@ -40,11 +40,11 @@ import type { Host } from "./host.ts";
40
40
  import { ancestors, dirnamePath, relativePath, resolvePath } from "./paths.ts";
41
41
  import type { SourceFiles } from "./source-files.ts";
42
42
  import { bytewise } from "./text/compare.ts";
43
- import { isRecord } from "./json-document.ts";
44
43
  import { unwrapped } from "./unwrap.ts";
45
44
  import { holdsManifest, inInstalledPackages, manifestAt } from "./fs/manifest.ts";
46
45
  import { globExpression } from "./text/glob.ts";
47
- import { scriptTokens } from "./text/shell.ts";
46
+ import { pathsNamed, readCommands } from "./commands/read.ts";
47
+ import { manifestCommands } from "./manifest.ts";
48
48
 
49
49
  /** One bundler API: the module, the functions it exports that build, and the option naming entries. */
50
50
  interface BundlerAPI {
@@ -473,19 +473,12 @@ function scriptEntries(file: SourceFile, dir: string, files: SourceFiles): reado
473
473
  return found;
474
474
  }
475
475
 
476
- /** The modules one manifest's scripts run, by absolute path, each below the target root. */
476
+ /** The modules one manifest's command strings run or name, by absolute path, each below the target root. */
477
477
  function scriptModules(host: Host, root: string, dir: string): readonly string[] {
478
- const scripts = manifestAt(host, dir)?.["scripts"];
479
- if (!isRecord(scripts)) {
480
- return [];
481
- }
478
+ const manifest = manifestAt(host, dir);
482
479
  const found = new Set<string>();
483
- for (const command of Object.values(scripts)) {
484
- if (typeof command !== "string") {
485
- continue;
486
- }
487
- for (const token of scriptTokens(command)) {
488
- const path = resolvePath(dir, token);
480
+ for (const { command } of manifest === undefined ? [] : manifestCommands(host, dir, manifest)) {
481
+ for (const { path } of pathsNamed(readCommands(command, dir))) {
489
482
  if (
490
483
  MODULE_EXTENSION.test(path) &&
491
484
  relativePath(root, path) !== undefined &&
@@ -0,0 +1,230 @@
1
+ /**
2
+ * The one reader of every command string the analysis reads. Layer 3, the holding rule
3
+ * `roots.ts` applies through `text/named-files.ts`, reads all of the string's text whatever
4
+ * the other two layers make of it; layer 1 parses the string and layer 2 reads each simple
5
+ * command a tool's reader understands, and they only add the files and modules it runs.
6
+ */
7
+
8
+ import { joinPath, resolvePath } from "../paths.ts";
9
+ import { shellLine, type Pipeline, type Word } from "./shell.ts";
10
+ import {
11
+ configurationsOf,
12
+ preloadTarget,
13
+ readBuiltin,
14
+ readTool,
15
+ type Outcome,
16
+ type Preload,
17
+ type Reading,
18
+ } from "./tools.ts";
19
+
20
+ /** One program a read command runs. */
21
+ interface Run {
22
+ /** The directory it runs in, absolute where the string's directory is. */
23
+ readonly dir: string;
24
+ /** The path of the file it runs, as written. */
25
+ readonly entry: string | undefined;
26
+ /** The modules it loads before the entry. */
27
+ readonly preloads: readonly Preload[];
28
+ readonly runtime: "node" | undefined;
29
+ }
30
+
31
+ /** One simple command's words, `undefined` for a dynamic one, with the directory it runs in. */
32
+ interface CommandWords {
33
+ readonly dir: string;
34
+ readonly words: readonly (string | undefined)[];
35
+ }
36
+
37
+ /** One configuration file a command names by its tool's configuration option, as written, with its command's directory. */
38
+ interface NamedConfiguration {
39
+ readonly dir: string;
40
+ readonly written: string;
41
+ }
42
+
43
+ /** What one command string runs. */
44
+ export interface CommandReading {
45
+ readonly runs: readonly Run[];
46
+ /**
47
+ * Each simple command read in the directory it runs in, and each one of the string's own
48
+ * line in the string's directory too, wherever and whether it runs.
49
+ */
50
+ readonly commands: readonly CommandWords[];
51
+ readonly configurations: readonly NamedConfiguration[];
52
+ /** The text layer 3 reads: all of the string. */
53
+ readonly held: string;
54
+ }
55
+
56
+ /**
57
+ * The outcomes of one pipeline run in `dir`: those of the builtin that is its one member, as
58
+ * `readBuiltin` gives them, a directory the analysis does not know after a part layer 1 does
59
+ * not read, and either status in `dir` otherwise, a pipeline of several members included,
60
+ * whose members each run in a subshell.
61
+ */
62
+ function outcomesOf(pipeline: Pipeline, dir: string | undefined): readonly Outcome[] {
63
+ const [only] = pipeline.alone ? pipeline.commands : [];
64
+ const outcomes = pipeline.opaque
65
+ ? [
66
+ { dir: undefined, ok: true },
67
+ { dir: undefined, ok: false },
68
+ ]
69
+ : ((only === undefined ? undefined : readBuiltin(only, dir)) ?? [
70
+ { dir, ok: true },
71
+ { dir, ok: false },
72
+ ]);
73
+ return outcomes.map((one) => ({ dir: one.dir, ok: one.ok !== pipeline.negated }));
74
+ }
75
+
76
+ /** The one directory every outcome is in, or `undefined` where they differ or none is known. */
77
+ function oneDirectory(outcomes: readonly Outcome[]): string | undefined {
78
+ const dirs = new Set(outcomes.map((one) => one.dir));
79
+ return dirs.size === 1 ? [...dirs][0] : undefined;
80
+ }
81
+
82
+ /**
83
+ * What `text`, run in `dir`, `undefined` where not known, runs. A pipeline is read where every
84
+ * way its and-or list reaches it leaves the shell in one known directory, none after an `exit`;
85
+ * a background list moves and ends no later one. The words and configuration files of every
86
+ * simple command of the string's own line are read in `dir` as well, wherever they run.
87
+ */
88
+ export function readCommands(text: string, dir: string | undefined): CommandReading {
89
+ const runs: Run[] = [];
90
+ const commands: CommandWords[] = [];
91
+ const configurations: NamedConfiguration[] = [];
92
+ const readCommand = (words: readonly Word[], at: string): void => {
93
+ commands.push({ dir: at, words: words.map((word) => word.value) });
94
+ let handed = words;
95
+ let reading: Reading | undefined = readTool(handed);
96
+ while (reading?.kind === "command") {
97
+ handed = reading.words;
98
+ reading = readTool(handed);
99
+ }
100
+ if (reading?.kind === "line") {
101
+ readLine(reading.text, at);
102
+ return;
103
+ }
104
+ configurations.push(...configurationsOf(handed).map((written) => ({ dir: at, written })));
105
+ if (reading !== undefined && (reading.entry !== undefined || reading.preloads.length > 0)) {
106
+ runs.push({
107
+ dir: at,
108
+ entry: reading.entry,
109
+ preloads: reading.preloads,
110
+ runtime: reading.runtime,
111
+ });
112
+ }
113
+ };
114
+ const readLine = (source: string, start: string | undefined): void => {
115
+ let at = start;
116
+ for (const list of shellLine(source)) {
117
+ let outcomes: readonly Outcome[] = [{ dir: at, ok: true }];
118
+ for (const pipeline of list.pipelines) {
119
+ const { operator } = pipeline;
120
+ const reached = outcomes.filter(
121
+ (one) => operator === undefined || one.ok === (operator === "&&"),
122
+ );
123
+ const where = reached.length === 0 ? undefined : oneDirectory(reached);
124
+ if (where !== undefined) {
125
+ pipeline.commands.forEach((words) => {
126
+ readCommand(words, where);
127
+ });
128
+ }
129
+ const next = reached.flatMap((one) => outcomesOf(pipeline, one.dir));
130
+ const passed = outcomes.filter((one) => !reached.includes(one));
131
+ outcomes = [
132
+ ...new Map(
133
+ [...passed, ...next].map((one) => [`${String(one.ok)}\u0000${one.dir ?? ""}`, one]),
134
+ ).values(),
135
+ ];
136
+ }
137
+ if (!list.background) {
138
+ at = oneDirectory(outcomes);
139
+ }
140
+ }
141
+ };
142
+ readLine(text, dir);
143
+ if (dir !== undefined) {
144
+ for (const words of shellLine(text).flatMap((list) =>
145
+ list.pipelines.flatMap((pipeline) => pipeline.commands),
146
+ )) {
147
+ commands.push({ dir, words: words.map((word) => word.value) });
148
+ configurations.push(...configurationsOf(words).map((written) => ({ dir, written })));
149
+ }
150
+ }
151
+ const unique = <T>(all: readonly T[]): T[] => [
152
+ ...new Map(all.map((one) => [JSON.stringify(one), one])).values(),
153
+ ];
154
+ return {
155
+ runs,
156
+ commands: unique(commands),
157
+ configurations: unique(configurations),
158
+ held: text,
159
+ };
160
+ }
161
+
162
+ /** The extensions a runtime or a loader tries after a path written without one. */
163
+ const TRIED_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"] as const;
164
+
165
+ /** One file a run loads, as written, and every absolute path it may be at. */
166
+ interface Load {
167
+ readonly written: string;
168
+ readonly paths: readonly string[];
169
+ }
170
+
171
+ /**
172
+ * The files `run` loads: its entry, and each preload written as a path. Each may be at its
173
+ * path, with each extension tried, and as a directory's `index` file.
174
+ */
175
+ export function loadsOf(run: Run): readonly Load[] {
176
+ const written = [
177
+ ...(run.entry === undefined
178
+ ? []
179
+ : [{ written: run.entry, path: resolvePath(run.dir, run.entry) }]),
180
+ ...run.preloads.flatMap((preload) => {
181
+ const target = preloadTarget(preload, run.dir);
182
+ return target !== undefined && "path" in target
183
+ ? [{ written: preload.specifier, path: target.path }]
184
+ : [];
185
+ }),
186
+ ];
187
+ return written.map(({ written: one, path }) => ({
188
+ written: one,
189
+ paths: [
190
+ path,
191
+ ...TRIED_EXTENSIONS.map((extension) => `${path}${extension}`),
192
+ ...TRIED_EXTENSIONS.map((extension) => joinPath(path, `index${extension}`)),
193
+ ],
194
+ }));
195
+ }
196
+
197
+ /** The module specifiers `run` preloads that name a package rather than a path. */
198
+ export function preloadedPackages(run: Run): readonly string[] {
199
+ return run.preloads.flatMap((preload) => {
200
+ const target = preloadTarget(preload, run.dir);
201
+ return target !== undefined && "package" in target ? [target.package] : [];
202
+ });
203
+ }
204
+
205
+ /** One path a command string names, with the word or the load that names it. */
206
+ interface NamedPath {
207
+ readonly path: string;
208
+ readonly written: string;
209
+ }
210
+
211
+ /**
212
+ * Every path `reading` names: each static word of its commands read against the directory the
213
+ * command runs in, a word holding `*` aside, which a tool reads as a pattern, and every path a
214
+ * file a read command runs or preloads may be at.
215
+ */
216
+ export function pathsNamed(reading: CommandReading): readonly NamedPath[] {
217
+ const named = [
218
+ ...reading.commands.flatMap(({ dir, words }) =>
219
+ words.flatMap((word) =>
220
+ word === undefined || word.includes("*")
221
+ ? []
222
+ : [{ path: resolvePath(dir, word), written: word }],
223
+ ),
224
+ ),
225
+ ...reading.runs
226
+ .flatMap(loadsOf)
227
+ .flatMap(({ written, paths }) => paths.map((path) => ({ path, written }))),
228
+ ];
229
+ return [...new Map(named.map((one) => [`${one.path}\u0000${one.written}`, one])).values()];
230
+ }