@cplieger/deadset-ts 3.12.1 → 3.13.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 +2 -2
- package/dist/src/analysis.js +100 -27
- package/dist/src/components.js +4 -2
- package/dist/src/configured-declarations.js +36 -0
- package/dist/src/conformance-record.js +2 -2
- package/dist/src/edges.js +4 -1
- package/dist/src/findings/completion.js +37 -8
- package/dist/src/findings/emitters.js +5 -5
- package/dist/src/findings/intra-function.js +91 -2
- package/dist/src/findings/self-check.js +30 -7
- package/dist/src/findings/unused-declarations.js +15 -3
- package/dist/src/findings-pass.js +18 -11
- package/dist/src/intra-function-parts.js +643 -0
- package/dist/src/kinds.js +337 -35
- package/dist/src/reflective-lookup.js +35 -2
- package/dist/src/report.js +11 -2
- package/dist/src/reporters/sarif.js +20 -15
- package/dist/src/reporters/template.js +64 -5
- package/dist/src/serialization-contract.js +41 -22
- package/dist/src/signatures.js +32 -0
- package/dist/src/suppress-file.js +27 -6
- package/dist/src/suppress-inline.js +8 -1
- package/dist/src/suppress.js +27 -0
- package/dist/src/value-reach.js +33 -9
- package/dist/src/verbs/analyze.js +19 -11
- package/dist/src/verbs/describe.js +12 -8
- package/dist/src/version.js +1 -1
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/analysis.ts +167 -48
- package/src/components.ts +4 -2
- package/src/configured-declarations.ts +57 -1
- package/src/conformance-record.ts +2 -2
- package/src/edges.ts +4 -1
- package/src/finding.ts +5 -1
- package/src/findings/completion.ts +46 -10
- package/src/findings/emitter.ts +3 -0
- package/src/findings/emitters.ts +9 -5
- package/src/findings/intra-function.ts +106 -3
- package/src/findings/self-check.ts +48 -7
- package/src/findings/unused-declarations.ts +18 -4
- package/src/findings-pass.ts +26 -11
- package/src/intra-function-parts.ts +871 -0
- package/src/kinds.ts +355 -41
- package/src/reflective-lookup.ts +42 -2
- package/src/report.ts +18 -2
- package/src/reporters/sarif.ts +24 -16
- package/src/reporters/template.ts +69 -6
- package/src/serialization-contract.ts +47 -30
- package/src/signatures.ts +55 -0
- package/src/suppress-file.ts +34 -6
- package/src/suppress-inline.ts +8 -1
- package/src/suppress.ts +31 -0
- package/src/value-reach.ts +53 -12
- package/src/verbs/analyze.ts +26 -11
- package/src/verbs/describe.ts +13 -9
- package/src/version.ts +1 -1
- package/dist/src/reporters/rules.js +0 -178
- 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,
|
|
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
|
|
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, 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
|
|
package/dist/src/analysis.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
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";
|
|
@@ -16,12 +17,13 @@ import { readEdgeSides } from "./edges.js";
|
|
|
16
17
|
import { enumGroup } from "./enum-group.js";
|
|
17
18
|
import { computeExemptions, disabledClasses, exemptionsOf, 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 {
|
|
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. */
|
|
@@ -202,7 +205,7 @@ function readRun(engine, host, scope, config, mode, detectors, extra) {
|
|
|
202
205
|
configFile: projectRead.configFile,
|
|
203
206
|
configured,
|
|
204
207
|
exempt,
|
|
205
|
-
extra: extra(project, projectRead),
|
|
208
|
+
extra: extra(project, projectRead, resolved.references),
|
|
206
209
|
};
|
|
207
210
|
});
|
|
208
211
|
const consumed = read.consumers.references;
|
|
@@ -247,7 +250,7 @@ export function runSweep(engine, host, scope, config, input, detectors = DETECTO
|
|
|
247
250
|
/** Reads a run's projects with every per-project fact an emitter reads. */
|
|
248
251
|
function readEmitterRun(engine, host, scope, config, mode, detectors) {
|
|
249
252
|
const targetRoot = scope.target.path;
|
|
250
|
-
return readRun(engine, host, scope, config, mode, detectors, (project, read) => ({
|
|
253
|
+
return readRun(engine, host, scope, config, mode, detectors, (project, read, references) => ({
|
|
251
254
|
deprecated: deprecatedDeclarations(project, read.held),
|
|
252
255
|
accessors: accessorsOf(project, read.held),
|
|
253
256
|
needs: projectNeeds(project, read.held, host),
|
|
@@ -255,6 +258,8 @@ function readEmitterRun(engine, host, scope, config, mode, detectors) {
|
|
|
255
258
|
heldByInclusion: heldByInclusion(project, targetRoot),
|
|
256
259
|
rooted: read.rooted,
|
|
257
260
|
directives: inlineDirectives(project, read.held, targetRoot),
|
|
261
|
+
declarationsNamed: declarationsNamed(project, read.held, configuredDeclarations(config)),
|
|
262
|
+
parts: projectParts(project, read.held, references, targetRoot),
|
|
258
263
|
}));
|
|
259
264
|
}
|
|
260
265
|
/**
|
|
@@ -288,6 +293,7 @@ function emitterInputOver(host, scope, config, read, input, selfCheck) {
|
|
|
288
293
|
edges: readEdgeSides(host, targetRoot),
|
|
289
294
|
},
|
|
290
295
|
selfCheck,
|
|
296
|
+
intraFunction: intraFunctionFacts(projects.map((one) => one.extra.parts), projects.flatMap((one) => one.configured.references), new Set(read.exempt.map((one) => one.id))),
|
|
291
297
|
};
|
|
292
298
|
}
|
|
293
299
|
/**
|
|
@@ -303,37 +309,22 @@ export function runEmitterInput(engine, host, scope, config, input, detectors =
|
|
|
303
309
|
}
|
|
304
310
|
/**
|
|
305
311
|
* 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
|
|
307
|
-
*
|
|
308
|
-
*
|
|
312
|
+
* directives, the ignore file's entries and the baseline's rows are bound before any
|
|
313
|
+
* sweep, and the run is swept once under every bound record's mark and once under none,
|
|
314
|
+
* so a record is decided against the finding its code would have produced. The
|
|
309
315
|
* self-check family reports the refused suppressions and the roots that named nothing.
|
|
310
316
|
* Beside the inputs it answers the projects and test-file rules a report names.
|
|
311
317
|
*/
|
|
312
|
-
function
|
|
318
|
+
function readAnalysis(engine, host, scope, config, provenance, mode, detectors) {
|
|
313
319
|
const targetRoot = scope.target.path;
|
|
314
320
|
const read = readEmitterRun(engine, host, scope, config, mode, detectors);
|
|
315
321
|
const symbols = read.matrix.union.symbols;
|
|
316
|
-
const
|
|
322
|
+
const fixed = [
|
|
317
323
|
inlineSuppressions(read.projects.map((one) => one.extra.directives)),
|
|
318
324
|
readIgnoreFile(host, targetRoot, symbols),
|
|
319
|
-
readBaseline(host, targetRoot, symbols),
|
|
320
325
|
];
|
|
321
|
-
const
|
|
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 !== "");
|
|
326
|
+
const inputsUnder = (baseline) => inputsOver(host, scope, config, provenance, mode, read, [...fixed, baseline]);
|
|
331
327
|
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
328
|
run: {
|
|
338
329
|
projects: read.projects.map((one) => ({
|
|
339
330
|
id: one.configured.configuration,
|
|
@@ -343,6 +334,38 @@ function runAnalysisInputs(engine, host, scope, config, provenance, mode, detect
|
|
|
343
334
|
consumers: read.consumers,
|
|
344
335
|
},
|
|
345
336
|
configured: read.projects.map((one) => one.configured),
|
|
337
|
+
symbols,
|
|
338
|
+
inputsUnder,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/** The inputs of one read run under the suppression documents given, in reading order. */
|
|
342
|
+
function inputsOver(host, scope, config, provenance, mode, read, documents) {
|
|
343
|
+
const targetRoot = scope.target.path;
|
|
344
|
+
const suppressions = {
|
|
345
|
+
records: documents.flatMap((one) => one.records),
|
|
346
|
+
refusals: documents.flatMap((one) => one.refusals),
|
|
347
|
+
};
|
|
348
|
+
const facts = {
|
|
349
|
+
refusals: suppressions.refusals,
|
|
350
|
+
unmatchedRoots: unmatchedEverywhere(config.rootPatterns, read.projects.map((one) => one.extra.rooted)),
|
|
351
|
+
rootsDocument: documentOf(host, provenance, ROOTS_SETTING, targetRoot),
|
|
352
|
+
unmatchedDeclarations: configuredDeclarations(config)
|
|
353
|
+
.filter((_, at) => !read.projects.some((one) => one.extra.declarationsNamed[at] === true))
|
|
354
|
+
.map((one) => ({
|
|
355
|
+
ref: configuredRef(one.entry),
|
|
356
|
+
key: one.key,
|
|
357
|
+
document: documentOf(host, provenance, one.key, targetRoot),
|
|
358
|
+
})),
|
|
359
|
+
};
|
|
360
|
+
const marked = [
|
|
361
|
+
...new Set(suppressions.records
|
|
362
|
+
.filter((record) => !isPartKind(record.code))
|
|
363
|
+
.map((record) => record.bound)),
|
|
364
|
+
].filter((id) => id !== "");
|
|
365
|
+
return {
|
|
366
|
+
marked: emitterInputOver(host, scope, config, read, { marked, mode }, facts),
|
|
367
|
+
unmarked: emitterInputOver(host, scope, config, read, { marked: [], mode }, facts),
|
|
368
|
+
suppressions,
|
|
346
369
|
};
|
|
347
370
|
}
|
|
348
371
|
/**
|
|
@@ -352,6 +375,56 @@ function runAnalysisInputs(engine, host, scope, config, provenance, mode, detect
|
|
|
352
375
|
* an explanation reads.
|
|
353
376
|
*/
|
|
354
377
|
export function runAnalysis(engine, host, scope, config, provenance, mode, detectors = DETECTORS) {
|
|
355
|
-
const
|
|
356
|
-
|
|
378
|
+
const reader = readAnalysis(engine, host, scope, config, provenance, mode, detectors);
|
|
379
|
+
const inputs = reader.inputsUnder(readBaseline(host, scope.target.path, reader.symbols));
|
|
380
|
+
return analysisOf(reader, inputs);
|
|
381
|
+
}
|
|
382
|
+
function analysisOf(reader, inputs) {
|
|
383
|
+
return {
|
|
384
|
+
result: findingsPass(inputs),
|
|
385
|
+
run: reader.run,
|
|
386
|
+
swept: inputs.marked.swept,
|
|
387
|
+
configured: reader.configured,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* The fixpoint a baseline write records. The first round reads no baseline; each later
|
|
392
|
+
* round reads the rows recorded so far, records a row for each recordable finding no row
|
|
393
|
+
* already names, and removes every row it reports stale. A removed row is not recorded
|
|
394
|
+
* again, so the write ends.
|
|
395
|
+
*/
|
|
396
|
+
export function runBaselineWrite(engine, host, scope, config, provenance, mode, reason, detectors = DETECTORS) {
|
|
397
|
+
const reader = readAnalysis(engine, host, scope, config, provenance, mode, detectors);
|
|
398
|
+
const first = analysisOf(reader, reader.inputsUnder({ records: [], refusals: [] }));
|
|
399
|
+
let rows = [];
|
|
400
|
+
const removed = new Set();
|
|
401
|
+
let result = first.result;
|
|
402
|
+
let stale = new Set();
|
|
403
|
+
for (;;) {
|
|
404
|
+
const held = new Set(rows.map(rowKey));
|
|
405
|
+
const added = recordedFindings(result.findings).filter((row) => !held.has(rowKey(row)) && !removed.has(rowKey(row)));
|
|
406
|
+
if (added.length === 0 && stale.size === 0) {
|
|
407
|
+
return { first, rows };
|
|
408
|
+
}
|
|
409
|
+
for (const key of stale) {
|
|
410
|
+
removed.add(key);
|
|
411
|
+
}
|
|
412
|
+
rows = [...rows.filter((row) => !stale.has(rowKey(row))), ...added];
|
|
413
|
+
const inputs = reader.inputsUnder(bindRows(rows, reason, reader.symbols));
|
|
414
|
+
result = findingsPass(inputs);
|
|
415
|
+
stale = staleRows(inputs.suppressions.records, result.ledger.verdicts);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/** The baseline rows a round reports stale: every record a row binds to matched nothing. */
|
|
419
|
+
function staleRows(records, verdicts) {
|
|
420
|
+
const live = new Set();
|
|
421
|
+
const stale = new Set();
|
|
422
|
+
records.forEach((record, at) => {
|
|
423
|
+
if (record.mechanism !== "baseline") {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const key = rowKey(record);
|
|
427
|
+
(verdicts[at] === "stale" ? stale : live).add(key);
|
|
428
|
+
});
|
|
429
|
+
return new Set([...stale].filter((key) => !live.has(key)));
|
|
357
430
|
}
|
package/dist/src/components.js
CHANGED
|
@@ -89,7 +89,8 @@ function linesSpanned(symbols) {
|
|
|
89
89
|
return total;
|
|
90
90
|
}
|
|
91
91
|
/**
|
|
92
|
-
* The dead declarations by their place in the subgraph,
|
|
92
|
+
* The dead declarations by their place in the subgraph, a test file's declaration only
|
|
93
|
+
* where it is a test of dead code, and the edges between them: each
|
|
93
94
|
* reference one makes to another, an edge each way between a dead member and its dead
|
|
94
95
|
* container (one direction alone leaves the member a component the container reaches),
|
|
95
96
|
* and an edge back from each production target of an admitted test, which puts the test in
|
|
@@ -98,7 +99,8 @@ function linesSpanned(symbols) {
|
|
|
98
99
|
function deadSubgraph(graph, dead, testOfDeadCode) {
|
|
99
100
|
const at = [];
|
|
100
101
|
const position = graph.symbols.map((_symbol, index) => {
|
|
101
|
-
|
|
102
|
+
// A test file's declaration belongs to a component only as a test of dead code.
|
|
103
|
+
if (dead[index] !== true || (graph.test[index] === true && testOfDeadCode[index] !== true)) {
|
|
102
104
|
return UNVISITED;
|
|
103
105
|
}
|
|
104
106
|
at.push(index);
|
|
@@ -230,3 +230,39 @@ export function spelledEntry(entry, held) {
|
|
|
230
230
|
return entry.global;
|
|
231
231
|
}
|
|
232
232
|
}
|
|
233
|
+
/** The key under which a lifecycle contract's declarations are configured. */
|
|
234
|
+
const LIFECYCLE_KEY = "ts.lifecycle_contracts";
|
|
235
|
+
/**
|
|
236
|
+
* Every entry of the three keys that name declarations, in the configuration's order:
|
|
237
|
+
* the injection registrations, each lifecycle contract's components and then its bases,
|
|
238
|
+
* and the serializers.
|
|
239
|
+
*/
|
|
240
|
+
export function configuredDeclarations(config) {
|
|
241
|
+
return [
|
|
242
|
+
...config.ts.injectionRegistrations.map((entry) => ({
|
|
243
|
+
key: "ts.injection_registrations",
|
|
244
|
+
entry,
|
|
245
|
+
})),
|
|
246
|
+
...config.ts.lifecycleContracts.flatMap((contract) => [...contract.components, ...contract.bases].map((entry) => ({ key: LIFECYCLE_KEY, entry }))),
|
|
247
|
+
...config.ts.serializers.map((entry) => ({ key: "ts.serializers", entry })),
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
/** Whether each configured entry names a declaration in one project, in the entries' order. */
|
|
251
|
+
export function declarationsNamed(project, held, configured) {
|
|
252
|
+
return resolveEntries(project, held, configured.map((one) => one.entry)).map(namesADeclaration);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The reference a finding about a configured entry names it by, the entry as the
|
|
256
|
+
* configuration spells it: a symbol entry's reference, a module entry's module, a number
|
|
257
|
+
* sign and its name, and a global entry's path after a number sign.
|
|
258
|
+
*/
|
|
259
|
+
export function configuredRef(entry) {
|
|
260
|
+
switch (entry.shape) {
|
|
261
|
+
case "symbol":
|
|
262
|
+
return entry.symbol;
|
|
263
|
+
case "module":
|
|
264
|
+
return `${entry.module}#${entry.name}`;
|
|
265
|
+
case "global":
|
|
266
|
+
return `#${entry.global}`;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// Regenerate it with `UPDATE_GOLDEN=1 npx vitest --run src/conformance.corpus.test.ts`.
|
|
3
3
|
export const RECORD = {
|
|
4
4
|
conformance: {
|
|
5
|
-
corpusVersion: "
|
|
5
|
+
corpusVersion: "2.0.0",
|
|
6
6
|
result: "pass",
|
|
7
|
-
digest: "sha256:
|
|
7
|
+
digest: "sha256:1274315595d7b944d22eb0126081e24cf293c9fe53a236f4d263c714644f32ee",
|
|
8
8
|
},
|
|
9
9
|
gaps: [
|
|
10
10
|
{
|
package/dist/src/edges.js
CHANGED
|
@@ -8,6 +8,7 @@ import { ConfigError } from "./config.js";
|
|
|
8
8
|
import { positionAt, walkDocument } from "./json-document.js";
|
|
9
9
|
import { joinPath } from "./paths.js";
|
|
10
10
|
import { positionKey } from "./position.js";
|
|
11
|
+
import { isPartSubject } from "./suppress.js";
|
|
11
12
|
/** The name and the location the Contract fixes for the edges document. */
|
|
12
13
|
export const EDGES_FILE = "deadset-edges.json";
|
|
13
14
|
/** The language prefix of every reference this analyzer evaluates. */
|
|
@@ -169,7 +170,9 @@ export function evaluateEdges(findings, sides, symbols) {
|
|
|
169
170
|
}
|
|
170
171
|
const held = new Map();
|
|
171
172
|
findings.forEach((finding, at) => {
|
|
172
|
-
|
|
173
|
+
if (!isPartSubject(finding.symbol.kind)) {
|
|
174
|
+
held.set(positionKey(finding.position), at);
|
|
175
|
+
}
|
|
173
176
|
});
|
|
174
177
|
const pending = new Set();
|
|
175
178
|
const evaluations = sides.map((side) => {
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
import { componentId } from "../components.js";
|
|
7
7
|
import { OUTSIDE } from "../graph.js";
|
|
8
8
|
import { KINDS } from "../kinds.js";
|
|
9
|
+
import { byPosition } from "../position.js";
|
|
9
10
|
import { FAMILY_KEY_LENGTH } from "../resolve.js";
|
|
10
|
-
import { isRowSubject } from "../suppress.js";
|
|
11
|
+
import { isPartSubject, isRowSubject } from "../suppress.js";
|
|
11
12
|
/** The classes ranked by the claim each makes, the strongest highest. */
|
|
12
13
|
const RANK = { certain: 3, probable: 2, possible: 1 };
|
|
13
14
|
/** The weaker of two classes, which is a class capped by a ceiling. */
|
|
@@ -66,14 +67,35 @@ function reachabilityClasses(input) {
|
|
|
66
67
|
: "probable";
|
|
67
68
|
return (id) => (published.has(id) ? outside : "certain");
|
|
68
69
|
}
|
|
69
|
-
/**
|
|
70
|
-
|
|
70
|
+
/**
|
|
71
|
+
* The component each dead declaration of the run falls in, and none for a live one, with
|
|
72
|
+
* its members listed in the canonical order where the run lists a component in full.
|
|
73
|
+
*/
|
|
74
|
+
function findingComponents(swept, cascade) {
|
|
71
75
|
const held = new Map();
|
|
72
76
|
for (const component of swept.sweep.components) {
|
|
73
77
|
for (const member of component.members) {
|
|
74
78
|
held.set(member, component);
|
|
75
79
|
}
|
|
76
80
|
}
|
|
81
|
+
const symbols = new Map(swept.matrix.union.symbols.map((symbol) => [symbol.id, symbol]));
|
|
82
|
+
const listed = (component) => component.members
|
|
83
|
+
.flatMap((member) => {
|
|
84
|
+
const symbol = symbols.get(member);
|
|
85
|
+
return symbol === undefined
|
|
86
|
+
? []
|
|
87
|
+
: [
|
|
88
|
+
{
|
|
89
|
+
ref: symbol.ref,
|
|
90
|
+
name: symbol.name,
|
|
91
|
+
position: {
|
|
92
|
+
...symbol.position,
|
|
93
|
+
endLine: Math.max(symbol.position.line, symbol.endLine),
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
})
|
|
98
|
+
.sort((a, b) => byPosition(a.position, b.position) || (a.ref === b.ref ? 0 : a.ref < b.ref ? -1 : 1));
|
|
77
99
|
return (id) => {
|
|
78
100
|
const component = held.get(id);
|
|
79
101
|
return component === undefined
|
|
@@ -83,6 +105,7 @@ function findingComponents(swept) {
|
|
|
83
105
|
root: component.roots.includes(id),
|
|
84
106
|
symbolCount: component.members.length,
|
|
85
107
|
deletableLines: component.deletableLines,
|
|
108
|
+
...(cascade === "full" ? { members: listed(component) } : {}),
|
|
86
109
|
};
|
|
87
110
|
};
|
|
88
111
|
}
|
|
@@ -101,7 +124,7 @@ export function completed(input, findings, kinds = KINDS) {
|
|
|
101
124
|
const positions = new Map(union.symbols.map((symbol, at) => [symbol.ref, at]));
|
|
102
125
|
const candidates = new Map(swept.sweep.candidates.map((candidate) => [candidate.id, candidate]));
|
|
103
126
|
const classOf = reachabilityClasses(input);
|
|
104
|
-
const componentOf = findingComponents(swept);
|
|
127
|
+
const componentOf = findingComponents(swept, input.config.reporters.cascade);
|
|
105
128
|
let minted = swept.sweep.components.length;
|
|
106
129
|
return findings.map((finding) => {
|
|
107
130
|
const row = kinds.get(finding.code);
|
|
@@ -113,11 +136,15 @@ export function completed(input, findings, kinds = KINDS) {
|
|
|
113
136
|
const at = isRowSubject(finding.symbol.kind)
|
|
114
137
|
? OUTSIDE
|
|
115
138
|
: (positions.get(finding.symbol.ref) ?? OUTSIDE);
|
|
139
|
+
// A part names the declaration that holds it, and is decided inside it rather than by
|
|
140
|
+
// the declaration's liveness, so it carries none of the declaration's sweep facts.
|
|
141
|
+
const part = isPartSubject(finding.symbol.kind);
|
|
116
142
|
const symbol = union.symbols[at];
|
|
117
|
-
const judged = union.subject[at] === true ? symbol : undefined;
|
|
143
|
+
const judged = !part && union.subject[at] === true ? symbol : undefined;
|
|
118
144
|
const candidate = judged === undefined ? undefined : candidates.get(judged.id);
|
|
119
|
-
const found = symbol === undefined ? undefined : componentOf(symbol.id);
|
|
120
|
-
|
|
145
|
+
const found = symbol === undefined || part ? undefined : componentOf(symbol.id);
|
|
146
|
+
// A test file's declaration is a component member only as a test of dead code.
|
|
147
|
+
if (candidate !== undefined && found === undefined && union.test[at] !== true) {
|
|
121
148
|
throw new Error(`${candidate.id} is judged dead and falls in no dead component`);
|
|
122
149
|
}
|
|
123
150
|
let component = found;
|
|
@@ -148,7 +175,9 @@ export function completed(input, findings, kinds = KINDS) {
|
|
|
148
175
|
component,
|
|
149
176
|
retainedBy: [],
|
|
150
177
|
configurations: candidate?.configurations ??
|
|
151
|
-
(judged === undefined
|
|
178
|
+
(judged === undefined && !part
|
|
179
|
+
? swept.matrix.configurations
|
|
180
|
+
: (swept.matrix.heldIn[at] ?? swept.matrix.configurations)),
|
|
152
181
|
consumersLoaded: input.boundary.consumers.loaded,
|
|
153
182
|
fixability: row.fixability,
|
|
154
183
|
severity: severityOf(input, finding.code, row.defaultSeverity),
|
|
@@ -7,7 +7,7 @@ import { intraFunction } from "./intra-function.js";
|
|
|
7
7
|
import { nonCodeArtifacts } from "./non-code-artifacts.js";
|
|
8
8
|
import { readsAndWrites } from "./reads-and-writes.js";
|
|
9
9
|
import { selfCheck } from "./self-check.js";
|
|
10
|
-
import { unusedDeclarations } from "./unused-declarations.js";
|
|
10
|
+
import { fallenMembers, unusedDeclarations } from "./unused-declarations.js";
|
|
11
11
|
import { visibilityNarrowing } from "./visibility-narrowing.js";
|
|
12
12
|
/** Every kind family this analyzer reports, each by its emitter, in code order. */
|
|
13
13
|
export const EMITTERS = new Map([
|
|
@@ -20,9 +20,9 @@ export const EMITTERS = new Map([
|
|
|
20
20
|
["self-check", selfCheck],
|
|
21
21
|
["intra-function", intraFunction],
|
|
22
22
|
]);
|
|
23
|
-
/** Every family's findings over one sweep, completed by one step. */
|
|
24
|
-
function completedOver(input) {
|
|
25
|
-
return completed(input, [...EMITTERS.values()].flatMap((emit) => emit(input)));
|
|
23
|
+
/** Every family's findings over one sweep, and those of any further emitter, completed by one step. */
|
|
24
|
+
function completedOver(input, ...more) {
|
|
25
|
+
return completed(input, [...EMITTERS.values(), ...more].flatMap((emit) => emit(input)));
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
28
|
* The dials over the findings a record could name, each read against the sweep that
|
|
@@ -47,7 +47,7 @@ function dialsOver(input, found, suppressed, unmarked) {
|
|
|
47
47
|
*/
|
|
48
48
|
export function decidedFindings(input, suppressed = { unmarked: input, records: [] }) {
|
|
49
49
|
const found = completedOver(input);
|
|
50
|
-
const unmarked = suppressed.records.length === 0 ? [] : completedOver(suppressed.unmarked);
|
|
50
|
+
const unmarked = suppressed.records.length === 0 ? [] : completedOver(suppressed.unmarked, fallenMembers);
|
|
51
51
|
const would = [...unmarked, ...found];
|
|
52
52
|
const ledger = ledgerOf(suppressed.records, would, dialsOver(input, found, suppressed, unmarked));
|
|
53
53
|
const kept = reportable(input, found.filter((finding) => !ledger.withheld(finding)));
|
|
@@ -1,2 +1,91 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { KINDS } from "../kinds.js";
|
|
2
|
+
import { UNUSED_PARAMETER, UNUSED_RESULT } from "../intra-function-parts.js";
|
|
3
|
+
/** A list of rules as a sentence names them. */
|
|
4
|
+
function spelled(rules) {
|
|
5
|
+
if (rules.length < 2) {
|
|
6
|
+
return rules.join("");
|
|
7
|
+
}
|
|
8
|
+
return `${rules.slice(0, -1).join(", ")} and ${rules.at(-1) ?? ""}`;
|
|
9
|
+
}
|
|
10
|
+
/** The clause a message ends with: the other rules that report the kind, or that none is known. */
|
|
11
|
+
function overlapClause(overlap) {
|
|
12
|
+
return overlap.length === 1 && overlap[0] === "none known"
|
|
13
|
+
? "no other rule is known to report it"
|
|
14
|
+
: `also reported by ${spelled(overlap)}`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Whether one part is reported. A parameter needs its function's signature free to
|
|
18
|
+
* change; a result needs that, every call in the loaded program to discard it, and every
|
|
19
|
+
* call site to be in the loaded program, so a function with a root or one a module
|
|
20
|
+
* exports to callers the run cannot see has no result to report. Every other part is
|
|
21
|
+
* reported wherever no project found it in use.
|
|
22
|
+
*/
|
|
23
|
+
function reported(input, part, callersUnknown) {
|
|
24
|
+
if (part.used) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
const { free, discardedEverywhere } = input.intraFunction;
|
|
28
|
+
switch (part.code) {
|
|
29
|
+
case UNUSED_PARAMETER:
|
|
30
|
+
return free.has(part.declaration);
|
|
31
|
+
case UNUSED_RESULT:
|
|
32
|
+
return (free.has(part.declaration) &&
|
|
33
|
+
discardedEverywhere.has(part.declaration) &&
|
|
34
|
+
!callersUnknown.has(part.declaration));
|
|
35
|
+
default:
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The findings of the intra-function family, `DS1800` to `DS1899`, each about a part of
|
|
41
|
+
* the declaration its reference names, at the part's own position, whatever the sweep
|
|
42
|
+
* decided about that declaration. Each message names the other rules that report the
|
|
43
|
+
* kind, as the vocabulary lists them, and so does the finding's details.
|
|
44
|
+
*/
|
|
45
|
+
export const intraFunction = (input) => {
|
|
46
|
+
const union = input.swept.matrix.union;
|
|
47
|
+
const byId = new Map(union.symbols.map((symbol) => [symbol.id, symbol]));
|
|
48
|
+
const unknown = new Set(union.rooted.flatMap((root) => {
|
|
49
|
+
const symbol = union.symbols[root.at];
|
|
50
|
+
return symbol === undefined ? [] : [symbol.id];
|
|
51
|
+
}));
|
|
52
|
+
const { declared, loaded } = input.boundary.consumers;
|
|
53
|
+
const closed = input.config.consumersComplete && declared.every((id) => loaded.includes(id));
|
|
54
|
+
const published = (id) => {
|
|
55
|
+
const symbol = byId.get(id);
|
|
56
|
+
return (symbol !== undefined &&
|
|
57
|
+
symbol.kind !== "file" &&
|
|
58
|
+
(symbol.exported || published(symbol.parent)));
|
|
59
|
+
};
|
|
60
|
+
if (!closed) {
|
|
61
|
+
for (const part of input.intraFunction.parts) {
|
|
62
|
+
if (published(part.declaration)) {
|
|
63
|
+
unknown.add(part.declaration);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const found = [];
|
|
68
|
+
for (const part of input.intraFunction.parts) {
|
|
69
|
+
const declaration = byId.get(part.declaration);
|
|
70
|
+
const overlap = KINDS.get(part.code)?.overlap ?? [];
|
|
71
|
+
if (declaration === undefined || !reported(input, part, unknown)) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
found.push({
|
|
75
|
+
code: part.code,
|
|
76
|
+
position: part.position,
|
|
77
|
+
symbol: {
|
|
78
|
+
ref: declaration.ref,
|
|
79
|
+
kind: part.kind,
|
|
80
|
+
name: part.name,
|
|
81
|
+
sizeLines: part.position.endLine - part.position.line + 1,
|
|
82
|
+
},
|
|
83
|
+
message: `${part.message}; ${overlapClause(overlap)}`,
|
|
84
|
+
details: {
|
|
85
|
+
...(part.kind === "store" ? { writePositions: [part.position] } : {}),
|
|
86
|
+
overlap,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return found;
|
|
91
|
+
};
|
|
@@ -2,6 +2,10 @@ import { REPOSITORY_DOCUMENT } from "../config.js";
|
|
|
2
2
|
import { STALE_SUPPRESSION, SUPPRESSION_WITHOUT_REASON, } from "../suppress.js";
|
|
3
3
|
/** The code a configured root that matches nothing is reported under. */
|
|
4
4
|
const UNMATCHED_ROOT = "DS1704";
|
|
5
|
+
/** The code a configured declaration that names nothing is reported under. */
|
|
6
|
+
const UNMATCHED_DECLARATION = "DS1706";
|
|
7
|
+
/** The subject kind of a finding about a configured declaration. */
|
|
8
|
+
const DECLARATION_SUBJECT = "configured-declaration";
|
|
5
9
|
/** The subject kind of a finding about a suppression record. */
|
|
6
10
|
const SUPPRESSION_SUBJECT = "suppression";
|
|
7
11
|
/** The subject kind of a finding about a configured root. */
|
|
@@ -13,6 +17,7 @@ export const NO_SELF_CHECK = {
|
|
|
13
17
|
refusals: [],
|
|
14
18
|
unmatchedRoots: [],
|
|
15
19
|
rootsDocument: "",
|
|
20
|
+
unmatchedDeclarations: [],
|
|
16
21
|
};
|
|
17
22
|
/** The words a message names a mechanism by. */
|
|
18
23
|
const WRITTEN = {
|
|
@@ -104,6 +109,27 @@ export function staleSuppressions(stale, files) {
|
|
|
104
109
|
};
|
|
105
110
|
});
|
|
106
111
|
}
|
|
112
|
+
/** The first position of the document that configured a setting, or of the conventional one. */
|
|
113
|
+
function documentPosition(document) {
|
|
114
|
+
return {
|
|
115
|
+
path: document === "" ? REPOSITORY_DOCUMENT : document,
|
|
116
|
+
line: DOCUMENT_POSITION,
|
|
117
|
+
column: DOCUMENT_POSITION,
|
|
118
|
+
endLine: DOCUMENT_POSITION,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* One finding per configured declaration that named no declaration in any project, at the
|
|
123
|
+
* first position of the document that configured its key, as a configured root's is.
|
|
124
|
+
*/
|
|
125
|
+
function unmatchedDeclarationFindings(unmatched) {
|
|
126
|
+
return unmatched.map((one) => ({
|
|
127
|
+
code: UNMATCHED_DECLARATION,
|
|
128
|
+
position: documentPosition(one.document),
|
|
129
|
+
symbol: { ref: one.ref, kind: DECLARATION_SUBJECT, name: one.ref, sizeLines: 1 },
|
|
130
|
+
message: `${one.key} entry names no declaration in any project`,
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
107
133
|
/**
|
|
108
134
|
* One finding per configured root or pattern that named nothing in any project. A finding
|
|
109
135
|
* names the document that declared the roots, or the conventional repository configuration
|
|
@@ -112,12 +138,7 @@ export function staleSuppressions(stale, files) {
|
|
|
112
138
|
* own, so the string each finding names tells one from another.
|
|
113
139
|
*/
|
|
114
140
|
export function unmatchedRootFindings(sources, document) {
|
|
115
|
-
const position =
|
|
116
|
-
path: document === "" ? REPOSITORY_DOCUMENT : document,
|
|
117
|
-
line: DOCUMENT_POSITION,
|
|
118
|
-
column: DOCUMENT_POSITION,
|
|
119
|
-
endLine: DOCUMENT_POSITION,
|
|
120
|
-
};
|
|
141
|
+
const position = documentPosition(document);
|
|
121
142
|
return sources.map((source) => ({
|
|
122
143
|
code: UNMATCHED_ROOT,
|
|
123
144
|
position,
|
|
@@ -129,11 +150,13 @@ export function unmatchedRootFindings(sources, document) {
|
|
|
129
150
|
}
|
|
130
151
|
/**
|
|
131
152
|
* The findings of the self-check family, `DS1700` to `DS1799`: a suppression refused for
|
|
132
|
-
* carrying no reason or naming no path, and a configured root that named
|
|
153
|
+
* carrying no reason or naming no path, and a configured root or declaration that named
|
|
154
|
+
* nothing. A
|
|
133
155
|
* suppression that matched nothing is a record of the report's stale suppressions, which
|
|
134
156
|
* {@link staleSuppressions} builds, and a stale cross-language edge is the merge's to report.
|
|
135
157
|
*/
|
|
136
158
|
export const selfCheck = ({ selfCheck: facts, swept }) => [
|
|
137
159
|
...refusedSuppressions(facts.refusals, fileRefsOf(swept.matrix.union.symbols)),
|
|
138
160
|
...unmatchedRootFindings(facts.unmatchedRoots, facts.rootsDocument),
|
|
161
|
+
...unmatchedDeclarationFindings(facts.unmatchedDeclarations),
|
|
139
162
|
];
|