@cplieger/deadset-ts 1.2.0 → 2.0.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/README.md +44 -5
- package/{bin/deadset-ts.ts → dist/bin/deadset-ts.js} +2 -3
- package/dist/bin/node-host.js +74 -0
- package/dist/src/config.js +63 -0
- package/dist/src/discover.js +171 -0
- package/dist/src/host.js +1 -0
- package/dist/src/index.js +6 -0
- package/dist/src/inventory.js +676 -0
- package/dist/src/kinds.js +12 -0
- package/dist/src/paths.js +86 -0
- package/dist/src/position.js +52 -0
- package/dist/src/print.js +56 -0
- package/dist/src/ref.js +117 -0
- package/dist/src/references.js +420 -0
- package/dist/src/resolve.js +482 -0
- package/dist/src/run.js +249 -0
- package/dist/src/schema.js +335 -0
- package/dist/src/scope.js +116 -0
- package/dist/src/session.js +101 -0
- package/dist/src/version.js +7 -0
- package/jsr.json +1 -1
- package/package.json +9 -4
- package/src/host.ts +10 -2
- package/src/index.ts +13 -1
- package/src/inventory.ts +26 -3
- package/src/references.ts +606 -0
- package/src/run.ts +4 -4
- package/src/session.ts +41 -22
- package/src/version.ts +3 -18
- package/bin/deadset-ts.mjs +0 -24
- package/bin/node-host.ts +0 -28
package/dist/src/run.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { ConfigError } from "./config.js";
|
|
2
|
+
import { diagnosticErrors, discoverProjects, DiscoveryError, renderDiagnostic, } from "./discover.js";
|
|
3
|
+
import { joinPath, resolvePath } from "./paths.js";
|
|
4
|
+
import { printConfig } from "./print.js";
|
|
5
|
+
import { resolve } from "./resolve.js";
|
|
6
|
+
import { readScope, ScopeError, scopeForDir } from "./scope.js";
|
|
7
|
+
import { diagnosticsOf, openEngine, runSession } from "./session.js";
|
|
8
|
+
import { CONTRACT_VERSION } from "./version.js";
|
|
9
|
+
/** The repository configuration's name at the target root. */
|
|
10
|
+
const REPOSITORY_DOCUMENT = "deadset.json";
|
|
11
|
+
/** The exit codes the Contract's exit-code table names. */
|
|
12
|
+
const EXIT_CLEAN = 0;
|
|
13
|
+
const EXIT_USAGE = 2;
|
|
14
|
+
const EXIT_FAILURE = 3;
|
|
15
|
+
const VERBS = [
|
|
16
|
+
"analyze",
|
|
17
|
+
"explain",
|
|
18
|
+
"print-config",
|
|
19
|
+
"print-projects",
|
|
20
|
+
"print-roots",
|
|
21
|
+
"print-retained",
|
|
22
|
+
"describe",
|
|
23
|
+
"version",
|
|
24
|
+
];
|
|
25
|
+
const USAGE = `usage: deadset-ts <verb> [options]\nverbs: ${VERBS.join(", ")}\n`;
|
|
26
|
+
/**
|
|
27
|
+
* The tokens whose presence in an option's name makes that option a request to
|
|
28
|
+
* edit a source file. The set is closed, and a token is matched in any position of
|
|
29
|
+
* the name.
|
|
30
|
+
*/
|
|
31
|
+
const SOURCE_EDIT_TOKENS = ["fix", "edit", "delete", "rewrite"];
|
|
32
|
+
/**
|
|
33
|
+
* Maps an option name to the setting it supplies, so one entry registers the
|
|
34
|
+
* option, supplies the document resolution reads, and gives the spelling the
|
|
35
|
+
* setting's provenance names.
|
|
36
|
+
*
|
|
37
|
+
* An option's name is this command's own vocabulary, chosen for how it reads on a
|
|
38
|
+
* command line, and its path is the setting of the Contract's configuration schema
|
|
39
|
+
* that option supplies. The two are mapped here and never derived from one
|
|
40
|
+
* another, so this table is the authority for both: a setting reaches the command
|
|
41
|
+
* line only by an entry, and a path names a key the schema declares.
|
|
42
|
+
*/
|
|
43
|
+
export const SETTING_OPTIONS = new Map([
|
|
44
|
+
["min-confidence", "analysis.min_confidence"],
|
|
45
|
+
["sort", "reporters.sort"],
|
|
46
|
+
["cascade", "reporters.cascade"],
|
|
47
|
+
["max-findings", "reporters.max_findings"],
|
|
48
|
+
["fail-on", "reporters.fail_on"],
|
|
49
|
+
]);
|
|
50
|
+
/** The options that take a value and are not settings. */
|
|
51
|
+
const PLAIN_OPTIONS = new Set(["target", "config", "central", "scope"]);
|
|
52
|
+
/** The setting options whose value is a number rather than a string. */
|
|
53
|
+
const NUMBER_OPTIONS = new Set(["max-findings"]);
|
|
54
|
+
/**
|
|
55
|
+
* The first option whose name carries a source-edit token, because every verb
|
|
56
|
+
* reports and never edits. The match is on the name alone, so an option whose
|
|
57
|
+
* value carries a token is not a request.
|
|
58
|
+
*/
|
|
59
|
+
function sourceEditOption(args) {
|
|
60
|
+
for (const arg of args) {
|
|
61
|
+
if (!arg.startsWith("-")) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const spelled = arg.split("=", 1)[0] ?? arg;
|
|
65
|
+
const name = spelled.replace(/^-+/u, "");
|
|
66
|
+
if (SOURCE_EDIT_TOKENS.some((token) => name.includes(token))) {
|
|
67
|
+
return spelled;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
/** Reads one verb's options, refusing a name this command does not offer. */
|
|
73
|
+
function readOptions(args) {
|
|
74
|
+
const named = new Map();
|
|
75
|
+
for (const arg of args) {
|
|
76
|
+
if (!arg.startsWith("-")) {
|
|
77
|
+
throw new ConfigError("malformed", "", `unexpected argument ${JSON.stringify(arg)}`);
|
|
78
|
+
}
|
|
79
|
+
const [spelled, ...rest] = arg.split("=");
|
|
80
|
+
const name = (spelled ?? "").replace(/^-+/u, "");
|
|
81
|
+
if (!PLAIN_OPTIONS.has(name) && !SETTING_OPTIONS.has(name)) {
|
|
82
|
+
throw new ConfigError("malformed", "", `unknown option ${JSON.stringify(spelled ?? arg)}`);
|
|
83
|
+
}
|
|
84
|
+
if (rest.length === 0) {
|
|
85
|
+
throw new ConfigError("malformed", "", `option ${JSON.stringify(spelled ?? arg)} takes a value`);
|
|
86
|
+
}
|
|
87
|
+
named.set(name, rest.join("="));
|
|
88
|
+
}
|
|
89
|
+
return { named };
|
|
90
|
+
}
|
|
91
|
+
function readFileOr(host, path, orUndefined) {
|
|
92
|
+
try {
|
|
93
|
+
return host.readFile(resolvePath(host.workingDirectory(), path));
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (orUndefined) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
throw new ConfigError("malformed", "", `${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The configuration documents one invocation reads: the repository configuration
|
|
104
|
+
* at the target root or the path an option named, the central configuration, and
|
|
105
|
+
* the settings the options supply as one flag document.
|
|
106
|
+
*/
|
|
107
|
+
function inputsOf(host, options) {
|
|
108
|
+
const target = options.named.get("target") ?? ".";
|
|
109
|
+
const named = options.named.get("config");
|
|
110
|
+
const repositoryLabel = named ?? joinPath(target, REPOSITORY_DOCUMENT);
|
|
111
|
+
const repository = readFileOr(host, repositoryLabel, named === undefined);
|
|
112
|
+
const centralLabel = options.named.get("central");
|
|
113
|
+
const central = centralLabel === undefined ? undefined : readFileOr(host, centralLabel, false);
|
|
114
|
+
const flat = {};
|
|
115
|
+
const flagLabels = new Map();
|
|
116
|
+
for (const [name, path] of SETTING_OPTIONS) {
|
|
117
|
+
const value = options.named.get(name);
|
|
118
|
+
if (value === undefined) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
flat[path] = NUMBER_OPTIONS.has(name) ? Number(value) : value;
|
|
122
|
+
flagLabels.set(path, `--${name}`);
|
|
123
|
+
}
|
|
124
|
+
const flags = Object.keys(flat).length === 0 ? undefined : JSON.stringify(flat);
|
|
125
|
+
return {
|
|
126
|
+
...(flags === undefined ? {} : { flags }),
|
|
127
|
+
...(repository === undefined ? {} : { repository, repositoryLabel }),
|
|
128
|
+
...(central === undefined || centralLabel === undefined ? {} : { central, centralLabel }),
|
|
129
|
+
flagLabels,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** The scope one invocation analyzes: the document an option named, or the target. */
|
|
133
|
+
function scopeOf(host, options) {
|
|
134
|
+
const document = options.named.get("scope");
|
|
135
|
+
if (document !== undefined) {
|
|
136
|
+
return readScope(host, document);
|
|
137
|
+
}
|
|
138
|
+
return scopeForDir(host, options.named.get("target") ?? ".");
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Runs the deadset-ts command line over `args` (the arguments after the program
|
|
142
|
+
* name) and returns the process exit code: 0 for a completed verb, 2 for a usage
|
|
143
|
+
* error or a configuration this analyzer refuses, 3 for a failure that produced no
|
|
144
|
+
* answer. It writes nothing outside `out` and `err` and never exits the process,
|
|
145
|
+
* so a caller decides what the code means.
|
|
146
|
+
*
|
|
147
|
+
* An option asking for a source edit is refused before the verb is read: this
|
|
148
|
+
* analyzer reports and never edits a source file.
|
|
149
|
+
*
|
|
150
|
+
* `host` is the platform the run reads, the version it reports included;
|
|
151
|
+
* `openClient` opens the compiler client a verb that reads projects needs.
|
|
152
|
+
* Both are parameters because both are the platform, which no module below this one
|
|
153
|
+
* names: the command entry binds them, and a caller that wants to watch what a run
|
|
154
|
+
* reads supplies its own.
|
|
155
|
+
*/
|
|
156
|
+
export function run(args, out, err, host, openClient = (collectTiming) => openEngine({ collectTiming })) {
|
|
157
|
+
const editing = sourceEditOption(args);
|
|
158
|
+
if (editing !== undefined) {
|
|
159
|
+
err.write(`deadset-ts: ${editing} is not supported: deadset-ts reports and never edits a source file\n`);
|
|
160
|
+
err.write(USAGE);
|
|
161
|
+
return EXIT_USAGE;
|
|
162
|
+
}
|
|
163
|
+
const verb = args[0];
|
|
164
|
+
if (verb === "version") {
|
|
165
|
+
out.write(`deadset-ts ${host.analyzerVersion()}\ncontract ${CONTRACT_VERSION}\n`);
|
|
166
|
+
return EXIT_CLEAN;
|
|
167
|
+
}
|
|
168
|
+
if (verb === "print-config") {
|
|
169
|
+
return printConfigVerb(args.slice(1), out, err, host);
|
|
170
|
+
}
|
|
171
|
+
if (verb === "print-projects") {
|
|
172
|
+
return printProjectsVerb(args.slice(1), out, err, host, openClient);
|
|
173
|
+
}
|
|
174
|
+
if (verb !== undefined && VERBS.includes(verb)) {
|
|
175
|
+
err.write(`deadset-ts: ${verb} is not implemented\n`);
|
|
176
|
+
return EXIT_USAGE;
|
|
177
|
+
}
|
|
178
|
+
if (verb !== undefined) {
|
|
179
|
+
err.write(`deadset-ts: unknown verb ${JSON.stringify(verb)}\n`);
|
|
180
|
+
}
|
|
181
|
+
err.write(USAGE);
|
|
182
|
+
return EXIT_USAGE;
|
|
183
|
+
}
|
|
184
|
+
/** Writes the resolved configuration with the source of every setting. */
|
|
185
|
+
function printConfigVerb(args, out, err, host) {
|
|
186
|
+
try {
|
|
187
|
+
const options = readOptions(args);
|
|
188
|
+
const { config, provenance } = resolve(inputsOf(host, options));
|
|
189
|
+
out.write(printConfig(config, provenance));
|
|
190
|
+
return EXIT_CLEAN;
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
return refuse(error, err);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Writes the compiler configuration of every project one run analyzes, one per
|
|
198
|
+
* line, in discovery order, having read each project's diagnostics first.
|
|
199
|
+
*/
|
|
200
|
+
function printProjectsVerb(args, out, err, host, openClient) {
|
|
201
|
+
let engine;
|
|
202
|
+
try {
|
|
203
|
+
const options = readOptions(args);
|
|
204
|
+
const scope = scopeOf(host, options);
|
|
205
|
+
engine = openClient(false);
|
|
206
|
+
const discovered = discoverProjects(engine, host, scope);
|
|
207
|
+
const failures = [];
|
|
208
|
+
const { projects } = runSession(engine, discovered.configFiles, (project) => {
|
|
209
|
+
const errors = diagnosticErrors(diagnosticsOf(project));
|
|
210
|
+
failures.push(...errors.map(renderDiagnostic));
|
|
211
|
+
return project.configFile;
|
|
212
|
+
});
|
|
213
|
+
if (failures.length > 0) {
|
|
214
|
+
for (const line of failures) {
|
|
215
|
+
err.write(`${line}\n`);
|
|
216
|
+
}
|
|
217
|
+
err.write(`deadset-ts: ${String(failures.length)} error(s); no answer was produced\n`);
|
|
218
|
+
return EXIT_FAILURE;
|
|
219
|
+
}
|
|
220
|
+
for (const configFile of projects) {
|
|
221
|
+
out.write(`${configFile}\n`);
|
|
222
|
+
}
|
|
223
|
+
return EXIT_CLEAN;
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
engine?.close();
|
|
227
|
+
return refuse(error, err);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/** Turns one refusal into the exit code the Contract's table gives it. */
|
|
231
|
+
function refuse(error, err) {
|
|
232
|
+
if (error instanceof ConfigError) {
|
|
233
|
+
err.write(`deadset-ts: ${error.message}\n`);
|
|
234
|
+
err.write(USAGE);
|
|
235
|
+
return EXIT_USAGE;
|
|
236
|
+
}
|
|
237
|
+
if (error instanceof DiscoveryError) {
|
|
238
|
+
for (const diagnostic of error.diagnostics) {
|
|
239
|
+
err.write(`${renderDiagnostic(diagnostic)}\n`);
|
|
240
|
+
}
|
|
241
|
+
err.write(`deadset-ts: ${error.message}\n`);
|
|
242
|
+
return EXIT_FAILURE;
|
|
243
|
+
}
|
|
244
|
+
if (error instanceof ScopeError) {
|
|
245
|
+
err.write(`deadset-ts: ${error.message}\n`);
|
|
246
|
+
return EXIT_FAILURE;
|
|
247
|
+
}
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { ConfigError } from "./config.js";
|
|
2
|
+
const leaf = { kind: "leaf" };
|
|
3
|
+
/**
|
|
4
|
+
* The closed key list the Contract's configuration schema declares, derived from
|
|
5
|
+
* that schema key by key. A test pins the two equal, so a Contract change that
|
|
6
|
+
* adds a key fails there rather than being silently unimplemented.
|
|
7
|
+
*/
|
|
8
|
+
export const SCHEMA_ROOT = {
|
|
9
|
+
kind: "section",
|
|
10
|
+
members: {
|
|
11
|
+
contract_version: leaf,
|
|
12
|
+
target: { kind: "section", members: { kind: leaf } },
|
|
13
|
+
analysis: {
|
|
14
|
+
kind: "section",
|
|
15
|
+
members: {
|
|
16
|
+
languages: leaf,
|
|
17
|
+
min_confidence: leaf,
|
|
18
|
+
generated_files: leaf,
|
|
19
|
+
consumer_tests: leaf,
|
|
20
|
+
configurations: {
|
|
21
|
+
kind: "list",
|
|
22
|
+
members: { id: leaf, os: leaf, arch: leaf, tags: leaf },
|
|
23
|
+
},
|
|
24
|
+
matrix: { kind: "section", members: { complete: leaf } },
|
|
25
|
+
template_dirs: leaf,
|
|
26
|
+
template_delimiters: {
|
|
27
|
+
kind: "object",
|
|
28
|
+
members: { left: leaf, right: leaf },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
consumers: { kind: "section", members: { complete: leaf } },
|
|
33
|
+
roots: { kind: "section", members: { patterns: leaf } },
|
|
34
|
+
severity: { kind: "map" },
|
|
35
|
+
exemptions: { kind: "section", members: { disabled: leaf } },
|
|
36
|
+
reporters: {
|
|
37
|
+
kind: "section",
|
|
38
|
+
members: {
|
|
39
|
+
formats: leaf,
|
|
40
|
+
sort: leaf,
|
|
41
|
+
cascade: leaf,
|
|
42
|
+
max_findings: leaf,
|
|
43
|
+
fail_on: leaf,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
go: { kind: "section", members: {} },
|
|
47
|
+
ts: { kind: "section", members: { test_files: leaf, entry_files: leaf } },
|
|
48
|
+
provenance: { kind: "map" },
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
/** The name of the severity object, whose member names the key list leaves open. */
|
|
52
|
+
export const SEVERITY_SECTION = "severity";
|
|
53
|
+
/**
|
|
54
|
+
* The node one member name resolves to, and whether the key list declares it. A
|
|
55
|
+
* member of an open object, and any member below a setting, resolves to a leaf,
|
|
56
|
+
* so the walk descends far enough to find a repeated member at any depth.
|
|
57
|
+
*/
|
|
58
|
+
function memberOf(node, name) {
|
|
59
|
+
if (node.kind === "section" || node.kind === "list" || node.kind === "object") {
|
|
60
|
+
const members = node.members ?? {};
|
|
61
|
+
return Object.hasOwn(members, name) ? members[name] : undefined;
|
|
62
|
+
}
|
|
63
|
+
return leaf;
|
|
64
|
+
}
|
|
65
|
+
function joinKey(at, name) {
|
|
66
|
+
return at === "" ? name : `${at}.${name}`;
|
|
67
|
+
}
|
|
68
|
+
function collect(node, at, keys, settingsOnly) {
|
|
69
|
+
for (const [name, child] of Object.entries(node.members ?? {})) {
|
|
70
|
+
const path = joinKey(at, name);
|
|
71
|
+
if (!settingsOnly) {
|
|
72
|
+
keys.push(path);
|
|
73
|
+
collect(child, child.kind === "list" ? `${path}[]` : path, keys, settingsOnly);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (child.kind === "leaf" || child.kind === "list" || child.kind === "object") {
|
|
77
|
+
keys.push(path);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
collect(child, path, keys, settingsOnly);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Every key the closed key list declares, as a dotted path, in ascending order:
|
|
85
|
+
* the sections, the settings, and the two open objects. A list's entry members
|
|
86
|
+
* are spelled under the list's own path followed by empty brackets.
|
|
87
|
+
*/
|
|
88
|
+
export function declaredKeys() {
|
|
89
|
+
const keys = [];
|
|
90
|
+
collect(SCHEMA_ROOT, "", keys, false);
|
|
91
|
+
return keys.sort();
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The dotted path of every setting that holds one value, in ascending order. A
|
|
95
|
+
* list and a setting written as an object are each one setting, their members not
|
|
96
|
+
* addressable on their own, and the severity object is not among them: it
|
|
97
|
+
* resolves per code.
|
|
98
|
+
*/
|
|
99
|
+
export function settingPaths() {
|
|
100
|
+
const paths = [];
|
|
101
|
+
collect(SCHEMA_ROOT, "", paths, true);
|
|
102
|
+
return paths.sort();
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Whether one dotted path names a setting a source may supply: a setting of the
|
|
106
|
+
* closed key list, or one code of the severity object, whose member names the key
|
|
107
|
+
* list leaves open. It is the predicate the command maps its own flag names
|
|
108
|
+
* through, so a flag that would be refused as an unimplemented key is a failing
|
|
109
|
+
* test rather than a flag nothing reads.
|
|
110
|
+
*
|
|
111
|
+
* A section holds no value of its own, so `analysis` is false. A setting written
|
|
112
|
+
* as an object is supplied whole, so `analysis.template_delimiters` is true and
|
|
113
|
+
* `analysis.template_delimiters.left` is false. A severity code is a declared
|
|
114
|
+
* setting whatever it names, so `severity.DS9999` is true here and refused by
|
|
115
|
+
* resolution: this answers the shape of a path, and resolution answers the
|
|
116
|
+
* vocabulary.
|
|
117
|
+
*/
|
|
118
|
+
export function declaresSetting(path) {
|
|
119
|
+
if (settingPaths().includes(path)) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
const dot = path.indexOf(".");
|
|
123
|
+
if (dot < 0) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
const code = path.slice(dot + 1);
|
|
127
|
+
return path.slice(0, dot) === SEVERITY_SECTION && code !== "" && !code.includes(".");
|
|
128
|
+
}
|
|
129
|
+
/** The Levenshtein distance between two strings, counted in code points. */
|
|
130
|
+
function editDistance(a, b) {
|
|
131
|
+
const from = Array.from(a);
|
|
132
|
+
const to = Array.from(b);
|
|
133
|
+
let previous = Array.from({ length: to.length + 1 }, (_unused, index) => index);
|
|
134
|
+
let current = new Array(to.length + 1).fill(0);
|
|
135
|
+
for (let i = 1; i <= from.length; i += 1) {
|
|
136
|
+
current[0] = i;
|
|
137
|
+
for (let j = 1; j <= to.length; j += 1) {
|
|
138
|
+
const substitution = (previous[j - 1] ?? 0) + (from[i - 1] === to[j - 1] ? 0 : 1);
|
|
139
|
+
current[j] = Math.min(substitution, (previous[j] ?? 0) + 1, (current[j - 1] ?? 0) + 1);
|
|
140
|
+
}
|
|
141
|
+
[previous, current] = [current, previous];
|
|
142
|
+
}
|
|
143
|
+
return previous[to.length] ?? 0;
|
|
144
|
+
}
|
|
145
|
+
function sharedSegments(a, b) {
|
|
146
|
+
let shared = 0;
|
|
147
|
+
while (shared < a.length && shared < b.length && a[shared] === b[shared]) {
|
|
148
|
+
shared += 1;
|
|
149
|
+
}
|
|
150
|
+
return shared;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The implemented key closest to `key`: the one sharing the most leading dotted
|
|
154
|
+
* segments, and among those the smallest edit distance over the whole path, ties
|
|
155
|
+
* broken in ascending order.
|
|
156
|
+
*/
|
|
157
|
+
export function nearestKey(key) {
|
|
158
|
+
const segments = key.split(".");
|
|
159
|
+
let nearest = "";
|
|
160
|
+
let bestShared = -1;
|
|
161
|
+
let bestDistance = 0;
|
|
162
|
+
for (const candidate of declaredKeys()) {
|
|
163
|
+
const shared = sharedSegments(segments, candidate.split("."));
|
|
164
|
+
const distance = editDistance(key, candidate);
|
|
165
|
+
if (shared < bestShared || (shared === bestShared && distance >= bestDistance)) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
nearest = candidate;
|
|
169
|
+
bestShared = shared;
|
|
170
|
+
bestDistance = distance;
|
|
171
|
+
}
|
|
172
|
+
return nearest;
|
|
173
|
+
}
|
|
174
|
+
/** A refusal of a document that is not one instance of the closed key list. */
|
|
175
|
+
export function malformed(label, path, detail) {
|
|
176
|
+
const message = path === "" ? `${label}: ${detail}` : `${label}: ${path}: ${detail}`;
|
|
177
|
+
return new ConfigError("malformed", path, message);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* A refusal of a key the closed key list does not declare, naming the nearest key
|
|
181
|
+
* it does.
|
|
182
|
+
*/
|
|
183
|
+
export function unimplementedKey(label, path) {
|
|
184
|
+
const nearest = nearestKey(path);
|
|
185
|
+
let hint = "";
|
|
186
|
+
if (nearest === path) {
|
|
187
|
+
hint = `; ${JSON.stringify(path)} is one setting written as nested objects, not as one key`;
|
|
188
|
+
}
|
|
189
|
+
else if (nearest !== "") {
|
|
190
|
+
hint = `; the nearest implemented key is ${JSON.stringify(nearest)}`;
|
|
191
|
+
}
|
|
192
|
+
return new ConfigError("unimplemented-key", path, `${label}: key ${JSON.stringify(path)} is not implemented${hint}`);
|
|
193
|
+
}
|
|
194
|
+
const SPACE = new Set([0x20, 0x09, 0x0a, 0x0d]);
|
|
195
|
+
function skipSpace(cursor) {
|
|
196
|
+
while (cursor.at < cursor.text.length && SPACE.has(cursor.text.charCodeAt(cursor.at))) {
|
|
197
|
+
cursor.at += 1;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The string literal at the cursor, decoded by the standard library: the walk
|
|
202
|
+
* finds the literal's extent and the parse reads it, so an escaped member name
|
|
203
|
+
* compares equal to the name it spells.
|
|
204
|
+
*/
|
|
205
|
+
function readName(cursor, at, label) {
|
|
206
|
+
const start = cursor.at;
|
|
207
|
+
cursor.at += 1;
|
|
208
|
+
while (cursor.at < cursor.text.length) {
|
|
209
|
+
const ch = cursor.text.charCodeAt(cursor.at);
|
|
210
|
+
if (ch === 0x5c) {
|
|
211
|
+
cursor.at += 2;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
cursor.at += 1;
|
|
215
|
+
if (ch === 0x22) {
|
|
216
|
+
return JSON.parse(cursor.text.slice(start, cursor.at));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
throw malformed(label, at, "a member name is not terminated");
|
|
220
|
+
}
|
|
221
|
+
function skipScalar(cursor) {
|
|
222
|
+
while (cursor.at < cursor.text.length) {
|
|
223
|
+
const ch = cursor.text.charCodeAt(cursor.at);
|
|
224
|
+
if (ch === 0x2c || ch === 0x7d || ch === 0x5d || SPACE.has(ch)) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
cursor.at += 1;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function walkValue(cursor, at, node, label) {
|
|
231
|
+
skipSpace(cursor);
|
|
232
|
+
const ch = cursor.text.charCodeAt(cursor.at);
|
|
233
|
+
if (ch === 0x7b) {
|
|
234
|
+
cursor.at += 1;
|
|
235
|
+
walkObject(cursor, at, node, label);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (ch === 0x5b) {
|
|
239
|
+
cursor.at += 1;
|
|
240
|
+
walkArray(cursor, at, node, label);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (ch === 0x22) {
|
|
244
|
+
readName(cursor, at, label);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
skipScalar(cursor);
|
|
248
|
+
}
|
|
249
|
+
function walkObject(cursor, at, node, label) {
|
|
250
|
+
const seen = new Set();
|
|
251
|
+
for (;;) {
|
|
252
|
+
skipSpace(cursor);
|
|
253
|
+
const ch = cursor.text.charCodeAt(cursor.at);
|
|
254
|
+
if (ch === 0x7d) {
|
|
255
|
+
cursor.at += 1;
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (ch === 0x2c) {
|
|
259
|
+
cursor.at += 1;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (ch !== 0x22) {
|
|
263
|
+
throw malformed(label, at, "a member name is expected");
|
|
264
|
+
}
|
|
265
|
+
const name = readName(cursor, at, label);
|
|
266
|
+
const path = joinKey(at, name);
|
|
267
|
+
if (seen.has(name)) {
|
|
268
|
+
throw malformed(label, path, `member ${JSON.stringify(name)} is written twice, so neither value is chosen`);
|
|
269
|
+
}
|
|
270
|
+
seen.add(name);
|
|
271
|
+
const child = memberOf(node, name);
|
|
272
|
+
if (child === undefined) {
|
|
273
|
+
throw unimplementedKey(label, path);
|
|
274
|
+
}
|
|
275
|
+
skipSpace(cursor);
|
|
276
|
+
if (cursor.text.charCodeAt(cursor.at) !== 0x3a) {
|
|
277
|
+
throw malformed(label, path, "a member name is followed by a colon");
|
|
278
|
+
}
|
|
279
|
+
cursor.at += 1;
|
|
280
|
+
walkValue(cursor, path, child, label);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function walkArray(cursor, at, node, label) {
|
|
284
|
+
const entry = node.kind === "list" ? { kind: "section", members: node.members ?? {} } : leaf;
|
|
285
|
+
let index = 0;
|
|
286
|
+
for (;;) {
|
|
287
|
+
skipSpace(cursor);
|
|
288
|
+
const ch = cursor.text.charCodeAt(cursor.at);
|
|
289
|
+
if (ch === 0x5d) {
|
|
290
|
+
cursor.at += 1;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (ch === 0x2c) {
|
|
294
|
+
cursor.at += 1;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
walkValue(cursor, `${at}[${index}]`, entry, label);
|
|
298
|
+
index += 1;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Walks one document's text against the closed key list, refusing a member the
|
|
303
|
+
* key list does not declare and a member one object writes twice, each naming its
|
|
304
|
+
* dotted path. The document is already known to parse.
|
|
305
|
+
*/
|
|
306
|
+
export function checkDocument(text, node, label) {
|
|
307
|
+
const cursor = { text, at: 0 };
|
|
308
|
+
walkValue(cursor, "", node, label);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* The flag document, whose keys are dotted setting paths, rewritten as the nested
|
|
312
|
+
* object the closed key list declares, so one decoder reads every source. A key
|
|
313
|
+
* naming no setting is refused.
|
|
314
|
+
*/
|
|
315
|
+
export function nestFlags(flat, label) {
|
|
316
|
+
const nested = {};
|
|
317
|
+
for (const path of Object.keys(flat).sort()) {
|
|
318
|
+
if (!declaresSetting(path)) {
|
|
319
|
+
throw unimplementedKey(label, path);
|
|
320
|
+
}
|
|
321
|
+
const segments = path.split(".");
|
|
322
|
+
let into = nested;
|
|
323
|
+
for (const segment of segments.slice(0, -1)) {
|
|
324
|
+
const child = into[segment] ?? {};
|
|
325
|
+
if (typeof child !== "object" || Array.isArray(child)) {
|
|
326
|
+
throw malformed(label, path, `the flag document sets ${JSON.stringify(segment)} twice`);
|
|
327
|
+
}
|
|
328
|
+
into[segment] = child;
|
|
329
|
+
into = child;
|
|
330
|
+
}
|
|
331
|
+
const last = segments[segments.length - 1] ?? "";
|
|
332
|
+
into[last] = flat[path];
|
|
333
|
+
}
|
|
334
|
+
return nested;
|
|
335
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { dirnamePath, isAbsolutePath, joinPath, resolvePath } from "./paths.js";
|
|
2
|
+
/** The roles a scope document declares for a module. */
|
|
3
|
+
export const ROLE_TARGET = "target";
|
|
4
|
+
export const ROLE_CONSUMER = "consumer";
|
|
5
|
+
/**
|
|
6
|
+
* A scope document above this length is refused rather than decoded. It names one
|
|
7
|
+
* target and its consumers, so a larger file is not one.
|
|
8
|
+
*/
|
|
9
|
+
const MAX_DOCUMENT_LENGTH = 1 << 20;
|
|
10
|
+
/**
|
|
11
|
+
* A scope document that cannot be read as one. It ends the run with the failure
|
|
12
|
+
* code: the run could not learn what to analyze, so it produced no answer.
|
|
13
|
+
*/
|
|
14
|
+
export class ScopeError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "ScopeError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const DECLARED_KEYS = new Set(["target", "workspace", "consumers"]);
|
|
21
|
+
const MODULE_KEYS = new Set(["id", "role", "path"]);
|
|
22
|
+
function isRecord(value) {
|
|
23
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
function stringMember(parent, name, at) {
|
|
26
|
+
if (!Object.hasOwn(parent, name)) {
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
const value = parent[name];
|
|
30
|
+
if (typeof value !== "string") {
|
|
31
|
+
throw new ScopeError(`scope: ${at}.${name} is not a string`);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
function readModule(value, at, role, documentDir) {
|
|
36
|
+
if (!isRecord(value)) {
|
|
37
|
+
throw new ScopeError(`scope: ${at} is not an object`);
|
|
38
|
+
}
|
|
39
|
+
for (const name of Object.keys(value)) {
|
|
40
|
+
if (!MODULE_KEYS.has(name)) {
|
|
41
|
+
throw new ScopeError(`scope: ${at}.${name} is not a declared key`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const declared = stringMember(value, "role", at);
|
|
45
|
+
if (declared !== "" && declared !== role) {
|
|
46
|
+
throw new ScopeError(`scope: ${at}.role is ${JSON.stringify(declared)}, want ${JSON.stringify(role)}`);
|
|
47
|
+
}
|
|
48
|
+
const path = stringMember(value, "path", at);
|
|
49
|
+
if (path === "") {
|
|
50
|
+
throw new ScopeError(`scope: ${at}.path names nothing`);
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
id: stringMember(value, "id", at),
|
|
54
|
+
path: isAbsolutePath(path) ? path : joinPath(documentDir, path),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Decodes the scope document at `file`. Decoding is strict: an undeclared key is an
|
|
59
|
+
* error, and a relative path inside the document resolves against the document's
|
|
60
|
+
* own directory.
|
|
61
|
+
*
|
|
62
|
+
* The `workspace` key the format declares is read and carries nothing here: it
|
|
63
|
+
* names the file a Go consumer resolves the target through, and a TypeScript
|
|
64
|
+
* project resolves one through its own compiler configuration.
|
|
65
|
+
*/
|
|
66
|
+
export function readScope(host, file) {
|
|
67
|
+
const absolute = resolvePath(host.workingDirectory(), file);
|
|
68
|
+
let text;
|
|
69
|
+
try {
|
|
70
|
+
text = host.readFile(absolute);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
throw new ScopeError(`scope: ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
74
|
+
}
|
|
75
|
+
if (text.length > MAX_DOCUMENT_LENGTH) {
|
|
76
|
+
throw new ScopeError(`scope: ${file} is longer than ${String(MAX_DOCUMENT_LENGTH)} characters`);
|
|
77
|
+
}
|
|
78
|
+
let value;
|
|
79
|
+
try {
|
|
80
|
+
value = JSON.parse(text);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
throw new ScopeError(`scope: ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
84
|
+
}
|
|
85
|
+
if (!isRecord(value)) {
|
|
86
|
+
throw new ScopeError(`scope: ${file} is not one JSON object`);
|
|
87
|
+
}
|
|
88
|
+
for (const name of Object.keys(value)) {
|
|
89
|
+
if (!DECLARED_KEYS.has(name)) {
|
|
90
|
+
throw new ScopeError(`scope: ${file}: ${name} is not a declared key`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const documentDir = dirnamePath(absolute);
|
|
94
|
+
if (!Object.hasOwn(value, "target")) {
|
|
95
|
+
throw new ScopeError(`scope: ${file} names no target`);
|
|
96
|
+
}
|
|
97
|
+
const target = readModule(value["target"], "target", ROLE_TARGET, documentDir);
|
|
98
|
+
const listed = Object.hasOwn(value, "consumers") ? value["consumers"] : [];
|
|
99
|
+
if (!Array.isArray(listed)) {
|
|
100
|
+
throw new ScopeError(`scope: ${file}: consumers is not an array`);
|
|
101
|
+
}
|
|
102
|
+
const consumers = listed.map((entry, index) => readModule(entry, `consumers[${String(index)}]`, ROLE_CONSUMER, documentDir));
|
|
103
|
+
return { target, consumers };
|
|
104
|
+
}
|
|
105
|
+
/** The single-package scope one directory is, when no scope document is given. */
|
|
106
|
+
export function scopeForDir(host, dir) {
|
|
107
|
+
const path = resolvePath(host.workingDirectory(), dir);
|
|
108
|
+
const kind = host.kindOf(path);
|
|
109
|
+
if (kind === "absent") {
|
|
110
|
+
throw new ScopeError(`scope: ${path} does not exist`);
|
|
111
|
+
}
|
|
112
|
+
if (kind !== "directory") {
|
|
113
|
+
throw new ScopeError(`scope: ${path} is not a directory`);
|
|
114
|
+
}
|
|
115
|
+
return { target: { id: "", path }, consumers: [] };
|
|
116
|
+
}
|