@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
  };
@@ -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
  /**
@@ -130,8 +130,14 @@ function fileNeverImported(input) {
130
130
  : fileFinding(CODE.fileNeverImported, file, "no import reaches this file and no root names it or a declaration in it");
131
131
  });
132
132
  }
133
- /** The findings of the files family, `DS1600` to `DS1699`. */
134
- export const nonCodeArtifacts = (input) => [
135
- ...fileNeverBuilt(input),
136
- ...fileNeverImported(input),
137
- ];
133
+ /**
134
+ * The findings of the files family, `DS1600` to `DS1699`, each at the class `possible` in a
135
+ * package a command of which may run a file whose path no word of it writes, since that
136
+ * command may run the file (confidence.35).
137
+ */
138
+ export const nonCodeArtifacts = (input) => {
139
+ const unwritten = [...(input.files.unwrittenRuns ?? [])];
140
+ return [...fileNeverBuilt(input), ...fileNeverImported(input)].map((finding) => unwritten.some((dir) => dir === "." || finding.position.path === dir || finding.position.path.startsWith(`${dir}/`))
141
+ ? { ...finding, reachabilityClass: "possible" }
142
+ : finding);
143
+ };
@@ -27,8 +27,9 @@ function below(member, key) {
27
27
  }
28
28
  /**
29
29
  * The whole text of nodemon's configuration, the manifest's `nodemonConfig` member and the
30
- * `nodemon.json` beside it, by the member each is. Layer 3 holds it whole, because nodemon runs
31
- * commands from keys besides `exec`, `events` among them: https://github.com/remy/nodemon#config-files
30
+ * `nodemon.json` beside it, by the member each is. The dependency rule reads it whole, and
31
+ * layer 3 each of its values, because nodemon runs commands from keys besides `exec`, `events`
32
+ * among them: https://github.com/remy/nodemon#config-files
32
33
  */
33
34
  export function nodemonTexts(host, dir, manifest) {
34
35
  const member = manifest["nodemonConfig"];
@@ -44,6 +45,39 @@ export function nodemonTexts(host, dir, manifest) {
44
45
  ...(file === undefined ? [] : [{ member: "nodemon.json", text: file }]),
45
46
  ];
46
47
  }
48
+ /**
49
+ * The texts layer 3 reads of one text of nodemon's configuration: each value of it but its
50
+ * `exec` command, which is read as a command string of its own, those under `events` read as
51
+ * command strings too, and the whole text where it does not parse. Its keys are never read.
52
+ */
53
+ function nodemonValues(text) {
54
+ let config;
55
+ try {
56
+ config = JSON.parse(text);
57
+ }
58
+ catch {
59
+ return [{ text, shell: false }];
60
+ }
61
+ const found = [];
62
+ const visit = (value, shell) => {
63
+ if (Array.isArray(value) || isRecord(value)) {
64
+ for (const one of Object.values(value)) {
65
+ visit(one, shell);
66
+ }
67
+ }
68
+ else if (typeof value === "string" ||
69
+ typeof value === "number" ||
70
+ typeof value === "boolean") {
71
+ found.push({ text: String(value), shell });
72
+ }
73
+ };
74
+ for (const [key, value] of Object.entries(isRecord(config) ? config : { "": config })) {
75
+ if (key !== "exec" || typeof value !== "string") {
76
+ visit(value, key === "events");
77
+ }
78
+ }
79
+ return found;
80
+ }
47
81
  /**
48
82
  * The command strings one manifest runs, each at the member it is written under: each of its
49
83
  * `scripts`, and the `exec` command of nodemon's configuration, in the manifest's `nodemonConfig`
@@ -141,10 +175,19 @@ export function readManifest(host, targetRoot) {
141
175
  for (const path of new Set(pathsNamed(readCommands(command, ".")).map((one) => one.path))) {
142
176
  found.push({ member, path, role: "script", published: false });
143
177
  }
144
- found.push({ member, path: ".", role: "script", published: false, held: command });
178
+ found.push({
179
+ member,
180
+ path: ".",
181
+ role: "script",
182
+ published: false,
183
+ held: [{ text: command, shell: true, tested: member.startsWith("scripts[") }],
184
+ });
145
185
  }
146
186
  for (const { member, text } of nodemonTexts(host, targetRoot, manifest)) {
147
- found.push({ member, path: ".", role: "script", published: false, held: text });
187
+ const held = nodemonValues(text);
188
+ if (held.length > 0) {
189
+ found.push({ member, path: ".", role: "script", published: false, held });
190
+ }
148
191
  }
149
192
  return {
150
193
  entries: found.map((entry) => ({
@@ -16,6 +16,7 @@ import { typeQueryAliases } from "../type-query-alias.js";
16
16
  import { bytewise } from "../text/compare.js";
17
17
  import { holdsManifest } from "../fs/manifest.js";
18
18
  import { globExpression } from "../text/glob.js";
19
+ import { heldWords } from "../commands/held.js";
19
20
  import { spelledFiles } from "../text/named-files.js";
20
21
  import { extensionOf, OUTPUT_EXTENSIONS } from "../extensions.js";
21
22
  import { publishedAPI, releaseTagged } from "./published-reach.js";
@@ -159,11 +160,12 @@ export function matchRef(pattern, ref) {
159
160
  return matchGlob(Array.from(pattern), Array.from(ref));
160
161
  }
161
162
  /**
162
- * Per package, the own files its held texts name, as `spelledFiles` reads them, but those a
163
- * root enters with all they export: a command can use no more of such a file than the root
164
- * keeps live. A manifest's are its entries' held texts, and the target root's package has the
165
- * workflow steps, git hooks and makefile recipes beside its own, with the whole text of each
166
- * file they are read from.
163
+ * Per package, the own files the words of its held texts name, as `spelledFiles` reads them,
164
+ * but those a root enters with all they export: a command can use no more of such a file than
165
+ * the root keeps live. A manifest's are its entries' held texts, and the target root's package
166
+ * has the workflow steps, git hooks and makefile recipes beside its own, with the values of
167
+ * each file they are read from. A package is listed too where a word that may write the file
168
+ * a command runs writes no path text.
167
169
  */
168
170
  function scriptHolds(entries, files, entered, host, targetRoot, workflows) {
169
171
  const rooted = new Set(entered.filter((one) => one.exports && one.names === undefined).map((one) => one.file.fileName));
@@ -171,24 +173,20 @@ function scriptHolds(entries, files, entered, host, targetRoot, workflows) {
171
173
  .filter((file) => !rooted.has(file.fileName))
172
174
  .map((file) => ({ file, paths: [file.fileName, ...files.writtenAs(file)] }));
173
175
  const cwd = host.workingDirectory();
174
- const commands = new Map([
175
- [
176
- resolvePath(cwd, targetRoot),
177
- [...workflows.readings.map((reading) => reading.held), ...workflows.texts],
178
- ],
179
- ]);
176
+ const root = resolvePath(cwd, targetRoot);
177
+ const commands = new Map([[root, [...workflows.held]]]);
180
178
  for (const entry of entries) {
181
179
  if (entry.held !== undefined) {
182
180
  const dir = resolvePath(cwd, entry.dir);
183
- commands.set(dir, [...(commands.get(dir) ?? []), entry.held]);
181
+ commands.set(dir, [...(commands.get(dir) ?? []), ...entry.held]);
184
182
  }
185
183
  }
186
184
  const held = [];
187
185
  for (const [dir, texts] of commands) {
188
- const named = spelledFiles(texts.join("\n"), dir, spelled);
189
- if (named.length > 0) {
190
- const paths = named.map((file) => relativePath(targetRoot, file.fileName) ?? file.fileName);
191
- held.push({ dir, files: paths });
186
+ const named = spelledFiles(heldWords(texts), dir, root, spelled);
187
+ if (named.files.length > 0 || named.unwritten) {
188
+ const paths = named.files.map((file) => relativePath(targetRoot, file.fileName) ?? file.fileName);
189
+ held.push({ dir, files: paths, unwritten: named.unwritten });
192
190
  }
193
191
  }
194
192
  return held;