@cplieger/deadset-ts 3.7.0 → 3.9.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 (53) hide show
  1. package/README.md +13 -13
  2. package/dist/bin/node-host.js +32 -2
  3. package/dist/src/analysis.js +111 -26
  4. package/dist/src/conformance.js +22 -0
  5. package/dist/src/edges.js +190 -0
  6. package/dist/src/findings/completion.js +39 -12
  7. package/dist/src/findings/emitters.js +42 -6
  8. package/dist/src/findings/self-check.js +139 -2
  9. package/dist/src/findings/visibility-narrowing.js +9 -40
  10. package/dist/src/findings-pass.js +97 -0
  11. package/dist/src/json-document.js +113 -0
  12. package/dist/src/references.js +18 -12
  13. package/dist/src/report.js +237 -0
  14. package/dist/src/reporters/json.js +4 -0
  15. package/dist/src/reporters/reporter.js +1 -0
  16. package/dist/src/reporters/reporters.js +10 -0
  17. package/dist/src/reporters/text.js +34 -0
  18. package/dist/src/roots.js +0 -34
  19. package/dist/src/run.js +23 -10
  20. package/dist/src/suppress-file.js +196 -0
  21. package/dist/src/suppress-inline.js +259 -0
  22. package/dist/src/suppress.js +148 -0
  23. package/dist/src/verbs/analyze.js +184 -0
  24. package/dist/src/verbs/describe.js +45 -0
  25. package/jsr.json +1 -1
  26. package/package.json +1 -1
  27. package/src/analysis.ts +230 -35
  28. package/src/conformance.ts +34 -0
  29. package/src/edges.ts +241 -0
  30. package/src/finding.ts +13 -0
  31. package/src/findings/boundary.ts +0 -11
  32. package/src/findings/completion.ts +54 -15
  33. package/src/findings/emitter.ts +3 -0
  34. package/src/findings/emitters.ts +81 -7
  35. package/src/findings/self-check.ts +226 -2
  36. package/src/findings/visibility-narrowing.ts +10 -52
  37. package/src/findings-pass.ts +145 -0
  38. package/src/host.ts +5 -0
  39. package/src/json-document.ts +130 -0
  40. package/src/references.ts +23 -15
  41. package/src/report.ts +430 -0
  42. package/src/reporters/json.ts +7 -0
  43. package/src/reporters/reporter.ts +18 -0
  44. package/src/reporters/reporters.ts +13 -0
  45. package/src/reporters/text.ts +45 -0
  46. package/src/roots.ts +0 -39
  47. package/src/run.ts +40 -12
  48. package/src/suppress-file.ts +283 -0
  49. package/src/suppress-inline.ts +318 -0
  50. package/src/suppress.ts +262 -0
  51. package/src/verbs/analyze.ts +234 -0
  52. package/src/verbs/describe.ts +46 -0
  53. package/src/verbs/verb.ts +13 -0
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. The analysis is reached through the command, and through `run()` for a caller that runs the command in-process; no other verb reports findings yet, so `analyze`, `explain` and `describe` exit with a usage message. 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 a text or JSON rendering beside it on request, and `describe` prints the analyzer's description, which states no conformance record, 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; `explain` exits with a usage message. 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,18 +28,18 @@ 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`, `explain`, `describe`, any other invocation and no arguments print the usage line and exit 2.
32
-
33
- | Verb | Job |
34
- | ---------------- | ------------------------------------------------------------------------------- |
35
- | `analyze` | Analyze a project and print the findings |
36
- | `explain` | Explain one finding: the relation, the roots and the references that decided it |
37
- | `print-config` | Print the resolved configuration and where each setting came from |
38
- | `print-projects` | Print the identifier of every project a run analyzes |
39
- | `print-roots` | Print every declaration the analysis keeps live without a reference, and why |
40
- | `print-retained` | Print every declaration an exemption held back: each class, its site and detail |
41
- | `describe` | Describe the analyzer: version, Contract version, supported issue kinds |
42
- | `version` | Print the analyzer and Contract versions |
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=text` or `--format=json` rendering at `PATH` with `.txt` or `.json` appended, 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, 3 for a failed load, 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`, any other invocation and no arguments print the usage line and exit 2.
32
+
33
+ | Verb | Job |
34
+ | ---------------- | -------------------------------------------------------------------------------- |
35
+ | `analyze` | Analyze a project and write the report, its renderings and a baseline |
36
+ | `explain` | Explain one finding: the relation, the roots and the references that decided it |
37
+ | `print-config` | Print the resolved configuration and where each setting came from |
38
+ | `print-projects` | Print the identifier of every project a run analyzes |
39
+ | `print-roots` | Print every declaration the analysis keeps live without a reference, and why |
40
+ | `print-retained` | Print every declaration an exemption held back: each class, its site and detail |
41
+ | `describe` | Describe the analyzer: versions, accepted report schemas, languages, conformance |
42
+ | `version` | Print the analyzer and Contract versions |
43
43
 
44
44
  ## Requirements
45
45
 
@@ -1,5 +1,6 @@
1
- import { readdirSync, readFileSync, statSync } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { randomBytes } from "node:crypto";
2
+ import { closeSync, fchmodSync, fsyncSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeSync, } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  /** The name the manifest of this package declares. */
5
6
  const PACKAGE_NAME = "@cplieger/deadset-ts";
@@ -43,6 +44,34 @@ function readVersion() {
43
44
  at = up;
44
45
  }
45
46
  }
47
+ /** The mode every document the command writes carries. */
48
+ const DOCUMENT_MODE = 0o644;
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.
52
+ */
53
+ function writeAtomically(path, text) {
54
+ const temporary = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString("hex")}`);
55
+ const fd = openSync(temporary, "wx", DOCUMENT_MODE);
56
+ try {
57
+ try {
58
+ 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);
62
+ }
63
+ fsyncSync(fd);
64
+ }
65
+ finally {
66
+ closeSync(fd);
67
+ }
68
+ renameSync(temporary, path);
69
+ }
70
+ catch (error) {
71
+ rmSync(temporary, { force: true });
72
+ throw error;
73
+ }
74
+ }
46
75
  /**
47
76
  * The filesystem of the platform the command runs on, and the version of the
48
77
  * package it was installed from.
@@ -70,5 +99,6 @@ export function nodeHost() {
70
99
  found ??= readVersion();
71
100
  return found;
72
101
  },
102
+ writeFile: writeAtomically,
73
103
  };
74
104
  }
@@ -11,9 +11,12 @@ import { decorator } from "./decorator.js";
11
11
  import { dependenciesOf, projectNeeds } from "./dependencies.js";
12
12
  import { deprecatedDeclarations } from "./deprecation.js";
13
13
  import { diagnosticErrors, discoverProjects, DiscoveryError } from "./discover.js";
14
+ import { readEdgeSides } from "./edges.js";
14
15
  import { enumGroup } from "./enum-group.js";
15
16
  import { computeExemptions, disabledClasses, exemptionsOf, retainedIn, } from "./exempt.js";
16
17
  import { heldByInclusion, sourceTree } from "./file-facts.js";
18
+ import { findingsPass } from "./findings-pass.js";
19
+ import { NO_SELF_CHECK, unmatchedRootFindings, } from "./findings/self-check.js";
17
20
  import { frameworkLifecycle } from "./framework-lifecycle.js";
18
21
  import { implementations, mergeImplementations } from "./implementations.js";
19
22
  import { injectionContainer } from "./injection-container.js";
@@ -24,11 +27,13 @@ import { matrixOf, sweepMatrix } from "./matrix.js";
24
27
  import { relativePath, resolvePath } from "./paths.js";
25
28
  import { byPosition } from "./position.js";
26
29
  import { reflectiveLookup } from "./reflective-lookup.js";
27
- import { references } from "./references.js";
28
- import { roots, unmatchedEverywhere, unmatchedRoots } from "./roots.js";
30
+ import { references, testFileRulesOf } from "./references.js";
31
+ import { roots, unmatchedEverywhere } from "./roots.js";
29
32
  import { serializationContract } from "./serialization-contract.js";
30
33
  import { diagnosticsOf, runSession } from "./session.js";
31
34
  import { accessorsOf, storesOf } from "./stores.js";
35
+ import { readBaseline, readIgnoreFile } from "./suppress-file.js";
36
+ import { inlineDirectives, inlineSuppressions } from "./suppress-inline.js";
32
37
  import { readTemplates, templateField } from "./template-field.js";
33
38
  /** The setting whose source decides where a finding about a configured root sits. */
34
39
  const ROOTS_SETTING = "roots.patterns";
@@ -80,7 +85,7 @@ function readProjects(engine, host, scope, config, stage) {
80
85
  const held = inventory(project, host, targetRoot);
81
86
  const rooted = roots(project, held, targetRoot, options);
82
87
  const configuration = ids.get(project.configFile) ?? project.configFile;
83
- return stage(project, { configuration, held, rooted });
88
+ return stage(project, { configuration, configFile: project.configFile, held, rooted });
84
89
  });
85
90
  if (failures.length > 0) {
86
91
  throw new DiscoveryError(`${String(failures.length)} error(s); no answer was produced`, failures);
@@ -128,7 +133,7 @@ export function runRoots(engine, host, scope, config, provenance) {
128
133
  return {
129
134
  configurations,
130
135
  roots: ordered,
131
- findings: unmatchedRoots(unmatched, documentOf(host, provenance, ROOTS_SETTING, targetRoot)),
136
+ findings: unmatchedRootFindings(unmatched, documentOf(host, provenance, ROOTS_SETTING, targetRoot)),
132
137
  };
133
138
  }
134
139
  /** The exemption classes this analyzer detects, each by its detector; any other retains nothing. */
@@ -143,10 +148,11 @@ const DETECTORS = new Map([
143
148
  ["serialization-contract", serializationContract],
144
149
  ]);
145
150
  /**
146
- * Sweeps the run, reading each project's references and exemptions, and `extra`'s
147
- * answer, while the project's view is open.
151
+ * Reads every project of the run, its references and exemptions, and `extra`'s answer,
152
+ * while the project's view is open, and merges the projects into one matrix in the run's
153
+ * order.
148
154
  */
149
- function sweepRun(engine, host, scope, config, input, detectors, extra) {
155
+ function readRun(engine, host, scope, config, mode, detectors, extra) {
150
156
  const targetRoot = scope.target.path;
151
157
  const disabled = disabledClasses(config);
152
158
  const templates = disabled.has("template-field")
@@ -166,20 +172,31 @@ function sweepRun(engine, host, scope, config, input, detectors, extra) {
166
172
  };
167
173
  const exempt = computeExemptions({ project, held: projectRead.held, targetRoot, templates, ts: config.ts, consumers }, detectors, {
168
174
  disabled,
169
- mode: input.mode,
175
+ mode,
170
176
  testFiles: new Set(resolved.testFilePaths),
171
177
  });
172
- return { configured, exempt, extra: extra(project, projectRead) };
178
+ return {
179
+ configFile: projectRead.configFile,
180
+ configured,
181
+ exempt,
182
+ extra: extra(project, projectRead),
183
+ };
173
184
  });
174
- const matrix = matrixOf(projects.map((one) => one.configured));
175
- const exempt = exemptionsOf(projects.flatMap((one) => one.exempt));
176
- const swept = { ...input, exempt };
177
185
  return {
178
- swept: { matrix, sweep: sweepMatrix(matrix, swept), retained: retainedIn(matrix, swept) },
179
- exempt,
186
+ matrix: matrixOf(projects.map((one) => one.configured)),
187
+ exempt: exemptionsOf(projects.flatMap((one) => one.exempt)),
180
188
  projects,
181
189
  };
182
190
  }
191
+ /** The sweep of a run read once, under one set of marks. */
192
+ function sweptOf(read, input) {
193
+ const swept = { ...input, exempt: read.exempt };
194
+ return {
195
+ matrix: read.matrix,
196
+ sweep: sweepMatrix(read.matrix, swept),
197
+ retained: retainedIn(read.matrix, swept),
198
+ };
199
+ }
183
200
  /**
184
201
  * Sweeps the run the scope and the configuration describe. Each project's references
185
202
  * and exemptions are read against its own inventory while its view is open; the
@@ -191,24 +208,29 @@ function sweepRun(engine, host, scope, config, input, detectors, extra) {
191
208
  * project is swept.
192
209
  */
193
210
  export function runSweep(engine, host, scope, config, input, detectors = DETECTORS) {
194
- return sweepRun(engine, host, scope, config, input, detectors, () => undefined).swept;
211
+ return sweptOf(readRun(engine, host, scope, config, input.mode, detectors, () => undefined), input);
195
212
  }
196
- /**
197
- * What every emitter reads of the run the scope and the configuration describe: the
198
- * run's sweep, and the facts beside it each kind family reads, every per-project one
199
- * read in the same session as the sweep. The run loads no consumer and reads no
200
- * declared cross-language edge, so its boundary names neither.
201
- */
202
- export function runEmitterInput(engine, host, scope, config, input, detectors = DETECTORS) {
213
+ /** Reads a run's projects with every per-project fact an emitter reads. */
214
+ function readEmitterRun(engine, host, scope, config, mode, detectors) {
203
215
  const targetRoot = scope.target.path;
204
- const run = sweepRun(engine, host, scope, config, input, detectors, (project, read) => ({
216
+ return readRun(engine, host, scope, config, mode, detectors, (project, read) => ({
205
217
  deprecated: deprecatedDeclarations(project, read.held),
206
218
  accessors: accessorsOf(project, read.held),
207
219
  needs: projectNeeds(project, read.held, host),
208
220
  implementations: implementations(project, read.held),
209
221
  heldByInclusion: heldByInclusion(project, targetRoot),
222
+ rooted: read.rooted,
223
+ directives: inlineDirectives(project, read.held, targetRoot),
210
224
  }));
211
- const { swept, projects } = run;
225
+ }
226
+ /**
227
+ * The input every emitter reads over one sweep of a read run: the facts beside the sweep
228
+ * each kind family reads, every per-project one read in the same session as the sweep.
229
+ */
230
+ function emitterInputOver(host, scope, config, read, input, selfCheck) {
231
+ const targetRoot = scope.target.path;
232
+ const swept = sweptOf(read, input);
233
+ const { projects } = read;
212
234
  return {
213
235
  config,
214
236
  swept,
@@ -216,7 +238,7 @@ export function runEmitterInput(engine, host, scope, config, input, detectors =
216
238
  stores: storesOf(projects.map((one) => ({
217
239
  references: one.configured.references,
218
240
  accessors: one.extra.accessors,
219
- })), run.exempt, input.mode),
241
+ })), read.exempt, input.mode),
220
242
  dependencies: dependenciesOf(host, targetRoot, projects.map((one) => one.extra.needs), swept.sweep.candidates.map((candidate) => candidate.id)),
221
243
  implementations: mergeImplementations(projects.map((one) => one.extra.implementations)),
222
244
  files: {
@@ -226,7 +248,70 @@ export function runEmitterInput(engine, host, scope, config, input, detectors =
226
248
  boundary: {
227
249
  consumers: { declared: scope.consumers.map((consumer) => consumer.id), loaded: [] },
228
250
  encapsulated: readManifest(host, targetRoot).declaresExports,
229
- edges: [],
251
+ edges: readEdgeSides(host, targetRoot),
252
+ },
253
+ selfCheck,
254
+ };
255
+ }
256
+ /**
257
+ * What every emitter reads of the run the scope and the configuration describe, swept
258
+ * under the caller's marks: the run's sweep, the facts beside it each kind family reads,
259
+ * 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.
262
+ */
263
+ export function runEmitterInput(engine, host, scope, config, input, detectors = DETECTORS) {
264
+ const read = readEmitterRun(engine, host, scope, config, input.mode, detectors);
265
+ return emitterInputOver(host, scope, config, read, input, NO_SELF_CHECK);
266
+ }
267
+ /**
268
+ * Reads the run the scope and the configuration describe for its findings. The inline
269
+ * directives, the ignore file's entries and the baseline's rows are read and bound before
270
+ * any sweep, and the run is swept once under every bound record's mark and once under
271
+ * none, so a record is decided against the finding its code would have produced. The
272
+ * self-check family reports the refused suppressions and the roots that named nothing.
273
+ * Beside the inputs it answers the projects and test-file rules a report names.
274
+ */
275
+ function runAnalysisInputs(engine, host, scope, config, provenance, mode, detectors) {
276
+ const targetRoot = scope.target.path;
277
+ const read = readEmitterRun(engine, host, scope, config, mode, detectors);
278
+ const symbols = read.matrix.union.symbols;
279
+ const documents = [
280
+ inlineSuppressions(read.projects.map((one) => one.extra.directives)),
281
+ readIgnoreFile(host, targetRoot, symbols),
282
+ readBaseline(host, targetRoot, symbols),
283
+ ];
284
+ const suppressions = {
285
+ records: documents.flatMap((one) => one.records),
286
+ refusals: documents.flatMap((one) => one.refusals),
287
+ };
288
+ const facts = {
289
+ refusals: suppressions.refusals,
290
+ unmatchedRoots: unmatchedEverywhere(config.rootPatterns, read.projects.map((one) => one.extra.rooted)),
291
+ rootsDocument: documentOf(host, provenance, ROOTS_SETTING, targetRoot),
292
+ };
293
+ const marked = [...new Set(suppressions.records.map((record) => record.bound))].filter((id) => id !== "");
294
+ return {
295
+ inputs: {
296
+ marked: emitterInputOver(host, scope, config, read, { marked, mode }, facts),
297
+ unmarked: emitterInputOver(host, scope, config, read, { marked: [], mode }, facts),
298
+ suppressions,
299
+ },
300
+ run: {
301
+ projects: read.projects.map((one) => ({
302
+ id: one.configured.configuration,
303
+ configFile: one.configFile,
304
+ })),
305
+ testFileRules: testFileRulesOf(config.ts.testFiles, read.projects.flatMap((one) => one.configured.testFiles)),
230
306
  },
231
307
  };
232
308
  }
309
+ /**
310
+ * The analysis of the run the scope and the configuration describe: the run read with its
311
+ * suppressions bound, every family's findings with the suppressions applied and the
312
+ * declared edges evaluated, and the projects and test-file rules the report names.
313
+ */
314
+ export function runAnalysis(engine, host, scope, config, provenance, mode, detectors = DETECTORS) {
315
+ const { inputs, run } = runAnalysisInputs(engine, host, scope, config, provenance, mode, detectors);
316
+ return { result: findingsPass(inputs), run };
317
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * What this analyzer states about its own conformance: the corpus version it is written
3
+ * against and its recorded result over that corpus, which `describe` and every report name.
4
+ */
5
+ /** The version of the conformance corpus this analyzer is written against. */
6
+ const CORPUS_VERSION = "1.9.0";
7
+ /** The SHA-256 of zero bytes, which is the digest of a results document no run wrote. */
8
+ const EMPTY_DIGEST = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
9
+ /**
10
+ * The conformance run this analyzer records, or undefined where it records none. A
11
+ * description states exactly this, so a reader that requires a pass refuses an analyzer
12
+ * that has not answered the corpus.
13
+ */
14
+ export const RECORDED_CONFORMANCE = undefined;
15
+ /**
16
+ * The conformance a report states. A report requires the block, so an analyzer that
17
+ * records no run states a failed one over the results document no run wrote, which a merge
18
+ * refuses as it refuses any result other than a pass.
19
+ */
20
+ export function reportedConformance(recorded) {
21
+ return recorded ?? { corpusVersion: CORPUS_VERSION, result: "fail", digest: EMPTY_DIGEST };
22
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Declared cross-language edges: the sides of the target's edges document that name a
3
+ * symbol of this language, and the evaluation the report publishes for each. This analyzer
4
+ * never resolves the paired symbol: the other language's analyzer decides whether the
5
+ * pairing is a use, and the merge reads the two evaluations together.
6
+ */
7
+ import { ConfigError } from "./config.js";
8
+ import { positionAt, walkDocument } from "./json-document.js";
9
+ import { joinPath } from "./paths.js";
10
+ import { positionKey } from "./position.js";
11
+ /** The name and the location the Contract fixes for the edges document. */
12
+ export const EDGES_FILE = "deadset-edges.json";
13
+ /** The language prefix of every reference this analyzer evaluates. */
14
+ const OWN_PREFIX = "ts://";
15
+ /** The closed key list of the document and of one edge. */
16
+ const DOCUMENT_KEYS = new Set(["description", "edges"]);
17
+ const EDGE_KEYS = new Set(["id", "because", "provides", "used_by"]);
18
+ /** An edge identifier: solidus-separated segments of ASCII letters, digits, `_`, `.` and `-`. */
19
+ const ID_FORM = /^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/u;
20
+ /** A reference naming one symbol of some language: `<language>://<scope>#<fragment>`. */
21
+ const REFERENCE_FORM = /^[a-z][a-z0-9]*:\/\/[^ \t\r\n#]+#[^ \t\r\n]*$/u;
22
+ const DOCUMENT_WANT = "an object carrying the edges array, and an optional description";
23
+ const EDGE_WANT = "an edge naming id, provides and used_by, each a string, and an optional because";
24
+ const ID_WANT = "one or more solidus-separated segments of ASCII letters, digits, underscore, full stop and hyphen";
25
+ const REFERENCE_WANT = "a stable symbol reference naming one symbol exactly, <language>://<scope>#<fragment>";
26
+ /** An edges document the Contract refuses: a malformed input, answered with the usage code. */
27
+ export class EdgesError extends ConfigError {
28
+ constructor(at, text, want) {
29
+ super("malformed", EDGES_FILE, `${at}: ${text}: want ${want}`);
30
+ this.name = "EdgesError";
31
+ }
32
+ }
33
+ /**
34
+ * Whether a reference carries a wildcard outside a quoted or a computed component, which
35
+ * would name a set of symbols rather than one.
36
+ */
37
+ function carriesWildcard(ref) {
38
+ let quoted = false;
39
+ let computed = false;
40
+ let escaped = false;
41
+ for (const char of ref) {
42
+ if (escaped) {
43
+ escaped = false;
44
+ }
45
+ else if (quoted && char === "\\") {
46
+ escaped = true;
47
+ }
48
+ else if (quoted) {
49
+ quoted = char !== "'";
50
+ }
51
+ else if (computed) {
52
+ computed = char !== "]";
53
+ }
54
+ else if (char === "'") {
55
+ quoted = true;
56
+ }
57
+ else if (char === "[") {
58
+ computed = true;
59
+ }
60
+ else if (char === "*") {
61
+ return true;
62
+ }
63
+ }
64
+ return false;
65
+ }
66
+ function isObject(value) {
67
+ return typeof value === "object" && value !== null && !Array.isArray(value);
68
+ }
69
+ /** One edge, checked: the closed key list, the value types and the value forms. */
70
+ function readEdge(value, at) {
71
+ const refuse = (text, want) => {
72
+ throw new EdgesError(at, text, want);
73
+ };
74
+ if (!isObject(value)) {
75
+ return refuse(JSON.stringify(value), EDGE_WANT);
76
+ }
77
+ for (const [key, held] of Object.entries(value)) {
78
+ if (!EDGE_KEYS.has(key) || typeof held !== "string") {
79
+ refuse(`the member ${JSON.stringify(key)} holding ${JSON.stringify(held)}`, EDGE_WANT);
80
+ }
81
+ }
82
+ const edge = value;
83
+ const id = edge["id"] ?? refuse("an edge naming no id", EDGE_WANT);
84
+ const provides = edge["provides"] ?? refuse("an edge naming no provides side", EDGE_WANT);
85
+ const usedBy = edge["used_by"] ?? refuse("an edge naming no used_by side", EDGE_WANT);
86
+ if (!ID_FORM.test(id)) {
87
+ refuse(JSON.stringify(id), ID_WANT);
88
+ }
89
+ for (const ref of [provides, usedBy]) {
90
+ if (!REFERENCE_FORM.test(ref) || carriesWildcard(ref)) {
91
+ refuse(JSON.stringify(ref), REFERENCE_WANT);
92
+ }
93
+ }
94
+ return { id, provides, usedBy };
95
+ }
96
+ /**
97
+ * Every side of a declared edge that names a symbol of this language, in document order,
98
+ * `provides` before `used_by` within an edge. An absent document declares no edge. The
99
+ * document is strict JSON with a closed key list at both levels, and a defect in it is an
100
+ * {@link EdgesError}.
101
+ */
102
+ export function readEdgeSides(host, targetRoot) {
103
+ const file = joinPath(targetRoot, EDGES_FILE);
104
+ if (host.kindOf(file) === "absent") {
105
+ return [];
106
+ }
107
+ const text = host.readFile(file);
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(text);
111
+ }
112
+ catch (error) {
113
+ throw new EdgesError(EDGES_FILE, error instanceof Error ? error.message : String(error), DOCUMENT_WANT);
114
+ }
115
+ if (!isObject(parsed)) {
116
+ throw new EdgesError(EDGES_FILE, JSON.stringify(parsed), DOCUMENT_WANT);
117
+ }
118
+ const walk = walkDocument(text, "edges");
119
+ if (walk.twice !== undefined) {
120
+ throw new EdgesError(positionKey(positionAt(text, EDGES_FILE, walk.twice.offset)), `the member ${JSON.stringify(walk.twice.name)} is written twice, so neither value is chosen`, DOCUMENT_WANT);
121
+ }
122
+ const unknown = Object.keys(parsed).find((key) => !DOCUMENT_KEYS.has(key));
123
+ const description = parsed["description"];
124
+ const edges = parsed["edges"];
125
+ if (unknown !== undefined ||
126
+ (description !== undefined && typeof description !== "string") ||
127
+ !Array.isArray(edges)) {
128
+ throw new EdgesError(EDGES_FILE, unknown === undefined ? EDGES_FILE : `the member ${JSON.stringify(unknown)}`, DOCUMENT_WANT);
129
+ }
130
+ const sides = [];
131
+ edges.forEach((value, index) => {
132
+ const offset = walk.opened[index];
133
+ const at = offset === undefined ? EDGES_FILE : positionKey(positionAt(text, EDGES_FILE, offset));
134
+ const edge = readEdge(value, at);
135
+ const named = [
136
+ ["provides", edge.provides],
137
+ ["used_by", edge.usedBy],
138
+ ];
139
+ for (const [side, symbol] of named) {
140
+ if (symbol.startsWith(OWN_PREFIX)) {
141
+ sides.push({ edge: edge.id, side, symbol });
142
+ }
143
+ }
144
+ });
145
+ return sides;
146
+ }
147
+ /** Two strings ordered bytewise. */
148
+ function compare(a, b) {
149
+ if (a === b) {
150
+ return 0;
151
+ }
152
+ return a < b ? -1 : 1;
153
+ }
154
+ /**
155
+ * Evaluates every declared side of this language over the run's findings. A side naming a
156
+ * symbol the run enumerates nothing under is `absent`; one naming a symbol the run holds a
157
+ * finding about is `dead` and carries that finding, pending until a merge reads the other
158
+ * side and reported nowhere else, a narrowing finding included; any other is `live`.
159
+ */
160
+ export function evaluateEdges(findings, sides, symbols) {
161
+ if (sides.length === 0) {
162
+ return { findings, evaluations: [] };
163
+ }
164
+ const byRef = new Map();
165
+ for (const symbol of symbols) {
166
+ if (symbol.kind !== "file" && !byRef.has(symbol.ref)) {
167
+ byRef.set(symbol.ref, symbol.id);
168
+ }
169
+ }
170
+ const held = new Map();
171
+ findings.forEach((finding, at) => {
172
+ held.set(positionKey(finding.position), at);
173
+ });
174
+ const pending = new Set();
175
+ const evaluations = sides.map((side) => {
176
+ const id = byRef.get(side.symbol);
177
+ if (id === undefined) {
178
+ return { ...side, state: "absent" };
179
+ }
180
+ const at = held.get(id);
181
+ const finding = at === undefined ? undefined : findings[at];
182
+ if (at === undefined || finding === undefined) {
183
+ return { ...side, state: "live" };
184
+ }
185
+ pending.add(at);
186
+ return { ...side, state: "dead", finding };
187
+ });
188
+ evaluations.sort((a, b) => compare(a.edge, b.edge) || compare(a.side, b.side));
189
+ return { findings: findings.filter((_finding, at) => !pending.has(at)), evaluations };
190
+ }
@@ -7,6 +7,7 @@ import { componentId } from "../components.js";
7
7
  import { OUTSIDE } from "../graph.js";
8
8
  import { KINDS } from "../kinds.js";
9
9
  import { FAMILY_KEY_LENGTH } from "../resolve.js";
10
+ import { isRowSubject } from "../suppress.js";
10
11
  /** The classes ranked by the claim each makes, the strongest highest. */
11
12
  const RANK = { certain: 3, probable: 2, possible: 1 };
12
13
  /** The weaker of two classes, which is a class capped by a ceiling. */
@@ -59,7 +60,8 @@ function findingComponents(swept) {
59
60
  * candidate's relation, counts, configurations and component; a live one carries no
60
61
  * relation and holds where it is declared; a file or a manifest row is `certain` and
61
62
  * holds in every configuration. A subject in no dead component gets one of its own,
62
- * numbered past the computed ones in finding order, so no two families mint one.
63
+ * 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.
63
65
  */
64
66
  export function completed(input, findings) {
65
67
  const { swept } = input;
@@ -74,7 +76,11 @@ export function completed(input, findings) {
74
76
  if (row === undefined) {
75
77
  throw new Error(`${finding.code} names no live row of the issue-kind vocabulary`);
76
78
  }
77
- const at = positions.get(finding.symbol.ref) ?? OUTSIDE;
79
+ // A row's reference may spell a declaration's, as an ignore entry naming one does, and
80
+ // a row is still never the declaration.
81
+ const at = isRowSubject(finding.symbol.kind)
82
+ ? OUTSIDE
83
+ : (positions.get(finding.symbol.ref) ?? OUTSIDE);
78
84
  const symbol = union.symbols[at];
79
85
  const judged = union.subject[at] === true ? symbol : undefined;
80
86
  const candidate = judged === undefined ? undefined : candidates.get(judged.id);
@@ -89,7 +95,7 @@ export function completed(input, findings) {
89
95
  id: componentId(minted),
90
96
  root: true,
91
97
  symbolCount: 1,
92
- deletableLines: finding.symbol.sizeLines,
98
+ deletableLines: 0,
93
99
  };
94
100
  }
95
101
  const counts = candidate === undefined
@@ -119,17 +125,38 @@ export function completed(input, findings) {
119
125
  };
120
126
  });
121
127
  }
128
+ /** Whether the dials withhold a finding by its own severity or confidence. */
129
+ function dialed(finding, minConfidence) {
130
+ return finding.severity === "allow" || RANK[finding.confidence] < RANK[minConfidence];
131
+ }
122
132
  /**
123
- * The findings no dial withholds. A finding at `allow` or below the minimum confidence
124
- * is withheld, and so is every finding of the component of a root the dials withhold,
125
- * because a symbol that falls with a root is dead only through it and its deletion
126
- * does not compile while the root stays. A withheld finding that is no root withholds
127
- * itself alone.
133
+ * The configuration's dials over one run's completed findings. A finding at `allow` or
134
+ * below the minimum confidence is withheld, and so is every finding of the component of a
135
+ * root the dials withhold, because a symbol that falls with a root is dead only through it
136
+ * and its deletion does not compile while the root stays. A withheld finding that is no
137
+ * root withholds itself alone. A code is withheld from a declaration when the finding it
138
+ * would report there, completed, is withheld by its own severity or confidence.
128
139
  */
129
- export function reportable(findings, minConfidence) {
130
- const dialed = (finding) => finding.severity === "allow" || RANK[finding.confidence] < RANK[minConfidence];
140
+ export function dialsOf(input, findings) {
141
+ const minConfidence = input.config.analysis.minConfidence;
142
+ const classOf = reachabilityClasses(input.swept);
131
143
  const components = new Set(findings
132
- .filter((finding) => finding.component.root && dialed(finding))
144
+ .filter((finding) => finding.component.root && dialed(finding, minConfidence))
133
145
  .map((finding) => finding.component.id));
134
- return findings.filter((finding) => !dialed(finding) && !components.has(finding.component.id));
146
+ return {
147
+ withholdsCode: (code, id) => {
148
+ const row = KINDS.get(code);
149
+ return (row !== undefined &&
150
+ dialed({
151
+ severity: severityOf(input.config, code, row.defaultSeverity),
152
+ confidence: capped(classOf(id), row.maxClass),
153
+ }, minConfidence));
154
+ },
155
+ withholds: (finding) => dialed(finding, minConfidence) || components.has(finding.component.id),
156
+ };
157
+ }
158
+ /** The findings of one run's completed findings that no dial withholds, in the order given. */
159
+ export function reportable(input, findings) {
160
+ const dials = dialsOf(input, findings);
161
+ return findings.filter((finding) => !dials.withholds(finding));
135
162
  }