@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
@@ -4,7 +4,7 @@ export const RECORD = {
4
4
  conformance: {
5
5
  corpusVersion: "6.0.0",
6
6
  result: "pass",
7
- digest: "sha256:a4ffbc1c2d9dc7903ccbf52af0e1a22094e0b50363f59365f6aa37b9437d3427",
7
+ digest: "sha256:e4af2b9bcd07fd745101bf3215cd21e9df1448bdc332a791f25d5676cf15c794",
8
8
  },
9
9
  gaps: [],
10
10
  };
@@ -372,7 +372,7 @@ export const KIND_ROWS = [
372
372
  subject: "declaration",
373
373
  holdsLive: true,
374
374
  rule: "An exported symbol whose every reference is inside the symbol's own package or module, reported as a candidate for unexporting. The subject is a package-level declaration or a method. Three subjects are excluded. An interface method is excluded because its exportedness is the contract of the interface that declares it. A struct field is excluded because an encoder reads it by name. A method that satisfies an interface some symbol uses as a type is excluded because it cannot be unexported without its type ceasing to satisfy that interface, and so is a method whose name and signature are those of a method of an exported interface type declared by a package outside the target that the import closure holds, because that interface fixes them whether or not the program converts the method's type to it. The finding names the narrower visibility the references support. A declared cross-language edge counts as an out-of-package reference. Where the edge's other side is unknown to the analyzer, the finding is emitted pending. A reference from a Go external test package, a test file whose package clause names its directory's package with the suffix _test, is a reference from outside the package, because that test builds as another package and needs the export.",
375
- precondition: "Closed world only. A Go main package, and an internal/ directory tree for importers outside its parent, are closed worlds whatever the configuration declares, because no other program can import them, so this finding is reported there always. A published package is a closed world only when the configuration declares the consumer set complete and every declared consumer loads. A library with no consumer loaded and no complete consumer set declared gets this finding on its internal/ tree and its main packages and never on its published API. A symbol the rule of DS1301 holds for is not reported under this code, whatever severity the configuration gives DS1301, because the write-only finding names the defect to fix first. A type that a parameter or a result of an exported function or method of its own package names is not reported when a reference from outside the package references that function or method, because the caller holds values of the type, and unexporting it would leave an exported signature naming a type its callers cannot name. A package-level declaration whose name a string literal spells carries the class grammar/analysis/confidence.md gives it.",
375
+ precondition: "Closed world only. A Go main package, and an internal/ directory tree for importers outside its parent, are closed worlds whatever the configuration declares, because no other program can import them, so this finding is reported there always. A published package is a closed world only when the configuration declares the consumer set complete and every declared consumer loads. A library with no consumer loaded and no complete consumer set declared gets this finding on its internal/ tree and its main packages and never on its published API. A symbol the rule of DS1301 holds for is not reported under this code, whatever severity the configuration gives DS1301, because the write-only finding names the defect to fix first. A type is not reported when a reference from outside the package reaches its values through its own package's exported declarations: a reference to an exported function or method whose parameters or results name the type, to an exported variable or constant whose type names it, to an exported field or property whose type names it, an embedded field included, or to an exported member of the type itself, a field promoted through an embedding included. A type a reference from outside the package names, or whose values such a reference reaches, passes the reach on to each type its definition names other than through an unexported member, whether it is exported or not, so an unexported type an exported function returns passes it to the types its exported fields name. A type names another through every composite that spells it: a pointer, an array, a slice, a map key or value, a channel, a function signature and a type argument. The referencing code holds values of the type, so unexporting it would leave an exported declaration naming a type its users cannot name. A package-level declaration whose name a string literal spells carries the class grammar/analysis/confidence.md gives it.",
376
376
  details: { narrower_visibility: "required", name_literal: "optional" },
377
377
  defaultEnabled: true,
378
378
  defaultSeverity: "warn",
@@ -387,7 +387,7 @@ export const KIND_ROWS = [
387
387
  subject: "declaration",
388
388
  holdsLive: true,
389
389
  rule: "An exported symbol of a non-internal package whose every reference is inside the target module, reported as a candidate for relocation behind an internal boundary. The subject is a package-level declaration or a method. Three subjects are excluded. An interface method is excluded because its exportedness is the contract of the interface that declares it. A struct field is excluded because an encoder reads it by name. A method that satisfies an interface some symbol uses as a type is excluded because it cannot be unexported without its type ceasing to satisfy that interface, and so is a method whose name and signature are those of a method of an exported interface type declared by a package outside the target that the import closure holds, because that interface fixes them whether or not the program converts the method's type to it. The finding names the narrower visibility the references support. A declared cross-language edge counts as an out-of-package reference. Where the edge's other side is unknown to the analyzer, the finding is emitted pending. A reference from a Go external test package, a test file whose package clause names its directory's package with the suffix _test, is a reference from outside the package, because that test builds as another package and needs the export.",
390
- precondition: "Closed world only. Reported always for a main package and an internal/ directory tree, and for a published package only when the configuration declares the consumer set complete and every declared consumer loads. A library with no consumer loaded and no complete consumer set declared gets this finding on its internal/ tree and its main packages and never on its published API. A symbol the rule of DS1301 holds for is not reported under this code, whatever severity the configuration gives DS1301, because the write-only finding names the defect to fix first. A type that a parameter or a result of an exported function or method of its own package names is not reported when a reference from outside the target module references that function or method, because the caller holds values of the type, and moving it behind an internal boundary would leave an exported signature naming a type its callers cannot name.",
390
+ precondition: "Closed world only. Reported always for a main package and an internal/ directory tree, and for a published package only when the configuration declares the consumer set complete and every declared consumer loads. A library with no consumer loaded and no complete consumer set declared gets this finding on its internal/ tree and its main packages and never on its published API. A symbol the rule of DS1301 holds for is not reported under this code, whatever severity the configuration gives DS1301, because the write-only finding names the defect to fix first. A type is not reported when a reference from outside the target module reaches its values through its own package's exported declarations, as DS1501 defines that reach, because the referencing code holds values of the type, and moving it behind an internal boundary would leave an exported declaration naming a type its users cannot name.",
391
391
  details: { narrower_visibility: "required", name_literal: "optional" },
392
392
  defaultEnabled: true,
393
393
  defaultSeverity: "warn",
@@ -1206,6 +1206,13 @@ export const EVIDENCE = [
1206
1206
  "DS1602",
1207
1207
  ],
1208
1208
  },
1209
+ {
1210
+ evidence: "unwritten-run-path",
1211
+ rule: "confidence.35",
1212
+ class: "possible",
1213
+ languages: ["ts"],
1214
+ codes: ["DS1601", "DS1602"],
1215
+ },
1209
1216
  {
1210
1217
  evidence: "schema-operation-default",
1211
1218
  rule: "schemas.10",
@@ -9,7 +9,7 @@ import { lineOf } from "../component-files.js";
9
9
  import { CONVENTION_ROWS, } from "./convention-rows.js";
10
10
  import { documentStrings } from "./configuration-files.js";
11
11
  import { readCommands } from "../commands/read.js";
12
- import { nodeTestGlobs, } from "./test-runners.js";
12
+ import { nodeTestGlobs } from "./test-runners.js";
13
13
  import { documentModule, membersNamed, staticModule, textsOf, valuesAt, } from "./static-module.js";
14
14
  import { ancestors, cannotStartPackageName, dirnamePath, joinPath, normalizePath, relativePath, resolvePath, } from "../paths.js";
15
15
  import { satisfies } from "../version-range.js";
@@ -520,11 +520,11 @@ function packageKeyUses(read, dir, manifest, keys) {
520
520
  text.startsWith(`${name}/`) ||
521
521
  (text.startsWith(`${name}:`) && text.length > name.length + 1)));
522
522
  }
523
- /** Every simple command of a manifest's `scripts`, as the command reader reads it in the manifest's directory. */
524
- function scriptCommands(manifest, dir) {
523
+ /** What the command reader reads each of a manifest's `scripts` as, in the manifest's directory. */
524
+ function scriptReadings(manifest, dir) {
525
525
  const scripts = isRecord(manifest) ? manifest["scripts"] : undefined;
526
526
  return isRecord(scripts)
527
- ? Object.values(scripts).flatMap((text) => typeof text === "string" ? readCommands(text, dir).commands : [])
527
+ ? Object.values(scripts).flatMap((text) => typeof text === "string" ? [readCommands(text, dir)] : [])
528
528
  : [];
529
529
  }
530
530
  /**
@@ -543,6 +543,7 @@ export function readConventions(host, parse, manifests, disabled, rows = CONVENT
543
543
  const read = readFilesOnce(host, parse);
544
544
  for (const manifest of manifests) {
545
545
  const declared = readJSON(host, joinPath(manifest.dir, MANIFEST));
546
+ const scripts = scriptReadings(declared, manifest.dir);
546
547
  let input;
547
548
  const runnerInput = () => {
548
549
  input ??= {
@@ -558,7 +559,7 @@ export function readConventions(host, parse, manifests, disabled, rows = CONVENT
558
559
  }
559
560
  return undefined;
560
561
  },
561
- scripts: scriptCommands(declared, manifest.dir),
562
+ scripts: scripts.flatMap((one) => one.commands),
562
563
  };
563
564
  return input;
564
565
  };
@@ -659,7 +660,7 @@ export function readConventions(host, parse, manifests, disabled, rows = CONVENT
659
660
  });
660
661
  }
661
662
  }
662
- testGlobs.push(...nodeTestGlobs(runnerInput().scripts));
663
+ testGlobs.push(...nodeTestGlobs(scripts.flatMap((one) => one.tests)));
663
664
  }
664
665
  const unique = [...new Map(applied.map((one) => [JSON.stringify(one), one])).values()];
665
666
  unique.sort((a, b) => bytewise(a.name, b.name) ||
@@ -107,7 +107,7 @@ function parsed(words, table) {
107
107
  else if (table.flags.has(name)) {
108
108
  keep(name, equals < 0 ? "true" : word.slice(equals + 1));
109
109
  }
110
- else if (!(table.assigned === true && equals > 0)) {
110
+ else {
111
111
  return undefined;
112
112
  }
113
113
  }
@@ -632,31 +632,6 @@ const AVA_CONFIG = ["ava.config.js", "ava.config.cjs", "ava.config.mjs"];
632
632
  export const AVA_6 = avaRunner(AVA_CONFIG, ["cjs", "mjs", "js"]);
633
633
  /** AVA 8, which reads no `ava.config.cjs` and defaults to no `.cjs` test. */
634
634
  export const AVA_8 = avaRunner(["ava.config.js", "ava.config.mjs"], ["mjs", "js"]);
635
- /**
636
- * The options of `node` (https://nodejs.org/api/cli.html) a test run writes: the ones that take
637
- * a value, the flags, and any long option whose value follows `=`.
638
- */
639
- const NODE_OPTIONS = {
640
- valued: new Set([
641
- ...["-r", "--require", "--import", "--loader", "--experimental-loader", "--env-file"],
642
- ...["--env-file-if-exists", "--conditions", "-C", "--input-type", "--watch-path"],
643
- ...["--test-reporter", "--test-reporter-destination", "--test-concurrency"],
644
- ...["--test-name-pattern", "--test-skip-pattern", "--test-timeout", "--test-shard"],
645
- ...["--test-coverage-include", "--test-coverage-exclude", "--test-coverage-branches"],
646
- ...["--test-coverage-functions", "--test-coverage-lines", "--test-isolation"],
647
- ...["--test-global-setup"],
648
- ]),
649
- flags: new Set([
650
- ...["--test", "--test-only", "--watch", "--test-force-exit", "--test-update-snapshots"],
651
- ...["--test-randomize", "--experimental-test-coverage", "--experimental-test-module-mocks"],
652
- ...["--experimental-test-snapshots", "--experimental-strip-types", "--no-strip-types"],
653
- ...["--no-experimental-strip-types", "--experimental-transform-types", "--enable-source-maps"],
654
- ...["--experimental-vm-modules", "--experimental-detect-module", "--no-warnings"],
655
- ...["--no-experimental-detect-module", "--experimental-require-module", "--trace-warnings"],
656
- ...["--no-experimental-require-module", "--expose-gc", "--inspect", "--inspect-brk"],
657
- ]),
658
- assigned: true,
659
- };
660
635
  /** The patterns `node --test` runs with no operand (`doc/api/test.md`), the TypeScript ones where types are stripped. */
661
636
  function nodeTestDefaults(stripped) {
662
637
  const forms = ["**/*.test", "**/*-test", "**/*_test", "**/test-*", "**/test", "**/test/**/*"];
@@ -666,19 +641,10 @@ function nodeTestDefaults(stripped) {
666
641
  ];
667
642
  }
668
643
  /**
669
- * The files each `node --test` command of a manifest's scripts runs: its operands, each a glob
670
- * read in the command's directory, or the default patterns where it writes none. A command
671
- * holding a dynamic word or an option {@link NODE_OPTIONS} does not name runs no file here.
644
+ * The files each `node --test` run of a manifest's scripts runs, as the command reader reads
645
+ * it: its operands, each a glob read in the command's directory, or the default patterns where
646
+ * it writes none.
672
647
  */
673
- export function nodeTestGlobs(scripts) {
674
- return scripts.flatMap((command) => {
675
- const words = afterProgram(command, ["node"]);
676
- const read = words === undefined ? undefined : parsed(words, NODE_OPTIONS);
677
- if (!read?.values.has("--test")) {
678
- return [];
679
- }
680
- const stripped = !read.values.has("--no-strip-types") && !read.values.has("--no-experimental-strip-types");
681
- const patterns = read.operands.length > 0 ? read.operands : nodeTestDefaults(stripped);
682
- return patterns.map((one) => belowGlob(command.dir, one));
683
- });
648
+ export function nodeTestGlobs(runs) {
649
+ return runs.flatMap((run) => (run.patterns.length > 0 ? run.patterns : nodeTestDefaults(run.stripped)).map((one) => belowGlob(run.dir, one)));
684
650
  }
@@ -1,10 +1,11 @@
1
1
  /**
2
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
3
+ * hooks, the hook managers' commands and scripts and the makefile's recipe lines, each with the
4
+ * directory it runs in. A workflow or hook manager file is read by `text/yaml.ts`, and one it does not
5
5
  * read gives no command; each command is read by `commands/read.ts`.
6
6
  */
7
7
  import { pathsNamed, readCommands } from "../commands/read.js";
8
+ import { PART } from "../commands/shell.js";
8
9
  import { isRecord } from "../json-document.js";
9
10
  import { joinPath, resolvePath } from "../paths.js";
10
11
  import { filesIn } from "../fs/tree.js";
@@ -14,14 +15,20 @@ const WORKFLOWS = ".github/workflows";
14
15
  const HOOK_DIRS = [".husky", ".githooks"];
15
16
  /** The key setting the directory a workflow step, or each step a `defaults.run` covers, runs in. */
16
17
  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
- */
21
18
  const HOOK_MANAGERS = [
22
- [".pre-commit-config.yaml", "entry", undefined],
23
- ...["lefthook.yml", "lefthook.yaml", ".lefthook.yml"].map((name) => [name, "run", "root"]),
19
+ { name: ".pre-commit-config.yaml", key: "entry" },
20
+ ...["lefthook.yml", "lefthook.yaml", ".lefthook.yml"].map((name) => ({
21
+ name,
22
+ key: "run",
23
+ dirKey: "root",
24
+ scripts: true,
25
+ })),
24
26
  ];
27
+ /**
28
+ * The directories lefthook searches for a hook's scripts after the file's `source_dir` and
29
+ * `source_dir_local`: https://github.com/evilmartians/lefthook/blob/master/internal/command/run.go
30
+ */
31
+ const LEFTHOOK_DIRS = [".config/lefthook", ".config/lefthook-local"];
25
32
  /** The names GNU make reads a makefile under, in the order it tries them. */
26
33
  const MAKEFILES = ["GNUmakefile", "makefile", "Makefile"];
27
34
  function readText(host, path) {
@@ -73,7 +80,6 @@ function keyedCommands(text, root, key, dirKey) {
73
80
  : written.includes("${{")
74
81
  ? undefined
75
82
  : resolvePath(root, written),
76
- writtenDirectory: written,
77
83
  });
78
84
  }
79
85
  for (const value of Object.values(node)) {
@@ -83,6 +89,51 @@ function keyedCommands(text, root, key, dirKey) {
83
89
  visit(yamlDocument(text), undefined);
84
90
  return found;
85
91
  }
92
+ /**
93
+ * The command lefthook runs for each script a lefthook file's hooks name, each key of a hook's
94
+ * `scripts` and each `script` of a job in its `jobs`, a group's among them, from each directory
95
+ * it searches: its `runner`, then the path `<dir>/<hook>/<name>` quoted as lefthook quotes it,
96
+ * run in the target root: https://lefthook.dev/configuration/Scripts/
97
+ */
98
+ function lefthookScripts(text, root) {
99
+ const document = yamlDocument(text);
100
+ if (!isRecord(document)) {
101
+ return [];
102
+ }
103
+ const dirs = [
104
+ scalarText(document["source_dir"]) ?? ".lefthook/",
105
+ scalarText(document["source_dir_local"]) ?? ".lefthook-local/",
106
+ ...LEFTHOOK_DIRS,
107
+ ];
108
+ const found = [];
109
+ const add = (hook, name, runner) => {
110
+ for (const dir of dirs) {
111
+ const path = `'${joinPath(dir, hook, name).replaceAll("'", "'\\''")}'`;
112
+ found.push({
113
+ text: runner === undefined || runner.trim() === "" ? path : `${runner} ${path}`,
114
+ dir: root,
115
+ });
116
+ }
117
+ };
118
+ const jobs = (hook, list) => {
119
+ for (const job of Array.isArray(list) ? list : []) {
120
+ const script = isRecord(job) ? scalarText(job["script"]) : undefined;
121
+ if (isRecord(job) && script !== undefined) {
122
+ add(hook, script, scalarText(job["runner"]));
123
+ }
124
+ const group = isRecord(job) ? job["group"] : undefined;
125
+ jobs(hook, isRecord(group) ? group["jobs"] : undefined);
126
+ }
127
+ };
128
+ for (const [hook, value] of Object.entries(document)) {
129
+ const scripts = isRecord(value) ? value["scripts"] : undefined;
130
+ for (const [name, options] of Object.entries(isRecord(scripts) ? scripts : {})) {
131
+ add(hook, name, isRecord(options) ? scalarText(options["runner"]) : undefined);
132
+ }
133
+ jobs(hook, isRecord(value) ? value["jobs"] : undefined);
134
+ }
135
+ return found;
136
+ }
86
137
  /** The path of each git hook script at `root`, every file in {@link HOOK_DIRS}. */
87
138
  function hookScripts(host, root) {
88
139
  return HOOK_DIRS.flatMap((dir) => filesIn(host, joinPath(root, dir), () => true).map((name) => joinPath(root, dir, name)));
@@ -91,10 +142,44 @@ function hookScripts(host, root) {
91
142
  function makefileAt(host, root) {
92
143
  return MAKEFILES.map((name) => joinPath(root, name)).find((candidate) => host.kindOf(candidate) === "file");
93
144
  }
145
+ /**
146
+ * One recipe line as make hands it to the shell: each `$$` a `$`, and each reference to a
147
+ * variable or a function, which make expands first, a part only the run knows:
148
+ * https://www.gnu.org/software/make/manual/html_node/Reference.html
149
+ */
150
+ function expandedByMake(line) {
151
+ let expanded = "";
152
+ for (let at = 0; at < line.length; at += 1) {
153
+ const char = line.charAt(at);
154
+ const next = line.charAt(at + 1);
155
+ if (char !== "$") {
156
+ expanded += char;
157
+ }
158
+ else if (next === "$") {
159
+ expanded += "$";
160
+ at += 1;
161
+ }
162
+ else {
163
+ let end = at + 1;
164
+ if (next === "(" || next === "{") {
165
+ const close = next === "(" ? ")" : "}";
166
+ for (let depth = 0; end < line.length; end += 1) {
167
+ depth += line.charAt(end) === next ? 1 : line.charAt(end) === close ? -1 : 0;
168
+ if (depth === 0) {
169
+ break;
170
+ }
171
+ }
172
+ }
173
+ expanded += PART;
174
+ at = Math.min(end, line.length);
175
+ }
176
+ }
177
+ return expanded;
178
+ }
94
179
  /**
95
180
  * 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.
181
+ * the lines a trailing `\` continues it onto, less the `@`, `-` and `+` make reads before it,
182
+ * as make expands it. Make runs each line in a shell of its own.
98
183
  */
99
184
  function recipeLines(host, root) {
100
185
  const path = makefileAt(host, root);
@@ -104,16 +189,15 @@ function recipeLines(host, root) {
104
189
  .split(/\r?\n/u)
105
190
  .filter((line) => line.startsWith("\t"))
106
191
  .map((line) => ({
107
- text: line.slice(1).replace(/^[@+-]+/u, ""),
192
+ text: expandedByMake(line.slice(1).replace(/^[@+-]+/u, "")),
108
193
  dir: root,
109
- writtenDirectory: undefined,
110
194
  }));
111
195
  }
112
196
  /**
113
197
  * Every command the target root's workflow steps, git hooks, hook managers and makefile run:
114
198
  * the `run:` steps of the workflow files in `.github/workflows`, each file in `.husky` and
115
199
  * `.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.
200
+ * `run:` commands and the scripts of a lefthook file and the makefile's recipe lines.
117
201
  */
118
202
  export function workflowCommands(host, targetRoot) {
119
203
  const root = resolvePath(host.workingDirectory(), targetRoot);
@@ -123,35 +207,125 @@ export function workflowCommands(host, targetRoot) {
123
207
  ...hookScripts(host, root).map((path) => ({
124
208
  text: readText(host, path) ?? "",
125
209
  dir: root,
126
- writtenDirectory: undefined,
127
210
  })),
128
- ...HOOK_MANAGERS.flatMap(([name, key, dirKey]) => keyedCommands(readText(host, joinPath(root, name)) ?? "", root, key, dirKey)),
211
+ ...HOOK_MANAGERS.flatMap(({ name, key, dirKey, scripts }) => {
212
+ const text = readText(host, joinPath(root, name)) ?? "";
213
+ return [
214
+ ...keyedCommands(text, root, key, dirKey),
215
+ ...(scripts === true ? lefthookScripts(text, root) : []),
216
+ ];
217
+ }),
129
218
  ...recipeLines(host, root),
130
219
  ];
131
220
  }
132
221
  /**
133
222
  * 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.
223
+ * git hook, hook manager file and makefile at the target root, which the dependency rule reads.
137
224
  */
138
225
  export function commandFileTexts(host, targetRoot) {
226
+ return commandFiles(host, targetRoot).flatMap(({ path }) => readText(host, path) ?? []);
227
+ }
228
+ /** Each file {@link workflowCommands} reads a command from, with the key a YAML file writes its commands under. */
229
+ function commandFiles(host, targetRoot) {
139
230
  const root = resolvePath(host.workingDirectory(), targetRoot);
140
231
  return [
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) ?? []);
232
+ ...filesIn(host, joinPath(root, WORKFLOWS), (name) => /\.ya?ml$/u.test(name)).map((name) => ({
233
+ path: joinPath(root, WORKFLOWS, name),
234
+ key: "run",
235
+ })),
236
+ ...hookScripts(host, root).map((path) => ({ path, key: undefined })),
237
+ ...HOOK_MANAGERS.map(({ name, key }) => ({ path: joinPath(root, name), key })),
238
+ ...[makefileAt(host, root) ?? []].flat().map((path) => ({ path, key: undefined })),
239
+ ];
240
+ }
241
+ /**
242
+ * The filters of a workflow's `on` key, which name the files and refs whose change triggers it
243
+ * and run none: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#onpushpull_requestpull_request_targetpathspaths-ignore
244
+ */
245
+ const TRIGGER_FILTERS = new Set([
246
+ "paths",
247
+ "paths-ignore",
248
+ "branches",
249
+ "branches-ignore",
250
+ "tags",
251
+ "tags-ignore",
252
+ ]);
253
+ /**
254
+ * The values of one YAML file that are no command, each with its key: every scalar but one
255
+ * written under `key`, which is read as a command string, and one of a trigger filter. A file
256
+ * `yamlDocument` does not read is one value, its whole text. No key and no comment is a value.
257
+ */
258
+ function yamlValues(text, key) {
259
+ const document = yamlDocument(text);
260
+ if (document === undefined) {
261
+ return [{ text, shell: false }];
262
+ }
263
+ const found = [];
264
+ // `trigger` is 1 for the value of the top-level `on`, a map of events, and 2 for an event's.
265
+ const visit = (node, under, trigger = 0) => {
266
+ if (Array.isArray(node)) {
267
+ for (const item of node) {
268
+ visit(item, under);
269
+ }
270
+ }
271
+ else if (isRecord(node)) {
272
+ for (const [name, value] of Object.entries(node)) {
273
+ const on = node === document && (name === "on" || name === "true");
274
+ if ((name !== key || scalarText(value) === undefined) &&
275
+ !(trigger === 2 && TRIGGER_FILTERS.has(name))) {
276
+ visit(value, name, on ? 1 : trigger === 1 ? 2 : 0);
277
+ }
278
+ }
279
+ }
280
+ else {
281
+ const value = scalarText(node);
282
+ if (value !== undefined) {
283
+ found.push({ text: value, shell: false, ...(under === undefined ? {} : { key: under }) });
284
+ }
285
+ }
286
+ };
287
+ visit(document, undefined);
288
+ return found;
289
+ }
290
+ /**
291
+ * The lines of a makefile that are no recipe line, joined with the lines a trailing `\`
292
+ * continues them onto, each without the comment a `#` make does not read as escaped starts.
293
+ */
294
+ function makefileValues(text) {
295
+ return text
296
+ .replace(/\\\r?\n/gu, " ")
297
+ .split(/\r?\n/u)
298
+ .filter((line) => !line.startsWith("\t"))
299
+ .map((line) => line.replace(/(?<!\\)#.*$/u, ""))
300
+ .filter((line) => line.trim() !== "")
301
+ .map((line) => ({ text: line, shell: false }));
302
+ }
303
+ /**
304
+ * The texts layer 3 reads of the target root's command files: each command string, and each
305
+ * value of a workflow file, a hook manager file and the makefile that is no command.
306
+ */
307
+ function heldTexts(host, targetRoot) {
308
+ const makefile = makefileAt(host, resolvePath(host.workingDirectory(), targetRoot));
309
+ return [
310
+ ...workflowCommands(host, targetRoot).map(({ text }) => ({ text, shell: true })),
311
+ ...commandFiles(host, targetRoot).flatMap(({ path, key }) => {
312
+ const text = readText(host, path);
313
+ return text === undefined
314
+ ? []
315
+ : key !== undefined
316
+ ? yamlValues(text, key)
317
+ : path === makefile
318
+ ? makefileValues(text)
319
+ : [];
320
+ }),
321
+ ];
146
322
  }
147
323
  /** Reads the target root's workflow steps, git hooks, hook managers and makefile. */
148
324
  export function readWorkflows(host, targetRoot) {
149
325
  return {
150
- readings: workflowCommands(host, targetRoot).map(({ text, dir, writtenDirectory }) => {
151
- const reading = readCommands(text, dir);
152
- return { ...reading, held: [reading.held, writtenDirectory ?? ""].join("\n") };
153
- }),
326
+ readings: workflowCommands(host, targetRoot).map(({ text, dir }) => readCommands(text, dir)),
154
327
  texts: commandFileTexts(host, targetRoot),
328
+ held: heldTexts(host, targetRoot),
155
329
  };
156
330
  }
157
331
  /**
@@ -10,7 +10,6 @@ import { EXEMPTION_CLASSES } from "./contract/contract.generated.js";
10
10
  import { TS_EXEMPTION_CLASSES, typescriptVisibilityOf, } from "./exempt-classes.js";
11
11
  import { byPosition, positionKey } from "./position.js";
12
12
  import { Unanswerable } from "./query.js";
13
- import { sweep } from "./sweep.js";
14
13
  import { bytewise } from "./text/compare.js";
15
14
  /**
16
15
  * The classes `exemptions.disabled` switches off. A class that runs on another language
@@ -130,13 +129,6 @@ export function exemptionsOf(found) {
130
129
  return true;
131
130
  });
132
131
  }
133
- /**
134
- * The records that hold while the component files at `live` are live: every record that
135
- * holds whatever is live, and each one a live file's markup holds, as a record of the run.
136
- */
137
- export function holdingWhile(records, live) {
138
- return exemptionsOf(records.flatMap(({ whileLive, ...record }) => whileLive === undefined || live.has(whileLive) ? [record] : []));
139
- }
140
132
  /** The records per declaration, each declaration's in the order given. */
141
133
  function byDeclaration(records) {
142
134
  const grouped = new Map();
@@ -151,24 +143,6 @@ function byDeclaration(records) {
151
143
  }
152
144
  return grouped;
153
145
  }
154
- /**
155
- * The records that held back a declaration: one some configuration's graph, swept
156
- * again with no exemption and its marks, judges a candidate, whatever another
157
- * configuration answers, or one in `writeOnly`, which the run would report as written
158
- * and never read without the record's read. A declaration a relation or a mark holds
159
- * live in every configuration is held back by no record. The order is the
160
- * declarations' site order, and for one declaration the input's.
161
- */
162
- export function retainedIn(matrix, input, writeOnly = new Set()) {
163
- const exempt = input.exempt ?? [];
164
- if (exempt.length === 0) {
165
- return [];
166
- }
167
- const bare = { marked: input.marked, mode: input.mode };
168
- const dead = new Set(matrix.graphs.flatMap((graph) => sweep(graph, bare).candidates.map((candidate) => candidate.id)));
169
- const held = byDeclaration(exempt.filter((record) => dead.has(record.id) || writeOnly.has(record.id)));
170
- return matrix.union.symbols.flatMap((symbol) => held.get(symbol.id) ?? []);
171
- }
172
146
  /**
173
147
  * One line per held-back declaration, in site order: its reference and position, then
174
148
  * each record's class, site and detail, all separated by tabs.