@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/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,234 @@
1
+ import { runAnalysis, type RunProject } from "../analysis.ts";
2
+ import { ConfigError, type Format } from "../config.ts";
3
+ import { RECORDED_CONFORMANCE, reportedConformance } from "../conformance.ts";
4
+ import { analyzerProvenance, recordedFindings, verdictOf } from "../findings-pass.ts";
5
+ import { packageScope } from "../inventory.ts";
6
+ import { normalizePath, relativePath, resolvePath } from "../paths.ts";
7
+ import type { Host } from "../host.ts";
8
+ import {
9
+ buildReport,
10
+ capFindings,
11
+ sortFindings,
12
+ type Report,
13
+ type ReportConfiguration,
14
+ } from "../report.ts";
15
+ import { json } from "../reporters/json.ts";
16
+ import { RENDERINGS } from "../reporters/reporters.ts";
17
+ import { resolve } from "../resolve.ts";
18
+ import type { Module } from "../scope.ts";
19
+ import { writeBaseline, type Recorded } from "../suppress-file.ts";
20
+ import { CONTRACT_VERSION } from "../version.ts";
21
+ import { EXIT_CLEAN, EXIT_FAILURE, type Verb, type VerbOptions } from "./verb.ts";
22
+
23
+ /** The options `analyze` takes beside the ones every verb takes. */
24
+ export const ANALYZE_OPTIONS: VerbOptions = {
25
+ plain: ["report", "baseline-write", "exit-code"],
26
+ repeatable: ["format"],
27
+ };
28
+
29
+ /** The two values `--exit-code` takes; on, the default, puts the run's verdict in the exit code. */
30
+ const EXIT_CODE_ON = "on";
31
+ const EXIT_CODE_OFF = "off";
32
+
33
+ /** Why a declared consumer is not loaded, which every report names it with. */
34
+ const NOT_LOADED = "this analyzer loads no consumer beside the target";
35
+
36
+ /** A path of the run no report can name, which ends the run with the failure code. */
37
+ class ReportPathError extends Error {
38
+ constructor(message: string) {
39
+ super(message);
40
+ this.name = "ReportPathError";
41
+ }
42
+ }
43
+
44
+ /** The formats this analyzer renders, for a message that names them. */
45
+ function formatNames(): string {
46
+ return [...RENDERINGS.keys()].sort().join(", ");
47
+ }
48
+
49
+ /** The renderings one invocation writes: the ones it named, else the configuration's. */
50
+ function formatsOf(named: readonly string[], configured: readonly Format[]): readonly Format[] {
51
+ const formats = named.length > 0 ? named : configured;
52
+ const seen = new Set<string>();
53
+ for (const format of formats) {
54
+ if (!RENDERINGS.has(format as Format)) {
55
+ throw new ConfigError(
56
+ "malformed",
57
+ "",
58
+ `${JSON.stringify(format)} is not a format this analyzer renders: the formats are ${formatNames()}`,
59
+ );
60
+ }
61
+ if (seen.has(format)) {
62
+ throw new ConfigError("malformed", "", `the format ${JSON.stringify(format)} is named twice`);
63
+ }
64
+ seen.add(format);
65
+ }
66
+ return formats as readonly Format[];
67
+ }
68
+
69
+ /**
70
+ * One directory of the run below the directory the run was invoked from, as a report names
71
+ * it. A report admits that directory and every path below it and none that climbs out, so a
72
+ * target elsewhere has no spelling a report can carry and fails the run.
73
+ */
74
+ function runRelative(invoked: string, path: string): string {
75
+ if (normalizePath(invoked) === normalizePath(path)) {
76
+ return ".";
77
+ }
78
+ const below = relativePath(invoked, path);
79
+ if (below === undefined) {
80
+ throw new ReportPathError(
81
+ `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`,
82
+ );
83
+ }
84
+ return below;
85
+ }
86
+
87
+ /** Each project of the run as a report names it, by its configuration file below the target. */
88
+ function configurationsOf(
89
+ projects: readonly RunProject[],
90
+ targetRoot: string,
91
+ ): readonly ReportConfiguration[] {
92
+ return projects.map((one) => {
93
+ const project = relativePath(targetRoot, one.configFile);
94
+ if (project === undefined) {
95
+ throw new ReportPathError(
96
+ `the project ${one.id} is opened from ${one.configFile}, which is not below the target ${targetRoot}, so no report can name it`,
97
+ );
98
+ }
99
+ return { id: one.id, project };
100
+ });
101
+ }
102
+
103
+ /** The name one module publishes itself under: the scope's, else its manifest's. */
104
+ function identityOf(module: Module, host: Host): string {
105
+ return module.id === "" ? packageScope(host, module.path)("index.ts").package : module.id;
106
+ }
107
+
108
+ /** A count with its noun, so a message reads for one record as well as for several. */
109
+ function counted(n: number, noun: string): string {
110
+ return `${String(n)} ${noun}${n === 1 ? "" : "s"}`;
111
+ }
112
+
113
+ /** Every document one invocation asked for: the report, each rendering beside it, a baseline. */
114
+ interface Documents {
115
+ readonly report: string;
116
+ readonly formats: readonly Format[];
117
+ readonly baseline: string | undefined;
118
+ }
119
+
120
+ /**
121
+ * Analyzes the scope under the configuration and writes the JSON report to the path
122
+ * `--report` names, each rendering `--format` names beside it at the report's path with the
123
+ * format's suffix appended, and a baseline of every finding where `--baseline-write` names a
124
+ * path. Nothing is written to the output stream; diagnostics go to the error stream and the
125
+ * verdict is the exit code, or 0 under `--exit-code=off`.
126
+ */
127
+ export const analyzeVerb: Verb = ({ err, host, inputs, scope, openClient, option, repeated }) => {
128
+ const reportPath = option("report");
129
+ if (reportPath === undefined || reportPath === "") {
130
+ throw new ConfigError(
131
+ "malformed",
132
+ "",
133
+ "analyze writes its report to the path --report names, and no path was named",
134
+ );
135
+ }
136
+ const exitCode = option("exit-code") ?? EXIT_CODE_ON;
137
+ if (exitCode !== EXIT_CODE_ON && exitCode !== EXIT_CODE_OFF) {
138
+ throw new ConfigError(
139
+ "malformed",
140
+ "",
141
+ `--exit-code=${JSON.stringify(exitCode)}: the values are ${EXIT_CODE_ON} and ${EXIT_CODE_OFF}`,
142
+ );
143
+ }
144
+ const { config, provenance } = resolve(inputs());
145
+ const kind = config.targetKind;
146
+ if (kind === "") {
147
+ throw new ConfigError("missing-target-kind", "target.kind", "target.kind is not set");
148
+ }
149
+ const invoked = host.workingDirectory();
150
+ const baseline = option("baseline-write");
151
+ const documents: Documents = {
152
+ report: resolvePath(invoked, reportPath),
153
+ formats: formatsOf(repeated("format"), config.reporters.formats),
154
+ baseline: baseline === undefined ? undefined : resolvePath(invoked, baseline),
155
+ };
156
+ const scoped = scope();
157
+ const targetRoot = scoped.target.path;
158
+
159
+ let version: string;
160
+ try {
161
+ version = host.analyzerVersion();
162
+ } catch (error: unknown) {
163
+ err.write(`deadset-ts: ${error instanceof Error ? error.message : String(error)}\n`);
164
+ return EXIT_FAILURE;
165
+ }
166
+
167
+ let report: Report;
168
+ let recorded: readonly Recorded[];
169
+ let verdict: number;
170
+ try {
171
+ const root = runRelative(invoked, targetRoot);
172
+ const { result, run } = runAnalysis(openClient(false), host, scoped, config, provenance, {
173
+ production: true,
174
+ });
175
+ const built = buildReport({
176
+ contractVersion: CONTRACT_VERSION,
177
+ version,
178
+ conformance: reportedConformance(RECORDED_CONFORMANCE),
179
+ target: { kind, root, identity: identityOf(scoped.target, host) },
180
+ configurations: configurationsOf(run.projects, targetRoot),
181
+ unavailable: scoped.consumers.map((consumer) => ({
182
+ id: identityOf(consumer, host),
183
+ reason: NOT_LOADED,
184
+ })),
185
+ result,
186
+ testFileRules: run.testFileRules,
187
+ });
188
+ // The baseline records every finding, so its rows are taken before the cap bounds what
189
+ // a rendering prints: a baseline missing a reported finding fails the next run on it.
190
+ recorded = recordedFindings(result);
191
+ report = capFindings(sortFindings(built, config.reporters.sort), config.reporters.maxFindings);
192
+ verdict = verdictOf(result, config);
193
+ } catch (error: unknown) {
194
+ if (!(error instanceof ReportPathError)) {
195
+ throw error;
196
+ }
197
+ err.write(`deadset-ts: ${error.message}\n`);
198
+ return EXIT_FAILURE;
199
+ }
200
+
201
+ try {
202
+ host.writeFile(documents.report, json(report));
203
+ for (const format of documents.formats) {
204
+ const rendering = RENDERINGS.get(format);
205
+ if (rendering !== undefined) {
206
+ host.writeFile(documents.report + rendering.suffix, rendering.render(report));
207
+ }
208
+ }
209
+ if (documents.baseline !== undefined) {
210
+ host.writeFile(documents.baseline, writeBaseline(recorded, analyzerProvenance(version)));
211
+ }
212
+ } catch (error: unknown) {
213
+ err.write(`deadset-ts: ${error instanceof Error ? error.message : String(error)}\n`);
214
+ return EXIT_FAILURE;
215
+ }
216
+
217
+ if (report.totals.pending > 0) {
218
+ err.write(
219
+ `deadset-ts: ${counted(report.totals.pending, "pending finding")}: the report names a declared cross-language edge, and no merge has resolved it\n`,
220
+ );
221
+ }
222
+ if (report.totals.stale_suppressions > 0) {
223
+ err.write(`deadset-ts: ${counted(report.totals.stale_suppressions, "stale suppression")}\n`);
224
+ }
225
+ if (exitCode === EXIT_CODE_OFF) {
226
+ if (verdict !== EXIT_CLEAN) {
227
+ err.write(
228
+ `deadset-ts: the exit code is configured off: the verdict of this run is ${String(verdict)}\n`,
229
+ );
230
+ }
231
+ return EXIT_CLEAN;
232
+ }
233
+ return verdict;
234
+ };
@@ -0,0 +1,46 @@
1
+ import { RECORDED_CONFORMANCE, type Conformance } from "../conformance.ts";
2
+ import { ANALYZER_NAME, LANGUAGE, SCHEMA_VERSION } from "../report.ts";
3
+ import { CONTRACT_VERSION } from "../version.ts";
4
+ import { EXIT_CLEAN, EXIT_FAILURE, EXIT_USAGE, type Verb } from "./verb.ts";
5
+
6
+ /**
7
+ * The document `describe` writes: the analyzer, the Contract version it implements, the
8
+ * report schema versions it reads, the languages it claims, and its recorded result over
9
+ * the conformance corpus, absent where it records none.
10
+ */
11
+ export function describeDocument(version: string, recorded: Conformance | undefined): string {
12
+ const document = {
13
+ name: ANALYZER_NAME,
14
+ version,
15
+ contract_version: CONTRACT_VERSION,
16
+ schema_versions_accepted: [SCHEMA_VERSION],
17
+ languages: [LANGUAGE],
18
+ ...(recorded === undefined
19
+ ? {}
20
+ : {
21
+ conformance: {
22
+ corpus_version: recorded.corpusVersion,
23
+ result: recorded.result,
24
+ digest: recorded.digest,
25
+ },
26
+ }),
27
+ };
28
+ return `${JSON.stringify(document, null, 2)}\n`;
29
+ }
30
+
31
+ /** Writes this analyzer's description to the output stream, and nothing to the error stream. */
32
+ export const describeVerb: Verb = ({ out, err, host, args }) => {
33
+ if (args.length > 0) {
34
+ err.write(`deadset-ts: describe takes no argument, got ${JSON.stringify(args[0])}\n`);
35
+ return EXIT_USAGE;
36
+ }
37
+ let version: string;
38
+ try {
39
+ version = host.analyzerVersion();
40
+ } catch (error: unknown) {
41
+ err.write(`deadset-ts: describe: ${error instanceof Error ? error.message : String(error)}\n`);
42
+ return EXIT_FAILURE;
43
+ }
44
+ out.write(describeDocument(version, RECORDED_CONFORMANCE));
45
+ return EXIT_CLEAN;
46
+ };
package/src/verbs/verb.ts CHANGED
@@ -28,6 +28,19 @@ export interface Invocation {
28
28
  readonly openClient: (collectTiming: boolean) => Engine;
29
29
  /** The emitter of every kind family, which a verb that reports findings runs. */
30
30
  readonly emitters: Emitters;
31
+ /** The arguments after the verb's name, as written. */
32
+ readonly args: readonly string[];
33
+ /** The last value one of the verb's own options was given, undefined where none was. */
34
+ readonly option: (name: string) => string | undefined;
35
+ /** Every value a repeatable option of the verb was given, in the order they were written. */
36
+ readonly repeated: (name: string) => readonly string[];
37
+ }
38
+
39
+ /** The options one verb takes beside the ones every verb takes, each taking a value. */
40
+ export interface VerbOptions {
41
+ readonly plain: readonly string[];
42
+ /** The options that may be written more than once, each occurrence one value. */
43
+ readonly repeatable: readonly string[];
31
44
  }
32
45
 
33
46
  /**