@cplieger/deadset-ts 8.0.0-dev.18 → 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.
@@ -4,7 +4,7 @@ export const RECORD = {
4
4
  conformance: {
5
5
  corpusVersion: "6.0.0",
6
6
  result: "pass",
7
- digest: "sha256:140f095d52fa8140de070f46d13a132b723c634ecf24a8b96e6a92c410c4b848",
7
+ digest: "sha256:e4af2b9bcd07fd745101bf3215cd21e9df1448bdc332a791f25d5676cf15c794",
8
8
  },
9
9
  gaps: [],
10
10
  } as const;
@@ -1280,6 +1280,13 @@ export const EVIDENCE = [
1280
1280
  "DS1602",
1281
1281
  ],
1282
1282
  },
1283
+ {
1284
+ evidence: "unwritten-run-path",
1285
+ rule: "confidence.35",
1286
+ class: "possible",
1287
+ languages: ["ts"],
1288
+ codes: ["DS1601", "DS1602"],
1289
+ },
1283
1290
  {
1284
1291
  evidence: "schema-operation-default",
1285
1292
  rule: "schemas.10",
@@ -519,7 +519,7 @@ interface CommandUses {
519
519
  readonly words: ReadonlySet<string>;
520
520
  /** The package each module a read command preloads by name is in. */
521
521
  readonly preloads: ReadonlySet<string>;
522
- /** The text layer 3 reads, all of every string, whose every package or command name is a use. */
522
+ /** The whole text of every string, which the dependency rule reads: each package or command name in it is a use. */
523
523
  readonly held: string;
524
524
  }
525
525
 
@@ -23,13 +23,8 @@ import {
23
23
  type TestFileReading,
24
24
  } from "./convention-rows.ts";
25
25
  import { documentStrings } from "./configuration-files.ts";
26
- import { readCommands } from "../commands/read.ts";
27
- import {
28
- nodeTestGlobs,
29
- type RunnerInput,
30
- type ScriptCommand,
31
- type TestGlob,
32
- } from "./test-runners.ts";
26
+ import { readCommands, type CommandReading } from "../commands/read.ts";
27
+ import { nodeTestGlobs, type RunnerInput, type TestGlob } from "./test-runners.ts";
33
28
  import {
34
29
  documentModule,
35
30
  membersNamed,
@@ -814,12 +809,12 @@ function packageKeyUses(
814
809
  );
815
810
  }
816
811
 
817
- /** Every simple command of a manifest's `scripts`, as the command reader reads it in the manifest's directory. */
818
- function scriptCommands(manifest: unknown, dir: string): readonly ScriptCommand[] {
812
+ /** What the command reader reads each of a manifest's `scripts` as, in the manifest's directory. */
813
+ function scriptReadings(manifest: unknown, dir: string): readonly CommandReading[] {
819
814
  const scripts = isRecord(manifest) ? manifest["scripts"] : undefined;
820
815
  return isRecord(scripts)
821
816
  ? Object.values(scripts).flatMap((text) =>
822
- typeof text === "string" ? readCommands(text, dir).commands : [],
817
+ typeof text === "string" ? [readCommands(text, dir)] : [],
823
818
  )
824
819
  : [];
825
820
  }
@@ -846,6 +841,7 @@ export function readConventions(
846
841
  const read = readFilesOnce(host, parse);
847
842
  for (const manifest of manifests) {
848
843
  const declared = readJSON(host, joinPath(manifest.dir, MANIFEST));
844
+ const scripts = scriptReadings(declared, manifest.dir);
849
845
  let input: RunnerInput | undefined;
850
846
  const runnerInput = (): RunnerInput => {
851
847
  input ??= {
@@ -861,7 +857,7 @@ export function readConventions(
861
857
  }
862
858
  return undefined;
863
859
  },
864
- scripts: scriptCommands(declared, manifest.dir),
860
+ scripts: scripts.flatMap((one) => one.commands),
865
861
  };
866
862
  return input;
867
863
  };
@@ -965,7 +961,7 @@ export function readConventions(
965
961
  });
966
962
  }
967
963
  }
968
- testGlobs.push(...nodeTestGlobs(runnerInput().scripts));
964
+ testGlobs.push(...nodeTestGlobs(scripts.flatMap((one) => one.tests)));
969
965
  }
970
966
  const unique = [...new Map(applied.map((one) => [JSON.stringify(one), one])).values()];
971
967
  unique.sort(
@@ -5,6 +5,7 @@
5
5
  * source reads them. A value a reader needs that the text does not write out names no file.
6
6
  */
7
7
 
8
+ import type { CommandTests } from "../commands/read.ts";
8
9
  import { readJSON, readJSONFile } from "../fs/manifest.ts";
9
10
  import type { Host } from "../host.ts";
10
11
  import { ancestors, dirnamePath, joinPath, relativePath, resolvePath } from "../paths.ts";
@@ -27,7 +28,7 @@ export interface TestGlob {
27
28
  }
28
29
 
29
30
  /** One simple command of a manifest's scripts, a dynamic word `undefined`, in the directory it runs in. */
30
- export interface ScriptCommand {
31
+ interface ScriptCommand {
31
32
  readonly dir: string;
32
33
  readonly words: readonly (string | undefined)[];
33
34
  }
@@ -140,8 +141,6 @@ interface OptionTable {
140
141
  /** The options that take the next word, or the text after `=`, as their value. */
141
142
  readonly valued: ReadonlySet<string>;
142
143
  readonly flags: ReadonlySet<string>;
143
- /** Whether a long option the table does not name is read where its value follows `=`. */
144
- readonly assigned?: boolean;
145
144
  }
146
145
 
147
146
  /** One command's options and operands. */
@@ -182,7 +181,7 @@ function parsed(words: readonly (string | undefined)[], table: OptionTable): Par
182
181
  keep(name, value);
183
182
  } else if (table.flags.has(name)) {
184
183
  keep(name, equals < 0 ? "true" : word.slice(equals + 1));
185
- } else if (!(table.assigned === true && equals > 0)) {
184
+ } else {
186
185
  return undefined;
187
186
  }
188
187
  }
@@ -813,32 +812,6 @@ export const AVA_6 = avaRunner(AVA_CONFIG, ["cjs", "mjs", "js"]);
813
812
  /** AVA 8, which reads no `ava.config.cjs` and defaults to no `.cjs` test. */
814
813
  export const AVA_8 = avaRunner(["ava.config.js", "ava.config.mjs"], ["mjs", "js"]);
815
814
 
816
- /**
817
- * The options of `node` (https://nodejs.org/api/cli.html) a test run writes: the ones that take
818
- * a value, the flags, and any long option whose value follows `=`.
819
- */
820
- const NODE_OPTIONS: OptionTable = {
821
- valued: new Set([
822
- ...["-r", "--require", "--import", "--loader", "--experimental-loader", "--env-file"],
823
- ...["--env-file-if-exists", "--conditions", "-C", "--input-type", "--watch-path"],
824
- ...["--test-reporter", "--test-reporter-destination", "--test-concurrency"],
825
- ...["--test-name-pattern", "--test-skip-pattern", "--test-timeout", "--test-shard"],
826
- ...["--test-coverage-include", "--test-coverage-exclude", "--test-coverage-branches"],
827
- ...["--test-coverage-functions", "--test-coverage-lines", "--test-isolation"],
828
- ...["--test-global-setup"],
829
- ]),
830
- flags: new Set([
831
- ...["--test", "--test-only", "--watch", "--test-force-exit", "--test-update-snapshots"],
832
- ...["--test-randomize", "--experimental-test-coverage", "--experimental-test-module-mocks"],
833
- ...["--experimental-test-snapshots", "--experimental-strip-types", "--no-strip-types"],
834
- ...["--no-experimental-strip-types", "--experimental-transform-types", "--enable-source-maps"],
835
- ...["--experimental-vm-modules", "--experimental-detect-module", "--no-warnings"],
836
- ...["--no-experimental-detect-module", "--experimental-require-module", "--trace-warnings"],
837
- ...["--no-experimental-require-module", "--expose-gc", "--inspect", "--inspect-brk"],
838
- ]),
839
- assigned: true,
840
- };
841
-
842
815
  /** The patterns `node --test` runs with no operand (`doc/api/test.md`), the TypeScript ones where types are stripped. */
843
816
  function nodeTestDefaults(stripped: boolean): readonly string[] {
844
817
  const forms = ["**/*.test", "**/*-test", "**/*_test", "**/test-*", "**/test", "**/test/**/*"];
@@ -849,20 +822,14 @@ function nodeTestDefaults(stripped: boolean): readonly string[] {
849
822
  }
850
823
 
851
824
  /**
852
- * The files each `node --test` command of a manifest's scripts runs: its operands, each a glob
853
- * read in the command's directory, or the default patterns where it writes none. A command
854
- * holding a dynamic word or an option {@link NODE_OPTIONS} does not name runs no file here.
825
+ * The files each `node --test` run of a manifest's scripts runs, as the command reader reads
826
+ * it: its operands, each a glob read in the command's directory, or the default patterns where
827
+ * it writes none.
855
828
  */
856
- export function nodeTestGlobs(scripts: readonly ScriptCommand[]): readonly TestGlob[] {
857
- return scripts.flatMap((command) => {
858
- const words = afterProgram(command, ["node"]);
859
- const read = words === undefined ? undefined : parsed(words, NODE_OPTIONS);
860
- if (!read?.values.has("--test")) {
861
- return [];
862
- }
863
- const stripped =
864
- !read.values.has("--no-strip-types") && !read.values.has("--no-experimental-strip-types");
865
- const patterns = read.operands.length > 0 ? read.operands : nodeTestDefaults(stripped);
866
- return patterns.map((one) => belowGlob(command.dir, one));
867
- });
829
+ export function nodeTestGlobs(runs: readonly CommandTests[]): readonly TestGlob[] {
830
+ return runs.flatMap((run) =>
831
+ (run.patterns.length > 0 ? run.patterns : nodeTestDefaults(run.stripped)).map((one) =>
832
+ belowGlob(run.dir, one),
833
+ ),
834
+ );
868
835
  }
@@ -1,12 +1,14 @@
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
 
8
8
  import type { SourceFile } from "@typescript/native/ast";
9
+ import type { HeldText } from "../commands/held.ts";
9
10
  import { pathsNamed, readCommands, type CommandReading } from "../commands/read.ts";
11
+ import { PART } from "../commands/shell.ts";
10
12
  import type { Host } from "../host.ts";
11
13
  import { isRecord } from "../json-document.ts";
12
14
  import { joinPath, resolvePath } from "../paths.ts";
@@ -29,8 +31,6 @@ interface CommandString {
29
31
  * where: a directory holding a `${{ }}` expression, which GitHub Actions evaluates.
30
32
  */
31
33
  readonly dir: string | undefined;
32
- /** The directory the file writes for it, as YAML decodes it, which layer 3 holds beside the text. */
33
- readonly writtenDirectory: string | undefined;
34
34
  }
35
35
 
36
36
  const WORKFLOWS = ".github/workflows";
@@ -41,16 +41,32 @@ const HOOK_DIRS = [".husky", ".githooks"] as const;
41
41
  /** The key setting the directory a workflow step, or each step a `defaults.run` covers, runs in. */
42
42
  const WORKING_DIRECTORY = "working-directory";
43
43
 
44
+ /** One hook manager's file. */
45
+ interface HookManager {
46
+ readonly name: string;
47
+ /** The key its commands are written under. */
48
+ readonly key: string;
49
+ /** The key of a command's directory: lefthook's `root`, https://lefthook.dev/configuration/root/ */
50
+ readonly dirKey?: string;
51
+ /** Whether its hooks name script files it runs, as lefthook's do. */
52
+ readonly scripts?: true;
53
+ }
54
+
55
+ const HOOK_MANAGERS: readonly HookManager[] = [
56
+ { name: ".pre-commit-config.yaml", key: "entry" },
57
+ ...["lefthook.yml", "lefthook.yaml", ".lefthook.yml"].map((name): HookManager => ({
58
+ name,
59
+ key: "run",
60
+ dirKey: "root",
61
+ scripts: true,
62
+ })),
63
+ ];
64
+
44
65
  /**
45
- * The hook manager files, each with the key its commands are written under and the key of their
46
- * directory: lefthook's `root`, https://lefthook.dev/configuration/root/
66
+ * The directories lefthook searches for a hook's scripts after the file's `source_dir` and
67
+ * `source_dir_local`: https://github.com/evilmartians/lefthook/blob/master/internal/command/run.go
47
68
  */
48
- const HOOK_MANAGERS: readonly (readonly [string, string, string | undefined])[] = [
49
- [".pre-commit-config.yaml", "entry", undefined],
50
- ...["lefthook.yml", "lefthook.yaml", ".lefthook.yml"].map(
51
- (name) => [name, "run", "root"] as const,
52
- ),
53
- ];
69
+ const LEFTHOOK_DIRS = [".config/lefthook", ".config/lefthook-local"] as const;
54
70
 
55
71
  /** The names GNU make reads a makefile under, in the order it tries them. */
56
72
  const MAKEFILES = ["GNUmakefile", "makefile", "Makefile"] as const;
@@ -112,7 +128,6 @@ function keyedCommands(
112
128
  : written.includes("${{")
113
129
  ? undefined
114
130
  : resolvePath(root, written),
115
- writtenDirectory: written,
116
131
  });
117
132
  }
118
133
  for (const value of Object.values(node)) {
@@ -123,6 +138,52 @@ function keyedCommands(
123
138
  return found;
124
139
  }
125
140
 
141
+ /**
142
+ * The command lefthook runs for each script a lefthook file's hooks name, each key of a hook's
143
+ * `scripts` and each `script` of a job in its `jobs`, a group's among them, from each directory
144
+ * it searches: its `runner`, then the path `<dir>/<hook>/<name>` quoted as lefthook quotes it,
145
+ * run in the target root: https://lefthook.dev/configuration/Scripts/
146
+ */
147
+ function lefthookScripts(text: string, root: string): readonly CommandString[] {
148
+ const document = yamlDocument(text);
149
+ if (!isRecord(document)) {
150
+ return [];
151
+ }
152
+ const dirs = [
153
+ scalarText(document["source_dir"]) ?? ".lefthook/",
154
+ scalarText(document["source_dir_local"]) ?? ".lefthook-local/",
155
+ ...LEFTHOOK_DIRS,
156
+ ];
157
+ const found: CommandString[] = [];
158
+ const add = (hook: string, name: string, runner: string | undefined): void => {
159
+ for (const dir of dirs) {
160
+ const path = `'${joinPath(dir, hook, name).replaceAll("'", "'\\''")}'`;
161
+ found.push({
162
+ text: runner === undefined || runner.trim() === "" ? path : `${runner} ${path}`,
163
+ dir: root,
164
+ });
165
+ }
166
+ };
167
+ const jobs = (hook: string, list: unknown): void => {
168
+ for (const job of Array.isArray(list) ? list : []) {
169
+ const script = isRecord(job) ? scalarText(job["script"]) : undefined;
170
+ if (isRecord(job) && script !== undefined) {
171
+ add(hook, script, scalarText(job["runner"]));
172
+ }
173
+ const group = isRecord(job) ? job["group"] : undefined;
174
+ jobs(hook, isRecord(group) ? group["jobs"] : undefined);
175
+ }
176
+ };
177
+ for (const [hook, value] of Object.entries(document)) {
178
+ const scripts = isRecord(value) ? value["scripts"] : undefined;
179
+ for (const [name, options] of Object.entries(isRecord(scripts) ? scripts : {})) {
180
+ add(hook, name, isRecord(options) ? scalarText(options["runner"]) : undefined);
181
+ }
182
+ jobs(hook, isRecord(value) ? value["jobs"] : undefined);
183
+ }
184
+ return found;
185
+ }
186
+
126
187
  /** The path of each git hook script at `root`, every file in {@link HOOK_DIRS}. */
127
188
  function hookScripts(host: Host, root: string): readonly string[] {
128
189
  return HOOK_DIRS.flatMap((dir) =>
@@ -137,10 +198,43 @@ function makefileAt(host: Host, root: string): string | undefined {
137
198
  );
138
199
  }
139
200
 
201
+ /**
202
+ * One recipe line as make hands it to the shell: each `$$` a `$`, and each reference to a
203
+ * variable or a function, which make expands first, a part only the run knows:
204
+ * https://www.gnu.org/software/make/manual/html_node/Reference.html
205
+ */
206
+ function expandedByMake(line: string): string {
207
+ let expanded = "";
208
+ for (let at = 0; at < line.length; at += 1) {
209
+ const char = line.charAt(at);
210
+ const next = line.charAt(at + 1);
211
+ if (char !== "$") {
212
+ expanded += char;
213
+ } else if (next === "$") {
214
+ expanded += "$";
215
+ at += 1;
216
+ } else {
217
+ let end = at + 1;
218
+ if (next === "(" || next === "{") {
219
+ const close = next === "(" ? ")" : "}";
220
+ for (let depth = 0; end < line.length; end += 1) {
221
+ depth += line.charAt(end) === next ? 1 : line.charAt(end) === close ? -1 : 0;
222
+ if (depth === 0) {
223
+ break;
224
+ }
225
+ }
226
+ }
227
+ expanded += PART;
228
+ at = Math.min(end, line.length);
229
+ }
230
+ }
231
+ return expanded;
232
+ }
233
+
140
234
  /**
141
235
  * Each recipe line of the target root's makefile: a line that starts with a tab, joined with
142
- * the lines a trailing `\` continues it onto, less the `@`, `-` and `+` make reads before it.
143
- * Make runs each line in a shell of its own.
236
+ * the lines a trailing `\` continues it onto, less the `@`, `-` and `+` make reads before it,
237
+ * as make expands it. Make runs each line in a shell of its own.
144
238
  */
145
239
  function recipeLines(host: Host, root: string): readonly CommandString[] {
146
240
  const path = makefileAt(host, root);
@@ -150,9 +244,8 @@ function recipeLines(host: Host, root: string): readonly CommandString[] {
150
244
  .split(/\r?\n/u)
151
245
  .filter((line) => line.startsWith("\t"))
152
246
  .map((line) => ({
153
- text: line.slice(1).replace(/^[@+-]+/u, ""),
247
+ text: expandedByMake(line.slice(1).replace(/^[@+-]+/u, "")),
154
248
  dir: root,
155
- writtenDirectory: undefined,
156
249
  }));
157
250
  }
158
251
 
@@ -160,7 +253,7 @@ function recipeLines(host: Host, root: string): readonly CommandString[] {
160
253
  * Every command the target root's workflow steps, git hooks, hook managers and makefile run:
161
254
  * the `run:` steps of the workflow files in `.github/workflows`, each file in `.husky` and
162
255
  * `.githooks` as one shell script, the `entry:` commands of `.pre-commit-config.yaml`, the
163
- * `run:` commands of a lefthook file and the makefile's recipe lines.
256
+ * `run:` commands and the scripts of a lefthook file and the makefile's recipe lines.
164
257
  */
165
258
  export function workflowCommands(host: Host, targetRoot: string): readonly CommandString[] {
166
259
  const root = resolvePath(host.workingDirectory(), targetRoot);
@@ -177,49 +270,145 @@ export function workflowCommands(host: Host, targetRoot: string): readonly Comma
177
270
  ...hookScripts(host, root).map((path): CommandString => ({
178
271
  text: readText(host, path) ?? "",
179
272
  dir: root,
180
- writtenDirectory: undefined,
181
273
  })),
182
- ...HOOK_MANAGERS.flatMap(([name, key, dirKey]) =>
183
- keyedCommands(readText(host, joinPath(root, name)) ?? "", root, key, dirKey),
184
- ),
274
+ ...HOOK_MANAGERS.flatMap(({ name, key, dirKey, scripts }) => {
275
+ const text = readText(host, joinPath(root, name)) ?? "";
276
+ return [
277
+ ...keyedCommands(text, root, key, dirKey),
278
+ ...(scripts === true ? lefthookScripts(text, root) : []),
279
+ ];
280
+ }),
185
281
  ...recipeLines(host, root),
186
282
  ];
187
283
  }
188
284
 
189
285
  /**
190
286
  * The whole text of each file {@link workflowCommands} reads a command from, each workflow file,
191
- * git hook, hook manager file and makefile at the target root. Layer 3 holds it whole, so a part
192
- * no command is read from, a comment included, or one the reading of the structure misses, still
193
- * holds every file it names.
287
+ * git hook, hook manager file and makefile at the target root, which the dependency rule reads.
194
288
  */
195
289
  export function commandFileTexts(host: Host, targetRoot: string): readonly string[] {
290
+ return commandFiles(host, targetRoot).flatMap(({ path }) => readText(host, path) ?? []);
291
+ }
292
+
293
+ /** Each file {@link workflowCommands} reads a command from, with the key a YAML file writes its commands under. */
294
+ function commandFiles(
295
+ host: Host,
296
+ targetRoot: string,
297
+ ): readonly { readonly path: string; readonly key: string | undefined }[] {
196
298
  const root = resolvePath(host.workingDirectory(), targetRoot);
197
299
  return [
198
- ...filesIn(host, joinPath(root, WORKFLOWS), (name) => /\.ya?ml$/u.test(name)).map((name) =>
199
- joinPath(root, WORKFLOWS, name),
200
- ),
201
- ...hookScripts(host, root),
202
- ...HOOK_MANAGERS.map(([name]) => joinPath(root, name)),
203
- ...[makefileAt(host, root) ?? []].flat(),
204
- ].flatMap((path) => readText(host, path) ?? []);
300
+ ...filesIn(host, joinPath(root, WORKFLOWS), (name) => /\.ya?ml$/u.test(name)).map((name) => ({
301
+ path: joinPath(root, WORKFLOWS, name),
302
+ key: "run",
303
+ })),
304
+ ...hookScripts(host, root).map((path) => ({ path, key: undefined })),
305
+ ...HOOK_MANAGERS.map(({ name, key }) => ({ path: joinPath(root, name), key })),
306
+ ...[makefileAt(host, root) ?? []].flat().map((path) => ({ path, key: undefined })),
307
+ ];
308
+ }
309
+
310
+ /**
311
+ * The filters of a workflow's `on` key, which name the files and refs whose change triggers it
312
+ * and run none: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#onpushpull_requestpull_request_targetpathspaths-ignore
313
+ */
314
+ const TRIGGER_FILTERS = new Set([
315
+ "paths",
316
+ "paths-ignore",
317
+ "branches",
318
+ "branches-ignore",
319
+ "tags",
320
+ "tags-ignore",
321
+ ]);
322
+
323
+ /**
324
+ * The values of one YAML file that are no command, each with its key: every scalar but one
325
+ * written under `key`, which is read as a command string, and one of a trigger filter. A file
326
+ * `yamlDocument` does not read is one value, its whole text. No key and no comment is a value.
327
+ */
328
+ function yamlValues(text: string, key: string): readonly HeldText[] {
329
+ const document = yamlDocument(text);
330
+ if (document === undefined) {
331
+ return [{ text, shell: false }];
332
+ }
333
+ const found: HeldText[] = [];
334
+ // `trigger` is 1 for the value of the top-level `on`, a map of events, and 2 for an event's.
335
+ const visit = (node: unknown, under: string | undefined, trigger = 0): void => {
336
+ if (Array.isArray(node)) {
337
+ for (const item of node) {
338
+ visit(item, under);
339
+ }
340
+ } else if (isRecord(node)) {
341
+ for (const [name, value] of Object.entries(node)) {
342
+ const on = node === document && (name === "on" || name === "true");
343
+ if (
344
+ (name !== key || scalarText(value) === undefined) &&
345
+ !(trigger === 2 && TRIGGER_FILTERS.has(name))
346
+ ) {
347
+ visit(value, name, on ? 1 : trigger === 1 ? 2 : 0);
348
+ }
349
+ }
350
+ } else {
351
+ const value = scalarText(node);
352
+ if (value !== undefined) {
353
+ found.push({ text: value, shell: false, ...(under === undefined ? {} : { key: under }) });
354
+ }
355
+ }
356
+ };
357
+ visit(document, undefined);
358
+ return found;
359
+ }
360
+
361
+ /**
362
+ * The lines of a makefile that are no recipe line, joined with the lines a trailing `\`
363
+ * continues them onto, each without the comment a `#` make does not read as escaped starts.
364
+ */
365
+ function makefileValues(text: string): readonly HeldText[] {
366
+ return text
367
+ .replace(/\\\r?\n/gu, " ")
368
+ .split(/\r?\n/u)
369
+ .filter((line) => !line.startsWith("\t"))
370
+ .map((line) => line.replace(/(?<!\\)#.*$/u, ""))
371
+ .filter((line) => line.trim() !== "")
372
+ .map((line) => ({ text: line, shell: false }));
373
+ }
374
+
375
+ /**
376
+ * The texts layer 3 reads of the target root's command files: each command string, and each
377
+ * value of a workflow file, a hook manager file and the makefile that is no command.
378
+ */
379
+ function heldTexts(host: Host, targetRoot: string): readonly HeldText[] {
380
+ const makefile = makefileAt(host, resolvePath(host.workingDirectory(), targetRoot));
381
+ return [
382
+ ...workflowCommands(host, targetRoot).map(({ text }) => ({ text, shell: true })),
383
+ ...commandFiles(host, targetRoot).flatMap(({ path, key }) => {
384
+ const text = readText(host, path);
385
+ return text === undefined
386
+ ? []
387
+ : key !== undefined
388
+ ? yamlValues(text, key)
389
+ : path === makefile
390
+ ? makefileValues(text)
391
+ : [];
392
+ }),
393
+ ];
205
394
  }
206
395
 
207
396
  /** What the target root's workflow steps, git hooks, hook managers and makefile run, read once per run. */
208
397
  export interface Workflows {
209
- /** Each command read, its held text followed by the directory the file writes for it. */
398
+ /** Each command read. */
210
399
  readonly readings: readonly CommandReading[];
211
- /** The whole text of each file a command is read from. */
400
+ /** The whole text of each file a command is read from, which the dependency rule reads. */
212
401
  readonly texts: readonly string[];
402
+ /** What layer 3 reads of them: each command string, and each value that is no command. */
403
+ readonly held: readonly HeldText[];
213
404
  }
214
405
 
215
406
  /** Reads the target root's workflow steps, git hooks, hook managers and makefile. */
216
407
  export function readWorkflows(host: Host, targetRoot: string): Workflows {
217
408
  return {
218
- readings: workflowCommands(host, targetRoot).map(({ text, dir, writtenDirectory }) => {
219
- const reading = readCommands(text, dir);
220
- return { ...reading, held: [reading.held, writtenDirectory ?? ""].join("\n") };
221
- }),
409
+ readings: workflowCommands(host, targetRoot).map(({ text, dir }) => readCommands(text, dir)),
222
410
  texts: commandFileTexts(host, targetRoot),
411
+ held: heldTexts(host, targetRoot),
223
412
  };
224
413
  }
225
414
 
package/src/file-facts.ts CHANGED
@@ -54,6 +54,11 @@ export interface FileFacts {
54
54
  readonly inOperands?: ReadonlySet<string>;
55
55
  /** The loads of production files whose target the analysis cannot resolve. */
56
56
  readonly unseenLoads?: readonly UnseenLoad[];
57
+ /**
58
+ * The directories below the target root, `.` for the root, of the packages a command of
59
+ * which may run a file whose path no word of it writes (`roots.13`).
60
+ */
61
+ readonly unwrittenRuns?: ReadonlySet<string>;
57
62
  }
58
63
 
59
64
  /** The line of the last character of one text, counted from one; an empty text is one line. */
@@ -164,8 +164,19 @@ function fileNeverImported(input: EmitterInput): readonly Finding[] {
164
164
  });
165
165
  }
166
166
 
167
- /** The findings of the files family, `DS1600` to `DS1699`. */
168
- export const nonCodeArtifacts: Emitter = (input) => [
169
- ...fileNeverBuilt(input),
170
- ...fileNeverImported(input),
171
- ];
167
+ /**
168
+ * The findings of the files family, `DS1600` to `DS1699`, each at the class `possible` in a
169
+ * package a command of which may run a file whose path no word of it writes, since that
170
+ * command may run the file (confidence.35).
171
+ */
172
+ export const nonCodeArtifacts: Emitter = (input) => {
173
+ const unwritten = [...(input.files.unwrittenRuns ?? [])];
174
+ return [...fileNeverBuilt(input), ...fileNeverImported(input)].map((finding) =>
175
+ unwritten.some(
176
+ (dir) =>
177
+ dir === "." || finding.position.path === dir || finding.position.path.startsWith(`${dir}/`),
178
+ )
179
+ ? { ...finding, reachabilityClass: "possible" as const }
180
+ : finding,
181
+ );
182
+ };