@cplieger/deadset-ts 1.0.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/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
+ }