@cplieger/deadset-ts 7.0.0-dev.1 → 7.0.0-dev.3

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 (113) hide show
  1. package/dist/src/analysis.js +95 -60
  2. package/dist/src/augmentations.js +14 -12
  3. package/dist/src/component-layer.js +1 -1
  4. package/dist/src/components.js +62 -20
  5. package/dist/src/configuration-files.js +230 -30
  6. package/dist/src/conformance-record.js +1 -1
  7. package/dist/src/contextual-uses.js +104 -13
  8. package/dist/src/convention-rows.js +10 -0
  9. package/dist/src/conventions.js +35 -1
  10. package/dist/src/dependencies.js +90 -32
  11. package/dist/src/discover.js +1 -1
  12. package/dist/src/edges.js +1 -1
  13. package/dist/src/element-access.js +341 -0
  14. package/dist/src/erased-stores.js +317 -0
  15. package/dist/src/exempt-classes.js +9 -22
  16. package/dist/src/exempt.js +3 -15
  17. package/dist/src/findings/completion.js +78 -16
  18. package/dist/src/findings/dependencies-and-module-machinery.js +1 -1
  19. package/dist/src/findings/emitters.js +1 -5
  20. package/dist/src/findings/self-check.js +27 -10
  21. package/dist/src/findings-pass.js +6 -5
  22. package/dist/src/in-operands.js +30 -0
  23. package/dist/src/indexed-access.js +264 -119
  24. package/dist/src/intra-function-parts.js +25 -11
  25. package/dist/src/inventory.js +28 -12
  26. package/dist/src/keyof-comparisons.js +170 -0
  27. package/dist/src/kinds.js +17 -7
  28. package/dist/src/manifest.js +6 -16
  29. package/dist/src/matrix.js +20 -2
  30. package/dist/src/query.js +3 -0
  31. package/dist/src/references.js +14 -3
  32. package/dist/src/report.js +4 -1
  33. package/dist/src/reporters/sarif.js +4 -0
  34. package/dist/src/roots.js +0 -1
  35. package/dist/src/run.js +0 -2
  36. package/dist/src/schema.js +1 -1
  37. package/dist/src/scope.js +2 -2
  38. package/dist/src/serialization-contract.js +43 -14
  39. package/dist/src/session.js +12 -8
  40. package/dist/src/suppress-file.js +58 -6
  41. package/dist/src/suppress-inline.js +14 -5
  42. package/dist/src/suppress.js +64 -35
  43. package/dist/src/unimplemented-callees.js +312 -0
  44. package/dist/src/value-reach.js +7 -7
  45. package/dist/src/verbs/analyze.js +11 -9
  46. package/dist/src/workspace-programs.js +0 -3
  47. package/dist/src/workspace-resolution.js +0 -2
  48. package/dist/src/workspace.js +0 -3
  49. package/jsr.json +1 -1
  50. package/package.json +3 -2
  51. package/src/analysis.ts +145 -82
  52. package/src/augmentations.ts +14 -11
  53. package/src/calls.ts +3 -3
  54. package/src/component-files.ts +7 -7
  55. package/src/component-layer.ts +2 -2
  56. package/src/components.ts +104 -22
  57. package/src/config.ts +4 -4
  58. package/src/configuration-files.ts +250 -31
  59. package/src/conformance-record.ts +1 -1
  60. package/src/contextual-uses.ts +135 -17
  61. package/src/convention-rows.ts +23 -0
  62. package/src/conventions.ts +53 -1
  63. package/src/dependencies.ts +119 -38
  64. package/src/discover.ts +3 -3
  65. package/src/edges.ts +3 -3
  66. package/src/element-access.ts +446 -0
  67. package/src/entry-points.ts +1 -1
  68. package/src/erased-stores.ts +413 -0
  69. package/src/exempt-classes.ts +10 -24
  70. package/src/exempt.ts +5 -29
  71. package/src/file-facts.ts +3 -1
  72. package/src/finding.ts +3 -1
  73. package/src/findings/completion.ts +97 -21
  74. package/src/findings/dependencies-and-module-machinery.ts +1 -1
  75. package/src/findings/emitters.ts +4 -9
  76. package/src/findings/self-check.ts +31 -14
  77. package/src/findings-pass.ts +14 -7
  78. package/src/graph.ts +2 -2
  79. package/src/html-entries.ts +1 -1
  80. package/src/in-operands.ts +45 -0
  81. package/src/indexed-access.ts +303 -137
  82. package/src/intra-function-parts.ts +41 -16
  83. package/src/inventory.ts +37 -33
  84. package/src/json-document.ts +1 -1
  85. package/src/keyof-comparisons.ts +211 -0
  86. package/src/kinds.ts +20 -7
  87. package/src/manifest.ts +7 -22
  88. package/src/matrix.ts +34 -2
  89. package/src/namespace-objects.ts +1 -1
  90. package/src/pnpm-workspace.ts +1 -1
  91. package/src/query.ts +10 -0
  92. package/src/references.ts +30 -7
  93. package/src/report.ts +11 -5
  94. package/src/reporters/sarif.ts +4 -0
  95. package/src/roots.ts +0 -3
  96. package/src/run.ts +0 -2
  97. package/src/schema.ts +2 -2
  98. package/src/scope.ts +2 -2
  99. package/src/serialization-contract.ts +53 -14
  100. package/src/session.ts +34 -31
  101. package/src/stores.ts +1 -1
  102. package/src/suppress-file.ts +68 -5
  103. package/src/suppress-inline.ts +17 -7
  104. package/src/suppress.ts +79 -47
  105. package/src/template-field.ts +1 -1
  106. package/src/unimplemented-callees.ts +411 -0
  107. package/src/value-reach.ts +12 -8
  108. package/src/verbs/analyze.ts +15 -9
  109. package/src/verbs/verb.ts +1 -4
  110. package/src/workspace-programs.ts +2 -9
  111. package/src/workspace-resolution.ts +0 -3
  112. package/src/workspace-run.ts +1 -1
  113. package/src/workspace.ts +1 -9
package/src/components.ts CHANGED
@@ -37,12 +37,31 @@ export interface Component {
37
37
  * cycle with a live container is one, so a component has at least one.
38
38
  */
39
39
  readonly roots: readonly string[];
40
- /** The source lines one deletion of the component removes: the distinct lines its members span. */
40
+ /**
41
+ * The source lines one deletion of the component removes: the distinct lines its
42
+ * members span, and none for a component of held declarations.
43
+ */
41
44
  readonly deletableLines: number;
45
+ /**
46
+ * Whether its members are held declarations, which no mechanical edit deletes: a dead
47
+ * declaration whose deletion changes a live declaration's value, and every dead
48
+ * declaration such a one references or whose deletion would change its value.
49
+ */
50
+ readonly held: boolean;
51
+ }
52
+
53
+ /**
54
+ * What the held-declaration rule reads of a graph, by place: the places of the later
55
+ * declarations whose value each one's deletion changes, and whether reachability holds
56
+ * each one live in some configuration.
57
+ */
58
+ export interface Positional {
59
+ readonly dependents: readonly (readonly number[])[];
60
+ readonly live: readonly boolean[];
42
61
  }
43
62
 
44
63
  /** What a report carries for one dead component under one cascade mode. */
45
- export interface Listing {
64
+ interface Listing {
46
65
  readonly roots: readonly string[];
47
66
  /** Every member under the full mode, and nothing under the default one. */
48
67
  readonly members: readonly string[];
@@ -63,41 +82,97 @@ export function listing(component: Component, cascade: Cascade): Listing {
63
82
  /**
64
83
  * Groups the declarations `dead` marks into components, ordered by their first member's
65
84
  * site; `dead`, `testOfDeadCode` and `unreferencedTest` hold one flag per declaration of
66
- * the graph. Root members sit on the cycles no other cycle references. A cycle two roots
67
- * both reach is dead only through both, so a component is a weakly connected part of the
68
- * dead subgraph: no dead declaration of one references a declaration of another.
85
+ * the graph. Root members sit on the cycles no other cycle references. A component is a
86
+ * weakly connected part of the dead subgraph, because a cycle two roots reach is dead only
87
+ * through both. The held declarations `positional` decides form components of their own,
88
+ * and a dead declaration that is not held shares one with each of its dead dependents.
69
89
  */
70
90
  export function componentsOf(
71
91
  graph: Graph,
72
92
  dead: readonly boolean[],
73
93
  testOfDeadCode: readonly boolean[],
74
94
  unreferencedTest: readonly boolean[] = [],
95
+ positional: Positional = { dependents: [], live: [] },
75
96
  ): readonly Component[] {
76
- const { at, adjacent } = deadSubgraph(graph, dead, testOfDeadCode, unreferencedTest);
97
+ const { at, position, adjacent, references } = deadSubgraph(
98
+ graph,
99
+ dead,
100
+ testOfDeadCode,
101
+ unreferencedTest,
102
+ );
77
103
  if (at.length === 0) {
78
104
  return [];
79
105
  }
80
- const { componentOf, members } = connected(adjacent);
81
- const referenced = referencedCycles(adjacent, componentOf, members.length);
82
- const groups = joined(adjacent);
83
- const idOf = (position: number): string => graph.symbols[at[position] ?? 0]?.id ?? "";
106
+ const dependents = at.map((index) =>
107
+ (positional.dependents[index] ?? []).map((to) => ({
108
+ to: position[to] ?? UNVISITED,
109
+ live: positional.live[to] === true,
110
+ })),
111
+ );
112
+ const held = heldSet(dependents, references);
113
+ const kept = adjacent.map((targets, from) => targets.filter((to) => held[to] === held[from]));
114
+ const { componentOf, members } = connected(kept);
115
+ const referenced = referencedCycles(kept, componentOf, members.length);
116
+ const groups = joined(
117
+ kept.map((targets, from) => [
118
+ ...targets,
119
+ ...(held[from] === true
120
+ ? []
121
+ : (dependents[from] ?? []).flatMap(({ to }) =>
122
+ to === UNVISITED || held[to] === true ? [] : [to],
123
+ )),
124
+ ]),
125
+ );
126
+ const idOf = (place: number): string => graph.symbols[at[place] ?? 0]?.id ?? "";
84
127
 
85
128
  return groups.map((group, place) => {
86
- const roots = group.filter((position) => {
129
+ const roots = group.filter((one) => {
87
130
  // A dead member's dead container is on the member's own cycle, and the container
88
131
  // is where the deletion starts.
89
- const container = graph.parent[at[position] ?? 0] ?? OUTSIDE;
90
- return referenced[componentOf[position] ?? 0] !== true && dead[container] !== true;
132
+ const container = graph.parent[at[one] ?? 0] ?? OUTSIDE;
133
+ return referenced[componentOf[one] ?? 0] !== true && dead[container] !== true;
91
134
  });
135
+ const isHeld = group.every((one) => held[one] === true);
92
136
  return {
93
137
  id: componentId(place + 1),
94
138
  members: group.map(idOf),
95
139
  roots: roots.map(idOf),
96
- deletableLines: linesSpanned(group.map((position) => graph.symbols[at[position] ?? 0])),
140
+ deletableLines: isHeld ? 0 : linesSpanned(group.map((one) => graph.symbols[at[one] ?? 0])),
141
+ held: isHeld,
97
142
  };
98
143
  });
99
144
  }
100
145
 
146
+ /**
147
+ * Per place of the dead subgraph, whether the declaration is held: one with a live
148
+ * dependent, and each dead declaration a held one references or has as a dependent,
149
+ * until no further one joins.
150
+ */
151
+ function heldSet(
152
+ dependents: readonly (readonly { readonly to: number; readonly live: boolean }[])[],
153
+ references: readonly (readonly number[])[],
154
+ ): readonly boolean[] {
155
+ const held = dependents.map((after) => after.some((one) => one.live));
156
+ const before: number[][] = dependents.map(() => []);
157
+ dependents.forEach((after, from) => {
158
+ for (const { to } of after) {
159
+ if (to !== UNVISITED) {
160
+ before[to]?.push(from);
161
+ }
162
+ }
163
+ });
164
+ const pending = held.flatMap((one, place) => (one ? [place] : []));
165
+ for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
166
+ for (const reached of [...(references[next] ?? []), ...(before[next] ?? [])]) {
167
+ if (held[reached] !== true) {
168
+ held[reached] = true;
169
+ pending.push(reached);
170
+ }
171
+ }
172
+ }
173
+ return held;
174
+ }
175
+
101
176
  /**
102
177
  * The distinct source lines a set of declarations spans, each declaration at least the
103
178
  * line it starts on. A member written inside its container's span adds no line, because
@@ -133,19 +208,24 @@ function linesSpanned(symbols: readonly (InventorySymbol | undefined)[]): number
133
208
  }
134
209
 
135
210
  /**
136
- * The dead declarations by their place in the subgraph, a test file's declaration only
137
- * where it is a test of dead code or nothing the tests reach, and the edges between them:
138
- * each reference one makes to another, except an unreferenced test-file declaration's
139
- * reference to production code, which decides nothing of its target, an edge each way
140
- * between a dead member and its dead container, and an edge back from each production
141
- * target of an admitted test, which puts the test in its subject's component.
211
+ * The dead declarations by subgraph place (`at`, and `position` per graph declaration),
212
+ * a test file's only where it tests dead code or nothing the tests reach. `adjacent` holds
213
+ * each reference one makes to another, except an unreferenced test-file declaration's to
214
+ * production code, which decides nothing of its target; an edge each way between a dead
215
+ * member and its dead container; and one back from each production target of an admitted
216
+ * test, which puts the test in its subject's component. `references` holds the first alone.
142
217
  */
143
218
  function deadSubgraph(
144
219
  graph: Graph,
145
220
  dead: readonly boolean[],
146
221
  testOfDeadCode: readonly boolean[],
147
222
  unreferencedTest: readonly boolean[],
148
- ): { readonly at: readonly number[]; readonly adjacent: readonly (readonly number[])[] } {
223
+ ): {
224
+ readonly at: readonly number[];
225
+ readonly position: readonly number[];
226
+ readonly adjacent: readonly (readonly number[])[];
227
+ readonly references: readonly (readonly number[])[];
228
+ } {
149
229
  const at: number[] = [];
150
230
  const position = graph.symbols.map((_symbol, index) => {
151
231
  const judged = testOfDeadCode[index] === true || unreferencedTest[index] === true;
@@ -156,6 +236,7 @@ function deadSubgraph(
156
236
  return at.length - 1;
157
237
  });
158
238
  const adjacent: number[][] = at.map(() => []);
239
+ const references: number[][] = at.map(() => []);
159
240
  at.forEach((index, from) => {
160
241
  for (const edge of graph.out[index] ?? []) {
161
242
  const to = position[edge.to] ?? UNVISITED;
@@ -163,6 +244,7 @@ function deadSubgraph(
163
244
  continue;
164
245
  }
165
246
  adjacent[from]?.push(to);
247
+ references[from]?.push(to);
166
248
  if (
167
249
  testOfDeadCode[index] === true &&
168
250
  graph.test[edge.to] !== true &&
@@ -177,7 +259,7 @@ function deadSubgraph(
177
259
  adjacent[container]?.push(from);
178
260
  }
179
261
  });
180
- return { at, adjacent };
262
+ return { at, position, adjacent, references };
181
263
  }
182
264
 
183
265
  /** One frame of the component walk: a declaration, and the next of its edges to follow. */
package/src/config.ts CHANGED
@@ -50,7 +50,7 @@ export type Cascade = "roots" | "full";
50
50
  * tags in effect. It is the shape a language built under build constraints reads,
51
51
  * and this analyzer leaves it to that analysis.
52
52
  */
53
- export interface PlatformConfiguration {
53
+ interface PlatformConfiguration {
54
54
  readonly shape: "platform";
55
55
  readonly id: string;
56
56
  readonly os: string;
@@ -97,7 +97,7 @@ export interface Analysis {
97
97
  }
98
98
 
99
99
  /** How findings are rendered. */
100
- export interface Reporters {
100
+ interface Reporters {
101
101
  readonly formats: readonly Format[];
102
102
  readonly sort: Sort;
103
103
  readonly cascade: Cascade;
@@ -154,7 +154,7 @@ export type Provider =
154
154
  };
155
155
 
156
156
  /** The analyzers the orchestrator runs. */
157
- export interface Providers {
157
+ interface Providers {
158
158
  readonly analyzers: readonly Provider[];
159
159
  }
160
160
 
@@ -293,7 +293,7 @@ export interface Inputs {
293
293
  }
294
294
 
295
295
  /** Which refusal a {@link ConfigError} carries. */
296
- export type ErrorKind =
296
+ type ErrorKind =
297
297
  /**
298
298
  * A document that is not one JSON instance of the closed key list: a syntax
299
299
  * error, a value of the wrong type, a value outside the closed set a key
@@ -69,11 +69,19 @@ export const RESOLVED_EXTENSIONS = [
69
69
  ".cjs",
70
70
  ] as const;
71
71
 
72
+ /** The directory a package manager installs packages into, whose files are no project's own. */
73
+ const MODULES_DIR = "node_modules";
74
+
72
75
  /** The file's name, the last segment of its path. */
73
76
  function basename(path: string): string {
74
77
  return path.slice(path.lastIndexOf("/") + 1);
75
78
  }
76
79
 
80
+ /** Whether one path is the project's own: below the target root and in no `node_modules`. */
81
+ function isProjectPath(root: string, path: string): boolean {
82
+ return relativePath(root, path)?.split("/").includes(MODULES_DIR) === false;
83
+ }
84
+
77
85
  function isRecord(value: unknown): value is Record<string, unknown> {
78
86
  return typeof value === "object" && value !== null && !Array.isArray(value);
79
87
  }
@@ -92,6 +100,8 @@ export function isConfigurationDocumentName(name: string): boolean {
92
100
  interface ConfigurationDocument {
93
101
  /** The file, absolute. */
94
102
  readonly path: string;
103
+ /** The directories of the manifests whose dependencies the strings are read against. */
104
+ readonly manifestDirs: readonly string[];
95
105
  /** Every string value at any depth, in document order; a member's name is no value. */
96
106
  readonly strings: readonly string[];
97
107
  }
@@ -100,11 +110,169 @@ interface ConfigurationDocument {
100
110
  export type ParseFile = (fileName: string, text: string) => SourceFile;
101
111
 
102
112
  /** One file no program holds that a configuration module outside the program leads to. */
103
- export interface OutsideModule {
113
+ interface OutsideModule {
104
114
  /** The file, parsed alone. */
105
115
  readonly file: SourceFile;
106
- /** The directory of the configuration module the file was reached from, which holds its manifest. */
107
- readonly manifestDir: string;
116
+ /** The directories of the manifests of the configuration files the file was reached from. */
117
+ readonly manifestDirs: readonly string[];
118
+ }
119
+
120
+ /** The flags a tool's command line names its configuration file with. */
121
+ const CONFIG_FLAGS: ReadonlySet<string> = new Set(["--config", "-c"]);
122
+
123
+ /** The extensions of a module a configuration flag may name. */
124
+ const MODULE_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"] as const;
125
+
126
+ /** The characters that end a word outside quotes and separate nothing: blanks. */
127
+ const BLANK = /[ \t\r\f\v]/u;
128
+
129
+ /** The characters that end a word outside quotes and begin an operator: newline included. */
130
+ const OPERATOR = /[\n;&|()<>]/u;
131
+
132
+ /** The characters a backslash escapes inside double quotes; before any other it stands for itself. */
133
+ const DOUBLE_QUOTE_ESCAPES = new Set(["$", "`", '"', "\\"]);
134
+
135
+ /**
136
+ * The words of one command line, as POSIX token recognition reads quotes, backslashes,
137
+ * line continuations, comments and operators. A word an expansion may change (an unquoted
138
+ * `$`, backquote, glob character or leading `~`, or a `$` or backquote in double quotes)
139
+ * is computed, and so is the rest of a line whose quote is never closed. A computed word
140
+ * and each operator are undefined, so a flag's file is never read across them.
141
+ */
142
+ function shellWords(command: string): readonly (string | undefined)[] {
143
+ const words: (string | undefined)[] = [];
144
+ let word: string | undefined;
145
+ let computed = false;
146
+ const end = (): void => {
147
+ if (word !== undefined) {
148
+ words.push(computed ? undefined : word);
149
+ }
150
+ word = undefined;
151
+ computed = false;
152
+ };
153
+ for (let at = 0; at < command.length; at += 1) {
154
+ const char = command.charAt(at);
155
+ if (char === "\\" && command.charAt(at + 1) === "\n") {
156
+ at += 1;
157
+ } else if (BLANK.test(char)) {
158
+ end();
159
+ } else if (OPERATOR.test(char)) {
160
+ end();
161
+ words.push(undefined);
162
+ } else if (char === "#" && word === undefined) {
163
+ const newline = command.indexOf("\n", at);
164
+ at = newline < 0 ? command.length : newline - 1;
165
+ } else if (char === "\\") {
166
+ at += 1;
167
+ word = (word ?? "") + command.charAt(at);
168
+ } else if (char === "'" || char === '"') {
169
+ const close = closingQuote(command, at);
170
+ if (close < 0) {
171
+ word ??= "";
172
+ computed = true;
173
+ break;
174
+ }
175
+ const quoted =
176
+ char === "'"
177
+ ? { text: command.slice(at + 1, close), computed: false }
178
+ : doubleQuoted(command.slice(at + 1, close));
179
+ word = (word ?? "") + quoted.text;
180
+ computed ||= quoted.computed;
181
+ at = close;
182
+ } else {
183
+ computed ||= /[$`*?[]/u.test(char) || (char === "~" && word === undefined);
184
+ word = (word ?? "") + char;
185
+ }
186
+ }
187
+ end();
188
+ return words;
189
+ }
190
+
191
+ /** The text between one pair of double quotes, unescaped, and whether an expansion may change it. */
192
+ function doubleQuoted(inner: string): { readonly text: string; readonly computed: boolean } {
193
+ let text = "";
194
+ let computed = false;
195
+ for (let at = 0; at < inner.length; at += 1) {
196
+ const char = inner.charAt(at);
197
+ const next = inner.charAt(at + 1);
198
+ if (char === "\\" && next === "\n") {
199
+ at += 1;
200
+ } else if (char === "\\" && DOUBLE_QUOTE_ESCAPES.has(next)) {
201
+ text += next;
202
+ at += 1;
203
+ } else {
204
+ computed ||= char === "$" || char === "`";
205
+ text += char;
206
+ }
207
+ }
208
+ return { text, computed };
209
+ }
210
+
211
+ /** The index of the quote that closes the one at `open`, or -1 where none does. */
212
+ function closingQuote(command: string, open: number): number {
213
+ const quote = command.charAt(open);
214
+ for (let at = open + 1; at < command.length; at += 1) {
215
+ const char = command.charAt(at);
216
+ if (char === quote) {
217
+ return at;
218
+ }
219
+ if (quote === '"' && char === "\\") {
220
+ at += 1;
221
+ }
222
+ }
223
+ return -1;
224
+ }
225
+
226
+ /**
227
+ * The files the scripts of the manifest in one directory name after a configuration flag,
228
+ * `--config <file>`, `-c <file>` or `--config=<file>`, each a JSON file or a module below
229
+ * the target root and in no `node_modules`.
230
+ */
231
+ function scriptConfigurations(host: Host, root: string, dir: string): readonly string[] {
232
+ let scripts: unknown;
233
+ try {
234
+ const manifest: unknown = JSON.parse(host.readFile(joinPath(dir, MANIFEST)));
235
+ scripts = isRecord(manifest) ? manifest["scripts"] : undefined;
236
+ } catch {
237
+ return [];
238
+ }
239
+ if (!isRecord(scripts)) {
240
+ return [];
241
+ }
242
+ const found: string[] = [];
243
+ for (const command of Object.values(scripts)) {
244
+ if (typeof command !== "string") {
245
+ continue;
246
+ }
247
+ const words = shellWords(command);
248
+ words.forEach((word, at) => {
249
+ const named =
250
+ word !== undefined && CONFIG_FLAGS.has(word)
251
+ ? words[at + 1]
252
+ : word === undefined
253
+ ? undefined
254
+ : /^--config=(.+)$/su.exec(word)?.[1];
255
+ const path = named === undefined ? undefined : resolvePath(dir, named);
256
+ if (
257
+ path !== undefined &&
258
+ [".json", ...MODULE_EXTENSIONS].some((extension) => path.endsWith(extension)) &&
259
+ isProjectPath(root, path) &&
260
+ host.kindOf(path) === "file"
261
+ ) {
262
+ found.push(path);
263
+ }
264
+ });
265
+ }
266
+ return found;
267
+ }
268
+
269
+ /** The decoded value of one JSON file, or undefined where it cannot be read or parsed. */
270
+ function readDocument(host: Host, path: string): unknown {
271
+ try {
272
+ return JSON.parse(host.readFile(path)) as unknown;
273
+ } catch {
274
+ return undefined;
275
+ }
108
276
  }
109
277
 
110
278
  /** The configuration files one project holds and sits beside. */
@@ -117,7 +285,12 @@ interface ConfigurationFiles {
117
285
  * the program does not hold either, read where a parser is given.
118
286
  */
119
287
  readonly outside: readonly OutsideModule[];
120
- /** Every JSON configuration file in a directory of the project's files or above one. */
288
+ /**
289
+ * Every JSON configuration file in a directory of the project's files or above one, every
290
+ * JSON file a script's configuration flag names, and every JSON file below the target
291
+ * root and in no `node_modules` a relative string of either names, read against each
292
+ * manifest whose chain reaches it.
293
+ */
121
294
  readonly documents: readonly ConfigurationDocument[];
122
295
  }
123
296
 
@@ -187,7 +360,8 @@ export function configurationFiles(
187
360
  }
188
361
  }
189
362
 
190
- const documents: ConfigurationDocument[] = [];
363
+ const pending: { path: string; manifestDir: string }[] = [];
364
+ const named: { path: string; manifestDir: string }[] = [];
191
365
  for (const dir of [...dirs].sort()) {
192
366
  if (!holdsManifest(dir)) {
193
367
  continue;
@@ -200,24 +374,59 @@ export function configurationFiles(
200
374
  .map((entry) => entry.name)
201
375
  .sort();
202
376
  } catch {
377
+ names = [];
378
+ }
379
+ pending.push(...names.map((name) => ({ path: joinPath(dir, name), manifestDir: dir })));
380
+ for (const path of scriptConfigurations(host, root, dir)) {
381
+ const held = files.byName.get(path);
382
+ if (path.endsWith(".json")) {
383
+ pending.push({ path, manifestDir: dir });
384
+ } else if (held !== undefined) {
385
+ modules.push({ file: held, form: "a script's configuration flag" });
386
+ } else {
387
+ named.push({ path, manifestDir: dir });
388
+ }
389
+ }
390
+ }
391
+
392
+ // A document reached from two manifests is read against each, so a chain is keyed by both.
393
+ const documents = new Map<string, { path: string; manifestDirs: string[]; strings: string[] }>();
394
+ const unreadable = new Set<string>();
395
+ for (let next = pending.shift(); next !== undefined; next = pending.shift()) {
396
+ let document = documents.get(next.path);
397
+ if (unreadable.has(next.path) || document?.manifestDirs.includes(next.manifestDir) === true) {
203
398
  continue;
204
399
  }
205
- for (const name of names) {
206
- const path = joinPath(dir, name);
207
- let value: unknown;
208
- try {
209
- value = JSON.parse(host.readFile(path));
210
- } catch {
400
+ if (document === undefined) {
401
+ const value = readDocument(host, next.path);
402
+ if (value === undefined) {
403
+ unreadable.add(next.path);
211
404
  continue;
212
405
  }
213
- documents.push({ path, strings: documentStrings(value) });
406
+ document = { path: next.path, manifestDirs: [], strings: documentStrings(value) };
407
+ documents.set(next.path, document);
408
+ }
409
+ document.manifestDirs.push(next.manifestDir);
410
+ for (const text of document.strings) {
411
+ const path = RELATIVE_PREFIXES.some((prefix) => text.startsWith(prefix))
412
+ ? resolvePath(dirnamePath(next.path), text)
413
+ : undefined;
414
+ if (
415
+ path?.endsWith(".json") === true &&
416
+ isProjectPath(root, path) &&
417
+ host.kindOf(path) === "file"
418
+ ) {
419
+ pending.push({ path, manifestDir: next.manifestDir });
420
+ }
214
421
  }
215
422
  }
216
423
  return {
217
424
  modules,
218
- documents,
425
+ documents: [...documents.values()],
219
426
  outside:
220
- parse === undefined ? [] : outsideModules(host, [...dirs], files, holdsManifest, parse),
427
+ parse === undefined
428
+ ? []
429
+ : outsideModules(host, root, [...dirs], files, holdsManifest, parse, named),
221
430
  };
222
431
  }
223
432
 
@@ -271,17 +480,19 @@ export function resolveRelativeSpecifier(
271
480
 
272
481
  /**
273
482
  * The configuration modules of the directories that hold a manifest which the program
274
- * does not hold, and the files they import by a relative specifier, until no further
275
- * file joins. A file that cannot be read is skipped.
483
+ * does not hold, and the project files below `root` they import by a relative specifier,
484
+ * until no further file joins. A file that cannot be read is skipped.
276
485
  */
277
486
  function outsideModules(
278
487
  host: Host,
488
+ root: string,
279
489
  dirs: readonly string[],
280
490
  files: SourceFiles,
281
491
  holdsManifest: (dir: string) => boolean,
282
492
  parse: ParseFile,
493
+ named: readonly { readonly path: string; readonly manifestDir: string }[],
283
494
  ): readonly OutsideModule[] {
284
- const pending: { path: string; manifestDir: string }[] = [];
495
+ const pending: { path: string; manifestDir: string }[] = [...named];
285
496
  for (const dir of [...dirs].sort()) {
286
497
  if (!holdsManifest(dir)) {
287
498
  continue;
@@ -298,29 +509,37 @@ function outsideModules(
298
509
  }
299
510
  pending.push(...names.map((name) => ({ path: joinPath(dir, name), manifestDir: dir })));
300
511
  }
301
- const found: OutsideModule[] = [];
302
- const seen = new Set<string>();
512
+ const found = new Map<string, { file: SourceFile; manifestDirs: string[] }>();
513
+ const unreadable = new Set<string>();
303
514
  for (let next = pending.shift(); next !== undefined; next = pending.shift()) {
304
- if (seen.has(next.path) || files.byName.has(next.path)) {
515
+ let outside = found.get(next.path);
516
+ if (
517
+ unreadable.has(next.path) ||
518
+ files.byName.has(next.path) ||
519
+ outside?.manifestDirs.includes(next.manifestDir) === true
520
+ ) {
305
521
  continue;
306
522
  }
307
- seen.add(next.path);
308
- let text: string;
309
- try {
310
- text = host.readFile(next.path);
311
- } catch {
312
- continue;
523
+ if (outside === undefined) {
524
+ let text: string;
525
+ try {
526
+ text = host.readFile(next.path);
527
+ } catch {
528
+ unreadable.add(next.path);
529
+ continue;
530
+ }
531
+ outside = { file: parse(next.path, text), manifestDirs: [] };
532
+ found.set(next.path, outside);
313
533
  }
314
- const file = parse(next.path, text);
315
- found.push({ file, manifestDir: next.manifestDir });
316
- for (const specifier of relativeSpecifiers(file)) {
534
+ outside.manifestDirs.push(next.manifestDir);
535
+ for (const specifier of relativeSpecifiers(outside.file)) {
317
536
  const reached = resolveRelativeSpecifier(host, dirnamePath(next.path), specifier);
318
- if (reached !== undefined) {
537
+ if (reached !== undefined && isProjectPath(root, reached)) {
319
538
  pending.push({ path: reached, manifestDir: next.manifestDir });
320
539
  }
321
540
  }
322
541
  }
323
- return found;
542
+ return [...found.values()];
324
543
  }
325
544
 
326
545
  /** Every dependency the manifest in one directory declares, or none where it holds none to read. */
@@ -4,7 +4,7 @@ export const RECORD = {
4
4
  conformance: {
5
5
  corpusVersion: "5.0.0",
6
6
  result: "pass",
7
- digest: "sha256:26c486907df44fb8eaf3f749f60a6010ac94e7cfeeda70c4aa406445193ab80a",
7
+ digest: "sha256:c46c7d06b6f0c58504c5f311870efefc0b85f18c2c46ba7d7b722081cb370c0c",
8
8
  },
9
9
  gaps: [],
10
10
  } as const;