@cplieger/deadset-ts 3.10.0 → 3.12.0

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 (57) hide show
  1. package/README.md +13 -13
  2. package/dist/src/analysis.js +56 -16
  3. package/dist/src/config.js +6 -0
  4. package/dist/src/conformance-record.js +2 -12
  5. package/dist/src/consumers.js +49 -0
  6. package/dist/src/discover.js +59 -23
  7. package/dist/src/findings/completion.js +49 -17
  8. package/dist/src/findings/non-code-artifacts.js +9 -2
  9. package/dist/src/findings/reads-and-writes.js +3 -1
  10. package/dist/src/findings-pass.js +3 -5
  11. package/dist/src/graph.js +8 -5
  12. package/dist/src/matrix.js +14 -3
  13. package/dist/src/references.js +21 -9
  14. package/dist/src/report.js +4 -1
  15. package/dist/src/reporters/annotations.js +36 -0
  16. package/dist/src/reporters/fingerprint.js +61 -0
  17. package/dist/src/reporters/reporter.js +7 -1
  18. package/dist/src/reporters/reporters.js +18 -0
  19. package/dist/src/reporters/rules.js +178 -0
  20. package/dist/src/reporters/sarif.js +235 -0
  21. package/dist/src/reporters/sha256.js +101 -0
  22. package/dist/src/reporters/template.js +877 -0
  23. package/dist/src/run.js +4 -10
  24. package/dist/src/session.js +15 -0
  25. package/dist/src/sweep.js +11 -0
  26. package/dist/src/verbs/analyze.js +48 -16
  27. package/dist/src/verbs/explain.js +317 -0
  28. package/jsr.json +1 -1
  29. package/package.json +1 -1
  30. package/src/analysis.ts +123 -29
  31. package/src/config.ts +8 -0
  32. package/src/conformance-record.ts +2 -14
  33. package/src/consumers.ts +86 -0
  34. package/src/discover.ts +67 -23
  35. package/src/findings/completion.ts +56 -20
  36. package/src/findings/non-code-artifacts.ts +9 -2
  37. package/src/findings/reads-and-writes.ts +3 -1
  38. package/src/findings-pass.ts +5 -7
  39. package/src/graph.ts +14 -5
  40. package/src/matrix.ts +14 -5
  41. package/src/references.ts +48 -9
  42. package/src/report.ts +17 -2
  43. package/src/reporters/annotations.ts +67 -0
  44. package/src/reporters/fingerprint.ts +69 -0
  45. package/src/reporters/json.ts +2 -2
  46. package/src/reporters/reporter.ts +21 -2
  47. package/src/reporters/reporters.ts +18 -1
  48. package/src/reporters/rules.ts +195 -0
  49. package/src/reporters/sarif.ts +287 -0
  50. package/src/reporters/sha256.ts +120 -0
  51. package/src/reporters/template.ts +989 -0
  52. package/src/reporters/text.ts +2 -3
  53. package/src/run.ts +5 -11
  54. package/src/session.ts +19 -0
  55. package/src/sweep.ts +13 -1
  56. package/src/verbs/analyze.ts +60 -19
  57. package/src/verbs/explain.ts +380 -0
package/src/analysis.ts CHANGED
@@ -10,6 +10,12 @@
10
10
 
11
11
  import type { Diagnostic } from "@typescript/native/unstable/sync";
12
12
  import type { Config, Provenance } from "./config.ts";
13
+ import {
14
+ consumerLoads,
15
+ consumerReferences,
16
+ moduleIdentity,
17
+ type LoadedConsumer,
18
+ } from "./consumers.ts";
13
19
  import { decorator } from "./decorator.ts";
14
20
  import { dependenciesOf, projectNeeds } from "./dependencies.ts";
15
21
  import { deprecatedDeclarations } from "./deprecation.ts";
@@ -41,11 +47,18 @@ import { injectionContainer } from "./injection-container.ts";
41
47
  import { interfaceSatisfaction } from "./interface-satisfaction.ts";
42
48
  import { inventory, type Inventory } from "./inventory.ts";
43
49
  import { readManifest } from "./manifest.ts";
44
- import { matrixOf, sweepMatrix, type Configured, type Matrix, type SweepResult } from "./matrix.ts";
50
+ import {
51
+ matrixOf,
52
+ referenceKey,
53
+ sweepMatrix,
54
+ type Configured,
55
+ type Matrix,
56
+ type SweepResult,
57
+ } from "./matrix.ts";
45
58
  import { relativePath, resolvePath } from "./paths.ts";
46
59
  import { byPosition, type Position } from "./position.ts";
47
60
  import { reflectiveLookup } from "./reflective-lookup.ts";
48
- import { references, testFileRulesOf, type TestFileRule } from "./references.ts";
61
+ import { references, testFileRulesOf, type Reference, type TestFileRule } from "./references.ts";
49
62
  import { roots, unmatchedEverywhere, type RootKind, type Roots } from "./roots.ts";
50
63
  import type { Scope } from "./scope.ts";
51
64
  import { serializationContract } from "./serialization-contract.ts";
@@ -125,11 +138,27 @@ interface ProjectRead {
125
138
  readonly rooted: Roots;
126
139
  }
127
140
 
141
+ /** What a run read of the consumers its scope declares. */
142
+ interface ConsumerReads {
143
+ /** Every declared consumer, each loaded, in the scope's order. */
144
+ readonly loaded: readonly LoadedConsumer[];
145
+ /** Every reference a consumer makes to a declaration of the target, each once. */
146
+ readonly references: readonly Reference[];
147
+ }
148
+
149
+ /** What a run read of its projects: each target project's answer, and its consumers. */
150
+ interface ReadProjects<Answer> {
151
+ readonly answers: readonly Answer[];
152
+ readonly consumers: ConsumerReads;
153
+ }
154
+
128
155
  /**
129
- * Reads every project the scope discovers, in one snapshot: each is checked for errors
130
- * first, then enumerated, then rooted, and `stage` reads the rest of what it needs from
131
- * the project while its view is open. A project carrying an error fails the run with no
132
- * answer, as every verb that reads declarations does.
156
+ * Reads every project the scope discovers, and every project of every consumer it
157
+ * declares, in one snapshot. Each is checked for errors first. A target project is then
158
+ * enumerated and rooted, and `stage` reads the rest of what it needs from the project
159
+ * while its view is open; a consumer project is read for its references to the target's
160
+ * declarations. A project carrying an error fails the run with no answer, as every verb
161
+ * that reads declarations does, and so does a declared consumer that cannot be loaded.
133
162
  */
134
163
  function readProjects<Answer>(
135
164
  engine: Engine,
@@ -137,10 +166,16 @@ function readProjects<Answer>(
137
166
  scope: Scope,
138
167
  config: Config,
139
168
  stage: <Brand>(project: ProjectView<Brand>, read: ProjectRead) => Answer,
140
- ): readonly Answer[] {
169
+ ): ReadProjects<Answer> {
141
170
  const targetRoot = scope.target.path;
142
171
  const discovered = discoverProjects(engine, host, scope, config.analysis.configurations);
143
172
  const ids = new Map(discovered.projects.map((project) => [project.configFile, project.id]));
173
+ const loads = consumerLoads(engine, host, scope, discovered.configFiles);
174
+ const consumerOf = new Map(
175
+ loads.flatMap((load) =>
176
+ load.configFiles.map((configFile) => [configFile, load.consumer] as const),
177
+ ),
178
+ );
144
179
  const options = {
145
180
  manifest: readManifest(host, targetRoot),
146
181
  patterns: config.rootPatterns,
@@ -149,24 +184,51 @@ function readProjects<Answer>(
149
184
  publishedAPI: config.targetKind === "library",
150
185
  };
151
186
  const failures: Diagnostic[] = [];
152
- const { projects } = runSession(engine, discovered.configFiles, (project) => {
153
- const errors = diagnosticErrors(diagnosticsOf(project));
154
- if (errors.length > 0) {
155
- failures.push(...errors);
156
- return undefined;
157
- }
158
- const held = inventory(project, host, targetRoot);
159
- const rooted = roots(project, held, targetRoot, options);
160
- const configuration = ids.get(project.configFile) ?? project.configFile;
161
- return stage(project, { configuration, configFile: project.configFile, held, rooted });
162
- });
187
+ const consumed = new Map<string, Reference>();
188
+ const { projects } = runSession(
189
+ engine,
190
+ [...discovered.configFiles, ...consumerOf.keys()],
191
+ (project) => {
192
+ const errors = diagnosticErrors(diagnosticsOf(project));
193
+ if (errors.length > 0) {
194
+ failures.push(...errors);
195
+ return undefined;
196
+ }
197
+ const consumer = consumerOf.get(project.configFile);
198
+ if (consumer !== undefined) {
199
+ for (const reference of consumerReferences(
200
+ project,
201
+ host,
202
+ targetRoot,
203
+ {
204
+ id: consumer.id,
205
+ root: consumer.path,
206
+ },
207
+ config,
208
+ )) {
209
+ consumed.set(referenceKey(reference), reference);
210
+ }
211
+ return undefined;
212
+ }
213
+ const held = inventory(project, host, targetRoot);
214
+ const rooted = roots(project, held, targetRoot, options);
215
+ const configuration = ids.get(project.configFile) ?? project.configFile;
216
+ return stage(project, { configuration, configFile: project.configFile, held, rooted });
217
+ },
218
+ );
163
219
  if (failures.length > 0) {
164
220
  throw new DiscoveryError(
165
221
  `${String(failures.length)} error(s); no answer was produced`,
166
222
  failures,
167
223
  );
168
224
  }
169
- return projects.filter((answer): answer is Answer => answer !== undefined);
225
+ return {
226
+ answers: projects.filter((answer): answer is Answer => answer !== undefined),
227
+ consumers: {
228
+ loaded: loads.map((load) => load.consumer),
229
+ references: [...consumed.values()],
230
+ },
231
+ };
170
232
  }
171
233
 
172
234
  /**
@@ -183,7 +245,7 @@ export function runRoots(
183
245
  provenance: Provenance,
184
246
  ): RunRoots {
185
247
  const targetRoot = scope.target.path;
186
- const projects = readProjects(engine, host, scope, config, (_project, read) => read);
248
+ const projects = readProjects(engine, host, scope, config, (_project, read) => read).answers;
187
249
 
188
250
  const configurations: string[] = [];
189
251
  const merged = new Map<string, Omit<RunRoot, "configurations"> & { configurations: string[] }>();
@@ -266,12 +328,15 @@ interface ReadRun<Extra> {
266
328
  readonly matrix: Matrix;
267
329
  readonly exempt: readonly Exemption[];
268
330
  readonly projects: readonly SweptProject<Extra>[];
331
+ /** Every declared consumer, each loaded, in the scope's order. */
332
+ readonly consumers: readonly LoadedConsumer[];
269
333
  }
270
334
 
271
335
  /**
272
336
  * Reads every project of the run, its references and exemptions, and `extra`'s answer,
273
337
  * while the project's view is open, and merges the projects into one matrix in the run's
274
- * order.
338
+ * order. Every configuration holds every consumer's references beside its own, because a
339
+ * consumer calls the target whichever configuration the target is built under.
275
340
  */
276
341
  function readRun<Extra>(
277
342
  engine: Engine,
@@ -288,7 +353,7 @@ function readRun<Extra>(
288
353
  ? { delimiters: config.analysis.templateDelimiters, files: [] }
289
354
  : readTemplates(host, targetRoot, config.analysis);
290
355
  const consumers = scope.consumers.map((consumer) => consumer.path);
291
- const projects = readProjects(engine, host, scope, config, (project, projectRead) => {
356
+ const read = readProjects(engine, host, scope, config, (project, projectRead) => {
292
357
  const resolved = references(project, projectRead.held, targetRoot, {
293
358
  testFiles: config.ts.testFiles,
294
359
  });
@@ -315,10 +380,23 @@ function readRun<Extra>(
315
380
  extra: extra(project, projectRead),
316
381
  };
317
382
  });
383
+ const consumed = read.consumers.references;
384
+ const projects = read.answers.map((one) =>
385
+ consumed.length === 0
386
+ ? one
387
+ : {
388
+ ...one,
389
+ configured: {
390
+ ...one.configured,
391
+ references: [...one.configured.references, ...consumed],
392
+ },
393
+ },
394
+ );
318
395
  return {
319
396
  matrix: matrixOf(projects.map((one) => one.configured)),
320
397
  exempt: exemptionsOf(projects.flatMap((one) => one.exempt)),
321
398
  projects,
399
+ consumers: read.consumers.loaded,
322
400
  };
323
401
  }
324
402
 
@@ -430,7 +508,10 @@ function emitterInputOver(
430
508
  heldByInclusion: new Set(projects.flatMap((one) => one.extra.heldByInclusion)),
431
509
  },
432
510
  boundary: {
433
- consumers: { declared: scope.consumers.map((consumer) => consumer.id), loaded: [] },
511
+ consumers: {
512
+ declared: scope.consumers.map((consumer) => moduleIdentity(host, consumer)),
513
+ loaded: read.consumers.map((consumer) => consumer.id),
514
+ },
434
515
  encapsulated: readManifest(host, targetRoot).declaresExports,
435
516
  edges: readEdgeSides(host, targetRoot),
436
517
  },
@@ -442,8 +523,8 @@ function emitterInputOver(
442
523
  * What every emitter reads of the run the scope and the configuration describe, swept
443
524
  * under the caller's marks: the run's sweep, the facts beside it each kind family reads,
444
525
  * and the sides of the target's declared cross-language edges that name a symbol of this
445
- * language. The run loads no consumer, and it reads no suppression document, so the
446
- * self-check family has nothing to report.
526
+ * language. The run reads no suppression document, so the self-check family has nothing
527
+ * to report.
447
528
  */
448
529
  export function runEmitterInput(
449
530
  engine: Engine,
@@ -483,7 +564,11 @@ function runAnalysisInputs(
483
564
  provenance: Provenance,
484
565
  mode: Mode,
485
566
  detectors: Detectors,
486
- ): { readonly inputs: AnalysisInputs; readonly run: RunFacts } {
567
+ ): {
568
+ readonly inputs: AnalysisInputs;
569
+ readonly run: RunFacts;
570
+ readonly configured: readonly Configured[];
571
+ } {
487
572
  const targetRoot = scope.target.path;
488
573
  const read = readEmitterRun(engine, host, scope, config, mode, detectors);
489
574
  const symbols = read.matrix.union.symbols;
@@ -522,7 +607,9 @@ function runAnalysisInputs(
522
607
  config.ts.testFiles,
523
608
  read.projects.flatMap((one) => one.configured.testFiles),
524
609
  ),
610
+ consumers: read.consumers,
525
611
  },
612
+ configured: read.projects.map((one) => one.configured),
526
613
  };
527
614
  }
528
615
 
@@ -539,18 +626,25 @@ export interface RunFacts {
539
626
  readonly projects: readonly RunProject[];
540
627
  /** The rules that classified files as test files, each counted over every project at once. */
541
628
  readonly testFileRules: readonly TestFileRule[];
629
+ /** Every consumer the run loaded beside the target, in the scope's order. */
630
+ readonly consumers: readonly LoadedConsumer[];
542
631
  }
543
632
 
544
633
  /** The findings of one run, beside what a report states about the run itself. */
545
634
  export interface RunAnalysis {
546
635
  readonly result: PassResult;
547
636
  readonly run: RunFacts;
637
+ /** The sweep the findings were decided over, under the mark of every bound record. */
638
+ readonly swept: RunSweep;
639
+ /** What each project answered, in the run's order, which the sweep was built from. */
640
+ readonly configured: readonly Configured[];
548
641
  }
549
642
 
550
643
  /**
551
644
  * The analysis of the run the scope and the configuration describe: the run read with its
552
645
  * suppressions bound, every family's findings with the suppressions applied and the
553
- * declared edges evaluated, and the projects and test-file rules the report names.
646
+ * declared edges evaluated, the projects and test-file rules the report names, and the sweep
647
+ * an explanation reads.
554
648
  */
555
649
  export function runAnalysis(
556
650
  engine: Engine,
@@ -561,7 +655,7 @@ export function runAnalysis(
561
655
  mode: Mode,
562
656
  detectors: Detectors = DETECTORS,
563
657
  ): RunAnalysis {
564
- const { inputs, run } = runAnalysisInputs(
658
+ const { inputs, run, configured } = runAnalysisInputs(
565
659
  engine,
566
660
  host,
567
661
  scope,
@@ -570,5 +664,5 @@ export function runAnalysis(
570
664
  mode,
571
665
  detectors,
572
666
  );
573
- return { result: findingsPass(inputs), run };
667
+ return { result: findingsPass(inputs), run, swept: inputs.marked.swept, configured };
574
668
  }
package/src/config.ts CHANGED
@@ -28,6 +28,14 @@ export type Language = "go" | "ts";
28
28
  /** What a finding of one issue kind does to a run. */
29
29
  export type Severity = "allow" | "warn" | "deny";
30
30
 
31
+ /** The severities ranked, the strongest highest. */
32
+ const SEVERITY_RANK: Readonly<Record<Severity, number>> = { allow: 0, warn: 1, deny: 2 };
33
+
34
+ /** Whether a finding at one severity fails a run under the failing severity. */
35
+ export function fails(severity: Severity, failOn: Severity): boolean {
36
+ return SEVERITY_RANK[severity] >= SEVERITY_RANK[failOn];
37
+ }
38
+
31
39
  /** One rendering of the finding list. */
32
40
  export type Format = "text" | "json" | "github" | "sarif" | "template";
33
41
 
@@ -3,8 +3,8 @@
3
3
  export const RECORD = {
4
4
  conformance: {
5
5
  corpusVersion: "1.9.0",
6
- result: "fail",
7
- digest: "sha256:8c29f19fc189f4f5d9ea210d556b62d12c3701c221eb7a8adde499b7060bec0d",
6
+ result: "pass",
7
+ digest: "sha256:3e9e40876551faa2c196311bf06d47d220ab5127a9fb8921969aae552c42e824",
8
8
  },
9
9
  gaps: [
10
10
  {
@@ -13,17 +13,5 @@ export const RECORD = {
13
13
  reason:
14
14
  "The reference pass resolves a string-literal element access to the member it names, so the member is live by that reference and the retained listing names no class for it.",
15
15
  },
16
- {
17
- fixture: "redundant-export-keyword",
18
- capability: "DS1104",
19
- reason:
20
- "No consumer is loaded beside the target, so the published surface is unknown and no export is narrowed; review an export only the target uses by hand.",
21
- },
22
- {
23
- fixture: "unused-exported-consumer",
24
- capability: "DS1001",
25
- reason:
26
- "No consumer is loaded beside the target, so an export nothing in the target references is reported at the possible class rather than the certain one.",
27
- },
28
16
  ],
29
17
  } as const;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The consumers a scope declares, loaded beside the target: each a directory on the
3
+ * local filesystem whose compiler configurations open as projects of the run's one
4
+ * snapshot, and whose references to the target's declarations count against them.
5
+ */
6
+
7
+ import type { Config } from "./config.ts";
8
+ import { consumerProjects } from "./discover.ts";
9
+ import type { Host } from "./host.ts";
10
+ import { inventory, packageScope } from "./inventory.ts";
11
+ import { relativePath } from "./paths.ts";
12
+ import { references, type ConsumerSide, type Reference } from "./references.ts";
13
+ import type { Module, Scope } from "./scope.ts";
14
+ import { narrowedTo, type Engine, type ProjectView } from "./session.ts";
15
+
16
+ /** One consumer the run loaded. */
17
+ export interface LoadedConsumer {
18
+ /** The name the consumer publishes itself under, which every finding names it by. */
19
+ readonly id: string;
20
+ /** The consumer's directory, absolute. */
21
+ readonly path: string;
22
+ }
23
+
24
+ /** One declared consumer and the compiler configurations it opens as. */
25
+ interface ConsumerLoad {
26
+ readonly consumer: LoadedConsumer;
27
+ /** The configuration files, absolute, in discovery order, none a target project's. */
28
+ readonly configFiles: readonly string[];
29
+ }
30
+
31
+ /** The name one module publishes itself under: the scope's, else its manifest's. */
32
+ export function moduleIdentity(host: Host, module: Module): string {
33
+ return module.id === "" ? packageScope(host, module.path)("index.ts").package : module.id;
34
+ }
35
+
36
+ /**
37
+ * Every consumer the scope declares, in its order, with the projects it opens as. A
38
+ * configuration that is also one of the target's projects, or an earlier consumer's, is
39
+ * that project's alone. A consumer whose directory is absent, is no directory or holds
40
+ * no configuration ends the run naming it, before any project opens.
41
+ */
42
+ export function consumerLoads(
43
+ engine: Engine,
44
+ host: Host,
45
+ scope: Scope,
46
+ targetConfigFiles: readonly string[],
47
+ ): readonly ConsumerLoad[] {
48
+ const opened = new Set(targetConfigFiles);
49
+ return scope.consumers.map((module) => {
50
+ const configFiles = consumerProjects(engine, host, module.path).filter((configFile) => {
51
+ const fresh = !opened.has(configFile);
52
+ opened.add(configFile);
53
+ return fresh;
54
+ });
55
+ return { consumer: { id: moduleIdentity(host, module), path: module.path }, configFiles };
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Every reference one consumer project's own files make to a declaration of the target.
61
+ * The target's declarations are enumerated over the target's files the consumer's
62
+ * program holds, against the target root, so each carries the identifier the target's
63
+ * own projects give it. A reference from a consumer's test file is a test reference
64
+ * unless the configuration counts it as a production one.
65
+ */
66
+ export function consumerReferences<Brand>(
67
+ project: ProjectView<Brand>,
68
+ host: Host,
69
+ targetRoot: string,
70
+ consumer: ConsumerSide,
71
+ config: Config,
72
+ ): readonly Reference[] {
73
+ const held = inventory(
74
+ narrowedTo(project, (file) => relativePath(targetRoot, file.fileName) !== undefined),
75
+ host,
76
+ targetRoot,
77
+ );
78
+ const found = references(project, held, targetRoot, {
79
+ testFiles: config.ts.testFiles,
80
+ consumer,
81
+ }).references;
82
+ if (config.analysis.consumerTests !== "production") {
83
+ return found;
84
+ }
85
+ return found.map((reference) => (reference.test ? { ...reference, test: false } : reference));
86
+ }
package/src/discover.ts CHANGED
@@ -160,17 +160,70 @@ function referencesOf(host: Host, configFile: string): string[] {
160
160
  }
161
161
 
162
162
  /**
163
- * The configuration files the scope names directly: one per module the scope
164
- * declares whose path names a configuration file rather than a directory. A module
165
- * naming a directory is reached by the walk over that directory instead.
163
+ * The configuration file the scope names for the target directly, where its path
164
+ * names one rather than a directory. A target naming a directory is reached by the
165
+ * walk over that directory instead. A consumer's projects are a consumer's, never the
166
+ * target's, so no consumer path is read here.
166
167
  */
167
168
  function scopeConfigFiles(host: Host, scope: Scope): string[] {
168
- return [scope.target, ...scope.consumers]
169
- .map((module) => module.path)
169
+ return [scope.target.path]
170
170
  .filter((path) => path.endsWith(".json"))
171
171
  .map((path) => resolvePath(host.workingDirectory(), path));
172
172
  }
173
173
 
174
+ /**
175
+ * Every configuration file reached from `pending`, in the order discovery reads them:
176
+ * each once, each followed by the configurations it references, transitively. A
177
+ * configuration the compiler cannot read ends the run naming it.
178
+ */
179
+ function derivedFrom(engine: Engine, host: Host, pending: string[]): string[] {
180
+ const seen = new Set<string>();
181
+ const found: string[] = [];
182
+ while (pending.length > 0) {
183
+ const configFile = pending.shift();
184
+ if (configFile === undefined) {
185
+ break;
186
+ }
187
+ const path = resolvePath(host.workingDirectory(), configFile);
188
+ if (seen.has(path)) {
189
+ continue;
190
+ }
191
+ seen.add(path);
192
+ parseOrRefuse(engine, path, path);
193
+ found.push(path);
194
+ pending.push(...referencesOf(host, path));
195
+ }
196
+ return found;
197
+ }
198
+
199
+ /**
200
+ * The compiler configurations one consumer is loaded through: every one under its
201
+ * root that is not inside an ignored directory, and the configurations each of those
202
+ * references, transitively, in that order. A root that is absent, that is no
203
+ * directory, or that holds no configuration ends the run naming it, because a
204
+ * finding computed without a declared consumer would claim a reference set the run
205
+ * never read.
206
+ */
207
+ export function consumerProjects(engine: Engine, host: Host, root: string): readonly string[] {
208
+ const kind = host.kindOf(root);
209
+ if (kind !== "directory") {
210
+ throw new DiscoveryError(
211
+ kind === "absent"
212
+ ? `the consumer ${root} does not exist`
213
+ : `the consumer ${root} is not a directory`,
214
+ [],
215
+ );
216
+ }
217
+ const found = derivedFrom(engine, host, configFilesUnder(host, root));
218
+ if (found.length === 0) {
219
+ throw new DiscoveryError(
220
+ `the consumer ${root} holds no compiler configuration, so none of its references can be read`,
221
+ [],
222
+ );
223
+ }
224
+ return found;
225
+ }
226
+
174
227
  /**
175
228
  * The name a derived project carries: its configuration file's path below the target
176
229
  * root, or the absolute path of a file outside it.
@@ -257,24 +310,15 @@ function declaredProjects(
257
310
  */
258
311
  function derivedProjects(engine: Engine, host: Host, scope: Scope): Discovery {
259
312
  const fromScope = scopeConfigFiles(host, scope);
260
- const seen = new Set<string>();
261
- const projects: DiscoveredProject[] = [];
262
- const pending = [...fromScope, ...configFilesUnder(host, scope.target.path)];
263
- while (pending.length > 0) {
264
- const configFile = pending.shift();
265
- if (configFile === undefined) {
266
- break;
267
- }
268
- const path = resolvePath(host.workingDirectory(), configFile);
269
- if (seen.has(path)) {
270
- continue;
271
- }
272
- seen.add(path);
273
- parseOrRefuse(engine, path, path);
274
- projects.push({ id: derivedId(scope.target.path, path), configFile: path });
275
- pending.push(...referencesOf(host, path));
276
- }
277
- return { projects, configFiles: projects.map((project) => project.configFile), fromScope };
313
+ const configFiles = derivedFrom(engine, host, [
314
+ ...fromScope,
315
+ ...configFilesUnder(host, scope.target.path),
316
+ ]);
317
+ const projects = configFiles.map((configFile) => ({
318
+ id: derivedId(scope.target.path, configFile),
319
+ configFile,
320
+ }));
321
+ return { projects, configFiles, fromScope };
278
322
  }
279
323
 
280
324
  /**