@cplieger/deadset-ts 1.0.0 → 1.2.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/NOTICE +3 -0
- package/README.md +5 -6
- package/bin/deadset-ts.mjs +24 -0
- package/bin/deadset-ts.ts +2 -1
- package/bin/node-host.ts +28 -0
- package/jsr.json +4 -2
- package/package.json +11 -6
- package/src/config.ts +218 -0
- package/src/discover.ts +198 -0
- package/src/host.ts +29 -0
- package/src/index.ts +29 -1
- package/src/inventory.ts +941 -0
- package/src/kinds.ts +12 -0
- package/src/paths.ts +93 -0
- package/src/position.ts +75 -0
- package/src/print.ts +59 -0
- package/src/ref.ts +179 -0
- package/src/resolve.ts +732 -0
- package/src/run.ts +272 -17
- package/src/schema.ts +401 -0
- package/src/scope.ts +153 -0
- package/src/session.ts +221 -0
- package/src/version.ts +22 -0
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
|
-
|
|
21
|
+
/** The repository configuration's name at the target root. */
|
|
22
|
+
const REPOSITORY_DOCUMENT = "deadset.json";
|
|
7
23
|
|
|
8
|
-
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
|
|
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
|
-
*
|
|
29
|
-
*
|
|
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(
|
|
32
|
-
|
|
33
|
-
|
|
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: ${
|
|
205
|
+
`deadset-ts: ${editing} is not supported: deadset-ts reports and never edits a source file\n`,
|
|
36
206
|
);
|
|
37
|
-
|
|
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 ${
|
|
42
|
-
return
|
|
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
|
|
227
|
+
err.write(`deadset-ts: unknown verb ${JSON.stringify(verb)}\n`);
|
|
46
228
|
}
|
|
47
229
|
err.write(USAGE);
|
|
48
|
-
return
|
|
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
|
}
|