@cplieger/deadset-ts 3.11.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.
package/README.md CHANGED
@@ -8,7 +8,7 @@ deadset-ts is a command-line analyzer for TypeScript and JavaScript projects. It
8
8
 
9
9
  deadset-ts is report-only. It never edits source, and it refuses any `--fix` flag.
10
10
 
11
- **Status: pre-release.** This version implements contract 3.2.0 and ships the analyzer's foundation: one compiler session per run over the projects `analysis.configurations` declares, in the order it lists them, each a compiler configuration file below the target, or, where it declares none, every `tsconfig` project discovered under the target (the explicit scope, every `tsconfig*.json`, each project's `references`), a fail-closed read of every project's diagnostics, configuration decoding against the contract's closed key list with `print-config` and its provenance, `print-projects`, the report-only guard, the symbol inventory of every declaration a project's own files hold down to its class and type members, the one-pass reference resolution over that inventory, which resolves each file's identifiers in batches ([batch-cap calibration](docs/batch-cap-calibration.md) is the measurement behind the batch size), and the root set `print-roots` prints. A root is a declaration the analysis keeps live without a reference: what a file exports when the manifest names it through `main`, `module`, `types`, `bin` or `exports`, or when `ts.entry_files` matches it; a library target's published API; the configuration, setup and test files of Vitest, Stryker and Playwright, the flat configuration of ESLint, and a worker or service worker a call addresses by a string literal; and every declaration a `roots.patterns` entry names. An entry that names nothing is reported as `DS1704` and fails the run. The entry-point conventions of other tools are not read, and `DECLINED_CONVENTIONS` lists each one with the reason. Seven exemption classes hold a declaration back from the sweep, and `print-retained` lists what each one held back: `interface-satisfaction`, a member an interface requires of a class whose value reaches that interface; `decorator`, a decorated member and every member of a decorated class; `injection-container`, a decorated property of a class a dependency-injection container constructs, being a class passed to a call `ts.injection_registrations` names or one a parameter of whose constructor carries a decorator; `framework-lifecycle`, a member a `ts.lifecycle_contracts` entry lists, on a class that is the entry's component by a decorator or a call the entry names or by extending a class it names; `serialization-contract`, the properties of a class whose values reach `JSON.stringify` or a declaration `ts.serializers` names, and those and its `toJSON` and `toString` where a value is passed to an `unknown` or `any` parameter of a function outside the analysis; and, at the lowest confidence, `template-field`, a member an action of a template under `analysis.template_dirs` names, and `reflective-lookup`, a member a literal key of an element access or of `Reflect.get`, `Reflect.set` or `Reflect.has` names. None of them holds back a `#private` member. `analyze` writes the report the Contract's report schema declares, with text, JSON, GitHub annotation, SARIF 2.1.0 and template renderings beside it on request. `explain` answers why one declaration is reported, retained, live or dead, from the same analysis the report is built from. `describe` prints the analyzer's description with its recorded conformance result, which is `fail` for this version because consumer projects are not loaded yet, so an orchestrator that requires a conformance pass refuses this analyzer. The analysis is reached through the command, and through `run()` for a caller that runs the command in-process. The one runtime dependency is the `typescript` package at exactly 7.0.2, installed under the `@typescript/native` name until TypeScript 7.1 ships a compiler API the tooling can share.
11
+ **Status: pre-release.** This version implements contract 3.2.0 and ships the analyzer's foundation: one compiler session per run over the projects `analysis.configurations` declares, in the order it lists them, each a compiler configuration file below the target, or, where it declares none, every `tsconfig` project discovered under the target (the explicit scope, every `tsconfig*.json`, each project's `references`), a fail-closed read of every project's diagnostics, configuration decoding against the contract's closed key list with `print-config` and its provenance, `print-projects`, the report-only guard, the symbol inventory of every declaration a project's own files hold down to its class and type members, the one-pass reference resolution over that inventory, which resolves each file's identifiers in batches ([batch-cap calibration](docs/batch-cap-calibration.md) is the measurement behind the batch size), and the root set `print-roots` prints. A root is a declaration the analysis keeps live without a reference: what a file exports when the manifest names it through `main`, `module`, `types`, `bin` or `exports`, or when `ts.entry_files` matches it; a library target's published API; the configuration, setup and test files of Vitest, Stryker and Playwright, the flat configuration of ESLint, and a worker or service worker a call addresses by a string literal; and every declaration a `roots.patterns` entry names. An entry that names nothing is reported as `DS1704` and fails the run. The entry-point conventions of other tools are not read, and `DECLINED_CONVENTIONS` lists each one with the reason. Seven exemption classes hold a declaration back from the sweep, and `print-retained` lists what each one held back: `interface-satisfaction`, a member an interface requires of a class whose value reaches that interface; `decorator`, a decorated member and every member of a decorated class; `injection-container`, a decorated property of a class a dependency-injection container constructs, being a class passed to a call `ts.injection_registrations` names or one a parameter of whose constructor carries a decorator; `framework-lifecycle`, a member a `ts.lifecycle_contracts` entry lists, on a class that is the entry's component by a decorator or a call the entry names or by extending a class it names; `serialization-contract`, the properties of a class whose values reach `JSON.stringify` or a declaration `ts.serializers` names, and those and its `toJSON` and `toString` where a value is passed to an `unknown` or `any` parameter of a function outside the analysis; and, at the lowest confidence, `template-field`, a member an action of a template under `analysis.template_dirs` names, and `reflective-lookup`, a member a literal key of an element access or of `Reflect.get`, `Reflect.set` or `Reflect.has` names. None of them holds back a `#private` member. `analyze` writes the report the Contract's report schema declares, with text, JSON, GitHub annotation, SARIF 2.1.0 and template renderings beside it on request. A scope document passed with `--scope` names the consumers loaded beside the target, each a directory whose compiler configurations open in the same session; a reference from a consumer keeps a target declaration live, the report names every consumer it loaded, and a consumer that is absent or does not load ends the run with exit code 3. A library target's published API is `certain` once every declared consumer loads and `possible` with no consumer information, and in that second case `DS1001` defaults to `allow` and the narrowing kinds report only in files no manifest export reaches. `explain` answers why one declaration is reported, retained, live or dead, from the same analysis the report is built from. `describe` prints the analyzer's description with its conformance record over the published corpus and the declared gaps `conformance.json` lists. The analysis is reached through the command, and through `run()` for a caller that runs the command in-process. The one runtime dependency is the `typescript` package at exactly 7.0.2, installed under the `@typescript/native` name until TypeScript 7.1 ships a compiler API the tooling can share.
12
12
 
13
13
  ## Install
14
14
 
@@ -28,7 +28,7 @@ The npm package carries the command and the library as JavaScript that the compi
28
28
  npx deadset-ts version
29
29
  ```
30
30
 
31
- `version` prints the analyzer version and the Contract version it implements, and exits 0. `print-config`, `print-projects`, `print-roots` and `print-retained` print what their row of the table says and exit 0, or 1 when `print-roots` reports a `DS1704`, and `print-retained` exits 2 when an `exemptions.disabled` entry is not an exemption class or an `analysis.template_dirs` entry is not a directory below the target root. `analyze --report=PATH` writes the JSON report to `PATH` through a temporary file renamed into place, each `--format` rendering at `PATH` with its suffix appended (`text` as `.txt`, `json` as `.json`, `github` as `.annotations` in GitHub workflow-command form, `sarif` as `.sarif`, and `template` as `.tmpl`, rendering the file `--template=FILE` names, written in a subset of Go's `text/template` action grammar over the report's JSON member names), and with `--baseline-write=FILE` a baseline of every finding; it writes nothing to standard output and exits with the Contract's exit-code table: 0 clean, 1 for a finding at or above `reporters.fail_on` or a stale suppression, 2 for a usage error, including a template that cannot be read or does not parse, 3 for a failed load or a rendering that cannot be written, and 4 for a pending finding, which outranks 1. `--exit-code=off` exits 0 whatever the verdict and names the verdict on standard error. The report names the target relative to the directory the run is invoked from, so a target outside that directory fails with 3. `describe` writes its JSON document to standard output and exits 0. `explain --why=SYM`, or the same request spelled `--why-live=SYM` or `--why-not=SYM`, prints why that one declaration is reported, retained, live or dead, with the shortest reference path from a root for a live one, and exits 0; a request that names no one declaration exits 2 and lists up to 20 declarations it partially matches. Any other invocation and no arguments print the usage line and exit 2.
31
+ `version` prints the analyzer version and the Contract version it implements, and exits 0. `print-config`, `print-projects`, `print-roots` and `print-retained` print what their row of the table says and exit 0, or 1 when `print-roots` reports a `DS1704`, and `print-retained` exits 2 when an `exemptions.disabled` entry is not an exemption class or an `analysis.template_dirs` entry is not a directory below the target root. `analyze --report=PATH` writes the JSON report to `PATH` through a temporary file renamed into place, each `--format` rendering at `PATH` with its suffix appended (`text` as `.txt`, `json` as `.json`, `github` as `.annotations` in GitHub workflow-command form, `sarif` as `.sarif`, and `template` as `.tmpl`, rendering the file `--template=FILE` names, written in a subset of Go's `text/template` action grammar over the report's JSON member names), and with `--baseline-write=FILE` a baseline of every finding; it writes nothing to standard output and exits with the Contract's exit-code table: 0 clean, 1 for a finding at or above `reporters.fail_on` or a stale suppression, 2 for a usage error, including a template that cannot be read or does not parse, 3 for a failed load or a rendering that cannot be written, and 4 for a pending finding, which outranks 1. `--exit-code=off` exits 0 whatever the verdict and names the verdict on standard error. The report names the target relative to the directory the run is invoked from, so a target outside that directory fails with 3. `describe` writes its JSON document to standard output and exits 0. `explain --why=SYM`, or the same request spelled `--why-live=SYM` or `--why-not=SYM`, prints why that one declaration is reported, retained, live or dead, with the shortest reference path from a root or a loaded consumer for a live one, and exits 0; a request that names no one declaration exits 2 and lists up to 20 declarations it partially matches. Any other invocation and no arguments print the usage line and exit 2.
32
32
 
33
33
  | Verb | Job |
34
34
  | ---------------- | ---------------------------------------------------------------------------------- |
@@ -7,6 +7,7 @@
7
7
  * carries a project's identity in its type so a handle cannot cross to another
8
8
  * project's checker, and that holds only for views the session itself hands out.
9
9
  */
10
+ import { consumerLoads, consumerReferences, moduleIdentity, } from "./consumers.js";
10
11
  import { decorator } from "./decorator.js";
11
12
  import { dependenciesOf, projectNeeds } from "./dependencies.js";
12
13
  import { deprecatedDeclarations } from "./deprecation.js";
@@ -23,7 +24,7 @@ import { injectionContainer } from "./injection-container.js";
23
24
  import { interfaceSatisfaction } from "./interface-satisfaction.js";
24
25
  import { inventory } from "./inventory.js";
25
26
  import { readManifest } from "./manifest.js";
26
- import { matrixOf, sweepMatrix } from "./matrix.js";
27
+ import { matrixOf, referenceKey, sweepMatrix, } from "./matrix.js";
27
28
  import { relativePath, resolvePath } from "./paths.js";
28
29
  import { byPosition } from "./position.js";
29
30
  import { reflectiveLookup } from "./reflective-lookup.js";
@@ -59,15 +60,19 @@ function compare(a, b) {
59
60
  return a < b ? -1 : 1;
60
61
  }
61
62
  /**
62
- * Reads every project the scope discovers, in one snapshot: each is checked for errors
63
- * first, then enumerated, then rooted, and `stage` reads the rest of what it needs from
64
- * the project while its view is open. A project carrying an error fails the run with no
65
- * answer, as every verb that reads declarations does.
63
+ * Reads every project the scope discovers, and every project of every consumer it
64
+ * declares, in one snapshot. Each is checked for errors first. A target project is then
65
+ * enumerated and rooted, and `stage` reads the rest of what it needs from the project
66
+ * while its view is open; a consumer project is read for its references to the target's
67
+ * declarations. A project carrying an error fails the run with no answer, as every verb
68
+ * that reads declarations does, and so does a declared consumer that cannot be loaded.
66
69
  */
67
70
  function readProjects(engine, host, scope, config, stage) {
68
71
  const targetRoot = scope.target.path;
69
72
  const discovered = discoverProjects(engine, host, scope, config.analysis.configurations);
70
73
  const ids = new Map(discovered.projects.map((project) => [project.configFile, project.id]));
74
+ const loads = consumerLoads(engine, host, scope, discovered.configFiles);
75
+ const consumerOf = new Map(loads.flatMap((load) => load.configFiles.map((configFile) => [configFile, load.consumer])));
71
76
  const options = {
72
77
  manifest: readManifest(host, targetRoot),
73
78
  patterns: config.rootPatterns,
@@ -76,12 +81,23 @@ function readProjects(engine, host, scope, config, stage) {
76
81
  publishedAPI: config.targetKind === "library",
77
82
  };
78
83
  const failures = [];
79
- const { projects } = runSession(engine, discovered.configFiles, (project) => {
84
+ const consumed = new Map();
85
+ const { projects } = runSession(engine, [...discovered.configFiles, ...consumerOf.keys()], (project) => {
80
86
  const errors = diagnosticErrors(diagnosticsOf(project));
81
87
  if (errors.length > 0) {
82
88
  failures.push(...errors);
83
89
  return undefined;
84
90
  }
91
+ const consumer = consumerOf.get(project.configFile);
92
+ if (consumer !== undefined) {
93
+ for (const reference of consumerReferences(project, host, targetRoot, {
94
+ id: consumer.id,
95
+ root: consumer.path,
96
+ }, config)) {
97
+ consumed.set(referenceKey(reference), reference);
98
+ }
99
+ return undefined;
100
+ }
85
101
  const held = inventory(project, host, targetRoot);
86
102
  const rooted = roots(project, held, targetRoot, options);
87
103
  const configuration = ids.get(project.configFile) ?? project.configFile;
@@ -90,7 +106,13 @@ function readProjects(engine, host, scope, config, stage) {
90
106
  if (failures.length > 0) {
91
107
  throw new DiscoveryError(`${String(failures.length)} error(s); no answer was produced`, failures);
92
108
  }
93
- return projects.filter((answer) => answer !== undefined);
109
+ return {
110
+ answers: projects.filter((answer) => answer !== undefined),
111
+ consumers: {
112
+ loaded: loads.map((load) => load.consumer),
113
+ references: [...consumed.values()],
114
+ },
115
+ };
94
116
  }
95
117
  /**
96
118
  * The root set of the run the scope and the configuration describe.
@@ -100,7 +122,7 @@ function readProjects(engine, host, scope, config, stage) {
100
122
  */
101
123
  export function runRoots(engine, host, scope, config, provenance) {
102
124
  const targetRoot = scope.target.path;
103
- const projects = readProjects(engine, host, scope, config, (_project, read) => read);
125
+ const projects = readProjects(engine, host, scope, config, (_project, read) => read).answers;
104
126
  const configurations = [];
105
127
  const merged = new Map();
106
128
  for (const answer of projects) {
@@ -150,7 +172,8 @@ const DETECTORS = new Map([
150
172
  /**
151
173
  * Reads every project of the run, its references and exemptions, and `extra`'s answer,
152
174
  * while the project's view is open, and merges the projects into one matrix in the run's
153
- * order.
175
+ * order. Every configuration holds every consumer's references beside its own, because a
176
+ * consumer calls the target whichever configuration the target is built under.
154
177
  */
155
178
  function readRun(engine, host, scope, config, mode, detectors, extra) {
156
179
  const targetRoot = scope.target.path;
@@ -159,7 +182,7 @@ function readRun(engine, host, scope, config, mode, detectors, extra) {
159
182
  ? { delimiters: config.analysis.templateDelimiters, files: [] }
160
183
  : readTemplates(host, targetRoot, config.analysis);
161
184
  const consumers = scope.consumers.map((consumer) => consumer.path);
162
- const projects = readProjects(engine, host, scope, config, (project, projectRead) => {
185
+ const read = readProjects(engine, host, scope, config, (project, projectRead) => {
163
186
  const resolved = references(project, projectRead.held, targetRoot, {
164
187
  testFiles: config.ts.testFiles,
165
188
  });
@@ -182,10 +205,21 @@ function readRun(engine, host, scope, config, mode, detectors, extra) {
182
205
  extra: extra(project, projectRead),
183
206
  };
184
207
  });
208
+ const consumed = read.consumers.references;
209
+ const projects = read.answers.map((one) => consumed.length === 0
210
+ ? one
211
+ : {
212
+ ...one,
213
+ configured: {
214
+ ...one.configured,
215
+ references: [...one.configured.references, ...consumed],
216
+ },
217
+ });
185
218
  return {
186
219
  matrix: matrixOf(projects.map((one) => one.configured)),
187
220
  exempt: exemptionsOf(projects.flatMap((one) => one.exempt)),
188
221
  projects,
222
+ consumers: read.consumers.loaded,
189
223
  };
190
224
  }
191
225
  /** The sweep of a run read once, under one set of marks. */
@@ -246,7 +280,10 @@ function emitterInputOver(host, scope, config, read, input, selfCheck) {
246
280
  heldByInclusion: new Set(projects.flatMap((one) => one.extra.heldByInclusion)),
247
281
  },
248
282
  boundary: {
249
- consumers: { declared: scope.consumers.map((consumer) => consumer.id), loaded: [] },
283
+ consumers: {
284
+ declared: scope.consumers.map((consumer) => moduleIdentity(host, consumer)),
285
+ loaded: read.consumers.map((consumer) => consumer.id),
286
+ },
250
287
  encapsulated: readManifest(host, targetRoot).declaresExports,
251
288
  edges: readEdgeSides(host, targetRoot),
252
289
  },
@@ -257,8 +294,8 @@ function emitterInputOver(host, scope, config, read, input, selfCheck) {
257
294
  * What every emitter reads of the run the scope and the configuration describe, swept
258
295
  * under the caller's marks: the run's sweep, the facts beside it each kind family reads,
259
296
  * and the sides of the target's declared cross-language edges that name a symbol of this
260
- * language. The run loads no consumer, and it reads no suppression document, so the
261
- * self-check family has nothing to report.
297
+ * language. The run reads no suppression document, so the self-check family has nothing
298
+ * to report.
262
299
  */
263
300
  export function runEmitterInput(engine, host, scope, config, input, detectors = DETECTORS) {
264
301
  const read = readEmitterRun(engine, host, scope, config, input.mode, detectors);
@@ -303,6 +340,7 @@ function runAnalysisInputs(engine, host, scope, config, provenance, mode, detect
303
340
  configFile: one.configFile,
304
341
  })),
305
342
  testFileRules: testFileRulesOf(config.ts.testFiles, read.projects.flatMap((one) => one.configured.testFiles)),
343
+ consumers: read.consumers,
306
344
  },
307
345
  configured: read.projects.map((one) => one.configured),
308
346
  };
@@ -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
  {
@@ -12,15 +12,5 @@ export const RECORD = {
12
12
  capability: "reflective-lookup",
13
13
  reason: "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.",
14
14
  },
15
- {
16
- fixture: "redundant-export-keyword",
17
- capability: "DS1104",
18
- reason: "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.",
19
- },
20
- {
21
- fixture: "unused-exported-consumer",
22
- capability: "DS1001",
23
- reason: "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.",
24
- },
25
15
  ],
26
16
  };
@@ -0,0 +1,49 @@
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
+ import { consumerProjects } from "./discover.js";
7
+ import { inventory, packageScope } from "./inventory.js";
8
+ import { relativePath } from "./paths.js";
9
+ import { references } from "./references.js";
10
+ import { narrowedTo } from "./session.js";
11
+ /** The name one module publishes itself under: the scope's, else its manifest's. */
12
+ export function moduleIdentity(host, module) {
13
+ return module.id === "" ? packageScope(host, module.path)("index.ts").package : module.id;
14
+ }
15
+ /**
16
+ * Every consumer the scope declares, in its order, with the projects it opens as. A
17
+ * configuration that is also one of the target's projects, or an earlier consumer's, is
18
+ * that project's alone. A consumer whose directory is absent, is no directory or holds
19
+ * no configuration ends the run naming it, before any project opens.
20
+ */
21
+ export function consumerLoads(engine, host, scope, targetConfigFiles) {
22
+ const opened = new Set(targetConfigFiles);
23
+ return scope.consumers.map((module) => {
24
+ const configFiles = consumerProjects(engine, host, module.path).filter((configFile) => {
25
+ const fresh = !opened.has(configFile);
26
+ opened.add(configFile);
27
+ return fresh;
28
+ });
29
+ return { consumer: { id: moduleIdentity(host, module), path: module.path }, configFiles };
30
+ });
31
+ }
32
+ /**
33
+ * Every reference one consumer project's own files make to a declaration of the target.
34
+ * The target's declarations are enumerated over the target's files the consumer's
35
+ * program holds, against the target root, so each carries the identifier the target's
36
+ * own projects give it. A reference from a consumer's test file is a test reference
37
+ * unless the configuration counts it as a production one.
38
+ */
39
+ export function consumerReferences(project, host, targetRoot, consumer, config) {
40
+ const held = inventory(narrowedTo(project, (file) => relativePath(targetRoot, file.fileName) !== undefined), host, targetRoot);
41
+ const found = references(project, held, targetRoot, {
42
+ testFiles: config.ts.testFiles,
43
+ consumer,
44
+ }).references;
45
+ if (config.analysis.consumerTests !== "production") {
46
+ return found;
47
+ }
48
+ return found.map((reference) => (reference.test ? { ...reference, test: false } : reference));
49
+ }
@@ -117,16 +117,61 @@ function referencesOf(host, configFile) {
117
117
  return found;
118
118
  }
119
119
  /**
120
- * The configuration files the scope names directly: one per module the scope
121
- * declares whose path names a configuration file rather than a directory. A module
122
- * naming a directory is reached by the walk over that directory instead.
120
+ * The configuration file the scope names for the target directly, where its path
121
+ * names one rather than a directory. A target naming a directory is reached by the
122
+ * walk over that directory instead. A consumer's projects are a consumer's, never the
123
+ * target's, so no consumer path is read here.
123
124
  */
124
125
  function scopeConfigFiles(host, scope) {
125
- return [scope.target, ...scope.consumers]
126
- .map((module) => module.path)
126
+ return [scope.target.path]
127
127
  .filter((path) => path.endsWith(".json"))
128
128
  .map((path) => resolvePath(host.workingDirectory(), path));
129
129
  }
130
+ /**
131
+ * Every configuration file reached from `pending`, in the order discovery reads them:
132
+ * each once, each followed by the configurations it references, transitively. A
133
+ * configuration the compiler cannot read ends the run naming it.
134
+ */
135
+ function derivedFrom(engine, host, pending) {
136
+ const seen = new Set();
137
+ const found = [];
138
+ while (pending.length > 0) {
139
+ const configFile = pending.shift();
140
+ if (configFile === undefined) {
141
+ break;
142
+ }
143
+ const path = resolvePath(host.workingDirectory(), configFile);
144
+ if (seen.has(path)) {
145
+ continue;
146
+ }
147
+ seen.add(path);
148
+ parseOrRefuse(engine, path, path);
149
+ found.push(path);
150
+ pending.push(...referencesOf(host, path));
151
+ }
152
+ return found;
153
+ }
154
+ /**
155
+ * The compiler configurations one consumer is loaded through: every one under its
156
+ * root that is not inside an ignored directory, and the configurations each of those
157
+ * references, transitively, in that order. A root that is absent, that is no
158
+ * directory, or that holds no configuration ends the run naming it, because a
159
+ * finding computed without a declared consumer would claim a reference set the run
160
+ * never read.
161
+ */
162
+ export function consumerProjects(engine, host, root) {
163
+ const kind = host.kindOf(root);
164
+ if (kind !== "directory") {
165
+ throw new DiscoveryError(kind === "absent"
166
+ ? `the consumer ${root} does not exist`
167
+ : `the consumer ${root} is not a directory`, []);
168
+ }
169
+ const found = derivedFrom(engine, host, configFilesUnder(host, root));
170
+ if (found.length === 0) {
171
+ throw new DiscoveryError(`the consumer ${root} holds no compiler configuration, so none of its references can be read`, []);
172
+ }
173
+ return found;
174
+ }
130
175
  /**
131
176
  * The name a derived project carries: its configuration file's path below the target
132
177
  * root, or the absolute path of a file outside it.
@@ -197,24 +242,15 @@ function declaredProjects(engine, host, targetRoot, entries) {
197
242
  */
198
243
  function derivedProjects(engine, host, scope) {
199
244
  const fromScope = scopeConfigFiles(host, scope);
200
- const seen = new Set();
201
- const projects = [];
202
- const pending = [...fromScope, ...configFilesUnder(host, scope.target.path)];
203
- while (pending.length > 0) {
204
- const configFile = pending.shift();
205
- if (configFile === undefined) {
206
- break;
207
- }
208
- const path = resolvePath(host.workingDirectory(), configFile);
209
- if (seen.has(path)) {
210
- continue;
211
- }
212
- seen.add(path);
213
- parseOrRefuse(engine, path, path);
214
- projects.push({ id: derivedId(scope.target.path, path), configFile: path });
215
- pending.push(...referencesOf(host, path));
216
- }
217
- return { projects, configFiles: projects.map((project) => project.configFile), fromScope };
245
+ const configFiles = derivedFrom(engine, host, [
246
+ ...fromScope,
247
+ ...configFilesUnder(host, scope.target.path),
248
+ ]);
249
+ const projects = configFiles.map((configFile) => ({
250
+ id: derivedId(scope.target.path, configFile),
251
+ configFile,
252
+ }));
253
+ return { projects, configFiles, fromScope };
218
254
  }
219
255
  /**
220
256
  * Every project one run analyzes.
@@ -14,26 +14,57 @@ const RANK = { certain: 3, probable: 2, possible: 1 };
14
14
  function capped(found, ceiling) {
15
15
  return RANK[found] <= RANK[ceiling] ? found : ceiling;
16
16
  }
17
+ /** The kind whose default turns on what the run knows about the target's consumers. */
18
+ const UNUSED_EXPORTED = "DS1001";
19
+ /** Whether the run loaded every consumer the scope declared, which holds of a scope declaring none. */
20
+ function everyDeclaredLoaded(boundary) {
21
+ const { declared, loaded } = boundary.consumers;
22
+ return declared.every((id) => loaded.includes(id));
23
+ }
24
+ /**
25
+ * Whether a library's published API may have a caller the run cannot see: the target is
26
+ * a library, and the run either holds no consumer information or did not load a consumer
27
+ * the scope declared. A configuration declaring the consumer set complete holds consumer
28
+ * information whether or not it declares a consumer, because it says no other exists.
29
+ */
30
+ function openWorld(input) {
31
+ const known = input.config.consumersComplete || input.boundary.consumers.declared.length > 0;
32
+ return input.config.targetKind === "library" && !(known && everyDeclaredLoaded(input.boundary));
33
+ }
17
34
  /**
18
35
  * The severity the configuration gives one code: the key naming the code, else the key
19
- * naming its family, else the code's default.
36
+ * naming its family, else the code's default. The unused-exported kind defaults to
37
+ * `allow` while a library's published API is open to callers the run cannot see.
20
38
  */
21
- function severityOf(config, code, fallback) {
22
- return (config.severity.get(code) ?? config.severity.get(code.slice(0, FAMILY_KEY_LENGTH)) ?? fallback);
39
+ function severityOf(input, code, fallback) {
40
+ const { config } = input;
41
+ const named = config.severity.get(code) ?? config.severity.get(code.slice(0, FAMILY_KEY_LENGTH));
42
+ if (named !== undefined) {
43
+ return named;
44
+ }
45
+ return code === UNUSED_EXPORTED && openWorld(input) ? "allow" : fallback;
23
46
  }
24
47
  /**
25
48
  * The reachability class of one declaration of the run. A declaration of a library's
26
- * published API has callers outside the target, and the run loads no consumer that
27
- * would name them, so it is `possible`; every other declaration has every reference
28
- * in the loaded program, a private member and a `#private` name among them, and is
29
- * `certain`.
49
+ * published API has callers outside the target: it is `certain` where the run loaded
50
+ * every consumer the scope declared and declared at least one, `probable` where it
51
+ * declared consumers and did not load them all, and `possible` where it declared none.
52
+ * Whether the configuration declares the consumer set complete decides nothing here.
53
+ * Every other declaration has every reference in the loaded program, a private member
54
+ * and a `#private` name among them, and is `certain`.
30
55
  */
31
- function reachabilityClasses(swept) {
32
- const union = swept.matrix.union;
56
+ function reachabilityClasses(input) {
57
+ const union = input.swept.matrix.union;
33
58
  const published = new Set(union.rooted
34
59
  .filter((root) => root.kind === "published-api")
35
60
  .map((root) => union.symbols[root.at]?.id));
36
- return (id) => (published.has(id) ? "possible" : "certain");
61
+ const { declared } = input.boundary.consumers;
62
+ const outside = declared.length === 0
63
+ ? "possible"
64
+ : everyDeclaredLoaded(input.boundary)
65
+ ? "certain"
66
+ : "probable";
67
+ return (id) => (published.has(id) ? outside : "certain");
37
68
  }
38
69
  /** The component each dead declaration of the run falls in, and none for a live one. */
39
70
  function findingComponents(swept) {
@@ -61,18 +92,19 @@ function findingComponents(swept) {
61
92
  * relation and holds where it is declared; a file or a manifest row is `certain` and
62
93
  * holds in every configuration. A subject in no dead component gets one of its own,
63
94
  * numbered past the computed ones in finding order, so no two families mint one; nothing
64
- * falls with it, so its deletion removes no line.
95
+ * falls with it, so its deletion removes no line. `kinds` is the vocabulary each code's
96
+ * row is read from, the shipped one unless a caller supplies its own.
65
97
  */
66
- export function completed(input, findings) {
98
+ export function completed(input, findings, kinds = KINDS) {
67
99
  const { swept } = input;
68
100
  const union = swept.matrix.union;
69
101
  const positions = new Map(union.symbols.map((symbol, at) => [symbol.ref, at]));
70
102
  const candidates = new Map(swept.sweep.candidates.map((candidate) => [candidate.id, candidate]));
71
- const classOf = reachabilityClasses(swept);
103
+ const classOf = reachabilityClasses(input);
72
104
  const componentOf = findingComponents(swept);
73
105
  let minted = swept.sweep.components.length;
74
106
  return findings.map((finding) => {
75
- const row = KINDS.get(finding.code);
107
+ const row = kinds.get(finding.code);
76
108
  if (row === undefined) {
77
109
  throw new Error(`${finding.code} names no live row of the issue-kind vocabulary`);
78
110
  }
@@ -119,7 +151,7 @@ export function completed(input, findings) {
119
151
  (judged === undefined ? swept.matrix.configurations : (swept.matrix.heldIn[at] ?? [])),
120
152
  consumersLoaded: input.boundary.consumers.loaded,
121
153
  fixability: row.fixability,
122
- severity: severityOf(input.config, finding.code, row.defaultSeverity),
154
+ severity: severityOf(input, finding.code, row.defaultSeverity),
123
155
  message: finding.message,
124
156
  details: finding.details ?? {},
125
157
  };
@@ -139,7 +171,7 @@ function dialed(finding, minConfidence) {
139
171
  */
140
172
  export function dialsOf(input, findings) {
141
173
  const minConfidence = input.config.analysis.minConfidence;
142
- const classOf = reachabilityClasses(input.swept);
174
+ const classOf = reachabilityClasses(input);
143
175
  const components = new Set(findings
144
176
  .filter((finding) => finding.component.root && dialed(finding, minConfidence))
145
177
  .map((finding) => finding.component.id));
@@ -148,7 +180,7 @@ export function dialsOf(input, findings) {
148
180
  const row = KINDS.get(code);
149
181
  return (row !== undefined &&
150
182
  dialed({
151
- severity: severityOf(input.config, code, row.defaultSeverity),
183
+ severity: severityOf(input, code, row.defaultSeverity),
152
184
  confidence: capped(classOf(id), row.maxClass),
153
185
  }, minConfidence));
154
186
  },
@@ -44,11 +44,18 @@ function fileNeverBuilt({ config, swept, files }) {
44
44
  }
45
45
  /**
46
46
  * The paths of every file a reference written in another file reaches, naming the
47
- * file or a declaration in it. An import is such a reference, so an import from any
48
- * file reaches what it names, whatever reaches the importing file.
47
+ * file or a declaration in it, a loaded consumer's file included. An import is such a
48
+ * reference, so an import from any file reaches what it names, whatever reaches the
49
+ * importing file.
49
50
  */
50
51
  function reachedFromOutside(union) {
51
52
  const reached = new Set();
53
+ for (const edge of union.consumed) {
54
+ const to = union.symbols[edge.to];
55
+ if (to !== undefined) {
56
+ reached.add(to.position.path);
57
+ }
58
+ }
52
59
  union.out.forEach((edges, from) => {
53
60
  const fromPath = union.symbols[from]?.position.path;
54
61
  for (const edge of edges) {
@@ -63,7 +63,9 @@ function writeOnlySymbols(run) {
63
63
  held = { writes: [], reads: 0 };
64
64
  uses.set(reference.to, held);
65
65
  }
66
- if (reference.use !== "write") {
66
+ // A consumer's store is written at a position no report of the target can name, so
67
+ // it counts as a use that keeps the declaration from this kind.
68
+ if (reference.use !== "write" || reference.consumer !== undefined) {
67
69
  held.reads += 1;
68
70
  continue;
69
71
  }
package/dist/src/graph.js CHANGED
@@ -71,6 +71,7 @@ export function graphOf(symbols, references, roots, testFiles) {
71
71
  const tests = new Set(testFiles);
72
72
  const out = symbols.map(() => []);
73
73
  const made = symbols.map(() => ({ production: 0, test: 0 }));
74
+ const consumed = [];
74
75
  for (const reference of references) {
75
76
  const to = at(reference.to);
76
77
  if (to === OUTSIDE) {
@@ -85,11 +86,12 @@ export function graphOf(symbols, references, roots, testFiles) {
85
86
  counts.production += 1;
86
87
  }
87
88
  }
88
- out[at(reference.from)]?.push({
89
- to,
90
- test: reference.test,
91
- evaluation: reference.use === "evaluation",
92
- });
89
+ const edge = { to, test: reference.test, evaluation: reference.use === "evaluation" };
90
+ if (reference.consumer !== undefined) {
91
+ consumed.push(edge);
92
+ continue;
93
+ }
94
+ out[at(reference.from)]?.push(edge);
93
95
  }
94
96
  const parent = symbols.map((symbol) => at(symbol.parent));
95
97
  const exportsOf = symbols.map(() => []);
@@ -115,6 +117,7 @@ export function graphOf(symbols, references, roots, testFiles) {
115
117
  subject: symbols.map((symbol) => swept(symbol.kind)),
116
118
  exportsOf,
117
119
  rooted,
120
+ consumed,
118
121
  at,
119
122
  };
120
123
  }
@@ -14,10 +14,21 @@ function compare(a, b) {
14
14
  }
15
15
  return a < b ? -1 : 1;
16
16
  }
17
- /** What makes one reference one reference, whichever configuration made it. */
18
- function referenceKey(reference) {
17
+ /**
18
+ * What makes one reference one reference, whichever configuration made it. A consumer's
19
+ * position is rendered against its own root, so the consumer is part of what it names.
20
+ */
21
+ export function referenceKey(reference) {
19
22
  const { path, line, column } = reference.position;
20
- return [reference.from, reference.to, path, String(line), String(column), reference.use].join("\u0000");
23
+ return [
24
+ reference.consumer ?? "",
25
+ reference.from,
26
+ reference.to,
27
+ path,
28
+ String(line),
29
+ String(column),
30
+ reference.use,
31
+ ].join("\u0000");
21
32
  }
22
33
  /**
23
34
  * Indexes what the passes answered for each configuration of one run, in the run's order.