@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/src/run.ts CHANGED
@@ -9,7 +9,9 @@ import { printConfigVerb } from "./verbs/print-config.ts";
9
9
  import { printProjectsVerb } from "./verbs/print-projects.ts";
10
10
  import { printRetainedVerb } from "./verbs/print-retained.ts";
11
11
  import { printRootsVerb } from "./verbs/print-roots.ts";
12
- import { EXIT_FAILURE, EXIT_USAGE, type Verb } from "./verbs/verb.ts";
12
+ import { ANALYZE_OPTIONS, analyzeVerb } from "./verbs/analyze.ts";
13
+ import { describeVerb } from "./verbs/describe.ts";
14
+ import { EXIT_FAILURE, EXIT_USAGE, type Verb, type VerbOptions } from "./verbs/verb.ts";
13
15
  import { versionVerb } from "./verbs/version.ts";
14
16
 
15
17
  /** One output stream of the command line. `process.stdout` and `process.stderr` satisfy it. */
@@ -21,14 +23,18 @@ export interface Writer {
21
23
  * Every verb this command answers, in the order the usage text lists them. A verb
22
24
  * with no `run` is listed, and invoking it is a usage error.
23
25
  */
24
- const VERBS: readonly { readonly name: string; readonly run?: Verb }[] = [
25
- { name: "analyze" },
26
+ const VERBS: readonly {
27
+ readonly name: string;
28
+ readonly run?: Verb;
29
+ readonly options?: VerbOptions;
30
+ }[] = [
31
+ { name: "analyze", run: analyzeVerb, options: ANALYZE_OPTIONS },
26
32
  { name: "explain" },
27
33
  { name: "print-config", run: printConfigVerb },
28
34
  { name: "print-projects", run: printProjectsVerb },
29
35
  { name: "print-roots", run: printRootsVerb },
30
36
  { name: "print-retained", run: printRetainedVerb },
31
- { name: "describe" },
37
+ { name: "describe", run: describeVerb },
32
38
  { name: "version", run: versionVerb },
33
39
  ];
34
40
 
@@ -69,11 +75,15 @@ const INTEGER_OPTIONS: ReadonlySet<string> = new Set(["max-findings"]);
69
75
  /** An integer as an option writes it: an optional minus sign and digits alone. */
70
76
  const INTEGER_TEXT = /^-?[0-9]+$/u;
71
77
 
72
- /** One invocation's options, by name, with the verb's remaining arguments. */
78
+ /** One invocation's options, by name: the last value of each, and every value of a repeatable one. */
73
79
  interface Options {
74
80
  readonly named: ReadonlyMap<string, string>;
81
+ readonly repeated: ReadonlyMap<string, readonly string[]>;
75
82
  }
76
83
 
84
+ /** No option beyond the ones every verb takes. */
85
+ const NO_OPTIONS: VerbOptions = { plain: [], repeatable: [] };
86
+
77
87
  /**
78
88
  * The first option whose name carries a source-edit token, because every verb
79
89
  * reports and never edits. The match is on the name alone, so an option whose
@@ -93,16 +103,22 @@ function sourceEditOption(args: readonly string[]): string | undefined {
93
103
  return undefined;
94
104
  }
95
105
 
96
- /** Reads one verb's options, refusing a name this command does not offer. */
97
- function readOptions(args: readonly string[]): Options {
106
+ /** Reads one verb's options, refusing a name neither this command nor the verb offers. */
107
+ function readOptions(args: readonly string[], own: VerbOptions): Options {
98
108
  const named = new Map<string, string>();
109
+ const repeated = new Map<string, string[]>();
99
110
  for (const arg of args) {
100
111
  if (!arg.startsWith("-")) {
101
112
  throw new ConfigError("malformed", "", `unexpected argument ${JSON.stringify(arg)}`);
102
113
  }
103
114
  const [spelled, ...rest] = arg.split("=");
104
115
  const name = (spelled ?? "").replace(/^-+/u, "");
105
- if (!PLAIN_OPTIONS.has(name) && !SETTING_OPTIONS.has(name)) {
116
+ if (
117
+ !PLAIN_OPTIONS.has(name) &&
118
+ !SETTING_OPTIONS.has(name) &&
119
+ !own.plain.includes(name) &&
120
+ !own.repeatable.includes(name)
121
+ ) {
106
122
  throw new ConfigError("malformed", "", `unknown option ${JSON.stringify(spelled ?? arg)}`);
107
123
  }
108
124
  if (rest.length === 0) {
@@ -112,9 +128,11 @@ function readOptions(args: readonly string[]): Options {
112
128
  `option ${JSON.stringify(spelled ?? arg)} takes a value`,
113
129
  );
114
130
  }
115
- named.set(name, rest.join("="));
131
+ const value = rest.join("=");
132
+ named.set(name, value);
133
+ repeated.set(name, [...(repeated.get(name) ?? []), value]);
116
134
  }
117
- return { named };
135
+ return { named, repeated };
118
136
  }
119
137
 
120
138
  function readFileOr(host: Host, path: string, orUndefined: boolean): string | undefined {
@@ -202,7 +220,13 @@ export function run(
202
220
  const name = args[0];
203
221
  const verb = VERBS.find((listed) => listed.name === name);
204
222
  if (verb?.run !== undefined) {
205
- return invoke(verb.run, args.slice(1), { out, err, host }, openClient);
223
+ return invoke(
224
+ verb.run,
225
+ verb.options ?? NO_OPTIONS,
226
+ args.slice(1),
227
+ { out, err, host },
228
+ openClient,
229
+ );
206
230
  }
207
231
  if (verb !== undefined) {
208
232
  err.write(`deadset-ts: ${verb.name} is not implemented\n`);
@@ -222,18 +246,22 @@ export function run(
222
246
  */
223
247
  function invoke(
224
248
  verb: Verb,
249
+ own: VerbOptions,
225
250
  args: readonly string[],
226
251
  streams: { readonly out: Writer; readonly err: Writer; readonly host: Host },
227
252
  openClient: (collectTiming: boolean) => Engine,
228
253
  ): number {
229
254
  let options: Options | undefined;
230
- const read = (): Options => (options ??= readOptions(args));
255
+ const read = (): Options => (options ??= readOptions(args, own));
231
256
  let opened: Engine | undefined;
232
257
  try {
233
258
  return verb({
234
259
  ...streams,
260
+ args,
235
261
  inputs: () => inputsOf(streams.host, read()),
236
262
  scope: () => scopeOf(streams.host, read()),
263
+ option: (name) => read().named.get(name),
264
+ repeated: (name) => read().repeated.get(name) ?? [],
237
265
  openClient: (collectTiming) => {
238
266
  opened = openClient(collectTiming);
239
267
  return opened;
@@ -0,0 +1,283 @@
1
+ /**
2
+ * The two file-backed suppression documents at the target root: the ignore file, whose
3
+ * entries a maintainer writes, and the baseline, whose rows an analyzer writes. They share
4
+ * one record shape, so one decoder and one matcher serve both, and a row moves into the
5
+ * ignore file by gaining a maintainer's reason.
6
+ */
7
+
8
+ import type { Host } from "./host.ts";
9
+ import type { InventorySymbol } from "./inventory.ts";
10
+ import { joinPath } from "./paths.ts";
11
+ import { positionAt, walkDocument } from "./json-document.ts";
12
+ import type { Position } from "./position.ts";
13
+ import {
14
+ CODE_FORM,
15
+ SUPPRESSION_WITHOUT_REASON,
16
+ SuppressionError,
17
+ UNSCOPED_ENTRY,
18
+ blankReason,
19
+ isEntryPath,
20
+ isSymbolRef,
21
+ siteText,
22
+ type Mechanism,
23
+ type Refusal,
24
+ type SuppressionRecord,
25
+ type Suppressions,
26
+ } from "./suppress.ts";
27
+
28
+ /** The name and the location the grammar fixes for the ignore file. */
29
+ export const IGNORE_FILE = "deadset-ignore.json";
30
+
31
+ /** The name and the location the grammar fixes for the baseline. */
32
+ export const BASELINE_FILE = "deadset-baseline.json";
33
+
34
+ /** One file-backed document as a reader sees it. */
35
+ interface Shape {
36
+ readonly file: string;
37
+ /** The member holding the records. */
38
+ readonly array: string;
39
+ readonly mechanism: Mechanism;
40
+ readonly documentWant: string;
41
+ readonly recordWant: string;
42
+ }
43
+
44
+ const IGNORE_SHAPE: Shape = {
45
+ file: IGNORE_FILE,
46
+ array: "ignore",
47
+ mechanism: "ignore",
48
+ documentWant: "an object carrying the ignore array, and an optional description",
49
+ recordWant: "an entry naming code, symbol, path and reason, each a string",
50
+ };
51
+
52
+ const BASELINE_SHAPE: Shape = {
53
+ file: BASELINE_FILE,
54
+ array: "baseline",
55
+ mechanism: "baseline",
56
+ documentWant: "an object carrying the baseline array, and an optional description",
57
+ recordWant: "a row naming code, symbol, path and reason, each a string",
58
+ };
59
+
60
+ /** The closed key list of one entry or row. */
61
+ const RECORD_KEYS: ReadonlySet<string> = new Set(["code", "symbol", "path", "reason"]);
62
+
63
+ const CODE_WANT = "DS followed by four digits";
64
+ const SYMBOL_WANT =
65
+ "a stable symbol reference, go://<import-path>#<fragment> or ts://<package>/<source-path>#<fragment>";
66
+ const PATH_WANT = "a path relative to the target root, with the solidus as separator";
67
+
68
+ /** The document itself, for a defect no record position describes. */
69
+ function documentSite(shape: Shape): Position {
70
+ return { path: shape.file, line: 0, column: 0 };
71
+ }
72
+
73
+ function isObject(value: unknown): value is Record<string, unknown> {
74
+ return typeof value === "object" && value !== null && !Array.isArray(value);
75
+ }
76
+
77
+ /** What one entry or row resolves to. */
78
+ interface Read {
79
+ readonly records: readonly SuppressionRecord[];
80
+ readonly refusals: readonly Refusal[];
81
+ }
82
+
83
+ /** The declarations of the run by reference and file, which is what an entry names. */
84
+ type ByReference = ReadonlyMap<string, readonly InventorySymbol[]>;
85
+
86
+ function referenceKey(ref: string, path: string): string {
87
+ return `${ref}\u0000${path}`;
88
+ }
89
+
90
+ /**
91
+ * Reads one entry or row: the records it binds, the refusals it carries, or the
92
+ * malformed value that ends the run.
93
+ */
94
+ function readRecord(value: unknown, site: Position, byReference: ByReference, shape: Shape): Read {
95
+ const refuse = (text: string, want: string): never => {
96
+ throw new SuppressionError(shape.mechanism, `${shape.file} ${siteText(site)}`, text, want);
97
+ };
98
+ if (!isObject(value)) {
99
+ return refuse(JSON.stringify(value), shape.recordWant);
100
+ }
101
+ for (const [key, held] of Object.entries(value)) {
102
+ if (!RECORD_KEYS.has(key)) {
103
+ refuse(`the member ${JSON.stringify(key)} is not one the grammar declares`, shape.recordWant);
104
+ }
105
+ if (typeof held !== "string") {
106
+ refuse(`the member ${JSON.stringify(key)} holds ${JSON.stringify(held)}`, shape.recordWant);
107
+ }
108
+ }
109
+ const entry = value as Readonly<Record<string, string | undefined>>;
110
+ const { code, symbol, path, reason } = {
111
+ code: entry["code"],
112
+ symbol: entry["symbol"],
113
+ path: entry["path"],
114
+ reason: entry["reason"],
115
+ };
116
+ if (code === undefined) {
117
+ return refuse("a record naming no code", shape.recordWant);
118
+ }
119
+ if (symbol === undefined) {
120
+ return refuse("a record naming no symbol", shape.recordWant);
121
+ }
122
+ if (!CODE_FORM.test(code)) {
123
+ return refuse(JSON.stringify(code), CODE_WANT);
124
+ }
125
+ if (!isSymbolRef(symbol)) {
126
+ return refuse(JSON.stringify(symbol), SYMBOL_WANT);
127
+ }
128
+ if (path !== undefined && path !== "" && !isEntryPath(path)) {
129
+ return refuse(JSON.stringify(path), PATH_WANT);
130
+ }
131
+
132
+ const written = {
133
+ code,
134
+ symbol,
135
+ path: path ?? "",
136
+ reason: reason ?? "",
137
+ site,
138
+ mechanism: shape.mechanism,
139
+ };
140
+ const refusals: Refusal[] = [];
141
+ if (blankReason(reason)) {
142
+ refusals.push({ ...written, reported: SUPPRESSION_WITHOUT_REASON });
143
+ }
144
+ if (written.path === "") {
145
+ refusals.push({ ...written, reported: UNSCOPED_ENTRY });
146
+ }
147
+ if (refusals.length > 0) {
148
+ return { records: [], refusals };
149
+ }
150
+ const named = byReference.get(referenceKey(symbol, written.path)) ?? [];
151
+ if (named.length === 0) {
152
+ return { records: [{ ...written, bound: "" }], refusals: [] };
153
+ }
154
+ return { records: named.map((one) => ({ ...written, bound: one.id })), refusals: [] };
155
+ }
156
+
157
+ /** Reads one document at the target root, binding each record against the run's declarations. */
158
+ function readDocument(
159
+ host: Host,
160
+ targetRoot: string,
161
+ symbols: readonly InventorySymbol[],
162
+ shape: Shape,
163
+ ): Suppressions {
164
+ const file = joinPath(targetRoot, shape.file);
165
+ if (host.kindOf(file) === "absent") {
166
+ return { records: [], refusals: [] };
167
+ }
168
+ const text = host.readFile(file);
169
+ const malformed = (detail: string, site = documentSite(shape)): never => {
170
+ throw new SuppressionError(shape.mechanism, siteText(site), detail, shape.documentWant);
171
+ };
172
+ let parsed: unknown;
173
+ try {
174
+ parsed = JSON.parse(text);
175
+ } catch (error: unknown) {
176
+ return malformed(`${shape.file}: ${error instanceof Error ? error.message : String(error)}`);
177
+ }
178
+ if (!isObject(parsed)) {
179
+ return malformed(shape.file);
180
+ }
181
+ const walk = walkDocument(text, shape.array);
182
+ if (walk.twice !== undefined) {
183
+ malformed(
184
+ `the member ${JSON.stringify(walk.twice.name)} is written twice, so neither value is chosen`,
185
+ positionAt(text, shape.file, walk.twice.offset),
186
+ );
187
+ }
188
+ for (const key of Object.keys(parsed)) {
189
+ if (key !== "description" && key !== shape.array) {
190
+ malformed(`the member ${JSON.stringify(key)} is not one the grammar declares`);
191
+ }
192
+ }
193
+ const description = parsed["description"];
194
+ const held = parsed[shape.array];
195
+ if ((description !== undefined && typeof description !== "string") || !Array.isArray(held)) {
196
+ return malformed(shape.file);
197
+ }
198
+
199
+ const byReference = new Map<string, InventorySymbol[]>();
200
+ for (const symbol of symbols) {
201
+ const key = referenceKey(symbol.ref, symbol.position.path);
202
+ byReference.set(key, [...(byReference.get(key) ?? []), symbol]);
203
+ }
204
+ const records: SuppressionRecord[] = [];
205
+ const refusals: Refusal[] = [];
206
+ (held as readonly unknown[]).forEach((value, index) => {
207
+ const offset = walk.opened[index];
208
+ const site = offset === undefined ? documentSite(shape) : positionAt(text, shape.file, offset);
209
+ const read = readRecord(value, site, byReference, shape);
210
+ records.push(...read.records);
211
+ refusals.push(...read.refusals);
212
+ });
213
+ return { records, refusals };
214
+ }
215
+
216
+ /**
217
+ * The ignore file at the target root, each entry bound to the declaration whose reference
218
+ * and file both equal the ones it names, and never by glob, pattern or substring. An
219
+ * absent file is an empty one.
220
+ */
221
+ export function readIgnoreFile(
222
+ host: Host,
223
+ targetRoot: string,
224
+ symbols: readonly InventorySymbol[],
225
+ ): Suppressions {
226
+ return readDocument(host, targetRoot, symbols, IGNORE_SHAPE);
227
+ }
228
+
229
+ /**
230
+ * The baseline at the target root, each row matched exactly as an ignore entry is. A row's
231
+ * reason is the provenance an analyzer wrote, never a maintainer's judgement: the finding
232
+ * it records is real, and the row says only that it was there when the baseline was
233
+ * written. An absent file is an empty one.
234
+ */
235
+ export function readBaseline(
236
+ host: Host,
237
+ targetRoot: string,
238
+ symbols: readonly InventorySymbol[],
239
+ ): Suppressions {
240
+ return readDocument(host, targetRoot, symbols, BASELINE_SHAPE);
241
+ }
242
+
243
+ /** One finding a written row records: what a later run matches the row on. */
244
+ export interface Recorded {
245
+ readonly code: string;
246
+ readonly symbol: string;
247
+ readonly path: string;
248
+ }
249
+
250
+ /** The analyzer a written row names as its provenance, as the same run's report names it. */
251
+ export interface Provenance {
252
+ readonly analyzer: string;
253
+ readonly version: string;
254
+ }
255
+
256
+ /** What a written document says about itself, so the same report writes the same bytes. */
257
+ const BASELINE_DESCRIPTION =
258
+ "Recorded findings: a run fails only on a finding this document does not hold. " +
259
+ "Every reason is the analyzer that recorded the row, never an adjudication.";
260
+
261
+ /**
262
+ * A baseline holding one row per finding, in the order given, which is the report's. Every
263
+ * row carries the provenance as its reason. A finding missing a value a row needs, and an
264
+ * identity missing its name or version, are refused rather than written, because the
265
+ * grammar requires a reason on every row.
266
+ */
267
+ export function writeBaseline(findings: readonly Recorded[], provenance: Provenance): string {
268
+ if (provenance.analyzer === "" || provenance.version === "") {
269
+ throw new Error(
270
+ `a baseline row would carry no reason: the analyzer identity names ${JSON.stringify(provenance.analyzer)} at version ${JSON.stringify(provenance.version)}`,
271
+ );
272
+ }
273
+ const reason = `recorded by ${provenance.analyzer} ${provenance.version}`;
274
+ const baseline = findings.map((found) => {
275
+ if (found.code === "" || found.symbol === "" || found.path === "") {
276
+ throw new Error(
277
+ `a baseline row would carry no reason: a finding names code ${JSON.stringify(found.code)}, symbol ${JSON.stringify(found.symbol)} and path ${JSON.stringify(found.path)}`,
278
+ );
279
+ }
280
+ return { code: found.code, symbol: found.symbol, path: found.path, reason };
281
+ });
282
+ return `${JSON.stringify({ description: BASELINE_DESCRIPTION, baseline }, null, 2)}\n`;
283
+ }