@cplieger/deadset-ts 3.8.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.
package/dist/src/run.js CHANGED
@@ -8,6 +8,8 @@ import { printConfigVerb } from "./verbs/print-config.js";
8
8
  import { printProjectsVerb } from "./verbs/print-projects.js";
9
9
  import { printRetainedVerb } from "./verbs/print-retained.js";
10
10
  import { printRootsVerb } from "./verbs/print-roots.js";
11
+ import { ANALYZE_OPTIONS, analyzeVerb } from "./verbs/analyze.js";
12
+ import { describeVerb } from "./verbs/describe.js";
11
13
  import { EXIT_FAILURE, EXIT_USAGE } from "./verbs/verb.js";
12
14
  import { versionVerb } from "./verbs/version.js";
13
15
  /**
@@ -15,13 +17,13 @@ import { versionVerb } from "./verbs/version.js";
15
17
  * with no `run` is listed, and invoking it is a usage error.
16
18
  */
17
19
  const VERBS = [
18
- { name: "analyze" },
20
+ { name: "analyze", run: analyzeVerb, options: ANALYZE_OPTIONS },
19
21
  { name: "explain" },
20
22
  { name: "print-config", run: printConfigVerb },
21
23
  { name: "print-projects", run: printProjectsVerb },
22
24
  { name: "print-roots", run: printRootsVerb },
23
25
  { name: "print-retained", run: printRetainedVerb },
24
- { name: "describe" },
26
+ { name: "describe", run: describeVerb },
25
27
  { name: "version", run: versionVerb },
26
28
  ];
27
29
  const USAGE = `usage: deadset-ts <verb> [options]\nverbs: ${VERBS.map((verb) => verb.name).join(", ")}\n`;
@@ -55,6 +57,8 @@ const PLAIN_OPTIONS = new Set(["target", "config", "central", "scope"]);
55
57
  const INTEGER_OPTIONS = new Set(["max-findings"]);
56
58
  /** An integer as an option writes it: an optional minus sign and digits alone. */
57
59
  const INTEGER_TEXT = /^-?[0-9]+$/u;
60
+ /** No option beyond the ones every verb takes. */
61
+ const NO_OPTIONS = { plain: [], repeatable: [] };
58
62
  /**
59
63
  * The first option whose name carries a source-edit token, because every verb
60
64
  * reports and never edits. The match is on the name alone, so an option whose
@@ -73,24 +77,30 @@ function sourceEditOption(args) {
73
77
  }
74
78
  return undefined;
75
79
  }
76
- /** Reads one verb's options, refusing a name this command does not offer. */
77
- function readOptions(args) {
80
+ /** Reads one verb's options, refusing a name neither this command nor the verb offers. */
81
+ function readOptions(args, own) {
78
82
  const named = new Map();
83
+ const repeated = new Map();
79
84
  for (const arg of args) {
80
85
  if (!arg.startsWith("-")) {
81
86
  throw new ConfigError("malformed", "", `unexpected argument ${JSON.stringify(arg)}`);
82
87
  }
83
88
  const [spelled, ...rest] = arg.split("=");
84
89
  const name = (spelled ?? "").replace(/^-+/u, "");
85
- if (!PLAIN_OPTIONS.has(name) && !SETTING_OPTIONS.has(name)) {
90
+ if (!PLAIN_OPTIONS.has(name) &&
91
+ !SETTING_OPTIONS.has(name) &&
92
+ !own.plain.includes(name) &&
93
+ !own.repeatable.includes(name)) {
86
94
  throw new ConfigError("malformed", "", `unknown option ${JSON.stringify(spelled ?? arg)}`);
87
95
  }
88
96
  if (rest.length === 0) {
89
97
  throw new ConfigError("malformed", "", `option ${JSON.stringify(spelled ?? arg)} takes a value`);
90
98
  }
91
- named.set(name, rest.join("="));
99
+ const value = rest.join("=");
100
+ named.set(name, value);
101
+ repeated.set(name, [...(repeated.get(name) ?? []), value]);
92
102
  }
93
- return { named };
103
+ return { named, repeated };
94
104
  }
95
105
  function readFileOr(host, path, orUndefined) {
96
106
  try {
@@ -160,7 +170,7 @@ export function run(args, out, err, host, openClient = (collectTiming) => openEn
160
170
  const name = args[0];
161
171
  const verb = VERBS.find((listed) => listed.name === name);
162
172
  if (verb?.run !== undefined) {
163
- return invoke(verb.run, args.slice(1), { out, err, host }, openClient);
173
+ return invoke(verb.run, verb.options ?? NO_OPTIONS, args.slice(1), { out, err, host }, openClient);
164
174
  }
165
175
  if (verb !== undefined) {
166
176
  err.write(`deadset-ts: ${verb.name} is not implemented\n`);
@@ -177,15 +187,18 @@ export function run(args, out, err, host, openClient = (collectTiming) => openEn
177
187
  * time the verb asks for what they name, and a refusal the verb throws is written
178
188
  * and turned into its exit code once the client the verb opened is closed.
179
189
  */
180
- function invoke(verb, args, streams, openClient) {
190
+ function invoke(verb, own, args, streams, openClient) {
181
191
  let options;
182
- const read = () => (options ??= readOptions(args));
192
+ const read = () => (options ??= readOptions(args, own));
183
193
  let opened;
184
194
  try {
185
195
  return verb({
186
196
  ...streams,
197
+ args,
187
198
  inputs: () => inputsOf(streams.host, read()),
188
199
  scope: () => scopeOf(streams.host, read()),
200
+ option: (name) => read().named.get(name),
201
+ repeated: (name) => read().repeated.get(name) ?? [],
189
202
  openClient: (collectTiming) => {
190
203
  opened = openClient(collectTiming);
191
204
  return opened;
@@ -0,0 +1,184 @@
1
+ import { runAnalysis } from "../analysis.js";
2
+ import { ConfigError } from "../config.js";
3
+ import { RECORDED_CONFORMANCE, reportedConformance } from "../conformance.js";
4
+ import { analyzerProvenance, recordedFindings, verdictOf } from "../findings-pass.js";
5
+ import { packageScope } from "../inventory.js";
6
+ import { normalizePath, relativePath, resolvePath } from "../paths.js";
7
+ import { buildReport, capFindings, sortFindings, } from "../report.js";
8
+ import { json } from "../reporters/json.js";
9
+ import { RENDERINGS } from "../reporters/reporters.js";
10
+ import { resolve } from "../resolve.js";
11
+ import { writeBaseline } from "../suppress-file.js";
12
+ import { CONTRACT_VERSION } from "../version.js";
13
+ import { EXIT_CLEAN, EXIT_FAILURE } from "./verb.js";
14
+ /** The options `analyze` takes beside the ones every verb takes. */
15
+ export const ANALYZE_OPTIONS = {
16
+ plain: ["report", "baseline-write", "exit-code"],
17
+ repeatable: ["format"],
18
+ };
19
+ /** The two values `--exit-code` takes; on, the default, puts the run's verdict in the exit code. */
20
+ const EXIT_CODE_ON = "on";
21
+ const EXIT_CODE_OFF = "off";
22
+ /** Why a declared consumer is not loaded, which every report names it with. */
23
+ const NOT_LOADED = "this analyzer loads no consumer beside the target";
24
+ /** A path of the run no report can name, which ends the run with the failure code. */
25
+ class ReportPathError extends Error {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = "ReportPathError";
29
+ }
30
+ }
31
+ /** The formats this analyzer renders, for a message that names them. */
32
+ function formatNames() {
33
+ return [...RENDERINGS.keys()].sort().join(", ");
34
+ }
35
+ /** The renderings one invocation writes: the ones it named, else the configuration's. */
36
+ function formatsOf(named, configured) {
37
+ const formats = named.length > 0 ? named : configured;
38
+ const seen = new Set();
39
+ for (const format of formats) {
40
+ if (!RENDERINGS.has(format)) {
41
+ throw new ConfigError("malformed", "", `${JSON.stringify(format)} is not a format this analyzer renders: the formats are ${formatNames()}`);
42
+ }
43
+ if (seen.has(format)) {
44
+ throw new ConfigError("malformed", "", `the format ${JSON.stringify(format)} is named twice`);
45
+ }
46
+ seen.add(format);
47
+ }
48
+ return formats;
49
+ }
50
+ /**
51
+ * One directory of the run below the directory the run was invoked from, as a report names
52
+ * it. A report admits that directory and every path below it and none that climbs out, so a
53
+ * target elsewhere has no spelling a report can carry and fails the run.
54
+ */
55
+ function runRelative(invoked, path) {
56
+ if (normalizePath(invoked) === normalizePath(path)) {
57
+ return ".";
58
+ }
59
+ const below = relativePath(invoked, path);
60
+ if (below === undefined) {
61
+ throw new ReportPathError(`a report names its target relative to the directory the run was invoked from, and ${invoked} does not contain ${path}: run from a directory that does`);
62
+ }
63
+ return below;
64
+ }
65
+ /** Each project of the run as a report names it, by its configuration file below the target. */
66
+ function configurationsOf(projects, targetRoot) {
67
+ return projects.map((one) => {
68
+ const project = relativePath(targetRoot, one.configFile);
69
+ if (project === undefined) {
70
+ throw new ReportPathError(`the project ${one.id} is opened from ${one.configFile}, which is not below the target ${targetRoot}, so no report can name it`);
71
+ }
72
+ return { id: one.id, project };
73
+ });
74
+ }
75
+ /** The name one module publishes itself under: the scope's, else its manifest's. */
76
+ function identityOf(module, host) {
77
+ return module.id === "" ? packageScope(host, module.path)("index.ts").package : module.id;
78
+ }
79
+ /** A count with its noun, so a message reads for one record as well as for several. */
80
+ function counted(n, noun) {
81
+ return `${String(n)} ${noun}${n === 1 ? "" : "s"}`;
82
+ }
83
+ /**
84
+ * Analyzes the scope under the configuration and writes the JSON report to the path
85
+ * `--report` names, each rendering `--format` names beside it at the report's path with the
86
+ * format's suffix appended, and a baseline of every finding where `--baseline-write` names a
87
+ * path. Nothing is written to the output stream; diagnostics go to the error stream and the
88
+ * verdict is the exit code, or 0 under `--exit-code=off`.
89
+ */
90
+ export const analyzeVerb = ({ err, host, inputs, scope, openClient, option, repeated }) => {
91
+ const reportPath = option("report");
92
+ if (reportPath === undefined || reportPath === "") {
93
+ throw new ConfigError("malformed", "", "analyze writes its report to the path --report names, and no path was named");
94
+ }
95
+ const exitCode = option("exit-code") ?? EXIT_CODE_ON;
96
+ if (exitCode !== EXIT_CODE_ON && exitCode !== EXIT_CODE_OFF) {
97
+ throw new ConfigError("malformed", "", `--exit-code=${JSON.stringify(exitCode)}: the values are ${EXIT_CODE_ON} and ${EXIT_CODE_OFF}`);
98
+ }
99
+ const { config, provenance } = resolve(inputs());
100
+ const kind = config.targetKind;
101
+ if (kind === "") {
102
+ throw new ConfigError("missing-target-kind", "target.kind", "target.kind is not set");
103
+ }
104
+ const invoked = host.workingDirectory();
105
+ const baseline = option("baseline-write");
106
+ const documents = {
107
+ report: resolvePath(invoked, reportPath),
108
+ formats: formatsOf(repeated("format"), config.reporters.formats),
109
+ baseline: baseline === undefined ? undefined : resolvePath(invoked, baseline),
110
+ };
111
+ const scoped = scope();
112
+ const targetRoot = scoped.target.path;
113
+ let version;
114
+ try {
115
+ version = host.analyzerVersion();
116
+ }
117
+ catch (error) {
118
+ err.write(`deadset-ts: ${error instanceof Error ? error.message : String(error)}\n`);
119
+ return EXIT_FAILURE;
120
+ }
121
+ let report;
122
+ let recorded;
123
+ let verdict;
124
+ try {
125
+ const root = runRelative(invoked, targetRoot);
126
+ const { result, run } = runAnalysis(openClient(false), host, scoped, config, provenance, {
127
+ production: true,
128
+ });
129
+ const built = buildReport({
130
+ contractVersion: CONTRACT_VERSION,
131
+ version,
132
+ conformance: reportedConformance(RECORDED_CONFORMANCE),
133
+ target: { kind, root, identity: identityOf(scoped.target, host) },
134
+ configurations: configurationsOf(run.projects, targetRoot),
135
+ unavailable: scoped.consumers.map((consumer) => ({
136
+ id: identityOf(consumer, host),
137
+ reason: NOT_LOADED,
138
+ })),
139
+ result,
140
+ testFileRules: run.testFileRules,
141
+ });
142
+ // The baseline records every finding, so its rows are taken before the cap bounds what
143
+ // a rendering prints: a baseline missing a reported finding fails the next run on it.
144
+ recorded = recordedFindings(result);
145
+ report = capFindings(sortFindings(built, config.reporters.sort), config.reporters.maxFindings);
146
+ verdict = verdictOf(result, config);
147
+ }
148
+ catch (error) {
149
+ if (!(error instanceof ReportPathError)) {
150
+ throw error;
151
+ }
152
+ err.write(`deadset-ts: ${error.message}\n`);
153
+ return EXIT_FAILURE;
154
+ }
155
+ try {
156
+ host.writeFile(documents.report, json(report));
157
+ for (const format of documents.formats) {
158
+ const rendering = RENDERINGS.get(format);
159
+ if (rendering !== undefined) {
160
+ host.writeFile(documents.report + rendering.suffix, rendering.render(report));
161
+ }
162
+ }
163
+ if (documents.baseline !== undefined) {
164
+ host.writeFile(documents.baseline, writeBaseline(recorded, analyzerProvenance(version)));
165
+ }
166
+ }
167
+ catch (error) {
168
+ err.write(`deadset-ts: ${error instanceof Error ? error.message : String(error)}\n`);
169
+ return EXIT_FAILURE;
170
+ }
171
+ if (report.totals.pending > 0) {
172
+ err.write(`deadset-ts: ${counted(report.totals.pending, "pending finding")}: the report names a declared cross-language edge, and no merge has resolved it\n`);
173
+ }
174
+ if (report.totals.stale_suppressions > 0) {
175
+ err.write(`deadset-ts: ${counted(report.totals.stale_suppressions, "stale suppression")}\n`);
176
+ }
177
+ if (exitCode === EXIT_CODE_OFF) {
178
+ if (verdict !== EXIT_CLEAN) {
179
+ err.write(`deadset-ts: the exit code is configured off: the verdict of this run is ${String(verdict)}\n`);
180
+ }
181
+ return EXIT_CLEAN;
182
+ }
183
+ return verdict;
184
+ };
@@ -0,0 +1,45 @@
1
+ import { RECORDED_CONFORMANCE } from "../conformance.js";
2
+ import { ANALYZER_NAME, LANGUAGE, SCHEMA_VERSION } from "../report.js";
3
+ import { CONTRACT_VERSION } from "../version.js";
4
+ import { EXIT_CLEAN, EXIT_FAILURE, EXIT_USAGE } from "./verb.js";
5
+ /**
6
+ * The document `describe` writes: the analyzer, the Contract version it implements, the
7
+ * report schema versions it reads, the languages it claims, and its recorded result over
8
+ * the conformance corpus, absent where it records none.
9
+ */
10
+ export function describeDocument(version, recorded) {
11
+ const document = {
12
+ name: ANALYZER_NAME,
13
+ version,
14
+ contract_version: CONTRACT_VERSION,
15
+ schema_versions_accepted: [SCHEMA_VERSION],
16
+ languages: [LANGUAGE],
17
+ ...(recorded === undefined
18
+ ? {}
19
+ : {
20
+ conformance: {
21
+ corpus_version: recorded.corpusVersion,
22
+ result: recorded.result,
23
+ digest: recorded.digest,
24
+ },
25
+ }),
26
+ };
27
+ return `${JSON.stringify(document, null, 2)}\n`;
28
+ }
29
+ /** Writes this analyzer's description to the output stream, and nothing to the error stream. */
30
+ export const describeVerb = ({ out, err, host, args }) => {
31
+ if (args.length > 0) {
32
+ err.write(`deadset-ts: describe takes no argument, got ${JSON.stringify(args[0])}\n`);
33
+ return EXIT_USAGE;
34
+ }
35
+ let version;
36
+ try {
37
+ version = host.analyzerVersion();
38
+ }
39
+ catch (error) {
40
+ err.write(`deadset-ts: describe: ${error instanceof Error ? error.message : String(error)}\n`);
41
+ return EXIT_FAILURE;
42
+ }
43
+ out.write(describeDocument(version, RECORDED_CONFORMANCE));
44
+ return EXIT_CLEAN;
45
+ };
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/deadset-ts",
3
- "version": "3.8.0",
3
+ "version": "3.9.0",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/deadset-ts",
3
- "version": "3.8.0",
3
+ "version": "3.9.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Deterministic dead-code analysis for TypeScript, class and type members included",
package/src/analysis.ts CHANGED
@@ -45,7 +45,7 @@ import { matrixOf, sweepMatrix, type Configured, type Matrix, type SweepResult }
45
45
  import { relativePath, resolvePath } from "./paths.ts";
46
46
  import { byPosition, type Position } from "./position.ts";
47
47
  import { reflectiveLookup } from "./reflective-lookup.ts";
48
- import { references } from "./references.ts";
48
+ import { references, testFileRulesOf, type TestFileRule } from "./references.ts";
49
49
  import { roots, unmatchedEverywhere, type RootKind, type Roots } from "./roots.ts";
50
50
  import type { Scope } from "./scope.ts";
51
51
  import { serializationContract } from "./serialization-contract.ts";
@@ -119,6 +119,8 @@ function compare(a: string, b: string): number {
119
119
  /** What every stage after the root set reads of one project. */
120
120
  interface ProjectRead {
121
121
  readonly configuration: string;
122
+ /** The compiler configuration file the project was opened from, absolute. */
123
+ readonly configFile: string;
122
124
  readonly held: Inventory;
123
125
  readonly rooted: Roots;
124
126
  }
@@ -156,7 +158,7 @@ function readProjects<Answer>(
156
158
  const held = inventory(project, host, targetRoot);
157
159
  const rooted = roots(project, held, targetRoot, options);
158
160
  const configuration = ids.get(project.configFile) ?? project.configFile;
159
- return stage(project, { configuration, held, rooted });
161
+ return stage(project, { configuration, configFile: project.configFile, held, rooted });
160
162
  });
161
163
  if (failures.length > 0) {
162
164
  throw new DiscoveryError(
@@ -253,6 +255,7 @@ const DETECTORS: Detectors = new Map<TSExemptionClass, Detector>([
253
255
 
254
256
  /** What the sweep of one run reads of each project, beside what a caller's stage adds. */
255
257
  interface SweptProject<Extra> {
258
+ readonly configFile: string;
256
259
  readonly configured: Configured;
257
260
  readonly exempt: readonly Exemption[];
258
261
  readonly extra: Extra;
@@ -305,7 +308,12 @@ function readRun<Extra>(
305
308
  testFiles: new Set(resolved.testFilePaths),
306
309
  },
307
310
  );
308
- return { configured, exempt, extra: extra(project, projectRead) };
311
+ return {
312
+ configFile: projectRead.configFile,
313
+ configured,
314
+ exempt,
315
+ extra: extra(project, projectRead),
316
+ };
309
317
  });
310
318
  return {
311
319
  matrix: matrixOf(projects.map((one) => one.configured)),
@@ -465,6 +473,7 @@ export interface AnalysisInputs {
465
473
  * any sweep, and the run is swept once under every bound record's mark and once under
466
474
  * none, so a record is decided against the finding its code would have produced. The
467
475
  * self-check family reports the refused suppressions and the roots that named nothing.
476
+ * Beside the inputs it answers the projects and test-file rules a report names.
468
477
  */
469
478
  function runAnalysisInputs(
470
479
  engine: Engine,
@@ -473,8 +482,8 @@ function runAnalysisInputs(
473
482
  config: Config,
474
483
  provenance: Provenance,
475
484
  mode: Mode,
476
- detectors: Detectors = DETECTORS,
477
- ): AnalysisInputs {
485
+ detectors: Detectors,
486
+ ): { readonly inputs: AnalysisInputs; readonly run: RunFacts } {
478
487
  const targetRoot = scope.target.path;
479
488
  const read = readEmitterRun(engine, host, scope, config, mode, detectors);
480
489
  const symbols = read.matrix.union.symbols;
@@ -499,18 +508,51 @@ function runAnalysisInputs(
499
508
  (id) => id !== "",
500
509
  );
501
510
  return {
502
- marked: emitterInputOver(host, scope, config, read, { marked, mode }, facts),
503
- unmarked: emitterInputOver(host, scope, config, read, { marked: [], mode }, facts),
504
- suppressions,
511
+ inputs: {
512
+ marked: emitterInputOver(host, scope, config, read, { marked, mode }, facts),
513
+ unmarked: emitterInputOver(host, scope, config, read, { marked: [], mode }, facts),
514
+ suppressions,
515
+ },
516
+ run: {
517
+ projects: read.projects.map((one) => ({
518
+ id: one.configured.configuration,
519
+ configFile: one.configFile,
520
+ })),
521
+ testFileRules: testFileRulesOf(
522
+ config.ts.testFiles,
523
+ read.projects.flatMap((one) => one.configured.testFiles),
524
+ ),
525
+ },
505
526
  };
506
527
  }
507
528
 
529
+ /** One project of a run: the name the run gives it and the file it was opened from. */
530
+ export interface RunProject {
531
+ readonly id: string;
532
+ /** The compiler configuration file, absolute. */
533
+ readonly configFile: string;
534
+ }
535
+
536
+ /** What a report states about the run beside its findings. */
537
+ export interface RunFacts {
538
+ /** The projects the run analyzed, in the run's order. */
539
+ readonly projects: readonly RunProject[];
540
+ /** The rules that classified files as test files, each counted over every project at once. */
541
+ readonly testFileRules: readonly TestFileRule[];
542
+ }
543
+
544
+ /** The findings of one run, beside what a report states about the run itself. */
545
+ export interface RunAnalysis {
546
+ readonly result: PassResult;
547
+ readonly run: RunFacts;
548
+ }
549
+
508
550
  /**
509
- * The findings of the run the scope and the configuration describe: the run read with its
510
- * suppressions bound, then every family's findings with the suppressions applied and the
511
- * declared edges evaluated.
551
+ * The analysis of the run the scope and the configuration describe: the run read with its
552
+ * suppressions bound, every family's findings with the suppressions applied and the
553
+ * declared edges evaluated, and the projects and test-file rules the report names.
512
554
  */
513
- export function runFindings(
555
+ export function runAnalysis(
514
556
  engine: Engine,
515
557
  host: Host,
516
558
  scope: Scope,
@@ -518,6 +560,15 @@ export function runFindings(
518
560
  provenance: Provenance,
519
561
  mode: Mode,
520
562
  detectors: Detectors = DETECTORS,
521
- ): PassResult {
522
- return findingsPass(runAnalysisInputs(engine, host, scope, config, provenance, mode, detectors));
563
+ ): RunAnalysis {
564
+ const { inputs, run } = runAnalysisInputs(
565
+ engine,
566
+ host,
567
+ scope,
568
+ config,
569
+ provenance,
570
+ mode,
571
+ detectors,
572
+ );
573
+ return { result: findingsPass(inputs), run };
523
574
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * What this analyzer states about its own conformance: the corpus version it is written
3
+ * against and its recorded result over that corpus, which `describe` and every report name.
4
+ */
5
+
6
+ /** The result one product reached over the conformance corpus. */
7
+ export interface Conformance {
8
+ readonly corpusVersion: string;
9
+ readonly result: "pass" | "fail";
10
+ /** `sha256:` and the lowercase hexadecimal SHA-256 of the results document the runner wrote. */
11
+ readonly digest: string;
12
+ }
13
+
14
+ /** The version of the conformance corpus this analyzer is written against. */
15
+ const CORPUS_VERSION = "1.9.0";
16
+
17
+ /** The SHA-256 of zero bytes, which is the digest of a results document no run wrote. */
18
+ const EMPTY_DIGEST = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
19
+
20
+ /**
21
+ * The conformance run this analyzer records, or undefined where it records none. A
22
+ * description states exactly this, so a reader that requires a pass refuses an analyzer
23
+ * that has not answered the corpus.
24
+ */
25
+ export const RECORDED_CONFORMANCE: Conformance | undefined = undefined;
26
+
27
+ /**
28
+ * The conformance a report states. A report requires the block, so an analyzer that
29
+ * records no run states a failed one over the results document no run wrote, which a merge
30
+ * refuses as it refuses any result other than a pass.
31
+ */
32
+ export function reportedConformance(recorded: Conformance | undefined): Conformance {
33
+ return recorded ?? { corpusVersion: CORPUS_VERSION, result: "fail", digest: EMPTY_DIGEST };
34
+ }
package/src/finding.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Confidence, Severity } from "./config.ts";
2
2
  import type { Fixability } from "./kinds.ts";
3
3
  import type { DependencySection } from "./ref.ts";
4
+ import type { Mechanism } from "./suppress.ts";
4
5
  import type { Relation } from "./sweep.ts";
5
6
 
6
7
  /**
@@ -58,6 +59,18 @@ export interface FindingDetails {
58
59
  readonly writePositions?: readonly FindingPosition[];
59
60
  /** On `DS1601`: the manifest section that declares the dependency. */
60
61
  readonly dependencyClass?: DependencySection;
62
+ /** On `DS1701` and `DS1702`: the document that holds the refused suppression. */
63
+ readonly mechanism?: Mechanism;
64
+ /** On `DS1701` and `DS1702`: the refused suppression as written, a member absent where it lacks one. */
65
+ readonly entry?: SuppressionEntry;
66
+ }
67
+
68
+ /** One suppression in the four-member form the ignore file and the baseline share. */
69
+ export interface SuppressionEntry {
70
+ readonly code: string;
71
+ readonly symbol?: string;
72
+ readonly path?: string;
73
+ readonly reason?: string;
61
74
  }
62
75
 
63
76
  /** One finding as its emitter states it. */
@@ -11,7 +11,7 @@ import type { CompletedFinding, Finding, FindingComponent } from "../finding.ts"
11
11
  import { OUTSIDE } from "../graph.ts";
12
12
  import { KINDS } from "../kinds.ts";
13
13
  import { FAMILY_KEY_LENGTH } from "../resolve.ts";
14
- import type { Dials } from "../suppress.ts";
14
+ import { isRowSubject, type Dials } from "../suppress.ts";
15
15
  import type { EmitterInput } from "./emitter.ts";
16
16
 
17
17
  /** The classes ranked by the claim each makes, the strongest highest. */
@@ -75,7 +75,8 @@ function findingComponents(swept: RunSweep): (id: string) => FindingComponent |
75
75
  * candidate's relation, counts, configurations and component; a live one carries no
76
76
  * relation and holds where it is declared; a file or a manifest row is `certain` and
77
77
  * holds in every configuration. A subject in no dead component gets one of its own,
78
- * numbered past the computed ones in finding order, so no two families mint one.
78
+ * numbered past the computed ones in finding order, so no two families mint one; nothing
79
+ * falls with it, so its deletion removes no line.
79
80
  */
80
81
  export function completed(
81
82
  input: EmitterInput,
@@ -94,7 +95,11 @@ export function completed(
94
95
  if (row === undefined) {
95
96
  throw new Error(`${finding.code} names no live row of the issue-kind vocabulary`);
96
97
  }
97
- const at = positions.get(finding.symbol.ref) ?? OUTSIDE;
98
+ // A row's reference may spell a declaration's, as an ignore entry naming one does, and
99
+ // a row is still never the declaration.
100
+ const at = isRowSubject(finding.symbol.kind)
101
+ ? OUTSIDE
102
+ : (positions.get(finding.symbol.ref) ?? OUTSIDE);
98
103
  const symbol = union.symbols[at];
99
104
  const judged = union.subject[at] === true ? symbol : undefined;
100
105
  const candidate = judged === undefined ? undefined : candidates.get(judged.id);
@@ -109,7 +114,7 @@ export function completed(
109
114
  id: componentId(minted),
110
115
  root: true,
111
116
  symbolCount: 1,
112
- deletableLines: finding.symbol.sizeLines,
117
+ deletableLines: 0,
113
118
  };
114
119
  }
115
120
  const counts =
@@ -1,5 +1,5 @@
1
1
  import { REPOSITORY_DOCUMENT } from "../config.ts";
2
- import type { Finding } from "../finding.ts";
2
+ import type { Finding, FindingDetails, SuppressionEntry } from "../finding.ts";
3
3
  import type { InventorySymbol } from "../inventory.ts";
4
4
  import type { Position } from "../position.ts";
5
5
  import {
@@ -73,12 +73,32 @@ function spelled(codes: readonly string[]): string {
73
73
  }
74
74
 
75
75
  /** A finding about one suppression, at the suppression's own site. */
76
- function aboutSuppression(code: string, ref: string, site: Position, message: string): Finding {
76
+ function aboutSuppression(
77
+ code: string,
78
+ ref: string,
79
+ site: Position,
80
+ message: string,
81
+ details: FindingDetails,
82
+ ): Finding {
77
83
  return {
78
84
  code,
79
85
  position: { ...site, endLine: site.line },
80
86
  symbol: { ref, kind: SUPPRESSION_SUBJECT, name: ref, sizeLines: 1 },
81
87
  message,
88
+ details,
89
+ };
90
+ }
91
+
92
+ /** Whether a reason holds a character other than whitespace. */
93
+ const STATED = /[^ \t\r\n]/u;
94
+
95
+ /** A refused suppression as written: a member it lacks is absent. */
96
+ function writtenEntry(refused: Refusal): SuppressionEntry {
97
+ return {
98
+ code: refused.code,
99
+ ...(refused.symbol === "" ? {} : { symbol: refused.symbol }),
100
+ ...(refused.path === "" ? {} : { path: refused.path }),
101
+ ...(STATED.test(refused.reason) ? { reason: refused.reason } : {}),
82
102
  };
83
103
  }
84
104
 
@@ -108,6 +128,7 @@ export function refusedSuppressions(
108
128
  ? "carries no reason"
109
129
  : "names a symbol and no path"
110
130
  }`,
131
+ { mechanism: refused.mechanism, entry: writtenEntry(refused) },
111
132
  ),
112
133
  );
113
134
  }
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
  }