@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
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The findings pass over one run: the emitter table's findings with the suppressions
3
+ * applied and the declared edges evaluated, and the stale suppressions, the totals and the
4
+ * verdict a report carries beside them.
5
+ */
6
+
7
+ import type { AnalysisInputs } from "./analysis.ts";
8
+ import type { Config, Severity } from "./config.ts";
9
+ import type { EdgeEvaluation } from "./edges.ts";
10
+ import type { CompletedFinding, Finding } from "./finding.ts";
11
+ import { decidedFindings } from "./findings/emitters.ts";
12
+ import {
13
+ fileRefsOf,
14
+ staleSuppressions,
15
+ type StaleRecord,
16
+ type StaleSuppression,
17
+ } from "./findings/self-check.ts";
18
+ import { isRowSubject, pairs, suppressionCounts, type Ledger } from "./suppress.ts";
19
+ import type { Provenance, Recorded } from "./suppress-file.ts";
20
+ import { EXIT_CLEAN, EXIT_FINDINGS } from "./verbs/verb.ts";
21
+
22
+ /** The counts a report's totals carry beside its findings. */
23
+ export interface PassTotals {
24
+ readonly suppressionsInEffect: number;
25
+ readonly reasonsRecorded: number;
26
+ readonly staleSuppressions: number;
27
+ /** The evaluations that hold a finding pending. */
28
+ readonly pending: number;
29
+ }
30
+
31
+ /** What the pass answers about one run. */
32
+ export interface PassResult {
33
+ /** Every finding the report publishes, in the canonical order. */
34
+ readonly findings: readonly CompletedFinding[];
35
+ /** One record per site at which a suppression matched nothing, in reading order. */
36
+ readonly staleSuppressions: readonly StaleSuppression[];
37
+ /** One evaluation per declared edge side of this language. */
38
+ readonly edgeEvaluations: readonly EdgeEvaluation<CompletedFinding>[];
39
+ readonly totals: PassTotals;
40
+ /** What every suppression record did, in reading order. */
41
+ readonly ledger: Ledger<CompletedFinding>;
42
+ }
43
+
44
+ /** The exit code of a report holding at least one pending finding. */
45
+ export const EXIT_PENDING = 4;
46
+
47
+ /** The severities ranked, the strongest highest. */
48
+ const SEVERITY_RANK: Readonly<Record<Severity, number>> = { allow: 0, warn: 1, deny: 2 };
49
+
50
+ /** Two strings ordered bytewise. */
51
+ function compare(a: string, b: string): number {
52
+ if (a === b) {
53
+ return 0;
54
+ }
55
+ return a < b ? -1 : 1;
56
+ }
57
+
58
+ /** The canonical order: path, line, column, code, then the symbol reference. */
59
+ function canonical(a: Finding, b: Finding): number {
60
+ return (
61
+ compare(a.position.path, b.position.path) ||
62
+ a.position.line - b.position.line ||
63
+ a.position.column - b.position.column ||
64
+ compare(a.code, b.code) ||
65
+ compare(a.symbol.ref, b.symbol.ref)
66
+ );
67
+ }
68
+
69
+ /**
70
+ * The pass over one run. The emitter table decides every record against every finding the
71
+ * run would produce with no record there, withholds what the records claim, and evaluates
72
+ * the declared edges; a stale record then names the codes its declaration reports instead.
73
+ */
74
+ export function findingsPass(inputs: AnalysisInputs): PassResult {
75
+ const { marked, unmarked, suppressions } = inputs;
76
+ const decided = decidedFindings(marked, { unmarked, records: suppressions.records });
77
+ const { ledger, would } = decided;
78
+
79
+ const stale: StaleRecord[] = [];
80
+ suppressions.records.forEach((record, at) => {
81
+ if (ledger.verdicts[at] !== "stale") {
82
+ return;
83
+ }
84
+ const reports = would
85
+ .filter(
86
+ (finding) =>
87
+ finding.code !== record.code && pairs({ ...record, code: finding.code }, finding),
88
+ )
89
+ .map((finding) => finding.code);
90
+ stale.push({ record, reports });
91
+ });
92
+
93
+ const records = staleSuppressions(stale, fileRefsOf(marked.swept.matrix.union.symbols));
94
+ const counts = suppressionCounts(suppressions.records, ledger);
95
+ return {
96
+ findings: [...decided.findings].sort(canonical),
97
+ staleSuppressions: records,
98
+ edgeEvaluations: decided.evaluations,
99
+ totals: {
100
+ suppressionsInEffect: counts.inEffect,
101
+ reasonsRecorded: counts.reasonsRecorded,
102
+ staleSuppressions: records.length,
103
+ pending: decided.evaluations.filter((one) => one.finding !== undefined).length,
104
+ },
105
+ ledger,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * The exit code of a report the pass answered: pending when an evaluation holds a finding,
111
+ * which outranks the rest because an unmerged report is not an answer; findings when a
112
+ * suppression is stale or a finding's severity reaches the configured failing severity;
113
+ * clean otherwise.
114
+ */
115
+ export function verdictOf(result: PassResult, config: Config): number {
116
+ if (result.totals.pending > 0) {
117
+ return EXIT_PENDING;
118
+ }
119
+ if (result.totals.staleSuppressions > 0) {
120
+ return EXIT_FINDINGS;
121
+ }
122
+ const failing = SEVERITY_RANK[config.reporters.failOn];
123
+ const fails = result.findings.some((finding) => SEVERITY_RANK[finding.severity] >= failing);
124
+ return fails ? EXIT_FINDINGS : EXIT_CLEAN;
125
+ }
126
+
127
+ /**
128
+ * The findings of a report as a baseline records them, in report order. A finding about a
129
+ * row of a document is not recorded: no record withholds one, so a row recording it would
130
+ * be stale on the first read back.
131
+ */
132
+ export function recordedFindings(result: PassResult): readonly Recorded[] {
133
+ return result.findings
134
+ .filter((finding) => !isRowSubject(finding.symbol.kind))
135
+ .map((finding) => ({
136
+ code: finding.code,
137
+ symbol: finding.symbol.ref,
138
+ path: finding.position.path,
139
+ }));
140
+ }
141
+
142
+ /** The analyzer identity a baseline this analyzer writes names as its rows' provenance. */
143
+ export function analyzerProvenance(version: string): Provenance {
144
+ return { analyzer: "deadset-ts", version };
145
+ }
package/src/host.ts CHANGED
@@ -34,4 +34,9 @@ export interface Host {
34
34
  * says so at the one moment the value is asked for.
35
35
  */
36
36
  analyzerVersion(): string;
37
+ /**
38
+ * Writes one document whole, or a throw naming why it cannot. The write is atomic: a
39
+ * reader of the path sees the previous document or this one and never a part of it.
40
+ */
41
+ writeFile(path: string, text: string): void;
37
42
  }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * A read of a strict JSON document beside its parse: what a parse into a value cannot
3
+ * report, the offset of each record of one array and a member written twice.
4
+ */
5
+
6
+ import type { Position } from "./position.ts";
7
+
8
+ /** The token walk over a document `JSON.parse` accepted, so every token is well formed. */
9
+ class Walk {
10
+ readonly opened: number[] = [];
11
+ /** The member written twice, where one is, as the offset of its name. */
12
+ twice: { readonly name: string; readonly offset: number } | undefined;
13
+ private readonly text: string;
14
+ private readonly array: string;
15
+ private at = 0;
16
+
17
+ constructor(text: string, array: string) {
18
+ this.text = text;
19
+ this.array = array;
20
+ }
21
+
22
+ run(): void {
23
+ this.value("document");
24
+ }
25
+
26
+ private skip(): void {
27
+ while (/[ \t\r\n]/u.test(this.text[this.at] ?? "")) {
28
+ this.at += 1;
29
+ }
30
+ }
31
+
32
+ private string(): string {
33
+ const start = this.at;
34
+ this.at += 1;
35
+ while (this.text[this.at] !== '"') {
36
+ this.at += this.text[this.at] === "\\" ? 2 : 1;
37
+ }
38
+ this.at += 1;
39
+ return JSON.parse(this.text.slice(start, this.at)) as string;
40
+ }
41
+
42
+ private value(role: "document" | "array" | "record" | "other"): void {
43
+ this.skip();
44
+ const char = this.text[this.at];
45
+ if (char === "{") {
46
+ if (role === "record") {
47
+ this.opened.push(this.at);
48
+ }
49
+ this.object(role);
50
+ } else if (char === "[") {
51
+ this.list(role === "array" ? "record" : "other");
52
+ } else if (char === '"') {
53
+ this.string();
54
+ } else {
55
+ while (this.at < this.text.length && !/[,\]} \t\r\n]/u.test(this.text[this.at] ?? "")) {
56
+ this.at += 1;
57
+ }
58
+ }
59
+ }
60
+
61
+ private object(role: string): void {
62
+ const seen = new Set<string>();
63
+ this.at += 1;
64
+ for (;;) {
65
+ this.skip();
66
+ if (this.text[this.at] === "}") {
67
+ this.at += 1;
68
+ return;
69
+ }
70
+ if (this.text[this.at] === ",") {
71
+ this.at += 1;
72
+ this.skip();
73
+ }
74
+ const offset = this.at;
75
+ const name = this.string();
76
+ if (seen.has(name) && this.twice === undefined) {
77
+ this.twice = { name, offset };
78
+ }
79
+ seen.add(name);
80
+ this.skip();
81
+ this.at += 1;
82
+ this.value(role === "document" && name === this.array ? "array" : "other");
83
+ }
84
+ }
85
+
86
+ private list(element: "record" | "other"): void {
87
+ this.at += 1;
88
+ for (;;) {
89
+ this.skip();
90
+ if (this.text[this.at] === "]") {
91
+ this.at += 1;
92
+ return;
93
+ }
94
+ if (this.text[this.at] === ",") {
95
+ this.at += 1;
96
+ }
97
+ this.value(element);
98
+ }
99
+ }
100
+ }
101
+
102
+ /** What a walk of one document found. */
103
+ export interface DocumentWalk {
104
+ /** The offset of the brace opening each object of the document's array member, in order. */
105
+ readonly opened: readonly number[];
106
+ /** The first member one object writes twice, at any depth, as the offset of its name. */
107
+ readonly twice: { readonly name: string; readonly offset: number } | undefined;
108
+ }
109
+
110
+ /**
111
+ * Walks a document `JSON.parse` accepted, whose top-level member `array` holds the
112
+ * records. A member one object writes twice reads as though it were written once, its
113
+ * later value silently replacing the earlier one, so a document holding one is one a
114
+ * reader names rather than reads.
115
+ */
116
+ export function walkDocument(text: string, array: string): DocumentWalk {
117
+ const walk = new Walk(text, array);
118
+ walk.run();
119
+ return { opened: walk.opened, twice: walk.twice };
120
+ }
121
+
122
+ /**
123
+ * One offset of a document as a finding carries it: the line counted from one, and the
124
+ * column counted from one in UTF-16 code units.
125
+ */
126
+ export function positionAt(text: string, file: string, offset: number): Position {
127
+ const before = text.slice(0, offset);
128
+ const lineStart = before.lastIndexOf("\n") + 1;
129
+ return { path: file, line: before.split("\n").length, column: offset - lineStart + 1 };
130
+ }
package/src/references.ts CHANGED
@@ -126,8 +126,6 @@ export interface References {
126
126
  /** The compiler configuration the project was opened from. */
127
127
  readonly configFile: string;
128
128
  readonly references: readonly Reference[];
129
- /** The rules that classified files as test files, ordered by rule. */
130
- readonly testFileRules: readonly TestFileRule[];
131
129
  /**
132
130
  * Every file the rules classified as a test file, by its path below the target root,
133
131
  * ascending. A declaration such a file holds is a test declaration, by the same
@@ -147,7 +145,7 @@ export interface ReferenceOptions {
147
145
  readonly batchCap?: number;
148
146
  /**
149
147
  * Glob patterns, relative to the target root, naming the files that are test files.
150
- * Each pattern is a rule of its own and reports the files it matched.
148
+ * Each pattern is a rule of its own, which {@link testFileRulesOf} counts.
151
149
  */
152
150
  readonly testFiles: readonly string[];
153
151
  }
@@ -499,6 +497,26 @@ function patternRule(index: number): string {
499
497
  return `test-file-pattern-${String(index + 1)}`;
500
498
  }
501
499
 
500
+ /**
501
+ * The rules over the test files of several projects at once, ordered by rule: a file two
502
+ * projects hold is one file, so each rule counts the distinct paths it matches.
503
+ */
504
+ export function testFileRulesOf(
505
+ testFiles: readonly string[],
506
+ paths: readonly string[],
507
+ ): readonly TestFileRule[] {
508
+ const distinct = [...new Set(paths)];
509
+ return testFiles
510
+ .map((pattern, index) => {
511
+ const expression = globExpression(pattern);
512
+ return {
513
+ rule: patternRule(index),
514
+ matched: distinct.filter((path) => expression.test(path)).length,
515
+ };
516
+ })
517
+ .sort((a, b) => compare(a.rule, b.rule));
518
+ }
519
+
502
520
  /** Two strings ordered bytewise, which is the order every set of a run is read in. */
503
521
  function compare(a: string, b: string): number {
504
522
  if (a === b) {
@@ -509,7 +527,7 @@ function compare(a: string, b: string): number {
509
527
 
510
528
  /**
511
529
  * Every reference one project's own files make to the project's own declarations, in
512
- * position order, and the test-file rules that classified those files.
530
+ * position order, and the files the test-file rules classified.
513
531
  *
514
532
  * `held` is the inventory of the same project, whose declaration map is what a resolved
515
533
  * symbol is looked up in; `targetRoot` is the absolute path every position is rendered
@@ -530,7 +548,6 @@ export function references<Brand>(
530
548
  const cap = options.batchCap ?? DEFAULT_BATCH_CAP;
531
549
  const declarations = held.declarations;
532
550
  const patterns = options.testFiles.map(globExpression);
533
- const matched = options.testFiles.map(() => 0);
534
551
  const found: Reference[] = [];
535
552
  const tests: string[] = [];
536
553
  const chains = aliasChains(project, held);
@@ -574,13 +591,7 @@ export function references<Brand>(
574
591
 
575
592
  for (const file of files) {
576
593
  const path = renderPosition(file, targetRoot, 0).path;
577
- const hits = patterns.map((pattern) => pattern.test(path));
578
- hits.forEach((hit, index) => {
579
- if (hit) {
580
- matched[index] = (matched[index] ?? 0) + 1;
581
- }
582
- });
583
- const test = hits.includes(true);
594
+ const test = patterns.some((pattern) => pattern.test(path));
584
595
  if (test) {
585
596
  tests.push(path);
586
597
  }
@@ -706,9 +717,6 @@ export function references<Brand>(
706
717
  return {
707
718
  configFile: project.configFile,
708
719
  references: found,
709
- testFileRules: options.testFiles
710
- .map((_pattern, index) => ({ rule: patternRule(index), matched: matched[index] ?? 0 }))
711
- .sort((a, b) => compare(a.rule, b.rule)),
712
720
  testFilePaths: tests.sort(compare),
713
721
  cost: { batched, fileBatches, residueFallbacks, shorthandLookups, aliasSteps: chains.steps },
714
722
  };