@cplieger/deadset-ts 3.12.1 → 4.0.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 (120) hide show
  1. package/README.md +4 -4
  2. package/dist/bin/node-host.js +10 -7
  3. package/dist/src/alias-chain.js +59 -7
  4. package/dist/src/analysis.js +217 -48
  5. package/dist/src/calls.js +23 -22
  6. package/dist/src/class-members.js +10 -11
  7. package/dist/src/components.js +4 -2
  8. package/dist/src/configured-declarations.js +56 -7
  9. package/dist/src/conformance-record.js +2 -2
  10. package/dist/src/dependencies.js +34 -17
  11. package/dist/src/deprecation.js +35 -24
  12. package/dist/src/destructuring.js +37 -29
  13. package/dist/src/discover.js +105 -28
  14. package/dist/src/edges.js +4 -1
  15. package/dist/src/entry-points.js +7 -0
  16. package/dist/src/enum-group.js +46 -40
  17. package/dist/src/exempt.js +42 -1
  18. package/dist/src/findings/completion.js +37 -8
  19. package/dist/src/findings/emitters.js +20 -6
  20. package/dist/src/findings/interfaces.js +7 -1
  21. package/dist/src/findings/intra-function.js +91 -2
  22. package/dist/src/findings/self-check.js +30 -7
  23. package/dist/src/findings/unused-declarations.js +15 -3
  24. package/dist/src/findings-pass.js +18 -11
  25. package/dist/src/framework-lifecycle.js +8 -6
  26. package/dist/src/implementations.js +45 -23
  27. package/dist/src/interface-satisfaction.js +30 -27
  28. package/dist/src/intra-function-parts.js +660 -0
  29. package/dist/src/inventory.js +85 -4
  30. package/dist/src/json-chunks.js +76 -0
  31. package/dist/src/kinds.js +337 -35
  32. package/dist/src/partial.js +23 -0
  33. package/dist/src/query.js +207 -0
  34. package/dist/src/references.js +92 -20
  35. package/dist/src/reflective-lookup.js +46 -4
  36. package/dist/src/report.js +14 -3
  37. package/dist/src/reporters/annotations.js +9 -5
  38. package/dist/src/reporters/json.js +3 -2
  39. package/dist/src/reporters/sarif.js +22 -16
  40. package/dist/src/reporters/template.js +112 -12
  41. package/dist/src/reporters/text.js +8 -6
  42. package/dist/src/roots.js +27 -11
  43. package/dist/src/serialization-contract.js +53 -32
  44. package/dist/src/session.js +79 -39
  45. package/dist/src/signatures.js +32 -0
  46. package/dist/src/suppress-file.js +29 -7
  47. package/dist/src/suppress-inline.js +8 -1
  48. package/dist/src/suppress.js +27 -0
  49. package/dist/src/sweep.js +4 -1
  50. package/dist/src/value-reach.js +44 -25
  51. package/dist/src/verbs/analyze.js +39 -14
  52. package/dist/src/verbs/describe.js +12 -8
  53. package/dist/src/verbs/explain.js +22 -9
  54. package/dist/src/verbs/print-projects.js +40 -7
  55. package/dist/src/verbs/print-retained.js +6 -2
  56. package/dist/src/verbs/print-roots.js +5 -1
  57. package/dist/src/version.js +1 -1
  58. package/jsr.json +1 -1
  59. package/package.json +3 -3
  60. package/src/alias-chain.ts +75 -11
  61. package/src/analysis.ts +385 -104
  62. package/src/calls.ts +10 -9
  63. package/src/class-members.ts +10 -11
  64. package/src/components.ts +4 -2
  65. package/src/configured-declarations.ts +80 -8
  66. package/src/conformance-record.ts +2 -2
  67. package/src/dependencies.ts +42 -20
  68. package/src/deprecation.ts +53 -27
  69. package/src/destructuring.ts +50 -39
  70. package/src/discover.ts +177 -33
  71. package/src/edges.ts +4 -1
  72. package/src/entry-points.ts +7 -0
  73. package/src/enum-group.ts +56 -42
  74. package/src/exempt.ts +50 -2
  75. package/src/finding.ts +5 -1
  76. package/src/findings/completion.ts +46 -10
  77. package/src/findings/emitter.ts +3 -0
  78. package/src/findings/emitters.ts +27 -6
  79. package/src/findings/interfaces.ts +7 -1
  80. package/src/findings/intra-function.ts +106 -3
  81. package/src/findings/self-check.ts +48 -7
  82. package/src/findings/unused-declarations.ts +18 -4
  83. package/src/findings-pass.ts +26 -11
  84. package/src/framework-lifecycle.ts +10 -6
  85. package/src/host.ts +5 -3
  86. package/src/implementations.ts +59 -23
  87. package/src/interface-satisfaction.ts +30 -27
  88. package/src/intra-function-parts.ts +889 -0
  89. package/src/inventory.ts +97 -4
  90. package/src/json-chunks.ts +87 -0
  91. package/src/kinds.ts +355 -41
  92. package/src/partial.ts +40 -0
  93. package/src/query.ts +522 -0
  94. package/src/references.ts +119 -38
  95. package/src/reflective-lookup.ts +56 -6
  96. package/src/report.ts +32 -4
  97. package/src/reporters/annotations.ts +23 -21
  98. package/src/reporters/json.ts +3 -2
  99. package/src/reporters/reporter.ts +2 -2
  100. package/src/reporters/sarif.ts +27 -18
  101. package/src/reporters/template.ts +119 -14
  102. package/src/reporters/text.ts +12 -12
  103. package/src/roots.ts +34 -12
  104. package/src/serialization-contract.ts +59 -40
  105. package/src/session.ts +146 -58
  106. package/src/signatures.ts +55 -0
  107. package/src/suppress-file.ts +40 -8
  108. package/src/suppress-inline.ts +8 -1
  109. package/src/suppress.ts +31 -0
  110. package/src/sweep.ts +9 -4
  111. package/src/value-reach.ts +66 -28
  112. package/src/verbs/analyze.ts +51 -14
  113. package/src/verbs/describe.ts +13 -9
  114. package/src/verbs/explain.ts +20 -9
  115. package/src/verbs/print-projects.ts +53 -7
  116. package/src/verbs/print-retained.ts +6 -2
  117. package/src/verbs/print-roots.ts +5 -1
  118. package/src/version.ts +1 -1
  119. package/dist/src/reporters/rules.js +0 -178
  120. package/src/reporters/rules.ts +0 -195
package/README.md CHANGED
@@ -4,11 +4,11 @@
4
4
 
5
5
  > Deterministic dead-code analysis for TypeScript, class and type members included.
6
6
 
7
- deadset-ts is a command-line analyzer for TypeScript and JavaScript projects. It builds the program from your `tsconfig.json` files through the TypeScript 7 compiler API, resolves every reference in one pass, and reports the declarations nothing reaches: unused exports, type-only exports without a consumer, unused class members (private, protected, static and accessors), unused interface, type-alias, enum and namespace members, exports that can be narrowed to a smaller visibility, and whole groups of declarations that only keep each other alive. Every finding carries an issue code, a confidence and a stable symbol reference from the [deadset Contract](https://github.com/cplieger/deadset-spec), so the same report shape works for a Go analyzer and for a merged multi-language report. The analysis is deterministic: the same tree gives the same report, with no cache and no text search.
7
+ deadset-ts is a command-line analyzer for TypeScript and JavaScript projects. It builds the program from your `tsconfig.json` files through the TypeScript 7 compiler API, resolves every reference in one pass, and reports the declarations nothing reaches: unused exports, type-only exports without a consumer, unused class members (private, protected, static and accessors), unused interface, type-alias, enum and namespace members, exports that can be narrowed to a smaller visibility, whole groups of declarations that only keep each other alive, and inside a function the unused parameters, results nobody reads, unreachable statements, dead stores and unreachable cases. Every finding carries an issue code, a confidence and a stable symbol reference from the [deadset Contract](https://github.com/cplieger/deadset-spec), so the same report shape works for a Go analyzer and for a merged multi-language report. The analysis is deterministic: the same tree gives the same report, with no cache and no text search.
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. 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.
11
+ **Status: pre-release.** This version implements contract 4.0.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, where a discovered project whose configuration names no input, carries an option the compiler refuses or cannot be read is dropped, named in the report's `configurations_not_built` with its error and on standard error, and the rest analyzed, while a project `analysis.configurations` or the scope names ends the run with exit code 3, 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, held by an unanswered question, 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. Every question the analysis asks the compiler is guarded: a question the compiler server fails on is answered as unknown, every declaration that answer could have kept live is kept live and reported by nothing, as is every declaration only those keep live or whose own references went unanswered, and standard error counts the unanswered questions by project. The one runtime dependency is the `typescript` package at exactly 7.1.0-dev.20261003.1, installed under the `@typescript/native` name.
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 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.
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, held by an unanswered question, 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
  | ---------------- | ---------------------------------------------------------------------------------- |
@@ -44,7 +44,7 @@ npx deadset-ts version
44
44
  ## Requirements
45
45
 
46
46
  - Node.js 24 or later. The npm command is JavaScript the compiler emits at publish, so it runs with no loader and no flag; a clone of this repository runs the same command from its TypeScript sources through Node's own type stripping, which is what needs the version.
47
- - TypeScript 7. The analyzer is written against the `typescript` package at exactly version 7.0.2 and its `unstable/*` API. It does not run on TypeScript 6, whose compiler API TypeScript 7 removed, and it does not fall back to it.
47
+ - TypeScript 7. The analyzer is written against the `typescript` package at exactly version 7.1.0-dev.20261003.1 and its `unstable/*` API. It does not run on TypeScript 6, whose compiler API TypeScript 7 removed, and it does not fall back to it.
48
48
  - Linux. Other platforms are untested.
49
49
 
50
50
  ## API
@@ -47,18 +47,21 @@ function readVersion() {
47
47
  /** The mode every document the command writes carries. */
48
48
  const DOCUMENT_MODE = 0o644;
49
49
  /**
50
- * Writes one document through a temporary file in the same directory, flushed and then
51
- * renamed into place, so a run that dies partway leaves no truncated document at the path.
50
+ * Writes one document through a temporary file in the same directory, piece by piece,
51
+ * flushed and then renamed into place, so a run that dies partway leaves no truncated
52
+ * document at the path.
52
53
  */
53
- function writeAtomically(path, text) {
54
+ function writeAtomically(path, pieces) {
54
55
  const temporary = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString("hex")}`);
55
56
  const fd = openSync(temporary, "wx", DOCUMENT_MODE);
56
57
  try {
57
58
  try {
58
59
  fchmodSync(fd, DOCUMENT_MODE);
59
- const bytes = Buffer.from(text, "utf8");
60
- for (let written = 0; written < bytes.length;) {
61
- written += writeSync(fd, bytes, written);
60
+ for (const piece of pieces) {
61
+ const bytes = Buffer.from(piece, "utf8");
62
+ for (let written = 0; written < bytes.length;) {
63
+ written += writeSync(fd, bytes, written);
64
+ }
62
65
  }
63
66
  fsyncSync(fd);
64
67
  }
@@ -99,6 +102,6 @@ export function nodeHost() {
99
102
  found ??= readVersion();
100
103
  return found;
101
104
  },
102
- writeFile: writeAtomically,
105
+ writeDocument: writeAtomically,
103
106
  };
104
107
  }
@@ -3,8 +3,10 @@
3
3
  * declaration of the target it is, and along the chain of aliases it heads to each
4
4
  * declaration every link of that chain is.
5
5
  */
6
+ import { isExportAssignment, isExportSpecifier, isIdentifier, isImportClause, isImportSpecifier, isNamespaceExport, isNamespaceImport, } from "@typescript/native/unstable/ast";
6
7
  import { SymbolFlags } from "@typescript/native/unstable/sync";
7
- import { nodeKey } from "./inventory.js";
8
+ import { declarationsByName, nodeKey } from "./inventory.js";
9
+ import { UNANSWERED } from "./query.js";
8
10
  /**
9
11
  * The chain walks over one project, against the declarations of `held`, the same
10
12
  * project's inventory.
@@ -14,7 +16,7 @@ import { nodeKey } from "./inventory.js";
14
16
  * library file is never fetched to find out that the inventory does not hold it.
15
17
  */
16
18
  export function aliasChains(project, held) {
17
- const ownFiles = new Set(project.ownSourceFiles().map((file) => file.fileName));
19
+ const ownFiles = project.ownPaths();
18
20
  const steps = new Map();
19
21
  const chains = new Map();
20
22
  let asked = 0;
@@ -50,23 +52,68 @@ export function aliasChains(project, held) {
50
52
  steps.set(symbol.id, next);
51
53
  return next;
52
54
  };
55
+ const files = held.symbols.filter((one) => one.kind === "file").map((one) => one.id);
56
+ /**
57
+ * The declarations an alias whose step went unanswered may stand for: every one the
58
+ * inventory holds under its own name or the name it carries forward, and every file
59
+ * where it carries a module, so a use through it still counts for what it reaches.
60
+ */
61
+ const sameNamed = (alias) => {
62
+ const names = new Set([alias.name]);
63
+ let module = false;
64
+ for (const handle of alias.declarations) {
65
+ const node = ownFiles.has(handle.path) ? project.declarationAt(handle)?.node : undefined;
66
+ if (node === undefined) {
67
+ continue;
68
+ }
69
+ if ((isImportSpecifier(node) || isExportSpecifier(node)) && node.propertyName !== undefined) {
70
+ names.add(node.propertyName.text);
71
+ }
72
+ else if (isImportClause(node)) {
73
+ names.add("default");
74
+ }
75
+ else if (isNamespaceImport(node) || isNamespaceExport(node)) {
76
+ module = true;
77
+ }
78
+ else if (isExportAssignment(node) && isIdentifier(node.expression)) {
79
+ names.add(node.expression.text);
80
+ }
81
+ }
82
+ const byName = declarationsByName(held);
83
+ const ids = [...names].flatMap((name) => byName.get(name) ?? []);
84
+ return [...new Set([...ids, ...(module ? files : [])])].map((id) => ({
85
+ id,
86
+ stepped: true,
87
+ guessed: true,
88
+ }));
89
+ };
53
90
  const chainOf = (symbol) => {
54
91
  const cached = chains.get(symbol.id);
55
92
  if (cached !== undefined) {
56
93
  return cached;
57
94
  }
58
95
  const targets = [];
96
+ const add = (id, stepped, guessed = false) => {
97
+ if (!targets.some((target) => target.id === id)) {
98
+ targets.push({ id, stepped, guessed });
99
+ }
100
+ };
59
101
  const walked = new Set();
60
102
  let at = symbol;
61
103
  let stepped = false;
62
104
  while (at !== undefined && !walked.has(at.id)) {
63
105
  walked.add(at.id);
64
106
  for (const id of declarationsOf(at)) {
65
- if (!targets.some((target) => target.id === id)) {
66
- targets.push({ id, stepped });
107
+ add(id, stepped);
108
+ }
109
+ const next = stepOf(at);
110
+ if (next === UNANSWERED) {
111
+ for (const target of sameNamed(at)) {
112
+ add(target.id, true, true);
67
113
  }
114
+ break;
68
115
  }
69
- at = stepOf(at);
116
+ at = next;
70
117
  stepped = true;
71
118
  }
72
119
  chains.set(symbol.id, targets);
@@ -74,13 +121,18 @@ export function aliasChains(project, held) {
74
121
  };
75
122
  const nextDeclared = (symbol) => {
76
123
  const walked = new Set([symbol.id]);
124
+ let from = symbol;
77
125
  let at = stepOf(symbol);
78
- while (at !== undefined && !walked.has(at.id)) {
126
+ while (at !== undefined && (at === UNANSWERED || !walked.has(at.id))) {
127
+ if (at === UNANSWERED) {
128
+ return sameNamed(from);
129
+ }
79
130
  walked.add(at.id);
80
131
  const ids = declarationsOf(at);
81
132
  if (ids.length > 0) {
82
- return ids;
133
+ return ids.map((id) => ({ id, stepped: true, guessed: false }));
83
134
  }
135
+ from = at;
84
136
  at = stepOf(at);
85
137
  }
86
138
  return [];
@@ -8,20 +8,22 @@
8
8
  * project's checker, and that holds only for views the session itself hands out.
9
9
  */
10
10
  import { consumerLoads, consumerReferences, moduleIdentity, } from "./consumers.js";
11
+ import { configuredDeclarations, configuredRef, declarationsNamed, } from "./configured-declarations.js";
11
12
  import { decorator } from "./decorator.js";
12
13
  import { dependenciesOf, projectNeeds } from "./dependencies.js";
13
14
  import { deprecatedDeclarations } from "./deprecation.js";
14
- import { diagnosticErrors, discoverProjects, DiscoveryError } from "./discover.js";
15
+ import { discoverProjects, DiscoveryError, isGuess, projectErrors, renderDiagnostic, targetRelative, } from "./discover.js";
15
16
  import { readEdgeSides } from "./edges.js";
16
17
  import { enumGroup } from "./enum-group.js";
17
- import { computeExemptions, disabledClasses, exemptionsOf, retainedIn, } from "./exempt.js";
18
+ import { computeExemptions, disabledClasses, exemptionsOf, isUnansweredRecord, retainedIn, } from "./exempt.js";
18
19
  import { heldByInclusion, sourceTree } from "./file-facts.js";
19
- import { findingsPass } from "./findings-pass.js";
20
+ import { findingsPass, recordedFindings, rowKey } from "./findings-pass.js";
20
21
  import { NO_SELF_CHECK, unmatchedRootFindings, } from "./findings/self-check.js";
21
22
  import { frameworkLifecycle } from "./framework-lifecycle.js";
22
23
  import { implementations, mergeImplementations } from "./implementations.js";
23
24
  import { injectionContainer } from "./injection-container.js";
24
25
  import { interfaceSatisfaction } from "./interface-satisfaction.js";
26
+ import { intraFunctionFacts, projectParts } from "./intra-function-parts.js";
25
27
  import { inventory } from "./inventory.js";
26
28
  import { readManifest } from "./manifest.js";
27
29
  import { matrixOf, referenceKey, sweepMatrix, } from "./matrix.js";
@@ -33,7 +35,8 @@ import { roots, unmatchedEverywhere } from "./roots.js";
33
35
  import { serializationContract } from "./serialization-contract.js";
34
36
  import { diagnosticsOf, runSession } from "./session.js";
35
37
  import { accessorsOf, storesOf } from "./stores.js";
36
- import { readBaseline, readIgnoreFile } from "./suppress-file.js";
38
+ import { isPartKind } from "./suppress.js";
39
+ import { bindRows, readBaseline, readIgnoreFile } from "./suppress-file.js";
37
40
  import { inlineDirectives, inlineSuppressions } from "./suppress-inline.js";
38
41
  import { readTemplates, templateField } from "./template-field.js";
39
42
  /** The setting whose source decides where a finding about a configured root sits. */
@@ -60,12 +63,12 @@ function compare(a, b) {
60
63
  return a < b ? -1 : 1;
61
64
  }
62
65
  /**
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
+ * Reads every project the scope discovers, and every project of each consumer it
67
+ * declares, in one snapshot, each checked for errors first. A target project is
68
+ * enumerated, rooted and handed to `stage`; a consumer project is read for its references
69
+ * to the target. A guess that does not load is dropped into `notBuilt` (see
70
+ * {@link isGuess}); any other project that does not load or carries an error fails the
71
+ * run, as does a run whose every guess was dropped.
69
72
  */
70
73
  function readProjects(engine, host, scope, config, stage) {
71
74
  const targetRoot = scope.target.path;
@@ -80,15 +83,31 @@ function readProjects(engine, host, scope, config, stage) {
80
83
  testFiles: config.ts.testFiles,
81
84
  publishedAPI: config.targetKind === "library",
82
85
  };
86
+ const absoluteRoot = resolvePath(host.workingDirectory(), targetRoot);
87
+ const notBuilt = [...discovered.notBuilt];
88
+ const dropped = (configFile, error) => {
89
+ notBuilt.push({
90
+ id: ids.get(configFile) ?? configFile,
91
+ configFile,
92
+ error: targetRelative(error, absoluteRoot),
93
+ });
94
+ };
83
95
  const failures = [];
84
96
  const consumed = new Map();
85
- const { projects } = runSession(engine, [...discovered.configFiles, ...consumerOf.keys()], (project) => {
86
- const errors = diagnosticErrors(diagnosticsOf(project));
87
- if (errors.length > 0) {
88
- failures.push(...errors);
97
+ const session = runSession(engine, [...discovered.configFiles, ...consumerOf.keys()], (project) => {
98
+ const errors = projectErrors(diagnosticsOf(project));
99
+ const consumer = consumerOf.get(project.configFile);
100
+ const [unloaded] = errors.load;
101
+ if (unloaded !== undefined &&
102
+ consumer === undefined &&
103
+ isGuess(discovered, project.configFile)) {
104
+ dropped(project.configFile, renderDiagnostic(unloaded, (path) => relativePath(absoluteRoot, path) ?? path));
105
+ return undefined;
106
+ }
107
+ if (errors.load.length > 0 || errors.check.length > 0) {
108
+ failures.push(...errors.load, ...errors.check);
89
109
  return undefined;
90
110
  }
91
- const consumer = consumerOf.get(project.configFile);
92
111
  if (consumer !== undefined) {
93
112
  for (const reference of consumerReferences(project, host, targetRoot, {
94
113
  id: consumer.id,
@@ -102,16 +121,29 @@ function readProjects(engine, host, scope, config, stage) {
102
121
  const rooted = roots(project, held, targetRoot, options);
103
122
  const configuration = ids.get(project.configFile) ?? project.configFile;
104
123
  return stage(project, { configuration, configFile: project.configFile, held, rooted });
105
- });
124
+ }, discovered.derived ? "record" : "refuse");
125
+ for (const configFile of session.unopened) {
126
+ if (consumerOf.has(configFile) || !isGuess(discovered, configFile)) {
127
+ throw new DiscoveryError(`${configFile} opened no project`, []);
128
+ }
129
+ dropped(configFile, "the compiler opened no project for the configuration");
130
+ }
106
131
  if (failures.length > 0) {
107
132
  throw new DiscoveryError(`${String(failures.length)} error(s); no answer was produced`, failures);
108
133
  }
134
+ const answers = session.projects.filter((answer) => answer !== undefined);
135
+ const [first] = notBuilt;
136
+ if (answers.length === 0 && first !== undefined) {
137
+ throw new DiscoveryError(`no configuration discovery derived could be built; ${first.id}: ${first.error}`, []);
138
+ }
109
139
  return {
110
- answers: projects.filter((answer) => answer !== undefined),
140
+ answers,
111
141
  consumers: {
112
142
  loaded: loads.map((load) => load.consumer),
113
143
  references: [...consumed.values()],
114
144
  },
145
+ notBuilt,
146
+ unanswered: session.unanswered,
115
147
  };
116
148
  }
117
149
  /**
@@ -122,7 +154,8 @@ function readProjects(engine, host, scope, config, stage) {
122
154
  */
123
155
  export function runRoots(engine, host, scope, config, provenance) {
124
156
  const targetRoot = scope.target.path;
125
- const projects = readProjects(engine, host, scope, config, (_project, read) => read).answers;
157
+ const read = readProjects(engine, host, scope, config, (_project, projectRead) => projectRead);
158
+ const projects = read.answers;
126
159
  const configurations = [];
127
160
  const merged = new Map();
128
161
  for (const answer of projects) {
@@ -156,6 +189,8 @@ export function runRoots(engine, host, scope, config, provenance) {
156
189
  configurations,
157
190
  roots: ordered,
158
191
  findings: unmatchedRootFindings(unmatched, documentOf(host, provenance, ROOTS_SETTING, targetRoot)),
192
+ notBuilt: read.notBuilt,
193
+ unanswered: read.unanswered,
159
194
  };
160
195
  }
161
196
  /** The exemption classes this analyzer detects, each by its detector; any other retains nothing. */
@@ -198,11 +233,20 @@ function readRun(engine, host, scope, config, mode, detectors, extra) {
198
233
  mode,
199
234
  testFiles: new Set(resolved.testFilePaths),
200
235
  });
236
+ const unanswered = [
237
+ ...projectRead.held.unanswered,
238
+ ...projectRead.rooted.unanswered,
239
+ ...resolved.references
240
+ .filter((reference) => reference.resolution === "by-name")
241
+ .map((reference) => reference.to),
242
+ ...exempt.filter(isUnansweredRecord).map((record) => record.id),
243
+ ];
201
244
  return {
202
245
  configFile: projectRead.configFile,
203
246
  configured,
204
247
  exempt,
205
- extra: extra(project, projectRead),
248
+ unanswered,
249
+ extra: extra(project, projectRead, resolved.references),
206
250
  };
207
251
  });
208
252
  const consumed = read.consumers.references;
@@ -218,17 +262,64 @@ function readRun(engine, host, scope, config, mode, detectors, extra) {
218
262
  return {
219
263
  matrix: matrixOf(projects.map((one) => one.configured)),
220
264
  exempt: exemptionsOf(projects.flatMap((one) => one.exempt)),
265
+ unansweredHeld: [
266
+ ...new Set([
267
+ ...read.answers.flatMap((one) => one.unanswered),
268
+ ...consumed.filter((one) => one.resolution === "by-name").map((one) => one.to),
269
+ ]),
270
+ ],
221
271
  projects,
222
272
  consumers: read.consumers.loaded,
273
+ notBuilt: read.notBuilt,
274
+ unanswered: read.unanswered,
223
275
  };
224
276
  }
225
- /** The sweep of a run read once, under one set of marks. */
277
+ /**
278
+ * The sweep of a run read once, under one set of marks. Where a question went unanswered,
279
+ * the run is swept once more without what that question could have kept live and without
280
+ * the references that stood in for its answer, and every declaration that sweep judges
281
+ * dead and this one judges live, or dead by another rule, is held by the gap too, as is
282
+ * every declaration that makes such a reference.
283
+ */
226
284
  function sweptOf(read, input) {
227
- const swept = { ...input, exempt: read.exempt };
285
+ const swept = { ...input, exempt: read.exempt, unanswered: read.unansweredHeld };
286
+ const result = sweepMatrix(read.matrix, swept);
287
+ const held = new Set(read.unansweredHeld);
288
+ if (held.size > 0) {
289
+ // What a declaration reads where the checker did not answer is unknown, so a rule
290
+ // over what it reads, the test of dead code among them, cannot judge it.
291
+ for (const { configured } of read.projects) {
292
+ for (const reference of configured.references) {
293
+ if (reference.resolution === "by-name") {
294
+ held.add(reference.from);
295
+ }
296
+ }
297
+ }
298
+ const dead = new Map(result.candidates.map((candidate) => [candidate.id, candidate]));
299
+ const answered = matrixOf(read.projects.map(({ configured }) => ({
300
+ ...configured,
301
+ references: configured.references.filter((one) => one.resolution !== "by-name"),
302
+ })));
303
+ const bare = {
304
+ ...swept,
305
+ exempt: read.exempt.filter((record) => !isUnansweredRecord(record)),
306
+ unanswered: [],
307
+ };
308
+ for (const candidate of sweepMatrix(answered, bare).candidates) {
309
+ const kept = dead.get(candidate.id);
310
+ if (kept?.testOfDeadCode !== candidate.testOfDeadCode ||
311
+ kept.relation !== candidate.relation) {
312
+ held.add(candidate.id);
313
+ }
314
+ }
315
+ }
228
316
  return {
229
317
  matrix: read.matrix,
230
- sweep: sweepMatrix(read.matrix, swept),
318
+ sweep: result,
231
319
  retained: retainedIn(read.matrix, swept),
320
+ notBuilt: read.notBuilt,
321
+ unanswered: read.unanswered,
322
+ heldByUnanswered: [...held],
232
323
  };
233
324
  }
234
325
  /**
@@ -247,15 +338,23 @@ export function runSweep(engine, host, scope, config, input, detectors = DETECTO
247
338
  /** Reads a run's projects with every per-project fact an emitter reads. */
248
339
  function readEmitterRun(engine, host, scope, config, mode, detectors) {
249
340
  const targetRoot = scope.target.path;
250
- return readRun(engine, host, scope, config, mode, detectors, (project, read) => ({
251
- deprecated: deprecatedDeclarations(project, read.held),
341
+ const run = readRun(engine, host, scope, config, mode, detectors, (project, read, references) => ({
342
+ deprecation: deprecatedDeclarations(project, read.held),
252
343
  accessors: accessorsOf(project, read.held),
253
344
  needs: projectNeeds(project, read.held, host),
254
345
  implementations: implementations(project, read.held),
255
346
  heldByInclusion: heldByInclusion(project, targetRoot),
256
347
  rooted: read.rooted,
257
348
  directives: inlineDirectives(project, read.held, targetRoot),
349
+ declarationsNamed: declarationsNamed(project, read.held, configuredDeclarations(config)),
350
+ parts: projectParts(project, read.held, references, targetRoot),
258
351
  }));
352
+ // Whether a declaration carries the marker decides its code, so one whose marker went
353
+ // unread is reported by nothing.
354
+ const unread = run.projects.flatMap((one) => one.extra.deprecation.unanswered);
355
+ return unread.length === 0
356
+ ? run
357
+ : { ...run, unansweredHeld: [...new Set([...run.unansweredHeld, ...unread])] };
259
358
  }
260
359
  /**
261
360
  * The input every emitter reads over one sweep of a read run: the facts beside the sweep
@@ -268,7 +367,7 @@ function emitterInputOver(host, scope, config, read, input, selfCheck) {
268
367
  return {
269
368
  config,
270
369
  swept,
271
- deprecated: new Set(projects.flatMap((one) => one.extra.deprecated)),
370
+ deprecated: new Set(projects.flatMap((one) => one.extra.deprecation.deprecated)),
272
371
  stores: storesOf(projects.map((one) => ({
273
372
  references: one.configured.references,
274
373
  accessors: one.extra.accessors,
@@ -288,6 +387,7 @@ function emitterInputOver(host, scope, config, read, input, selfCheck) {
288
387
  edges: readEdgeSides(host, targetRoot),
289
388
  },
290
389
  selfCheck,
390
+ intraFunction: intraFunctionFacts(projects.map((one) => one.extra.parts), projects.flatMap((one) => one.configured.references), new Set([...read.exempt.map((one) => one.id), ...swept.heldByUnanswered])),
291
391
  };
292
392
  }
293
393
  /**
@@ -303,37 +403,22 @@ export function runEmitterInput(engine, host, scope, config, input, detectors =
303
403
  }
304
404
  /**
305
405
  * Reads the run the scope and the configuration describe for its findings. The inline
306
- * directives, the ignore file's entries and the baseline's rows are read and bound before
307
- * any sweep, and the run is swept once under every bound record's mark and once under
308
- * none, so a record is decided against the finding its code would have produced. The
406
+ * directives, the ignore file's entries and the baseline's rows are bound before any
407
+ * sweep, and the run is swept once under every bound record's mark and once under none,
408
+ * so a record is decided against the finding its code would have produced. The
309
409
  * self-check family reports the refused suppressions and the roots that named nothing.
310
410
  * Beside the inputs it answers the projects and test-file rules a report names.
311
411
  */
312
- function runAnalysisInputs(engine, host, scope, config, provenance, mode, detectors) {
412
+ function readAnalysis(engine, host, scope, config, provenance, mode, detectors) {
313
413
  const targetRoot = scope.target.path;
314
414
  const read = readEmitterRun(engine, host, scope, config, mode, detectors);
315
415
  const symbols = read.matrix.union.symbols;
316
- const documents = [
416
+ const fixed = [
317
417
  inlineSuppressions(read.projects.map((one) => one.extra.directives)),
318
418
  readIgnoreFile(host, targetRoot, symbols),
319
- readBaseline(host, targetRoot, symbols),
320
419
  ];
321
- const suppressions = {
322
- records: documents.flatMap((one) => one.records),
323
- refusals: documents.flatMap((one) => one.refusals),
324
- };
325
- const facts = {
326
- refusals: suppressions.refusals,
327
- unmatchedRoots: unmatchedEverywhere(config.rootPatterns, read.projects.map((one) => one.extra.rooted)),
328
- rootsDocument: documentOf(host, provenance, ROOTS_SETTING, targetRoot),
329
- };
330
- const marked = [...new Set(suppressions.records.map((record) => record.bound))].filter((id) => id !== "");
420
+ const inputsUnder = (baseline) => inputsOver(host, scope, config, provenance, mode, read, [...fixed, baseline]);
331
421
  return {
332
- inputs: {
333
- marked: emitterInputOver(host, scope, config, read, { marked, mode }, facts),
334
- unmarked: emitterInputOver(host, scope, config, read, { marked: [], mode }, facts),
335
- suppressions,
336
- },
337
422
  run: {
338
423
  projects: read.projects.map((one) => ({
339
424
  id: one.configured.configuration,
@@ -341,8 +426,42 @@ function runAnalysisInputs(engine, host, scope, config, provenance, mode, detect
341
426
  })),
342
427
  testFileRules: testFileRulesOf(config.ts.testFiles, read.projects.flatMap((one) => one.configured.testFiles)),
343
428
  consumers: read.consumers,
429
+ notBuilt: read.notBuilt,
430
+ unanswered: read.unanswered,
344
431
  },
345
432
  configured: read.projects.map((one) => one.configured),
433
+ symbols,
434
+ inputsUnder,
435
+ };
436
+ }
437
+ /** The inputs of one read run under the suppression documents given, in reading order. */
438
+ function inputsOver(host, scope, config, provenance, mode, read, documents) {
439
+ const targetRoot = scope.target.path;
440
+ const suppressions = {
441
+ records: documents.flatMap((one) => one.records),
442
+ refusals: documents.flatMap((one) => one.refusals),
443
+ };
444
+ const facts = {
445
+ refusals: suppressions.refusals,
446
+ unmatchedRoots: unmatchedEverywhere(config.rootPatterns, read.projects.map((one) => one.extra.rooted)),
447
+ rootsDocument: documentOf(host, provenance, ROOTS_SETTING, targetRoot),
448
+ unmatchedDeclarations: configuredDeclarations(config)
449
+ .filter((_, at) => !read.projects.some((one) => one.extra.declarationsNamed[at] === true))
450
+ .map((one) => ({
451
+ ref: configuredRef(one.entry),
452
+ key: one.key,
453
+ document: documentOf(host, provenance, one.key, targetRoot),
454
+ })),
455
+ };
456
+ const marked = [
457
+ ...new Set(suppressions.records
458
+ .filter((record) => !isPartKind(record.code))
459
+ .map((record) => record.bound)),
460
+ ].filter((id) => id !== "");
461
+ return {
462
+ marked: emitterInputOver(host, scope, config, read, { marked, mode }, facts),
463
+ unmarked: emitterInputOver(host, scope, config, read, { marked: [], mode }, facts),
464
+ suppressions,
346
465
  };
347
466
  }
348
467
  /**
@@ -352,6 +471,56 @@ function runAnalysisInputs(engine, host, scope, config, provenance, mode, detect
352
471
  * an explanation reads.
353
472
  */
354
473
  export function runAnalysis(engine, host, scope, config, provenance, mode, detectors = DETECTORS) {
355
- const { inputs, run, configured } = runAnalysisInputs(engine, host, scope, config, provenance, mode, detectors);
356
- return { result: findingsPass(inputs), run, swept: inputs.marked.swept, configured };
474
+ const reader = readAnalysis(engine, host, scope, config, provenance, mode, detectors);
475
+ const inputs = reader.inputsUnder(readBaseline(host, scope.target.path, reader.symbols));
476
+ return analysisOf(reader, inputs);
477
+ }
478
+ function analysisOf(reader, inputs) {
479
+ return {
480
+ result: findingsPass(inputs),
481
+ run: reader.run,
482
+ swept: inputs.marked.swept,
483
+ configured: reader.configured,
484
+ };
485
+ }
486
+ /**
487
+ * The fixpoint a baseline write records. The first round reads no baseline; each later
488
+ * round reads the rows recorded so far, records a row for each recordable finding no row
489
+ * already names, and removes every row it reports stale. A removed row is not recorded
490
+ * again, so the write ends.
491
+ */
492
+ export function runBaselineWrite(engine, host, scope, config, provenance, mode, reason, detectors = DETECTORS) {
493
+ const reader = readAnalysis(engine, host, scope, config, provenance, mode, detectors);
494
+ const first = analysisOf(reader, reader.inputsUnder({ records: [], refusals: [] }));
495
+ let rows = [];
496
+ const removed = new Set();
497
+ let result = first.result;
498
+ let stale = new Set();
499
+ for (;;) {
500
+ const held = new Set(rows.map(rowKey));
501
+ const added = recordedFindings(result.findings).filter((row) => !held.has(rowKey(row)) && !removed.has(rowKey(row)));
502
+ if (added.length === 0 && stale.size === 0) {
503
+ return { first, rows };
504
+ }
505
+ for (const key of stale) {
506
+ removed.add(key);
507
+ }
508
+ rows = [...rows.filter((row) => !stale.has(rowKey(row))), ...added];
509
+ const inputs = reader.inputsUnder(bindRows(rows, reason, reader.symbols));
510
+ result = findingsPass(inputs);
511
+ stale = staleRows(inputs.suppressions.records, result.ledger.verdicts);
512
+ }
513
+ }
514
+ /** The baseline rows a round reports stale: every record a row binds to matched nothing. */
515
+ function staleRows(records, verdicts) {
516
+ const live = new Set();
517
+ const stale = new Set();
518
+ records.forEach((record, at) => {
519
+ if (record.mechanism !== "baseline") {
520
+ return;
521
+ }
522
+ const key = rowKey(record);
523
+ (verdicts[at] === "stale" ? stale : live).add(key);
524
+ });
525
+ return new Set([...stale].filter((key) => !live.has(key)));
357
526
  }