@cplieger/deadset-ts 3.7.0 → 3.8.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.
@@ -1,6 +1,8 @@
1
- import { dependenciesAndModuleMachinery } from "./dependencies-and-module-machinery.ts";
1
+ import { evaluateEdges, type EdgeEvaluation } from "../edges.ts";
2
2
  import type { CompletedFinding } from "../finding.ts";
3
- import { completed, reportable } from "./completion.ts";
3
+ import { ledgerOf, type Dials, type Ledger, type SuppressionRecord } from "../suppress.ts";
4
+ import { completed, dialsOf, reportable } from "./completion.ts";
5
+ import { dependenciesAndModuleMachinery } from "./dependencies-and-module-machinery.ts";
4
6
  import type { Emitter, EmitterInput, Emitters } from "./emitter.ts";
5
7
  import { interfaces } from "./interfaces.ts";
6
8
  import { intraFunction } from "./intra-function.ts";
@@ -22,12 +24,84 @@ export const EMITTERS: Emitters = new Map<string, Emitter>([
22
24
  ["intra-function", intraFunction],
23
25
  ]);
24
26
 
27
+ /** What the suppressions of a run swept under their marks are decided against. */
28
+ export interface Suppressed {
29
+ /** The same run swept with no mark, which is where a kind about liveness reports a marked declaration. */
30
+ readonly unmarked: EmitterInput;
31
+ /** The run's suppression records, in reading order. */
32
+ readonly records: readonly SuppressionRecord[];
33
+ }
34
+
35
+ /** What the emitter table answers about one run. */
36
+ export interface DecidedFindings {
37
+ /** Every finding the run reports, each family's in table order. */
38
+ readonly findings: readonly CompletedFinding[];
39
+ /** One evaluation per declared edge side of this language, ordered by edge, then by side. */
40
+ readonly evaluations: readonly EdgeEvaluation<CompletedFinding>[];
41
+ /** What every suppression record did, in reading order. */
42
+ readonly ledger: Ledger<CompletedFinding>;
43
+ /** Every finding a record could name: the run's with no mark, then the run's as swept. */
44
+ readonly would: readonly CompletedFinding[];
45
+ }
46
+
47
+ /** Every family's findings over one sweep, completed by one step. */
48
+ function completedOver(input: EmitterInput): readonly CompletedFinding[] {
49
+ return completed(
50
+ input,
51
+ [...EMITTERS.values()].flatMap((emit) => emit(input)),
52
+ );
53
+ }
54
+
25
55
  /**
26
- * Every finding of the run the dials do not withhold, each family's in table order: every
27
- * family's findings completed by one step, then withheld by one step, so a root one
28
- * family reports withholds what another family reports in its component.
56
+ * The dials over the findings a record could name, each read against the sweep that
57
+ * produced it, so a component one sweep numbers withholds nothing of the other's.
29
58
  */
59
+ function dialsOver(
60
+ input: EmitterInput,
61
+ found: readonly CompletedFinding[],
62
+ suppressed: Suppressed,
63
+ unmarked: readonly CompletedFinding[],
64
+ ): Dials<CompletedFinding> {
65
+ const marked = dialsOf(input, found);
66
+ const before = dialsOf(suppressed.unmarked, unmarked);
67
+ const fromUnmarked = new Set(unmarked);
68
+ return {
69
+ withholdsCode: marked.withholdsCode,
70
+ withholds: (finding) =>
71
+ fromUnmarked.has(finding) ? before.withholds(finding) : marked.withholds(finding),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Every finding of one run swept under its suppressions' marks, decided in one pass: every
77
+ * family's findings completed by one step, so every component number is minted once, a
78
+ * pending finding's among them; the finding each record claims withheld, whatever its
79
+ * kind; the rest withheld by the dials, so a root one family reports withholds what
80
+ * another family reports in its component; and every declared edge evaluated over what
81
+ * remains, so a finding it holds pending is in its evaluation and nowhere else.
82
+ */
83
+ export function decidedFindings(
84
+ input: EmitterInput,
85
+ suppressed: Suppressed = { unmarked: input, records: [] },
86
+ ): DecidedFindings {
87
+ const found = completedOver(input);
88
+ const unmarked = suppressed.records.length === 0 ? [] : completedOver(suppressed.unmarked);
89
+ const would = [...unmarked, ...found];
90
+ const ledger = ledgerOf(suppressed.records, would, dialsOver(input, found, suppressed, unmarked));
91
+ const kept = reportable(
92
+ input,
93
+ found.filter((finding) => !ledger.withheld(finding)),
94
+ );
95
+ const evaluated = evaluateEdges(kept, input.boundary.edges, input.swept.matrix.union.symbols);
96
+ return {
97
+ findings: evaluated.findings,
98
+ evaluations: evaluated.evaluations,
99
+ ledger,
100
+ would,
101
+ };
102
+ }
103
+
104
+ /** Every finding the run reports, each family's in table order. */
30
105
  export function findingsOf(input: EmitterInput): readonly CompletedFinding[] {
31
- const found = [...EMITTERS.values()].flatMap((emit) => emit(input));
32
- return reportable(completed(input, found), input.config.analysis.minConfidence);
106
+ return decidedFindings(input).findings;
33
107
  }
@@ -1,4 +1,207 @@
1
+ import { REPOSITORY_DOCUMENT } from "../config.ts";
2
+ import type { Finding } from "../finding.ts";
3
+ import type { InventorySymbol } from "../inventory.ts";
4
+ import type { Position } from "../position.ts";
5
+ import {
6
+ STALE_SUPPRESSION,
7
+ SUPPRESSION_WITHOUT_REASON,
8
+ type Mechanism,
9
+ type Refusal,
10
+ type SuppressionRecord,
11
+ } from "../suppress.ts";
1
12
  import type { Emitter } from "./emitter.ts";
2
13
 
3
- /** The findings of the self-check family, `DS1700` to `DS1799`. */
4
- export const selfCheck: Emitter = () => [];
14
+ /** The code a configured root that matches nothing is reported under. */
15
+ const UNMATCHED_ROOT = "DS1704";
16
+
17
+ /** The subject kind of a finding about a suppression record. */
18
+ const SUPPRESSION_SUBJECT = "suppression";
19
+
20
+ /** The subject kind of a finding about a configured root. */
21
+ const ROOT_SUBJECT = "root";
22
+
23
+ /** The fixed position of a finding about a document rather than about a line of one. */
24
+ const DOCUMENT_POSITION = 1;
25
+
26
+ /** One record no finding matched, and the codes its declaration reports instead. */
27
+ export interface StaleRecord {
28
+ readonly record: SuppressionRecord;
29
+ /** The codes the bound declaration would report, none of them the record's own. */
30
+ readonly reports: readonly string[];
31
+ }
32
+
33
+ /** What the self-check family reads: the run's refused suppressions and its configured roots. */
34
+ export interface SelfCheckFacts {
35
+ /** The suppressions the grammar refused with a finding, in reading order. */
36
+ readonly refusals: readonly Refusal[];
37
+ /** Every configured root or pattern that matched nothing in any project. */
38
+ readonly unmatchedRoots: readonly string[];
39
+ /** The document below the target root that declared the roots, empty where none did. */
40
+ readonly rootsDocument: string;
41
+ }
42
+
43
+ /** A run with nothing for the self-check family to report. */
44
+ export const NO_SELF_CHECK: SelfCheckFacts = {
45
+ refusals: [],
46
+ unmatchedRoots: [],
47
+ rootsDocument: "",
48
+ };
49
+
50
+ /** The words a message names a mechanism by. */
51
+ const WRITTEN: Readonly<Record<Mechanism, string>> = {
52
+ inline: "inline directive",
53
+ ignore: "ignore entry",
54
+ baseline: "baseline row",
55
+ };
56
+
57
+ /** The file references of a run's declarations, by path. */
58
+ export function fileRefsOf(symbols: readonly InventorySymbol[]): FileRefs {
59
+ const byPath = new Map(
60
+ symbols
61
+ .filter((symbol) => symbol.kind === "file")
62
+ .map((symbol) => [symbol.position.path, symbol.ref]),
63
+ );
64
+ return (path) => byPath.get(path);
65
+ }
66
+
67
+ /** A list of codes as a sentence names them. */
68
+ function spelled(codes: readonly string[]): string {
69
+ if (codes.length < 2) {
70
+ return codes.join("");
71
+ }
72
+ return `${codes.slice(0, -1).join(", ")} and ${codes.at(-1) ?? ""}`;
73
+ }
74
+
75
+ /** A finding about one suppression, at the suppression's own site. */
76
+ function aboutSuppression(code: string, ref: string, site: Position, message: string): Finding {
77
+ return {
78
+ code,
79
+ position: { ...site, endLine: site.line },
80
+ symbol: { ref, kind: SUPPRESSION_SUBJECT, name: ref, sizeLines: 1 },
81
+ message,
82
+ };
83
+ }
84
+
85
+ /** Each file of the run by its path, as the reference of the file it is. */
86
+ export type FileRefs = (path: string) => string | undefined;
87
+
88
+ /**
89
+ * The reference a finding about a suppression names: the symbol it names or bound, else
90
+ * the file form of the document that holds it, else that document's path.
91
+ */
92
+ function suppressionRef(symbol: string, path: string, files: FileRefs): string {
93
+ return symbol === "" ? (files(path) ?? path) : symbol;
94
+ }
95
+
96
+ /** One finding per refused suppression: the grammar's reason refusal and its scope refusal. */
97
+ export function refusedSuppressions(
98
+ refusals: readonly Refusal[],
99
+ files: FileRefs,
100
+ ): readonly Finding[] {
101
+ return refusals.map((refused) =>
102
+ aboutSuppression(
103
+ refused.reported,
104
+ suppressionRef(refused.symbol, refused.path, files),
105
+ refused.site,
106
+ `${WRITTEN[refused.mechanism]} for ${refused.code} ${
107
+ refused.reported === SUPPRESSION_WITHOUT_REASON
108
+ ? "carries no reason"
109
+ : "names a symbol and no path"
110
+ }`,
111
+ ),
112
+ );
113
+ }
114
+
115
+ /** One stale suppression as the report's stale-suppression record carries it. */
116
+ export interface StaleSuppression {
117
+ readonly code: typeof STALE_SUPPRESSION;
118
+ readonly mechanism: Mechanism;
119
+ readonly entry: {
120
+ readonly code: string;
121
+ readonly symbol?: string;
122
+ readonly path: string;
123
+ readonly reason: string;
124
+ };
125
+ readonly position: Position;
126
+ readonly symbol: string;
127
+ readonly message: string;
128
+ }
129
+
130
+ /**
131
+ * One record per site at which a suppression matched nothing, whatever the number of
132
+ * codes or declarations written there: the first record names the entry, and the message
133
+ * names every code the site's records named and every code the declaration reports
134
+ * instead, so the maintainer corrects the code rather than hunting for it.
135
+ */
136
+ export function staleSuppressions(
137
+ stale: readonly StaleRecord[],
138
+ files: FileRefs,
139
+ ): readonly StaleSuppression[] {
140
+ const bySite = new Map<string, { first: SuppressionRecord; records: StaleRecord[] }>();
141
+ for (const one of stale) {
142
+ const { site, mechanism } = one.record;
143
+ const key = `${mechanism}\u0000${site.path}\u0000${String(site.line)}\u0000${String(site.column)}`;
144
+ const held = bySite.get(key) ?? { first: one.record, records: [] };
145
+ held.records.push(one);
146
+ bySite.set(key, held);
147
+ }
148
+ return [...bySite.values()].map(({ first, records }) => {
149
+ const codes = [...new Set(records.map((one) => one.record.code))].sort();
150
+ const reports = [...new Set(records.flatMap((one) => one.reports))]
151
+ .filter((code) => !codes.includes(code))
152
+ .sort();
153
+ const instead = reports.length === 0 ? "" : `, and its declaration reports ${spelled(reports)}`;
154
+ return {
155
+ code: STALE_SUPPRESSION,
156
+ mechanism: first.mechanism,
157
+ entry: {
158
+ code: first.code,
159
+ ...(first.symbol === "" ? {} : { symbol: first.symbol }),
160
+ path: first.path,
161
+ reason: first.reason,
162
+ },
163
+ position: first.site,
164
+ symbol: suppressionRef(first.symbol, first.path, files),
165
+ message: `${WRITTEN[first.mechanism]} for ${spelled(codes)} matches no current finding${instead}`,
166
+ };
167
+ });
168
+ }
169
+
170
+ /**
171
+ * One finding per configured root or pattern that named nothing in any project. A finding
172
+ * names the document that declared the roots, or the conventional repository configuration
173
+ * where no document of the target did, and every finding of one run sits at that
174
+ * document's first position: the roots are an array whose members carry no line of their
175
+ * own, so the string each finding names tells one from another.
176
+ */
177
+ export function unmatchedRootFindings(
178
+ sources: readonly string[],
179
+ document: string,
180
+ ): readonly Finding[] {
181
+ const position = {
182
+ path: document === "" ? REPOSITORY_DOCUMENT : document,
183
+ line: DOCUMENT_POSITION,
184
+ column: DOCUMENT_POSITION,
185
+ endLine: DOCUMENT_POSITION,
186
+ };
187
+ return sources.map((source) => ({
188
+ code: UNMATCHED_ROOT,
189
+ position,
190
+ symbol: { ref: source, kind: ROOT_SUBJECT, name: source, sizeLines: 1 },
191
+ message:
192
+ source.includes("*") || source.includes("?")
193
+ ? "configured root pattern matches no symbol of the inventory"
194
+ : "configured root matches no symbol of the inventory",
195
+ }));
196
+ }
197
+
198
+ /**
199
+ * The findings of the self-check family, `DS1700` to `DS1799`: a suppression refused for
200
+ * carrying no reason or naming no path, and a configured root that named nothing. A
201
+ * suppression that matched nothing is a record of the report's stale suppressions, which
202
+ * {@link staleSuppressions} builds, and a stale cross-language edge is the merge's to report.
203
+ */
204
+ export const selfCheck: Emitter = ({ selfCheck: facts, swept }) => [
205
+ ...refusedSuppressions(facts.refusals, fileRefsOf(swept.matrix.union.symbols)),
206
+ ...unmatchedRootFindings(facts.unmatchedRoots, facts.rootsDocument),
207
+ ];
@@ -3,7 +3,6 @@ import { OUTSIDE, type Graph } from "../graph.ts";
3
3
  import type { InventorySymbol, SymbolKind } from "../inventory.ts";
4
4
  import type { MatrixCandidate } from "../matrix.ts";
5
5
  import type { RootKind } from "../roots.ts";
6
- import type { PendingFinding } from "./boundary.ts";
7
6
  import type { Emitter, EmitterInput } from "./emitter.ts";
8
7
  import { writeOnlyDeclarations } from "./reads-and-writes.ts";
9
8
 
@@ -33,13 +32,6 @@ const WORDS: Partial<Readonly<Record<SymbolKind, string>>> = {
33
32
  "export-alias": "re-export",
34
33
  };
35
34
 
36
- /** What the family found: the findings it reports, and those a declared edge holds pending. */
37
- export interface Narrowings {
38
- readonly findings: readonly Finding[];
39
- /** Ordered by edge, then by side. */
40
- readonly pending: readonly PendingFinding[];
41
- }
42
-
43
35
  /** Per declaration of the run, the facts both rules read. */
44
36
  interface Facts {
45
37
  readonly graph: Graph;
@@ -236,26 +228,15 @@ function unreachable(
236
228
  );
237
229
  }
238
230
 
239
- /** Two strings ordered bytewise. */
240
- function compare(a: string, b: string): number {
241
- if (a === b) {
242
- return 0;
243
- }
244
- return a < b ? -1 : 1;
245
- }
246
-
247
231
  /**
248
- * The visibility-narrowing family over one run: `DS1101`, `DS1103` and `DS1104`.
249
- *
250
- * A finding about a symbol a declared cross-language edge names is pending instead of
251
- * reported, once per side naming it: the edge counts as a reference from outside the
252
- * file and the package, and whether it is a use is decided on the side in the other
253
- * language.
232
+ * The visibility-narrowing family over one run: `DS1101`, `DS1103` and `DS1104`. A finding
233
+ * about a symbol a declared cross-language edge names is reported here like any other, and
234
+ * the edge's evaluation then holds it pending, because the edge stands for a reference from
235
+ * outside the file and the package whose use is decided in the other language.
254
236
  */
255
- export function narrowings(input: EmitterInput): Narrowings {
256
- const { boundary } = input;
237
+ export function narrowings(input: EmitterInput): readonly Finding[] {
257
238
  const facts = factsOf(input.swept.matrix.union);
258
- const { declared, loaded } = boundary.consumers;
239
+ const { declared, loaded } = input.boundary.consumers;
259
240
  const closed = input.config.consumersComplete && declared.every((id) => loaded.includes(id));
260
241
  const world = { closed, writeOnly: writeOnlyDeclarations(input) };
261
242
  const candidates = new Map(
@@ -263,27 +244,17 @@ export function narrowings(input: EmitterInput): Narrowings {
263
244
  );
264
245
 
265
246
  const findings: Finding[] = [];
266
- const pending: PendingFinding[] = [];
267
247
  facts.graph.symbols.forEach((symbol, at) => {
268
248
  const candidate = candidates.get(symbol.id);
269
249
  const found =
270
250
  candidate === undefined
271
251
  ? narrowing(facts, at, symbol, world)
272
252
  : unreachable(facts, at, symbol, candidate, input);
273
- if (found === undefined) {
274
- return;
275
- }
276
- const named = boundary.edges.filter((side) => side.symbol === symbol.ref);
277
- if (named.length === 0) {
253
+ if (found !== undefined) {
278
254
  findings.push(found);
279
- return;
280
- }
281
- for (const side of named) {
282
- pending.push({ ...side, finding: found });
283
255
  }
284
256
  });
285
- pending.sort((a, b) => compare(a.edge, b.edge) || compare(a.side, b.side));
286
- return { findings, pending };
257
+ return findings;
287
258
  }
288
259
 
289
260
  /**
@@ -303,18 +274,5 @@ export function unreachableExports(input: EmitterInput): ReadonlySet<string> {
303
274
  return found;
304
275
  }
305
276
 
306
- /**
307
- * The findings of the visibility-narrowing family, `DS1100` to `DS1199`. A finding a
308
- * declared edge holds pending belongs in that edge's evaluation, which this analyzer
309
- * does not publish yet, so a run that holds one is refused rather than reported
310
- * without it.
311
- */
312
- export const visibilityNarrowing: Emitter = (input) => {
313
- const { findings, pending } = narrowings(input);
314
- if (pending.length > 0) {
315
- throw new Error(
316
- `${String(pending.length)} finding(s) a declared edge holds pending, and no edge evaluation is published`,
317
- );
318
- }
319
- return findings;
320
- };
277
+ /** The findings of the visibility-narrowing family, `DS1100` to `DS1199`. */
278
+ export const visibilityNarrowing: Emitter = narrowings;
@@ -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
+ }
@@ -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
+ }