@cplieger/deadset-ts 0.1.0 → 1.1.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
@@ -1,16 +1,36 @@
1
+ import { ConfigError, type Inputs } from "./config.ts";
2
+ import {
3
+ diagnosticErrors,
4
+ discoverProjects,
5
+ DiscoveryError,
6
+ renderDiagnostic,
7
+ } from "./discover.ts";
8
+ import type { Host } from "./host.ts";
9
+ import { joinPath, resolvePath } from "./paths.ts";
10
+ import { printConfig } from "./print.ts";
11
+ import { resolve } from "./resolve.ts";
12
+ import { readScope, ScopeError, scopeForDir, type Scope } from "./scope.ts";
13
+ import { diagnosticsOf, openEngine, runSession, type Engine } from "./session.ts";
14
+ import { CONTRACT_VERSION, version } from "./version.ts";
15
+
1
16
  /** One output stream of the command line. `process.stdout` and `process.stderr` satisfy it. */
2
17
  export interface Writer {
3
18
  write(text: string): void;
4
19
  }
5
20
 
6
- const VERSION = "0.1.0-dev";
21
+ /** The repository configuration's name at the target root. */
22
+ const REPOSITORY_DOCUMENT = "deadset.json";
7
23
 
8
- const CONTRACT_VERSION = "0.1.0";
24
+ /** The exit codes the Contract's exit-code table names. */
25
+ const EXIT_CLEAN = 0;
26
+ const EXIT_USAGE = 2;
27
+ const EXIT_FAILURE = 3;
9
28
 
10
29
  const VERBS = [
11
30
  "analyze",
12
31
  "explain",
13
32
  "print-config",
33
+ "print-projects",
14
34
  "print-roots",
15
35
  "print-retained",
16
36
  "describe",
@@ -20,30 +40,265 @@ const VERBS = [
20
40
  const USAGE = `usage: deadset-ts <verb> [options]\nverbs: ${VERBS.join(", ")}\n`;
21
41
 
22
42
  /**
23
- * Runs the deadset-ts command line over `args` (the arguments after the
24
- * program name) and returns the process exit code: 0 for a completed verb,
25
- * 2 for a usage error. It writes nothing outside `out` and `err` and never
26
- * exits the process, so a caller decides what the code means.
43
+ * The tokens whose presence in an option's name makes that option a request to
44
+ * edit a source file. The set is closed, and a token is matched in any position of
45
+ * the name.
46
+ */
47
+ const SOURCE_EDIT_TOKENS = ["fix", "edit", "delete", "rewrite"] as const;
48
+
49
+ /**
50
+ * Maps an option name to the setting it supplies, so one entry registers the
51
+ * option, supplies the document resolution reads, and gives the spelling the
52
+ * setting's provenance names.
53
+ *
54
+ * An option's name is this command's own vocabulary, chosen for how it reads on a
55
+ * command line, and its path is the setting of the Contract's configuration schema
56
+ * that option supplies. The two are mapped here and never derived from one
57
+ * another, so this table is the authority for both: a setting reaches the command
58
+ * line only by an entry, and a path names a key the schema declares.
59
+ */
60
+ export const SETTING_OPTIONS: ReadonlyMap<string, string> = new Map([
61
+ ["min-confidence", "analysis.min_confidence"],
62
+ ["sort", "reporters.sort"],
63
+ ["cascade", "reporters.cascade"],
64
+ ["max-findings", "reporters.max_findings"],
65
+ ["fail-on", "reporters.fail_on"],
66
+ ]);
67
+
68
+ /** The options that take a value and are not settings. */
69
+ const PLAIN_OPTIONS: ReadonlySet<string> = new Set(["target", "config", "central", "scope"]);
70
+
71
+ /** The setting options whose value is a number rather than a string. */
72
+ const NUMBER_OPTIONS: ReadonlySet<string> = new Set(["max-findings"]);
73
+
74
+ /** One invocation's options, by name, with the verb's remaining arguments. */
75
+ interface Options {
76
+ readonly named: ReadonlyMap<string, string>;
77
+ }
78
+
79
+ /**
80
+ * The first option whose name carries a source-edit token, because every verb
81
+ * reports and never edits. The match is on the name alone, so an option whose
82
+ * value carries a token is not a request.
83
+ */
84
+ function sourceEditOption(args: readonly string[]): string | undefined {
85
+ for (const arg of args) {
86
+ if (!arg.startsWith("-")) {
87
+ continue;
88
+ }
89
+ const spelled = arg.split("=", 1)[0] ?? arg;
90
+ const name = spelled.replace(/^-+/u, "");
91
+ if (SOURCE_EDIT_TOKENS.some((token) => name.includes(token))) {
92
+ return spelled;
93
+ }
94
+ }
95
+ return undefined;
96
+ }
97
+
98
+ /** Reads one verb's options, refusing a name this command does not offer. */
99
+ function readOptions(args: readonly string[]): Options {
100
+ const named = new Map<string, string>();
101
+ for (const arg of args) {
102
+ if (!arg.startsWith("-")) {
103
+ throw new ConfigError("malformed", "", `unexpected argument ${JSON.stringify(arg)}`);
104
+ }
105
+ const [spelled, ...rest] = arg.split("=");
106
+ const name = (spelled ?? "").replace(/^-+/u, "");
107
+ if (!PLAIN_OPTIONS.has(name) && !SETTING_OPTIONS.has(name)) {
108
+ throw new ConfigError("malformed", "", `unknown option ${JSON.stringify(spelled ?? arg)}`);
109
+ }
110
+ if (rest.length === 0) {
111
+ throw new ConfigError(
112
+ "malformed",
113
+ "",
114
+ `option ${JSON.stringify(spelled ?? arg)} takes a value`,
115
+ );
116
+ }
117
+ named.set(name, rest.join("="));
118
+ }
119
+ return { named };
120
+ }
121
+
122
+ function readFileOr(host: Host, path: string, orUndefined: boolean): string | undefined {
123
+ try {
124
+ return host.readFile(resolvePath(host.workingDirectory(), path));
125
+ } catch (error: unknown) {
126
+ if (orUndefined) {
127
+ return undefined;
128
+ }
129
+ throw new ConfigError(
130
+ "malformed",
131
+ "",
132
+ `${path}: ${error instanceof Error ? error.message : String(error)}`,
133
+ );
134
+ }
135
+ }
136
+
137
+ /**
138
+ * The configuration documents one invocation reads: the repository configuration
139
+ * at the target root or the path an option named, the central configuration, and
140
+ * the settings the options supply as one flag document.
141
+ */
142
+ function inputsOf(host: Host, options: Options): Inputs {
143
+ const target = options.named.get("target") ?? ".";
144
+ const named = options.named.get("config");
145
+ const repositoryLabel = named ?? joinPath(target, REPOSITORY_DOCUMENT);
146
+ const repository = readFileOr(host, repositoryLabel, named === undefined);
147
+ const centralLabel = options.named.get("central");
148
+ const central = centralLabel === undefined ? undefined : readFileOr(host, centralLabel, false);
149
+
150
+ const flat: Record<string, unknown> = {};
151
+ const flagLabels = new Map<string, string>();
152
+ for (const [name, path] of SETTING_OPTIONS) {
153
+ const value = options.named.get(name);
154
+ if (value === undefined) {
155
+ continue;
156
+ }
157
+ flat[path] = NUMBER_OPTIONS.has(name) ? Number(value) : value;
158
+ flagLabels.set(path, `--${name}`);
159
+ }
160
+ const flags = Object.keys(flat).length === 0 ? undefined : JSON.stringify(flat);
161
+
162
+ return {
163
+ ...(flags === undefined ? {} : { flags }),
164
+ ...(repository === undefined ? {} : { repository, repositoryLabel }),
165
+ ...(central === undefined || centralLabel === undefined ? {} : { central, centralLabel }),
166
+ flagLabels,
167
+ };
168
+ }
169
+
170
+ /** The scope one invocation analyzes: the document an option named, or the target. */
171
+ function scopeOf(host: Host, options: Options): Scope {
172
+ const document = options.named.get("scope");
173
+ if (document !== undefined) {
174
+ return readScope(host, document);
175
+ }
176
+ return scopeForDir(host, options.named.get("target") ?? ".");
177
+ }
178
+
179
+ /**
180
+ * Runs the deadset-ts command line over `args` (the arguments after the program
181
+ * name) and returns the process exit code: 0 for a completed verb, 2 for a usage
182
+ * error or a configuration this analyzer refuses, 3 for a failure that produced no
183
+ * answer. It writes nothing outside `out` and `err` and never exits the process,
184
+ * so a caller decides what the code means.
185
+ *
186
+ * An option asking for a source edit is refused before the verb is read: this
187
+ * analyzer reports and never edits a source file.
27
188
  *
28
- * Any `--fix` flag is refused before the verb is read: deadset-ts is
29
- * report-only and never edits source.
189
+ * `host` is the filesystem the run reads and the directory it reads relative paths
190
+ * against; `openClient` opens the compiler client a verb that reads projects needs.
191
+ * Both are parameters because both are the platform, which no module below this one
192
+ * names: the command entry binds them, and a caller that wants to watch what a run
193
+ * reads supplies its own.
30
194
  */
31
- export function run(args: readonly string[], out: Writer, err: Writer): number {
32
- const fix = args.find((arg) => arg === "--fix" || arg.startsWith("--fix="));
33
- if (fix !== undefined) {
195
+ export function run(
196
+ args: readonly string[],
197
+ out: Writer,
198
+ err: Writer,
199
+ host: Host,
200
+ openClient: (collectTiming: boolean) => Engine = (collectTiming) => openEngine({ collectTiming }),
201
+ ): number {
202
+ const editing = sourceEditOption(args);
203
+ if (editing !== undefined) {
34
204
  err.write(
35
- `deadset-ts: ${fix} is not supported: deadset-ts is report-only and never edits source\n`,
205
+ `deadset-ts: ${editing} is not supported: deadset-ts reports and never edits a source file\n`,
36
206
  );
37
- return 2;
207
+ err.write(USAGE);
208
+ return EXIT_USAGE;
38
209
  }
210
+
39
211
  const verb = args[0];
40
212
  if (verb === "version") {
41
- out.write(`deadset-ts ${VERSION}\ncontract ${CONTRACT_VERSION}\n`);
42
- return 0;
213
+ out.write(`deadset-ts ${version()}\ncontract ${CONTRACT_VERSION}\n`);
214
+ return EXIT_CLEAN;
215
+ }
216
+ if (verb === "print-config") {
217
+ return printConfigVerb(args.slice(1), out, err, host);
218
+ }
219
+ if (verb === "print-projects") {
220
+ return printProjectsVerb(args.slice(1), out, err, host, openClient);
221
+ }
222
+ if (verb !== undefined && (VERBS as readonly string[]).includes(verb)) {
223
+ err.write(`deadset-ts: ${verb} is not implemented\n`);
224
+ return EXIT_USAGE;
43
225
  }
44
226
  if (verb !== undefined) {
45
- err.write(`deadset-ts: unknown verb "${verb}"\n`);
227
+ err.write(`deadset-ts: unknown verb ${JSON.stringify(verb)}\n`);
46
228
  }
47
229
  err.write(USAGE);
48
- return 2;
230
+ return EXIT_USAGE;
231
+ }
232
+
233
+ /** Writes the resolved configuration with the source of every setting. */
234
+ function printConfigVerb(args: readonly string[], out: Writer, err: Writer, host: Host): number {
235
+ try {
236
+ const options = readOptions(args);
237
+ const { config, provenance } = resolve(inputsOf(host, options));
238
+ out.write(printConfig(config, provenance));
239
+ return EXIT_CLEAN;
240
+ } catch (error: unknown) {
241
+ return refuse(error, err);
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Writes the compiler configuration of every project one run analyzes, one per
247
+ * line, in discovery order, having read each project's diagnostics first.
248
+ */
249
+ function printProjectsVerb(
250
+ args: readonly string[],
251
+ out: Writer,
252
+ err: Writer,
253
+ host: Host,
254
+ openClient: (collectTiming: boolean) => Engine,
255
+ ): number {
256
+ let engine: Engine | undefined;
257
+ try {
258
+ const options = readOptions(args);
259
+ const scope = scopeOf(host, options);
260
+ engine = openClient(false);
261
+ const discovered = discoverProjects(engine, host, scope);
262
+ const failures: string[] = [];
263
+ const { projects } = runSession(engine, discovered.configFiles, (project) => {
264
+ const errors = diagnosticErrors(diagnosticsOf(project));
265
+ failures.push(...errors.map(renderDiagnostic));
266
+ return project.configFile;
267
+ });
268
+ if (failures.length > 0) {
269
+ for (const line of failures) {
270
+ err.write(`${line}\n`);
271
+ }
272
+ err.write(`deadset-ts: ${String(failures.length)} error(s); no answer was produced\n`);
273
+ return EXIT_FAILURE;
274
+ }
275
+ for (const configFile of projects) {
276
+ out.write(`${configFile}\n`);
277
+ }
278
+ return EXIT_CLEAN;
279
+ } catch (error: unknown) {
280
+ engine?.close();
281
+ return refuse(error, err);
282
+ }
283
+ }
284
+
285
+ /** Turns one refusal into the exit code the Contract's table gives it. */
286
+ function refuse(error: unknown, err: Writer): number {
287
+ if (error instanceof ConfigError) {
288
+ err.write(`deadset-ts: ${error.message}\n`);
289
+ err.write(USAGE);
290
+ return EXIT_USAGE;
291
+ }
292
+ if (error instanceof DiscoveryError) {
293
+ for (const diagnostic of error.diagnostics) {
294
+ err.write(`${renderDiagnostic(diagnostic)}\n`);
295
+ }
296
+ err.write(`deadset-ts: ${error.message}\n`);
297
+ return EXIT_FAILURE;
298
+ }
299
+ if (error instanceof ScopeError) {
300
+ err.write(`deadset-ts: ${error.message}\n`);
301
+ return EXIT_FAILURE;
302
+ }
303
+ throw error;
49
304
  }