@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/LICENSE +0 -0
- package/NOTICE +3 -0
- package/README.md +2 -5
- package/bin/deadset-ts.ts +2 -1
- package/bin/node-host.ts +28 -0
- package/jsr.json +13 -3
- package/package.json +9 -5
- package/src/config.ts +218 -0
- package/src/discover.ts +198 -0
- package/src/host.ts +29 -0
- package/src/index.ts +3 -1
- package/src/kinds.ts +12 -0
- package/src/paths.ts +73 -0
- package/src/print.ts +59 -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 +205 -0
- package/src/version.ts +22 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { ConfigError } from "./config.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How one node of the closed key list is written.
|
|
5
|
+
*
|
|
6
|
+
* - `leaf` is a setting: a scalar, or an array of scalars.
|
|
7
|
+
* - `section` is an object whose member names the key list declares and which
|
|
8
|
+
* holds no value of its own.
|
|
9
|
+
* - `map` is an object whose member names the key list leaves open, its values
|
|
10
|
+
* scalars: the severity and provenance objects.
|
|
11
|
+
* - `list` is an array whose entries are objects with declared members.
|
|
12
|
+
* - `object` is one setting written as an object: its member names are declared
|
|
13
|
+
* and a source supplies the object whole.
|
|
14
|
+
*/
|
|
15
|
+
export type KeyKind = "leaf" | "section" | "map" | "list" | "object";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One node of the closed key list. `members` holds a section's declared members,
|
|
19
|
+
* or a list entry's, and is absent otherwise.
|
|
20
|
+
*/
|
|
21
|
+
export interface KeyNode {
|
|
22
|
+
readonly kind: KeyKind;
|
|
23
|
+
readonly members?: Readonly<Record<string, KeyNode>>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const leaf: KeyNode = { kind: "leaf" };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The closed key list the Contract's configuration schema declares, derived from
|
|
30
|
+
* that schema key by key. A test pins the two equal, so a Contract change that
|
|
31
|
+
* adds a key fails there rather than being silently unimplemented.
|
|
32
|
+
*/
|
|
33
|
+
export const SCHEMA_ROOT: KeyNode = {
|
|
34
|
+
kind: "section",
|
|
35
|
+
members: {
|
|
36
|
+
contract_version: leaf,
|
|
37
|
+
target: { kind: "section", members: { kind: leaf } },
|
|
38
|
+
analysis: {
|
|
39
|
+
kind: "section",
|
|
40
|
+
members: {
|
|
41
|
+
languages: leaf,
|
|
42
|
+
min_confidence: leaf,
|
|
43
|
+
generated_files: leaf,
|
|
44
|
+
consumer_tests: leaf,
|
|
45
|
+
configurations: {
|
|
46
|
+
kind: "list",
|
|
47
|
+
members: { id: leaf, os: leaf, arch: leaf, tags: leaf },
|
|
48
|
+
},
|
|
49
|
+
matrix: { kind: "section", members: { complete: leaf } },
|
|
50
|
+
template_dirs: leaf,
|
|
51
|
+
template_delimiters: {
|
|
52
|
+
kind: "object",
|
|
53
|
+
members: { left: leaf, right: leaf },
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
consumers: { kind: "section", members: { complete: leaf } },
|
|
58
|
+
roots: { kind: "section", members: { patterns: leaf } },
|
|
59
|
+
severity: { kind: "map" },
|
|
60
|
+
exemptions: { kind: "section", members: { disabled: leaf } },
|
|
61
|
+
reporters: {
|
|
62
|
+
kind: "section",
|
|
63
|
+
members: {
|
|
64
|
+
formats: leaf,
|
|
65
|
+
sort: leaf,
|
|
66
|
+
cascade: leaf,
|
|
67
|
+
max_findings: leaf,
|
|
68
|
+
fail_on: leaf,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
go: { kind: "section", members: {} },
|
|
72
|
+
ts: { kind: "section", members: { test_files: leaf, entry_files: leaf } },
|
|
73
|
+
provenance: { kind: "map" },
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** The name of the severity object, whose member names the key list leaves open. */
|
|
78
|
+
export const SEVERITY_SECTION = "severity";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The node one member name resolves to, and whether the key list declares it. A
|
|
82
|
+
* member of an open object, and any member below a setting, resolves to a leaf,
|
|
83
|
+
* so the walk descends far enough to find a repeated member at any depth.
|
|
84
|
+
*/
|
|
85
|
+
function memberOf(node: KeyNode, name: string): KeyNode | undefined {
|
|
86
|
+
if (node.kind === "section" || node.kind === "list" || node.kind === "object") {
|
|
87
|
+
const members = node.members ?? {};
|
|
88
|
+
return Object.hasOwn(members, name) ? members[name] : undefined;
|
|
89
|
+
}
|
|
90
|
+
return leaf;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function joinKey(at: string, name: string): string {
|
|
94
|
+
return at === "" ? name : `${at}.${name}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function collect(node: KeyNode, at: string, keys: string[], settingsOnly: boolean): void {
|
|
98
|
+
for (const [name, child] of Object.entries(node.members ?? {})) {
|
|
99
|
+
const path = joinKey(at, name);
|
|
100
|
+
if (!settingsOnly) {
|
|
101
|
+
keys.push(path);
|
|
102
|
+
collect(child, child.kind === "list" ? `${path}[]` : path, keys, settingsOnly);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (child.kind === "leaf" || child.kind === "list" || child.kind === "object") {
|
|
106
|
+
keys.push(path);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
collect(child, path, keys, settingsOnly);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Every key the closed key list declares, as a dotted path, in ascending order:
|
|
115
|
+
* the sections, the settings, and the two open objects. A list's entry members
|
|
116
|
+
* are spelled under the list's own path followed by empty brackets.
|
|
117
|
+
*/
|
|
118
|
+
export function declaredKeys(): string[] {
|
|
119
|
+
const keys: string[] = [];
|
|
120
|
+
collect(SCHEMA_ROOT, "", keys, false);
|
|
121
|
+
return keys.sort();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The dotted path of every setting that holds one value, in ascending order. A
|
|
126
|
+
* list and a setting written as an object are each one setting, their members not
|
|
127
|
+
* addressable on their own, and the severity object is not among them: it
|
|
128
|
+
* resolves per code.
|
|
129
|
+
*/
|
|
130
|
+
export function settingPaths(): string[] {
|
|
131
|
+
const paths: string[] = [];
|
|
132
|
+
collect(SCHEMA_ROOT, "", paths, true);
|
|
133
|
+
return paths.sort();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Whether one dotted path names a setting a source may supply: a setting of the
|
|
138
|
+
* closed key list, or one code of the severity object, whose member names the key
|
|
139
|
+
* list leaves open. It is the predicate the command maps its own flag names
|
|
140
|
+
* through, so a flag that would be refused as an unimplemented key is a failing
|
|
141
|
+
* test rather than a flag nothing reads.
|
|
142
|
+
*
|
|
143
|
+
* A section holds no value of its own, so `analysis` is false. A setting written
|
|
144
|
+
* as an object is supplied whole, so `analysis.template_delimiters` is true and
|
|
145
|
+
* `analysis.template_delimiters.left` is false. A severity code is a declared
|
|
146
|
+
* setting whatever it names, so `severity.DS9999` is true here and refused by
|
|
147
|
+
* resolution: this answers the shape of a path, and resolution answers the
|
|
148
|
+
* vocabulary.
|
|
149
|
+
*/
|
|
150
|
+
export function declaresSetting(path: string): boolean {
|
|
151
|
+
if (settingPaths().includes(path)) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
const dot = path.indexOf(".");
|
|
155
|
+
if (dot < 0) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
const code = path.slice(dot + 1);
|
|
159
|
+
return path.slice(0, dot) === SEVERITY_SECTION && code !== "" && !code.includes(".");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The Levenshtein distance between two strings, counted in code points. */
|
|
163
|
+
function editDistance(a: string, b: string): number {
|
|
164
|
+
const from = Array.from(a);
|
|
165
|
+
const to = Array.from(b);
|
|
166
|
+
let previous = Array.from({ length: to.length + 1 }, (_unused, index) => index);
|
|
167
|
+
let current = new Array<number>(to.length + 1).fill(0);
|
|
168
|
+
for (let i = 1; i <= from.length; i += 1) {
|
|
169
|
+
current[0] = i;
|
|
170
|
+
for (let j = 1; j <= to.length; j += 1) {
|
|
171
|
+
const substitution = (previous[j - 1] ?? 0) + (from[i - 1] === to[j - 1] ? 0 : 1);
|
|
172
|
+
current[j] = Math.min(substitution, (previous[j] ?? 0) + 1, (current[j - 1] ?? 0) + 1);
|
|
173
|
+
}
|
|
174
|
+
[previous, current] = [current, previous];
|
|
175
|
+
}
|
|
176
|
+
return previous[to.length] ?? 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function sharedSegments(a: readonly string[], b: readonly string[]): number {
|
|
180
|
+
let shared = 0;
|
|
181
|
+
while (shared < a.length && shared < b.length && a[shared] === b[shared]) {
|
|
182
|
+
shared += 1;
|
|
183
|
+
}
|
|
184
|
+
return shared;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The implemented key closest to `key`: the one sharing the most leading dotted
|
|
189
|
+
* segments, and among those the smallest edit distance over the whole path, ties
|
|
190
|
+
* broken in ascending order.
|
|
191
|
+
*/
|
|
192
|
+
export function nearestKey(key: string): string {
|
|
193
|
+
const segments = key.split(".");
|
|
194
|
+
let nearest = "";
|
|
195
|
+
let bestShared = -1;
|
|
196
|
+
let bestDistance = 0;
|
|
197
|
+
for (const candidate of declaredKeys()) {
|
|
198
|
+
const shared = sharedSegments(segments, candidate.split("."));
|
|
199
|
+
const distance = editDistance(key, candidate);
|
|
200
|
+
if (shared < bestShared || (shared === bestShared && distance >= bestDistance)) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
nearest = candidate;
|
|
204
|
+
bestShared = shared;
|
|
205
|
+
bestDistance = distance;
|
|
206
|
+
}
|
|
207
|
+
return nearest;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A refusal of a document that is not one instance of the closed key list. */
|
|
211
|
+
export function malformed(label: string, path: string, detail: string): ConfigError {
|
|
212
|
+
const message = path === "" ? `${label}: ${detail}` : `${label}: ${path}: ${detail}`;
|
|
213
|
+
return new ConfigError("malformed", path, message);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A refusal of a key the closed key list does not declare, naming the nearest key
|
|
218
|
+
* it does.
|
|
219
|
+
*/
|
|
220
|
+
export function unimplementedKey(label: string, path: string): ConfigError {
|
|
221
|
+
const nearest = nearestKey(path);
|
|
222
|
+
let hint = "";
|
|
223
|
+
if (nearest === path) {
|
|
224
|
+
hint = `; ${JSON.stringify(path)} is one setting written as nested objects, not as one key`;
|
|
225
|
+
} else if (nearest !== "") {
|
|
226
|
+
hint = `; the nearest implemented key is ${JSON.stringify(nearest)}`;
|
|
227
|
+
}
|
|
228
|
+
return new ConfigError(
|
|
229
|
+
"unimplemented-key",
|
|
230
|
+
path,
|
|
231
|
+
`${label}: key ${JSON.stringify(path)} is not implemented${hint}`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The position a walk over one document's text has reached. The walk exists
|
|
237
|
+
* because the value a JSON parse returns cannot answer one question the Contract
|
|
238
|
+
* asks: a member written twice at one level is a refusal, and a parse keeps the
|
|
239
|
+
* last value, so the document reads as though it named the member once. Nothing
|
|
240
|
+
* here produces a value; the value is the parse's.
|
|
241
|
+
*/
|
|
242
|
+
interface Cursor {
|
|
243
|
+
readonly text: string;
|
|
244
|
+
at: number;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const SPACE = new Set([0x20, 0x09, 0x0a, 0x0d]);
|
|
248
|
+
|
|
249
|
+
function skipSpace(cursor: Cursor): void {
|
|
250
|
+
while (cursor.at < cursor.text.length && SPACE.has(cursor.text.charCodeAt(cursor.at))) {
|
|
251
|
+
cursor.at += 1;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The string literal at the cursor, decoded by the standard library: the walk
|
|
257
|
+
* finds the literal's extent and the parse reads it, so an escaped member name
|
|
258
|
+
* compares equal to the name it spells.
|
|
259
|
+
*/
|
|
260
|
+
function readName(cursor: Cursor, at: string, label: string): string {
|
|
261
|
+
const start = cursor.at;
|
|
262
|
+
cursor.at += 1;
|
|
263
|
+
while (cursor.at < cursor.text.length) {
|
|
264
|
+
const ch = cursor.text.charCodeAt(cursor.at);
|
|
265
|
+
if (ch === 0x5c) {
|
|
266
|
+
cursor.at += 2;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
cursor.at += 1;
|
|
270
|
+
if (ch === 0x22) {
|
|
271
|
+
return JSON.parse(cursor.text.slice(start, cursor.at)) as string;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
throw malformed(label, at, "a member name is not terminated");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function skipScalar(cursor: Cursor): void {
|
|
278
|
+
while (cursor.at < cursor.text.length) {
|
|
279
|
+
const ch = cursor.text.charCodeAt(cursor.at);
|
|
280
|
+
if (ch === 0x2c || ch === 0x7d || ch === 0x5d || SPACE.has(ch)) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
cursor.at += 1;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function walkValue(cursor: Cursor, at: string, node: KeyNode, label: string): void {
|
|
288
|
+
skipSpace(cursor);
|
|
289
|
+
const ch = cursor.text.charCodeAt(cursor.at);
|
|
290
|
+
if (ch === 0x7b) {
|
|
291
|
+
cursor.at += 1;
|
|
292
|
+
walkObject(cursor, at, node, label);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (ch === 0x5b) {
|
|
296
|
+
cursor.at += 1;
|
|
297
|
+
walkArray(cursor, at, node, label);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (ch === 0x22) {
|
|
301
|
+
readName(cursor, at, label);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
skipScalar(cursor);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function walkObject(cursor: Cursor, at: string, node: KeyNode, label: string): void {
|
|
308
|
+
const seen = new Set<string>();
|
|
309
|
+
for (;;) {
|
|
310
|
+
skipSpace(cursor);
|
|
311
|
+
const ch = cursor.text.charCodeAt(cursor.at);
|
|
312
|
+
if (ch === 0x7d) {
|
|
313
|
+
cursor.at += 1;
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (ch === 0x2c) {
|
|
317
|
+
cursor.at += 1;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (ch !== 0x22) {
|
|
321
|
+
throw malformed(label, at, "a member name is expected");
|
|
322
|
+
}
|
|
323
|
+
const name = readName(cursor, at, label);
|
|
324
|
+
const path = joinKey(at, name);
|
|
325
|
+
if (seen.has(name)) {
|
|
326
|
+
throw malformed(
|
|
327
|
+
label,
|
|
328
|
+
path,
|
|
329
|
+
`member ${JSON.stringify(name)} is written twice, so neither value is chosen`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
seen.add(name);
|
|
333
|
+
const child = memberOf(node, name);
|
|
334
|
+
if (child === undefined) {
|
|
335
|
+
throw unimplementedKey(label, path);
|
|
336
|
+
}
|
|
337
|
+
skipSpace(cursor);
|
|
338
|
+
if (cursor.text.charCodeAt(cursor.at) !== 0x3a) {
|
|
339
|
+
throw malformed(label, path, "a member name is followed by a colon");
|
|
340
|
+
}
|
|
341
|
+
cursor.at += 1;
|
|
342
|
+
walkValue(cursor, path, child, label);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function walkArray(cursor: Cursor, at: string, node: KeyNode, label: string): void {
|
|
347
|
+
const entry: KeyNode =
|
|
348
|
+
node.kind === "list" ? { kind: "section", members: node.members ?? {} } : leaf;
|
|
349
|
+
let index = 0;
|
|
350
|
+
for (;;) {
|
|
351
|
+
skipSpace(cursor);
|
|
352
|
+
const ch = cursor.text.charCodeAt(cursor.at);
|
|
353
|
+
if (ch === 0x5d) {
|
|
354
|
+
cursor.at += 1;
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (ch === 0x2c) {
|
|
358
|
+
cursor.at += 1;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
walkValue(cursor, `${at}[${index}]`, entry, label);
|
|
362
|
+
index += 1;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Walks one document's text against the closed key list, refusing a member the
|
|
368
|
+
* key list does not declare and a member one object writes twice, each naming its
|
|
369
|
+
* dotted path. The document is already known to parse.
|
|
370
|
+
*/
|
|
371
|
+
export function checkDocument(text: string, node: KeyNode, label: string): void {
|
|
372
|
+
const cursor: Cursor = { text, at: 0 };
|
|
373
|
+
walkValue(cursor, "", node, label);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* The flag document, whose keys are dotted setting paths, rewritten as the nested
|
|
378
|
+
* object the closed key list declares, so one decoder reads every source. A key
|
|
379
|
+
* naming no setting is refused.
|
|
380
|
+
*/
|
|
381
|
+
export function nestFlags(flat: Readonly<Record<string, unknown>>, label: string): unknown {
|
|
382
|
+
const nested: Record<string, unknown> = {};
|
|
383
|
+
for (const path of Object.keys(flat).sort()) {
|
|
384
|
+
if (!declaresSetting(path)) {
|
|
385
|
+
throw unimplementedKey(label, path);
|
|
386
|
+
}
|
|
387
|
+
const segments = path.split(".");
|
|
388
|
+
let into = nested;
|
|
389
|
+
for (const segment of segments.slice(0, -1)) {
|
|
390
|
+
const child = into[segment] ?? {};
|
|
391
|
+
if (typeof child !== "object" || Array.isArray(child)) {
|
|
392
|
+
throw malformed(label, path, `the flag document sets ${JSON.stringify(segment)} twice`);
|
|
393
|
+
}
|
|
394
|
+
into[segment] = child;
|
|
395
|
+
into = child as Record<string, unknown>;
|
|
396
|
+
}
|
|
397
|
+
const last = segments[segments.length - 1] ?? "";
|
|
398
|
+
into[last] = flat[path];
|
|
399
|
+
}
|
|
400
|
+
return nested;
|
|
401
|
+
}
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { Host } from "./host.ts";
|
|
2
|
+
import { dirnamePath, isAbsolutePath, joinPath, resolvePath } from "./paths.ts";
|
|
3
|
+
|
|
4
|
+
/** The roles a scope document declares for a module. */
|
|
5
|
+
export const ROLE_TARGET = "target";
|
|
6
|
+
export const ROLE_CONSUMER = "consumer";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A scope document above this length is refused rather than decoded. It names one
|
|
10
|
+
* target and its consumers, so a larger file is not one.
|
|
11
|
+
*/
|
|
12
|
+
const MAX_DOCUMENT_LENGTH = 1 << 20;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A scope document that cannot be read as one. It ends the run with the failure
|
|
16
|
+
* code: the run could not learn what to analyze, so it produced no answer.
|
|
17
|
+
*/
|
|
18
|
+
export class ScopeError extends Error {
|
|
19
|
+
constructor(message: string) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "ScopeError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One module the run reads: the target or a declared consumer.
|
|
27
|
+
*
|
|
28
|
+
* The role a document declares is not carried here, because the resolved scope
|
|
29
|
+
* holds exactly one target and a list of consumers, so a module's role is where it
|
|
30
|
+
* sits. What a document declares is read and refused where it contradicts that
|
|
31
|
+
* position, which is the only place the declaration can be wrong.
|
|
32
|
+
*/
|
|
33
|
+
export interface Module {
|
|
34
|
+
/** The package name. A document may state it; otherwise it is empty. */
|
|
35
|
+
readonly id: string;
|
|
36
|
+
/** An absolute filesystem path. */
|
|
37
|
+
readonly path: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The resolved scope: exactly one target and zero or more consumers. */
|
|
41
|
+
export interface Scope {
|
|
42
|
+
readonly target: Module;
|
|
43
|
+
readonly consumers: readonly Module[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DECLARED_KEYS = new Set(["target", "workspace", "consumers"]);
|
|
47
|
+
const MODULE_KEYS = new Set(["id", "role", "path"]);
|
|
48
|
+
|
|
49
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
50
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function stringMember(parent: Record<string, unknown>, name: string, at: string): string {
|
|
54
|
+
if (!Object.hasOwn(parent, name)) {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
const value = parent[name];
|
|
58
|
+
if (typeof value !== "string") {
|
|
59
|
+
throw new ScopeError(`scope: ${at}.${name} is not a string`);
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readModule(value: unknown, at: string, role: string, documentDir: string): Module {
|
|
65
|
+
if (!isRecord(value)) {
|
|
66
|
+
throw new ScopeError(`scope: ${at} is not an object`);
|
|
67
|
+
}
|
|
68
|
+
for (const name of Object.keys(value)) {
|
|
69
|
+
if (!MODULE_KEYS.has(name)) {
|
|
70
|
+
throw new ScopeError(`scope: ${at}.${name} is not a declared key`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const declared = stringMember(value, "role", at);
|
|
74
|
+
if (declared !== "" && declared !== role) {
|
|
75
|
+
throw new ScopeError(
|
|
76
|
+
`scope: ${at}.role is ${JSON.stringify(declared)}, want ${JSON.stringify(role)}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const path = stringMember(value, "path", at);
|
|
80
|
+
if (path === "") {
|
|
81
|
+
throw new ScopeError(`scope: ${at}.path names nothing`);
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
id: stringMember(value, "id", at),
|
|
85
|
+
path: isAbsolutePath(path) ? path : joinPath(documentDir, path),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Decodes the scope document at `file`. Decoding is strict: an undeclared key is an
|
|
91
|
+
* error, and a relative path inside the document resolves against the document's
|
|
92
|
+
* own directory.
|
|
93
|
+
*
|
|
94
|
+
* The `workspace` key the format declares is read and carries nothing here: it
|
|
95
|
+
* names the file a Go consumer resolves the target through, and a TypeScript
|
|
96
|
+
* project resolves one through its own compiler configuration.
|
|
97
|
+
*/
|
|
98
|
+
export function readScope(host: Host, file: string): Scope {
|
|
99
|
+
const absolute = resolvePath(host.workingDirectory(), file);
|
|
100
|
+
let text: string;
|
|
101
|
+
try {
|
|
102
|
+
text = host.readFile(absolute);
|
|
103
|
+
} catch (error: unknown) {
|
|
104
|
+
throw new ScopeError(
|
|
105
|
+
`scope: ${file}: ${error instanceof Error ? error.message : String(error)}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (text.length > MAX_DOCUMENT_LENGTH) {
|
|
109
|
+
throw new ScopeError(`scope: ${file} is longer than ${String(MAX_DOCUMENT_LENGTH)} characters`);
|
|
110
|
+
}
|
|
111
|
+
let value: unknown;
|
|
112
|
+
try {
|
|
113
|
+
value = JSON.parse(text);
|
|
114
|
+
} catch (error: unknown) {
|
|
115
|
+
throw new ScopeError(
|
|
116
|
+
`scope: ${file}: ${error instanceof Error ? error.message : String(error)}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (!isRecord(value)) {
|
|
120
|
+
throw new ScopeError(`scope: ${file} is not one JSON object`);
|
|
121
|
+
}
|
|
122
|
+
for (const name of Object.keys(value)) {
|
|
123
|
+
if (!DECLARED_KEYS.has(name)) {
|
|
124
|
+
throw new ScopeError(`scope: ${file}: ${name} is not a declared key`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const documentDir = dirnamePath(absolute);
|
|
128
|
+
if (!Object.hasOwn(value, "target")) {
|
|
129
|
+
throw new ScopeError(`scope: ${file} names no target`);
|
|
130
|
+
}
|
|
131
|
+
const target = readModule(value["target"], "target", ROLE_TARGET, documentDir);
|
|
132
|
+
const listed = Object.hasOwn(value, "consumers") ? value["consumers"] : [];
|
|
133
|
+
if (!Array.isArray(listed)) {
|
|
134
|
+
throw new ScopeError(`scope: ${file}: consumers is not an array`);
|
|
135
|
+
}
|
|
136
|
+
const consumers = (listed as unknown[]).map((entry, index) =>
|
|
137
|
+
readModule(entry, `consumers[${String(index)}]`, ROLE_CONSUMER, documentDir),
|
|
138
|
+
);
|
|
139
|
+
return { target, consumers };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The single-package scope one directory is, when no scope document is given. */
|
|
143
|
+
export function scopeForDir(host: Host, dir: string): Scope {
|
|
144
|
+
const path = resolvePath(host.workingDirectory(), dir);
|
|
145
|
+
const kind = host.kindOf(path);
|
|
146
|
+
if (kind === "absent") {
|
|
147
|
+
throw new ScopeError(`scope: ${path} does not exist`);
|
|
148
|
+
}
|
|
149
|
+
if (kind !== "directory") {
|
|
150
|
+
throw new ScopeError(`scope: ${path} is not a directory`);
|
|
151
|
+
}
|
|
152
|
+
return { target: { id: "", path }, consumers: [] };
|
|
153
|
+
}
|