@descent-vtt/spec-guard 0.5.0 → 0.6.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/CHANGELOG.md +162 -0
- package/README.md +201 -14
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +10 -0
- package/dist/cli.js.map +1 -1
- package/dist/comments.d.ts.map +1 -1
- package/dist/comments.js +41 -19
- package/dist/comments.js.map +1 -1
- package/dist/engine.d.ts +117 -6
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +279 -150
- package/dist/engine.js.map +1 -1
- package/dist/glob.d.ts +11 -1
- package/dist/glob.d.ts.map +1 -1
- package/dist/glob.js +20 -5
- package/dist/glob.js.map +1 -1
- package/dist/graph.d.ts +124 -0
- package/dist/graph.d.ts.map +1 -0
- package/dist/graph.js +241 -0
- package/dist/graph.js.map +1 -0
- package/dist/imports.d.ts.map +1 -1
- package/dist/imports.js +10 -4
- package/dist/imports.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/layers.d.ts +72 -0
- package/dist/layers.d.ts.map +1 -0
- package/dist/layers.js +102 -0
- package/dist/layers.js.map +1 -0
- package/dist/parser.d.ts +36 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +189 -4
- package/dist/parser.js.map +1 -1
- package/dist/polyglot.d.ts +11 -1
- package/dist/polyglot.d.ts.map +1 -1
- package/dist/polyglot.js +21 -17
- package/dist/polyglot.js.map +1 -1
- package/dist/reporter.d.ts.map +1 -1
- package/dist/reporter.js +62 -5
- package/dist/reporter.js.map +1 -1
- package/dist/runner.d.ts +25 -0
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +318 -93
- package/dist/runner.js.map +1 -1
- package/dist/text.d.ts +9 -1
- package/dist/text.d.ts.map +1 -1
- package/dist/text.js +29 -18
- package/dist/text.js.map +1 -1
- package/dist/types.d.ts +60 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/runner.js
CHANGED
|
@@ -7,13 +7,26 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { promises as fs } from "node:fs";
|
|
9
9
|
import path from "node:path";
|
|
10
|
-
import { createCachedEngine, enumerateCandidates, resolveEngine, runSearches, ANY_FILE_PROBE, } from "./engine.js";
|
|
10
|
+
import { comparePaths, createCachedEngine, enumerateCandidates, passKey, resolveEngine, runSearches, ANY_FILE_PROBE, ROOT_TARGETS, } from "./engine.js";
|
|
11
11
|
import { createExcludeMatcher, expandSpecPatterns, toPosix } from "./glob.js";
|
|
12
|
+
import { buildGraph, cyclicComponents, edgeKey, isGraphFile, witness, } from "./graph.js";
|
|
12
13
|
import { ANALYSABLE_EXTENSIONS, createImportIndex, resolveModule, } from "./imports.js";
|
|
14
|
+
import { checkLayers } from "./layers.js";
|
|
13
15
|
import { parseDirectives } from "./parser.js";
|
|
14
|
-
import { createScope, DEFAULT_SCOPE, EMPTY_LEDGER,
|
|
16
|
+
import { createScope, DEFAULT_SCOPE, EMPTY_LEDGER, } from "./scope.js";
|
|
15
17
|
export const DEFAULT_MAX_SNIPPETS = 5;
|
|
16
18
|
export const DEFAULT_CONCURRENCY = 8;
|
|
19
|
+
/**
|
|
20
|
+
* Milliseconds between two `performance.now()` marks.
|
|
21
|
+
*
|
|
22
|
+
* Seven results carry a duration and each used to subtract inline, which is
|
|
23
|
+
* seven chances to write the operands the wrong way round and report a negative
|
|
24
|
+
* time - and seven copies of one subtraction that nothing could pin down,
|
|
25
|
+
* because "a plausible number of milliseconds" is not an assertion.
|
|
26
|
+
*/
|
|
27
|
+
export function elapsed(from, to = performance.now()) {
|
|
28
|
+
return to - from;
|
|
29
|
+
}
|
|
17
30
|
const TRUE_VALUES = new Set(["true", "1", "yes", "on"]);
|
|
18
31
|
const FALSE_VALUES = new Set(["false", "0", "no", "off"]);
|
|
19
32
|
function parseBoolean(value, attribute) {
|
|
@@ -44,13 +57,19 @@ function parseCount(value, attribute) {
|
|
|
44
57
|
function splitList(value) {
|
|
45
58
|
if (value === undefined)
|
|
46
59
|
return [];
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
60
|
+
// Taking the items rather than splitting on the separators. Split needed a
|
|
61
|
+
// `+` on the separator class, a trim and a length filter, and any two of the
|
|
62
|
+
// three made the third unnecessary - so none of them could be tested. What is
|
|
63
|
+
// wanted is "the runs of non-separator characters", and that is one pattern.
|
|
64
|
+
return value.match(/[^,\s]+/g) ?? [];
|
|
51
65
|
}
|
|
52
66
|
/** Rejects absolute paths and any `..` escape out of the root. */
|
|
53
67
|
function normalizeTarget(target, root, attribute) {
|
|
68
|
+
// The second test is not redundant with the first: `path.isAbsolute` follows
|
|
69
|
+
// the host platform, so a Windows drive path is absolute on Windows and an
|
|
70
|
+
// ordinary relative name on Linux. A spec naming `C:/repo/src` has to be
|
|
71
|
+
// refused on both, or the same document means two different things. It is
|
|
72
|
+
// anchored, because a colon partway along a path is just a character.
|
|
54
73
|
if (path.isAbsolute(target) || /^[a-zA-Z]:[\\/]/.test(target)) {
|
|
55
74
|
throw new Error(`Attribute "${attribute}" must be relative to --root, got "${target}".`);
|
|
56
75
|
}
|
|
@@ -61,22 +80,22 @@ function normalizeTarget(target, root, attribute) {
|
|
|
61
80
|
}
|
|
62
81
|
return toPosix(relative) || ".";
|
|
63
82
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
function describeBounds(bounds) {
|
|
83
|
+
const MATCHES = { one: "match", many: "matches" };
|
|
84
|
+
const CYCLES = { one: "import cycle", many: "import cycles" };
|
|
85
|
+
const VIOLATING_FILES = { one: "violating file", many: "violating files" };
|
|
86
|
+
function describeBounds(bounds, unit = MATCHES) {
|
|
68
87
|
const { min, max } = bounds;
|
|
88
|
+
const counted = (count) => `${count} ${count === 1 ? unit.one : unit.many}`;
|
|
69
89
|
if (min !== undefined && max !== undefined) {
|
|
70
90
|
return min === max
|
|
71
|
-
? `exactly ${
|
|
72
|
-
: `between ${min} and ${max}
|
|
91
|
+
? `exactly ${counted(min)}`
|
|
92
|
+
: `between ${min} and ${max} ${unit.many}`;
|
|
73
93
|
}
|
|
74
94
|
if (min !== undefined)
|
|
75
|
-
return `at least ${
|
|
95
|
+
return `at least ${counted(min)}`;
|
|
76
96
|
if (max !== undefined)
|
|
77
|
-
return max === 0 ?
|
|
78
|
-
|
|
79
|
-
return "any number of matches";
|
|
97
|
+
return max === 0 ? `no ${unit.many}` : `at most ${counted(max)}`;
|
|
98
|
+
return `any number of ${unit.many}`;
|
|
80
99
|
}
|
|
81
100
|
/** Prose form used in the assertion description ("must appear at most 3 times"). */
|
|
82
101
|
function describeExpectation(bounds) {
|
|
@@ -91,10 +110,10 @@ function describeExpectation(bounds) {
|
|
|
91
110
|
return `must appear at least ${times(min)}`;
|
|
92
111
|
if (max === 0)
|
|
93
112
|
return "must not appear";
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
113
|
+
// No branch for "neither bound": this is only ever called from
|
|
114
|
+
// `resolveDirective`, which refuses a directive that names none. Carrying a
|
|
115
|
+
// fourth case here meant carrying a string no run could print.
|
|
116
|
+
return `must appear at most ${times(max ?? 0)}`;
|
|
98
117
|
}
|
|
99
118
|
/** Prose for an import claim: "must not import", "must import at least 2 files". */
|
|
100
119
|
function describeImportExpectation(bounds) {
|
|
@@ -109,10 +128,8 @@ function describeImportExpectation(bounds) {
|
|
|
109
128
|
}
|
|
110
129
|
if (min !== undefined)
|
|
111
130
|
return `must import from at least ${files(min)}`;
|
|
112
|
-
|
|
113
|
-
return max
|
|
114
|
-
? "may import"
|
|
115
|
-
: `must import from at most ${files(max)}`;
|
|
131
|
+
// As above: `resolveDirective` never produces an unbounded import claim.
|
|
132
|
+
return `must import from at most ${files(max ?? 0)}`;
|
|
116
133
|
}
|
|
117
134
|
/**
|
|
118
135
|
* What a report says when an assertion covered nothing.
|
|
@@ -136,8 +153,8 @@ const EMPTY_SCOPE_HINT = 'add allow-empty="true" if that is expected';
|
|
|
136
153
|
* baseline, an unreadable file and a stale entry says all three things instead
|
|
137
154
|
* of whichever one the branch order happened to reach first.
|
|
138
155
|
*/
|
|
139
|
-
function describeOutcome(bounds, actual, extras) {
|
|
140
|
-
const parts = [`expected ${describeBounds(bounds)}, found ${actual}`];
|
|
156
|
+
function describeOutcome(bounds, actual, extras, unit = MATCHES) {
|
|
157
|
+
const parts = [`expected ${describeBounds(bounds, unit)}, found ${actual}`];
|
|
141
158
|
if (extras.excluded > 0) {
|
|
142
159
|
parts.push(`${extras.excluded} more ${extras.excluded === 1 ? "is" : "are"} on the baseline`);
|
|
143
160
|
}
|
|
@@ -244,12 +261,16 @@ export function createScopeProbe() {
|
|
|
244
261
|
return pending;
|
|
245
262
|
};
|
|
246
263
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
264
|
+
/**
|
|
265
|
+
* Whether a count is inside the bounds. An absent bound is no bound.
|
|
266
|
+
*
|
|
267
|
+
* Written as defaults rather than as two `!== undefined` guards. Those guards
|
|
268
|
+
* could not change an answer - `count < undefined` is false for every count, so
|
|
269
|
+
* the comparison already admitted everything - which meant two conditions in
|
|
270
|
+
* the middle of the pass/fail decision that no test could hold in place.
|
|
271
|
+
*/
|
|
272
|
+
function satisfies(count, { min = 0, max = Number.POSITIVE_INFINITY }) {
|
|
273
|
+
return count >= min && count <= max;
|
|
253
274
|
}
|
|
254
275
|
/** Turns one directive into an executable assertion, or an error. */
|
|
255
276
|
export function resolveDirective(directive, context) {
|
|
@@ -281,10 +302,14 @@ export function resolveDirective(directive, context) {
|
|
|
281
302
|
},
|
|
282
303
|
};
|
|
283
304
|
}
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
305
|
+
const perModule = kind === "assert-import-absence" || kind === "assert-import-count";
|
|
306
|
+
// The two directives of ADR-0011. A cycle is a claim about the graph
|
|
307
|
+
// itself and names no subject; layers name theirs in `order`.
|
|
308
|
+
const graphKind = kind === "assert-import-cycle" || kind === "assert-layers";
|
|
309
|
+
const isImportKind = perModule || graphKind;
|
|
310
|
+
const subject = kind === "assert-layers" ? "order" : perModule ? "module" : "symbol";
|
|
311
|
+
const symbol = attributes[subject] ?? "";
|
|
312
|
+
if (kind !== "assert-import-cycle" && symbol.length === 0) {
|
|
288
313
|
return fail(`@${kind} requires a non-empty ${subject}="..." attribute.`);
|
|
289
314
|
}
|
|
290
315
|
const allowEmpty = parseBoolean(attributes["allow-empty"], "allow-empty");
|
|
@@ -305,9 +330,9 @@ export function resolveDirective(directive, context) {
|
|
|
305
330
|
return fail(`Attribute "comments" must be ignore or include, got "${attributes["comments"]}".`);
|
|
306
331
|
}
|
|
307
332
|
const rawTargets = splitList(attributes["target"]);
|
|
308
|
-
const targets = (rawTargets.length > 0 ? rawTargets :
|
|
333
|
+
const targets = (rawTargets.length > 0 ? rawTargets : ROOT_TARGETS).map((target) => normalizeTarget(target, context.root, "target"));
|
|
309
334
|
const bounds = {};
|
|
310
|
-
if (kind === "assert-absence" || kind === "assert-import-absence") {
|
|
335
|
+
if (kind === "assert-absence" || kind === "assert-import-absence" || graphKind) {
|
|
311
336
|
if (attributes["expected"] !== undefined &&
|
|
312
337
|
attributes["max"] !== undefined) {
|
|
313
338
|
return fail(`@${kind} accepts either expected="..." or max="...", not both.`);
|
|
@@ -338,9 +363,10 @@ export function resolveDirective(directive, context) {
|
|
|
338
363
|
bounds.min = parseCount(attributes["min"], "min");
|
|
339
364
|
if (attributes["max"] !== undefined)
|
|
340
365
|
bounds.max = parseCount(attributes["max"], "max");
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
366
|
+
// Defaults rather than presence checks, for the same reason as
|
|
367
|
+
// `satisfies`: `min > undefined` is false whatever min is, so the two
|
|
368
|
+
// `!== undefined` guards this used to carry could not decide anything.
|
|
369
|
+
if ((bounds.min ?? 0) > (bounds.max ?? Number.POSITIVE_INFINITY)) {
|
|
344
370
|
return fail(`min="${bounds.min}" is greater than max="${bounds.max}".`);
|
|
345
371
|
}
|
|
346
372
|
}
|
|
@@ -355,13 +381,30 @@ export function resolveDirective(directive, context) {
|
|
|
355
381
|
if (!["include", "ignore"].includes((attributes["types"] ?? "include").trim().toLowerCase())) {
|
|
356
382
|
return fail(`Attribute "types" must be include or ignore, got "${attributes["types"]}".`);
|
|
357
383
|
}
|
|
384
|
+
const layers = splitList(symbol);
|
|
385
|
+
if (kind === "assert-layers") {
|
|
386
|
+
if (layers.length < 2) {
|
|
387
|
+
return fail(`@assert-layers needs at least two layers in order="...", got ${layers.length}.`);
|
|
388
|
+
}
|
|
389
|
+
const repeated = layers.find((layer, at) => layers.indexOf(layer) !== at);
|
|
390
|
+
if (repeated !== undefined) {
|
|
391
|
+
return fail(`Layer "${repeated}" is listed twice in order="...".`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const typesNote = includeTypes ? "" : " (type-only imports ignored)";
|
|
395
|
+
const description = kind === "assert-import-cycle"
|
|
396
|
+
? `${scope} must have ${describeBounds(bounds, CYCLES)}${typesNote}${except}`
|
|
397
|
+
: kind === "assert-layers"
|
|
398
|
+
? `${scope} must keep its layers in order, ${layers.join(" < ")}${bounds.max === 0 ? "" : `, with ${describeBounds(bounds, VIOLATING_FILES)}`}${typesNote}${except}`
|
|
399
|
+
: `${scope} ${describeImportExpectation(bounds)} "${symbol}"${except}`;
|
|
358
400
|
return {
|
|
359
401
|
assertion: {
|
|
360
402
|
kind,
|
|
361
403
|
location,
|
|
362
|
-
description
|
|
404
|
+
description,
|
|
363
405
|
reason,
|
|
364
|
-
|
|
406
|
+
// Only the per-module kinds have a subject a report can quote.
|
|
407
|
+
symbol: perModule ? symbol : undefined,
|
|
365
408
|
targets,
|
|
366
409
|
files: [],
|
|
367
410
|
bounds,
|
|
@@ -378,7 +421,8 @@ export function resolveDirective(directive, context) {
|
|
|
378
421
|
scope: context.scope ?? DEFAULT_SCOPE,
|
|
379
422
|
excludeFiles: context.excludeFiles,
|
|
380
423
|
},
|
|
381
|
-
imports: { modules: splitList(symbol), includeTypes },
|
|
424
|
+
imports: { modules: perModule ? splitList(symbol) : [], includeTypes },
|
|
425
|
+
...(kind === "assert-layers" ? { layers } : {}),
|
|
382
426
|
missingTargets: [],
|
|
383
427
|
allowEmpty,
|
|
384
428
|
baseline,
|
|
@@ -401,7 +445,10 @@ export function resolveDirective(directive, context) {
|
|
|
401
445
|
new RegExp(symbol);
|
|
402
446
|
}
|
|
403
447
|
catch (error) {
|
|
404
|
-
|
|
448
|
+
// No prefix of our own. V8's message already begins "Invalid regular
|
|
449
|
+
// expression: /(/: ...", so adding one produced the phrase twice in a
|
|
450
|
+
// row and pushed the pattern itself off the useful part of the line.
|
|
451
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
405
452
|
}
|
|
406
453
|
}
|
|
407
454
|
const scope = targets.join(", ");
|
|
@@ -472,7 +519,7 @@ async function prepareAssertion(assertion, options) {
|
|
|
472
519
|
? `all ${assertion.files.length} referenced ${assertion.files.length === 1 ? "path exists" : "paths exist"}`
|
|
473
520
|
: `missing: ${missing.join(", ")}`,
|
|
474
521
|
matches: [],
|
|
475
|
-
durationMs:
|
|
522
|
+
durationMs: elapsed(startedAt),
|
|
476
523
|
};
|
|
477
524
|
}
|
|
478
525
|
if (assertion.imports) {
|
|
@@ -495,7 +542,7 @@ async function prepareAssertion(assertion, options) {
|
|
|
495
542
|
actual: 0,
|
|
496
543
|
message: `target path${assertion.missingTargets.length === 1 ? " does" : "s do"} not exist: ${assertion.missingTargets.join(", ")}`,
|
|
497
544
|
matches: [],
|
|
498
|
-
durationMs:
|
|
545
|
+
durationMs: elapsed(startedAt),
|
|
499
546
|
};
|
|
500
547
|
}
|
|
501
548
|
const request = {
|
|
@@ -512,7 +559,7 @@ async function prepareAssertion(assertion, options) {
|
|
|
512
559
|
actual: 0,
|
|
513
560
|
message: `no files were inspected, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`,
|
|
514
561
|
matches: [],
|
|
515
|
-
durationMs:
|
|
562
|
+
durationMs: elapsed(startedAt),
|
|
516
563
|
};
|
|
517
564
|
}
|
|
518
565
|
}
|
|
@@ -524,7 +571,13 @@ async function prepareAssertion(assertion, options) {
|
|
|
524
571
|
// The count is still reported, because it is still true of everything
|
|
525
572
|
// that was read; --strict is for runs where "true of what we read" is not
|
|
526
573
|
// good enough.
|
|
527
|
-
|
|
574
|
+
//
|
|
575
|
+
// No filter for uncertainty here any more. The ledger only ever holds
|
|
576
|
+
// gaps: the walk drops a policy skip before recording it, and the only
|
|
577
|
+
// other reasons the scanner adds are "unreadable" and "binary". Filtering
|
|
578
|
+
// again was a second statement of an invariant that is enforced where it
|
|
579
|
+
// is created, and being a no-op, nothing could tell it from its absence.
|
|
580
|
+
const gaps = search.scope.skipped;
|
|
528
581
|
const strictFailure = options.strictTargets && gaps.length > 0;
|
|
529
582
|
const { excluded, stale, shows } = applyBaseline(assertion.baseline, search.fileCounts);
|
|
530
583
|
const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
|
|
@@ -548,7 +601,7 @@ async function prepareAssertion(assertion, options) {
|
|
|
548
601
|
unclassifiedFiles: search.unclassifiedFiles,
|
|
549
602
|
scope: search.scope,
|
|
550
603
|
engine: search.engine,
|
|
551
|
-
durationMs:
|
|
604
|
+
durationMs: elapsed(startedAt),
|
|
552
605
|
};
|
|
553
606
|
},
|
|
554
607
|
};
|
|
@@ -581,13 +634,21 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
|
|
|
581
634
|
if (assertion.missingTargets.length > 0) {
|
|
582
635
|
warnings.push(`target path${assertion.missingTargets.length === 1 ? "" : "s"} not found: ${assertion.missingTargets.join(", ")}`);
|
|
583
636
|
}
|
|
637
|
+
// No symbol: an import assertion never runs a text search, and the walk does
|
|
638
|
+
// not depend on one. This used to pass `symbol: ""`, an invented value that
|
|
639
|
+
// reached nothing and that no test could therefore be wrong about.
|
|
584
640
|
const enumeration = await enumerateCandidates({
|
|
585
641
|
root: options.root,
|
|
586
|
-
symbol: "",
|
|
587
642
|
targets: existingTargets,
|
|
588
643
|
options: assertion.search,
|
|
589
644
|
});
|
|
590
|
-
|
|
645
|
+
// A cycle needs files the resolver can name, which today means JavaScript
|
|
646
|
+
// and TypeScript; every other import rule reads any language spec-guard
|
|
647
|
+
// tokenizes. ADR-0011 has the reasons, language by language.
|
|
648
|
+
const cycles = assertion.kind === "assert-import-cycle";
|
|
649
|
+
const analysable = enumeration.files.filter((file) => cycles
|
|
650
|
+
? isGraphFile(file.relativePath)
|
|
651
|
+
: ANALYSABLE_EXTENSIONS.has(path.posix.extname(file.relativePath)));
|
|
591
652
|
if (analysable.length === 0 && !options.allowEmptyScope && !assertion.allowEmpty) {
|
|
592
653
|
// For an import rule the bar is higher than "some file exists": a directory
|
|
593
654
|
// of YAML has nothing this can read, so a dependency claim about it is a
|
|
@@ -598,35 +659,60 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
|
|
|
598
659
|
actual: 0,
|
|
599
660
|
message: enumeration.files.length === 0
|
|
600
661
|
? `no files were inspected, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`
|
|
601
|
-
:
|
|
662
|
+
: cycles
|
|
663
|
+
? `none of the ${enumeration.files.length} files here are JavaScript or TypeScript, so there is no import graph to check and this assertion verified nothing (${EMPTY_SCOPE_HINT})`
|
|
664
|
+
: `none of the ${enumeration.files.length} files here are in a language whose imports spec-guard can read, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`,
|
|
602
665
|
matches: [],
|
|
603
|
-
durationMs:
|
|
666
|
+
durationMs: elapsed(startedAt),
|
|
604
667
|
};
|
|
605
668
|
}
|
|
606
669
|
const skipped = enumeration.files.length - analysable.length;
|
|
607
670
|
if (skipped > 0) {
|
|
608
|
-
warnings.push(
|
|
671
|
+
warnings.push(cycles
|
|
672
|
+
? `placed ${analysable.length} of ${enumeration.files.length} files in the import graph; ${skipped} ${skipped === 1 ? "is" : "are"} not JavaScript or TypeScript`
|
|
673
|
+
: `analysed ${analysable.length} of ${enumeration.files.length} files; ${skipped} ${skipped === 1 ? "is" : "are"} in a language whose imports spec-guard cannot read`);
|
|
609
674
|
}
|
|
610
|
-
const
|
|
611
|
-
const matches = [];
|
|
675
|
+
const analysed = [];
|
|
612
676
|
const unresolved = [];
|
|
613
677
|
for (const file of analysable) {
|
|
614
678
|
const analysis = await options.imports.analyze(file.absolutePath, file.relativePath);
|
|
615
679
|
for (const note of analysis.notes) {
|
|
616
680
|
unresolved.push(`${note.file}:${note.line} ${note.detail}`);
|
|
617
681
|
}
|
|
618
|
-
|
|
682
|
+
analysed.push({ file: file.relativePath, references: analysis.references });
|
|
683
|
+
}
|
|
684
|
+
if (unresolved.length > 0) {
|
|
685
|
+
warnings.push(`${unresolved.length} module reference${unresolved.length === 1 ? "" : "s"} could not be resolved statically`, ...unresolved.slice(0, options.maxSnippets).map((entry) => ` ${entry}`));
|
|
686
|
+
}
|
|
687
|
+
const read = {
|
|
688
|
+
analysed,
|
|
689
|
+
walked: enumeration.files.map((file) => file.relativePath),
|
|
690
|
+
targets: existingTargets,
|
|
691
|
+
unresolved: unresolved.length,
|
|
692
|
+
missing: !options.allowMissingTargets && assertion.missingTargets.length > 0
|
|
693
|
+
? `target path${assertion.missingTargets.length === 1 ? " does" : "s do"} not exist: ${assertion.missingTargets.join(", ")}`
|
|
694
|
+
: null,
|
|
695
|
+
};
|
|
696
|
+
if (cycles)
|
|
697
|
+
return finishCycles(assertion, options, base, warnings, startedAt, read);
|
|
698
|
+
if (assertion.layers) {
|
|
699
|
+
return finishLayers(assertion, assertion.layers, options, base, warnings, startedAt, read);
|
|
700
|
+
}
|
|
701
|
+
const matchesModule = createExcludeMatcher(query.modules);
|
|
702
|
+
const matches = [];
|
|
703
|
+
for (const { file, references } of analysed) {
|
|
704
|
+
const hit = references.find((reference) => {
|
|
619
705
|
if (reference.typeOnly && !query.includeTypes)
|
|
620
706
|
return false;
|
|
621
707
|
// Both forms are tried: the resolved one so `module="app/db/**"` works
|
|
622
708
|
// everywhere, and the raw one so a Python or C# author can write the
|
|
623
709
|
// dotted path they see in their own source and still be understood.
|
|
624
|
-
return (matchesModule(resolveModule(reference.specifier, file
|
|
710
|
+
return (matchesModule(resolveModule(reference.specifier, file)) ||
|
|
625
711
|
matchesModule(reference.specifier));
|
|
626
712
|
});
|
|
627
713
|
if (hit) {
|
|
628
714
|
matches.push({
|
|
629
|
-
file
|
|
715
|
+
file,
|
|
630
716
|
line: hit.line,
|
|
631
717
|
column: hit.column,
|
|
632
718
|
text: `${IMPORT_VERBS[hit.kind]} ${hit.specifier}`,
|
|
@@ -634,36 +720,149 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
|
|
|
634
720
|
});
|
|
635
721
|
}
|
|
636
722
|
}
|
|
637
|
-
if (unresolved.length > 0) {
|
|
638
|
-
warnings.push(`${unresolved.length} module reference${unresolved.length === 1 ? "" : "s"} could not be resolved statically`, ...unresolved.slice(0, options.maxSnippets).map((entry) => ` ${entry}`));
|
|
639
|
-
}
|
|
640
|
-
const missingFailure = !options.allowMissingTargets && assertion.missingTargets.length > 0;
|
|
641
723
|
// One reference per file, so the file counts are the matches themselves.
|
|
642
724
|
const fileCounts = new Map(matches.map((match) => [match.file, 1]));
|
|
643
725
|
const { excluded, stale, shows } = applyBaseline(assertion.baseline, fileCounts);
|
|
644
726
|
const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
|
|
645
727
|
const actual = matches.length - excluded;
|
|
646
|
-
const strictFailure =
|
|
728
|
+
const strictFailure = read.missing !== null || (options.strictTargets && unresolved.length > 0);
|
|
647
729
|
const ok = satisfies(actual, assertion.bounds) && !strictFailure && !staleFailure;
|
|
648
730
|
return {
|
|
649
731
|
...base,
|
|
650
732
|
ok,
|
|
651
733
|
actual,
|
|
652
|
-
message:
|
|
653
|
-
|
|
654
|
-
: describeOutcome(assertion.bounds, actual, {
|
|
734
|
+
message: read.missing ??
|
|
735
|
+
describeOutcome(assertion.bounds, actual, {
|
|
655
736
|
excluded,
|
|
656
737
|
stale: staleFailure ? stale : [],
|
|
657
738
|
gaps: 0,
|
|
658
|
-
}) +
|
|
659
|
-
(options.strictTargets && unresolved.length > 0
|
|
660
|
-
? `; ${unresolved.length} reference${unresolved.length === 1 ? "" : "s"} could not be resolved`
|
|
661
|
-
: ""),
|
|
739
|
+
}) + strictSuffix(options, unresolved.length),
|
|
662
740
|
baselinedMatches: excluded,
|
|
663
741
|
staleBaseline: stale,
|
|
664
742
|
fileMatches: [...fileCounts].map(([file, count]) => ({ file, count })),
|
|
665
743
|
matches: matches.filter((match) => shows(match.file)).slice(0, options.maxSnippets),
|
|
666
|
-
durationMs:
|
|
744
|
+
durationMs: elapsed(startedAt),
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
/** What `--strict` adds to a message when references went unresolved. */
|
|
748
|
+
function strictSuffix(options, unresolved) {
|
|
749
|
+
return options.strictTargets && unresolved > 0
|
|
750
|
+
? `; ${unresolved} reference${unresolved === 1 ? "" : "s"} could not be resolved`
|
|
751
|
+
: "";
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Counts the import cycles in scope - components, not simple cycles.
|
|
755
|
+
*
|
|
756
|
+
* Each is shown as one concrete loop with the line of every import on it, at
|
|
757
|
+
* the location of the first. The count does not move when someone adds a
|
|
758
|
+
* second route around a loop that already exists, which is what makes it a
|
|
759
|
+
* number a spec can hold. See ADR-0011.
|
|
760
|
+
*/
|
|
761
|
+
function finishCycles(assertion, options, base, warnings, startedAt, read) {
|
|
762
|
+
const query = assertion.imports;
|
|
763
|
+
const scope = {
|
|
764
|
+
nodes: new Set(read.analysed.map((entry) => entry.file)),
|
|
765
|
+
walked: new Set(read.walked),
|
|
766
|
+
excluded: createExcludeMatcher(assertion.search.excludeGlobs),
|
|
767
|
+
covers: (relativePath) => relativePath !== ".." &&
|
|
768
|
+
!relativePath.startsWith("../") &&
|
|
769
|
+
read.targets.some((target) => target === "." || relativePath === target || relativePath.startsWith(`${target}/`)),
|
|
770
|
+
};
|
|
771
|
+
const graph = buildGraph(read.analysed, scope, query.includeTypes);
|
|
772
|
+
const loops = cyclicComponents(graph).map((component) => witness(component, graph.successors));
|
|
773
|
+
if (graph.unresolved.length > 0) {
|
|
774
|
+
const count = graph.unresolved.length;
|
|
775
|
+
warnings.push(`${count} import${count === 1 ? "" : "s"} could not be resolved to a file, so ${count === 1 ? "its edge is" : "their edges are"} missing from the graph`, ...graph.unresolved
|
|
776
|
+
.slice(0, options.maxSnippets)
|
|
777
|
+
.map(({ file, reference }) => ` ${file}:${reference.line} ${reference.specifier}`));
|
|
778
|
+
}
|
|
779
|
+
const gaps = read.unresolved + graph.unresolved.length;
|
|
780
|
+
const actual = loops.length;
|
|
781
|
+
const ok = satisfies(actual, assertion.bounds) &&
|
|
782
|
+
read.missing === null &&
|
|
783
|
+
!(options.strictTargets && gaps > 0);
|
|
784
|
+
const matches = loops.map((loop) => {
|
|
785
|
+
const hop = (at) => graph.via.get(edgeKey(loop[at], loop[at + 1]));
|
|
786
|
+
return {
|
|
787
|
+
file: loop[0],
|
|
788
|
+
line: hop(0).line,
|
|
789
|
+
column: hop(0).column,
|
|
790
|
+
// Every file on the loop with the line that leaves it, so the imports to
|
|
791
|
+
// change can be read off without opening each file to find them.
|
|
792
|
+
text: loop.map((file, at) => (at < loop.length - 1 ? `${file}:${hop(at).line}` : file)).join(" -> "),
|
|
793
|
+
count: 1,
|
|
794
|
+
};
|
|
795
|
+
});
|
|
796
|
+
return {
|
|
797
|
+
...base,
|
|
798
|
+
ok,
|
|
799
|
+
actual,
|
|
800
|
+
message: read.missing ??
|
|
801
|
+
describeOutcome(assertion.bounds, actual, { excluded: 0, stale: [], gaps: 0 }, CYCLES) +
|
|
802
|
+
strictSuffix(options, gaps),
|
|
803
|
+
matches: matches.slice(0, options.maxSnippets),
|
|
804
|
+
durationMs: elapsed(startedAt),
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Checks each file's imports against the layer order.
|
|
809
|
+
*
|
|
810
|
+
* Three things fail besides a violation, because each is a rule that would
|
|
811
|
+
* otherwise pass while checking less than it says: a layer that matches no
|
|
812
|
+
* file, a file two layers claim, and a missing target. See ADR-0011.
|
|
813
|
+
*/
|
|
814
|
+
function finishLayers(assertion, order, options, base, warnings, startedAt, read) {
|
|
815
|
+
const query = assertion.imports;
|
|
816
|
+
const report = checkLayers(read.analysed, order, query.includeTypes);
|
|
817
|
+
const quoted = (layers) => layers.map((layer) => `"${order[layer]}"`).join(" and ");
|
|
818
|
+
const empty = order.flatMap((_, layer) => (report.members[layer] === 0 ? [layer] : []));
|
|
819
|
+
const emptyAllowed = options.allowEmptyScope || assertion.allowEmpty;
|
|
820
|
+
const one = empty.length === 1;
|
|
821
|
+
const unmatched = `${one ? "layer" : "layers"} ${quoted(empty)} ${one ? "matches" : "match"} no file in scope`;
|
|
822
|
+
if (report.unassigned.length > 0) {
|
|
823
|
+
warnings.push(`${report.unassigned.length} of ${read.analysed.length} files ${report.unassigned.length === 1 ? "belongs" : "belong"} to no layer, so nothing here constrains ${report.unassigned.length === 1 ? "it" : "them"}`);
|
|
824
|
+
}
|
|
825
|
+
if (empty.length > 0 && emptyAllowed)
|
|
826
|
+
warnings.push(unmatched);
|
|
827
|
+
const fileCounts = new Map(report.violations.map((violation) => [violation.file, 1]));
|
|
828
|
+
const { excluded, stale, shows } = applyBaseline(assertion.baseline, fileCounts);
|
|
829
|
+
const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
|
|
830
|
+
const actual = report.violations.length - excluded;
|
|
831
|
+
const emptyFailure = empty.length > 0 && !emptyAllowed;
|
|
832
|
+
const ok = satisfies(actual, assertion.bounds) &&
|
|
833
|
+
read.missing === null &&
|
|
834
|
+
!staleFailure &&
|
|
835
|
+
!emptyFailure &&
|
|
836
|
+
report.ambiguous.length === 0 &&
|
|
837
|
+
!(options.strictTargets && read.unresolved > 0);
|
|
838
|
+
const message = read.missing ??
|
|
839
|
+
(emptyFailure
|
|
840
|
+
? `${unmatched}, so nothing is held to ${one ? "it" : "them"} (${EMPTY_SCOPE_HINT})`
|
|
841
|
+
: report.ambiguous.length > 0
|
|
842
|
+
? `${report.ambiguous
|
|
843
|
+
.slice(0, options.maxSnippets)
|
|
844
|
+
.map(({ file, layers }) => `${file} is in both ${quoted(layers)}`)
|
|
845
|
+
.join("; ")}; a file in two layers has no single rule to follow`
|
|
846
|
+
: describeOutcome(assertion.bounds, actual, { excluded, stale: staleFailure ? stale : [], gaps: 0 }, VIOLATING_FILES) + strictSuffix(options, read.unresolved));
|
|
847
|
+
return {
|
|
848
|
+
...base,
|
|
849
|
+
ok,
|
|
850
|
+
actual,
|
|
851
|
+
message,
|
|
852
|
+
baselinedMatches: excluded,
|
|
853
|
+
staleBaseline: stale,
|
|
854
|
+
fileMatches: [...fileCounts].map(([file, count]) => ({ file, count })),
|
|
855
|
+
matches: report.violations
|
|
856
|
+
.filter((violation) => shows(violation.file))
|
|
857
|
+
.slice(0, options.maxSnippets)
|
|
858
|
+
.map((violation) => ({
|
|
859
|
+
file: violation.file,
|
|
860
|
+
line: violation.reference.line,
|
|
861
|
+
column: violation.reference.column,
|
|
862
|
+
text: `${order[violation.from]} -> ${order[violation.to]}: ${IMPORT_VERBS[violation.reference.kind]} ${violation.reference.specifier}`,
|
|
863
|
+
count: 1,
|
|
864
|
+
})),
|
|
865
|
+
durationMs: elapsed(startedAt),
|
|
667
866
|
};
|
|
668
867
|
}
|
|
669
868
|
/** Executes a single resolved assertion. */
|
|
@@ -674,19 +873,14 @@ export async function executeAssertion(assertion, options) {
|
|
|
674
873
|
return prepared.finish(await options.engine.search(prepared.request));
|
|
675
874
|
}
|
|
676
875
|
/**
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
*
|
|
876
|
+
* How many workers to run over the batches.
|
|
877
|
+
*
|
|
878
|
+
* At least one, so an empty run still terminates; never more than there are
|
|
879
|
+
* batches, so a spec with three groups does not start eight workers to find
|
|
880
|
+
* five of them nothing to do.
|
|
680
881
|
*/
|
|
681
|
-
function
|
|
682
|
-
|
|
683
|
-
return JSON.stringify([
|
|
684
|
-
request.targets,
|
|
685
|
-
options.regex,
|
|
686
|
-
options.word,
|
|
687
|
-
options.ignoreCase,
|
|
688
|
-
options.globs,
|
|
689
|
-
]);
|
|
882
|
+
export function batchConcurrency(requested, batches) {
|
|
883
|
+
return Math.max(1, Math.min(requested, batches));
|
|
690
884
|
}
|
|
691
885
|
/** Reads, parses and executes every directive found in the given spec files. */
|
|
692
886
|
export async function runSpecGuard(options) {
|
|
@@ -700,7 +894,10 @@ export async function runSpecGuard(options) {
|
|
|
700
894
|
const specFiles = await expandSpecPatterns(options.patterns, root);
|
|
701
895
|
const excludeFiles = new Set(options.includeSpecs ? [] : specFiles.map((file) => path.resolve(file)));
|
|
702
896
|
const directives = [];
|
|
897
|
+
/** Parsed, validated, and then not run: see ADR-0010. */
|
|
898
|
+
const withheld = [];
|
|
703
899
|
const errors = [];
|
|
900
|
+
const inactiveSpecs = [];
|
|
704
901
|
for (const file of specFiles) {
|
|
705
902
|
const relativeFile = toPosix(path.relative(root, file)) || toPosix(file);
|
|
706
903
|
const source = await fs.readFile(file, "utf8").catch((error) => {
|
|
@@ -714,8 +911,23 @@ export async function runSpecGuard(options) {
|
|
|
714
911
|
if (source === null)
|
|
715
912
|
continue;
|
|
716
913
|
const parsed = parseDirectives(source, { file, relativeFile });
|
|
717
|
-
directives.push(...parsed.directives);
|
|
718
914
|
errors.push(...parsed.errors);
|
|
915
|
+
const status = parsed.status;
|
|
916
|
+
if (status && !status.active && !(options.ignoreStatus ?? false)) {
|
|
917
|
+
withheld.push(...parsed.directives);
|
|
918
|
+
// Recorded even when it held no directives. "docs/adr/0011.md is a
|
|
919
|
+
// draft" is worth saying to someone wondering why their new rule has no
|
|
920
|
+
// effect, and a report that only mentions the documents it happened to
|
|
921
|
+
// find directives in cannot answer that.
|
|
922
|
+
inactiveSpecs.push({
|
|
923
|
+
file: relativeFile,
|
|
924
|
+
status: status.value,
|
|
925
|
+
label: status.label,
|
|
926
|
+
directives: parsed.directives.length,
|
|
927
|
+
});
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
directives.push(...parsed.directives);
|
|
719
931
|
}
|
|
720
932
|
const assertions = [];
|
|
721
933
|
for (const directive of directives) {
|
|
@@ -725,6 +937,15 @@ export async function runSpecGuard(options) {
|
|
|
725
937
|
else
|
|
726
938
|
assertions.push(resolved.assertion);
|
|
727
939
|
}
|
|
940
|
+
// Not in force is not the same as not checked. A withheld directive is still
|
|
941
|
+
// held to being well-formed, so a draft's typo is found on the day it is
|
|
942
|
+
// written rather than on the day the ADR is accepted - which is the day
|
|
943
|
+
// everyone has already agreed the rule is right and stopped looking at it.
|
|
944
|
+
for (const directive of withheld) {
|
|
945
|
+
const resolved = resolveDirective(directive, { root, excludeFiles, scope });
|
|
946
|
+
if ("error" in resolved)
|
|
947
|
+
errors.push(resolved.error);
|
|
948
|
+
}
|
|
728
949
|
const engine = createCachedEngine(await resolveEngine(options.engine ?? "auto"));
|
|
729
950
|
const executeOptions = {
|
|
730
951
|
root,
|
|
@@ -736,7 +957,7 @@ export async function runSpecGuard(options) {
|
|
|
736
957
|
imports: createImportIndex(),
|
|
737
958
|
hasFiles: createScopeProbe(),
|
|
738
959
|
};
|
|
739
|
-
const results =
|
|
960
|
+
const results = [];
|
|
740
961
|
if (options.failFast) {
|
|
741
962
|
// Fail-fast trades throughput for an early exit, so it runs unbatched.
|
|
742
963
|
for (let index = 0; index < assertions.length; index++) {
|
|
@@ -756,7 +977,11 @@ export async function runSpecGuard(options) {
|
|
|
756
977
|
results[index] = entry;
|
|
757
978
|
return;
|
|
758
979
|
}
|
|
759
|
-
|
|
980
|
+
// The engine's own definition of "these can share one pass", rather than
|
|
981
|
+
// a second list here that had drifted four fields behind it. This is
|
|
982
|
+
// where most of spec-guard's speed comes from: a spec with twenty
|
|
983
|
+
// assertions over `src/` costs one ripgrep pass, not twenty.
|
|
984
|
+
const key = passKey(entry.request);
|
|
760
985
|
const group = groups.get(key);
|
|
761
986
|
if (group)
|
|
762
987
|
group.push({ index, pending: entry });
|
|
@@ -764,7 +989,7 @@ export async function runSpecGuard(options) {
|
|
|
764
989
|
groups.set(key, [{ index, pending: entry }]);
|
|
765
990
|
});
|
|
766
991
|
const batches = [...groups.values()];
|
|
767
|
-
const concurrency =
|
|
992
|
+
const concurrency = batchConcurrency(options.concurrency ?? DEFAULT_CONCURRENCY, batches.length);
|
|
768
993
|
let cursor = 0;
|
|
769
994
|
const worker = async () => {
|
|
770
995
|
while (cursor < batches.length) {
|
|
@@ -784,25 +1009,25 @@ export async function runSpecGuard(options) {
|
|
|
784
1009
|
// Errors arrive in two waves (parse, then resolve); readers expect file order.
|
|
785
1010
|
errors.sort((a, b) => a.location.relativeFile === b.location.relativeFile
|
|
786
1011
|
? a.location.line - b.location.line
|
|
787
|
-
: a.location.relativeFile
|
|
788
|
-
? -1
|
|
789
|
-
: 1);
|
|
1012
|
+
: comparePaths(a.location.relativeFile, b.location.relativeFile));
|
|
790
1013
|
const failed = results.filter((result) => !result.ok).length;
|
|
791
1014
|
return {
|
|
792
1015
|
ok: failed === 0 && errors.length === 0,
|
|
793
1016
|
root,
|
|
794
1017
|
engine: warnings.length > 0 ? "javascript" : engine.name,
|
|
795
|
-
durationMs:
|
|
1018
|
+
durationMs: elapsed(startedAt),
|
|
796
1019
|
summary: {
|
|
797
1020
|
specs: specFiles.length,
|
|
798
1021
|
total: results.length,
|
|
799
1022
|
passed: results.length - failed,
|
|
800
1023
|
failed,
|
|
801
1024
|
skipped: assertions.length - results.length,
|
|
1025
|
+
inactive: withheld.length,
|
|
802
1026
|
},
|
|
803
1027
|
results,
|
|
804
1028
|
errors,
|
|
805
1029
|
warnings,
|
|
1030
|
+
inactiveSpecs,
|
|
806
1031
|
specFiles: specFiles.map((file) => toPosix(path.relative(root, file)) || toPosix(file)),
|
|
807
1032
|
};
|
|
808
1033
|
}
|