@descent-vtt/spec-guard 0.5.1 → 0.7.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 +444 -283
- package/README.md +1094 -868
- package/dist/cli.d.ts +18 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +136 -7
- package/dist/cli.js.map +1 -1
- package/dist/glob.d.ts +17 -2
- package/dist/glob.d.ts.map +1 -1
- package/dist/glob.js +25 -9
- 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/index.d.ts +19 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -3
- 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/mcp.d.ts +117 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +544 -0
- package/dist/mcp.js.map +1 -0
- package/dist/parser.d.ts +50 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +232 -16
- package/dist/parser.js.map +1 -1
- package/dist/query.d.ts +108 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +206 -0
- package/dist/query.js.map +1 -0
- package/dist/reporter.d.ts.map +1 -1
- package/dist/reporter.js +62 -5
- package/dist/reporter.js.map +1 -1
- package/dist/rules.d.ts +108 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +153 -0
- package/dist/rules.js.map +1 -0
- package/dist/runner.d.ts +24 -0
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +335 -117
- package/dist/runner.js.map +1 -1
- package/dist/specs.d.ts +44 -0
- package/dist/specs.d.ts.map +1 -0
- package/dist/specs.js +62 -0
- package/dist/specs.js.map +1 -0
- package/dist/types.d.ts +60 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +73 -73
package/dist/runner.js
CHANGED
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
import { promises as fs } from "node:fs";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { comparePaths, createCachedEngine, enumerateCandidates, passKey, resolveEngine, runSearches, ANY_FILE_PROBE, ROOT_TARGETS, } from "./engine.js";
|
|
11
|
-
import { createExcludeMatcher,
|
|
11
|
+
import { createExcludeMatcher, toPosix } from "./glob.js";
|
|
12
|
+
import { buildGraph, cyclicComponents, edgeKey, isGraphFile, witness, } from "./graph.js";
|
|
12
13
|
import { ANALYSABLE_EXTENSIONS, createImportIndex, resolveModule, } from "./imports.js";
|
|
13
|
-
import {
|
|
14
|
+
import { checkLayers } from "./layers.js";
|
|
15
|
+
import { readSpecs, specPath } from "./specs.js";
|
|
14
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;
|
|
@@ -78,21 +80,22 @@ function normalizeTarget(target, root, attribute) {
|
|
|
78
80
|
}
|
|
79
81
|
return toPosix(relative) || ".";
|
|
80
82
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
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) {
|
|
85
87
|
const { min, max } = bounds;
|
|
88
|
+
const counted = (count) => `${count} ${count === 1 ? unit.one : unit.many}`;
|
|
86
89
|
if (min !== undefined && max !== undefined) {
|
|
87
90
|
return min === max
|
|
88
|
-
? `exactly ${
|
|
89
|
-
: `between ${min} and ${max}
|
|
91
|
+
? `exactly ${counted(min)}`
|
|
92
|
+
: `between ${min} and ${max} ${unit.many}`;
|
|
90
93
|
}
|
|
91
94
|
if (min !== undefined)
|
|
92
|
-
return `at least ${
|
|
95
|
+
return `at least ${counted(min)}`;
|
|
93
96
|
if (max !== undefined)
|
|
94
|
-
return max === 0 ?
|
|
95
|
-
return
|
|
97
|
+
return max === 0 ? `no ${unit.many}` : `at most ${counted(max)}`;
|
|
98
|
+
return `any number of ${unit.many}`;
|
|
96
99
|
}
|
|
97
100
|
/** Prose form used in the assertion description ("must appear at most 3 times"). */
|
|
98
101
|
function describeExpectation(bounds) {
|
|
@@ -150,8 +153,8 @@ const EMPTY_SCOPE_HINT = 'add allow-empty="true" if that is expected';
|
|
|
150
153
|
* baseline, an unreadable file and a stale entry says all three things instead
|
|
151
154
|
* of whichever one the branch order happened to reach first.
|
|
152
155
|
*/
|
|
153
|
-
function describeOutcome(bounds, actual, extras) {
|
|
154
|
-
const parts = [`expected ${describeBounds(bounds)}, found ${actual}`];
|
|
156
|
+
function describeOutcome(bounds, actual, extras, unit = MATCHES) {
|
|
157
|
+
const parts = [`expected ${describeBounds(bounds, unit)}, found ${actual}`];
|
|
155
158
|
if (extras.excluded > 0) {
|
|
156
159
|
parts.push(`${extras.excluded} more ${extras.excluded === 1 ? "is" : "are"} on the baseline`);
|
|
157
160
|
}
|
|
@@ -299,10 +302,14 @@ export function resolveDirective(directive, context) {
|
|
|
299
302
|
},
|
|
300
303
|
};
|
|
301
304
|
}
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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) {
|
|
306
313
|
return fail(`@${kind} requires a non-empty ${subject}="..." attribute.`);
|
|
307
314
|
}
|
|
308
315
|
const allowEmpty = parseBoolean(attributes["allow-empty"], "allow-empty");
|
|
@@ -325,7 +332,7 @@ export function resolveDirective(directive, context) {
|
|
|
325
332
|
const rawTargets = splitList(attributes["target"]);
|
|
326
333
|
const targets = (rawTargets.length > 0 ? rawTargets : ROOT_TARGETS).map((target) => normalizeTarget(target, context.root, "target"));
|
|
327
334
|
const bounds = {};
|
|
328
|
-
if (kind === "assert-absence" || kind === "assert-import-absence") {
|
|
335
|
+
if (kind === "assert-absence" || kind === "assert-import-absence" || graphKind) {
|
|
329
336
|
if (attributes["expected"] !== undefined &&
|
|
330
337
|
attributes["max"] !== undefined) {
|
|
331
338
|
return fail(`@${kind} accepts either expected="..." or max="...", not both.`);
|
|
@@ -374,13 +381,30 @@ export function resolveDirective(directive, context) {
|
|
|
374
381
|
if (!["include", "ignore"].includes((attributes["types"] ?? "include").trim().toLowerCase())) {
|
|
375
382
|
return fail(`Attribute "types" must be include or ignore, got "${attributes["types"]}".`);
|
|
376
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}`;
|
|
377
400
|
return {
|
|
378
401
|
assertion: {
|
|
379
402
|
kind,
|
|
380
403
|
location,
|
|
381
|
-
description
|
|
404
|
+
description,
|
|
382
405
|
reason,
|
|
383
|
-
|
|
406
|
+
// Only the per-module kinds have a subject a report can quote.
|
|
407
|
+
symbol: perModule ? symbol : undefined,
|
|
384
408
|
targets,
|
|
385
409
|
files: [],
|
|
386
410
|
bounds,
|
|
@@ -397,7 +421,8 @@ export function resolveDirective(directive, context) {
|
|
|
397
421
|
scope: context.scope ?? DEFAULT_SCOPE,
|
|
398
422
|
excludeFiles: context.excludeFiles,
|
|
399
423
|
},
|
|
400
|
-
imports: { modules: splitList(symbol), includeTypes },
|
|
424
|
+
imports: { modules: perModule ? splitList(symbol) : [], includeTypes },
|
|
425
|
+
...(kind === "assert-layers" ? { layers } : {}),
|
|
401
426
|
missingTargets: [],
|
|
402
427
|
allowEmpty,
|
|
403
428
|
baseline,
|
|
@@ -452,6 +477,15 @@ export function resolveDirective(directive, context) {
|
|
|
452
477
|
return fail(error instanceof Error ? error.message : String(error));
|
|
453
478
|
}
|
|
454
479
|
}
|
|
480
|
+
/**
|
|
481
|
+
* The spec files a search must not count, as absolute paths.
|
|
482
|
+
*
|
|
483
|
+
* A spec names the symbol it forbids, so without this every absence rule would
|
|
484
|
+
* find itself. Empty under `--include-specs`.
|
|
485
|
+
*/
|
|
486
|
+
export function specExclusions(specFiles, includeSpecs) {
|
|
487
|
+
return new Set(includeSpecs ? undefined : specFiles.map((file) => path.resolve(file)));
|
|
488
|
+
}
|
|
455
489
|
async function pathExists(candidate) {
|
|
456
490
|
return (await fs.stat(candidate).catch(() => null)) !== null;
|
|
457
491
|
}
|
|
@@ -526,61 +560,71 @@ async function prepareAssertion(assertion, options) {
|
|
|
526
560
|
targets: existingTargets,
|
|
527
561
|
options: assertion.search,
|
|
528
562
|
};
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
563
|
+
// Every target missing, and that tolerated, leaves nothing to search - and
|
|
564
|
+
// must not be handed to the engine as an empty target list, which the engine
|
|
565
|
+
// reads, by contract, as the root. It used to be: a rule on a deleted
|
|
566
|
+
// `src/auth` searched the whole repository, where `min="1"` could pass on a
|
|
567
|
+
// test file. Found by holding `spec-guard query` to the walk; see ADR-0012.
|
|
568
|
+
const nothingLeft = existingTargets.length === 0;
|
|
569
|
+
if (!options.allowEmptyScope && !assertion.allowEmpty && (nothingLeft || !(await options.hasFiles(request)))) {
|
|
570
|
+
return {
|
|
571
|
+
...base,
|
|
572
|
+
ok: false,
|
|
573
|
+
actual: 0,
|
|
574
|
+
message: `no files were inspected, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`,
|
|
575
|
+
matches: [],
|
|
576
|
+
durationMs: elapsed(startedAt),
|
|
577
|
+
};
|
|
540
578
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
durationMs: elapsed(startedAt),
|
|
580
|
-
};
|
|
581
|
-
},
|
|
579
|
+
const finish = (search) => {
|
|
580
|
+
// A file that could not be read, or whose bytes are not text but did
|
|
581
|
+
// contain the symbol, is a hole in the answer rather than a detail of it.
|
|
582
|
+
// The count is still reported, because it is still true of everything
|
|
583
|
+
// that was read; --strict is for runs where "true of what we read" is not
|
|
584
|
+
// good enough.
|
|
585
|
+
//
|
|
586
|
+
// No filter for uncertainty here any more. The ledger only ever holds
|
|
587
|
+
// gaps: the walk drops a policy skip before recording it, and the only
|
|
588
|
+
// other reasons the scanner adds are "unreadable" and "binary". Filtering
|
|
589
|
+
// again was a second statement of an invariant that is enforced where it
|
|
590
|
+
// is created, and being a no-op, nothing could tell it from its absence.
|
|
591
|
+
const gaps = search.scope.skipped;
|
|
592
|
+
const strictFailure = options.strictTargets && gaps.length > 0;
|
|
593
|
+
const { excluded, stale, shows } = applyBaseline(assertion.baseline, search.fileCounts);
|
|
594
|
+
const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
|
|
595
|
+
const actual = search.count - excluded;
|
|
596
|
+
return {
|
|
597
|
+
...base,
|
|
598
|
+
ok: satisfies(actual, assertion.bounds) && !strictFailure && !staleFailure,
|
|
599
|
+
actual,
|
|
600
|
+
message: describeOutcome(assertion.bounds, actual, {
|
|
601
|
+
excluded,
|
|
602
|
+
stale: staleFailure ? stale : [],
|
|
603
|
+
gaps: strictFailure ? gaps.length : 0,
|
|
604
|
+
}),
|
|
605
|
+
baselinedMatches: excluded,
|
|
606
|
+
staleBaseline: stale,
|
|
607
|
+
fileMatches: [...search.fileCounts].map(([file, count]) => ({ file, count })),
|
|
608
|
+
matches: search.matches.filter((match) => shows(match.file)).slice(0, options.maxSnippets),
|
|
609
|
+
// Carried as numbers, not prose, so the reporter can total them across a
|
|
610
|
+
// run and JSON consumers can act on them.
|
|
611
|
+
commentMatches: search.commentMatches,
|
|
612
|
+
unclassifiedFiles: search.unclassifiedFiles,
|
|
613
|
+
scope: search.scope,
|
|
614
|
+
engine: search.engine,
|
|
615
|
+
durationMs: elapsed(startedAt),
|
|
616
|
+
};
|
|
582
617
|
};
|
|
618
|
+
return nothingLeft ? finish(NOTHING_SEARCHED) : { request, finish };
|
|
583
619
|
}
|
|
620
|
+
const NOTHING_SEARCHED = {
|
|
621
|
+
count: 0,
|
|
622
|
+
commentMatches: 0,
|
|
623
|
+
unclassifiedFiles: 0,
|
|
624
|
+
matches: [],
|
|
625
|
+
fileCounts: new Map(),
|
|
626
|
+
scope: EMPTY_LEDGER,
|
|
627
|
+
};
|
|
584
628
|
/** How each kind of reference is spelled in a report snippet. */
|
|
585
629
|
const IMPORT_VERBS = {
|
|
586
630
|
import: "import",
|
|
@@ -612,13 +656,30 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
|
|
|
612
656
|
// No symbol: an import assertion never runs a text search, and the walk does
|
|
613
657
|
// not depend on one. This used to pass `symbol: ""`, an invented value that
|
|
614
658
|
// reached nothing and that no test could therefore be wrong about.
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
659
|
+
//
|
|
660
|
+
// And no walk at all when every target is missing: an empty target list is
|
|
661
|
+
// the root to the engine, as the text rules above found out.
|
|
662
|
+
const enumeration = existingTargets.length === 0
|
|
663
|
+
? { files: [] }
|
|
664
|
+
: await enumerateCandidates({
|
|
665
|
+
root: options.root,
|
|
666
|
+
targets: existingTargets,
|
|
667
|
+
options: assertion.search,
|
|
668
|
+
});
|
|
669
|
+
// Why a missing target fails the assertion, or null when it does not. Decided
|
|
670
|
+
// before scope, so a rule whose only target is gone says so rather than that
|
|
671
|
+
// its scope is empty - which is true, and not the thing to fix.
|
|
672
|
+
const missing = !options.allowMissingTargets && assertion.missingTargets.length > 0
|
|
673
|
+
? `target path${assertion.missingTargets.length === 1 ? " does" : "s do"} not exist: ${assertion.missingTargets.join(", ")}`
|
|
674
|
+
: null;
|
|
675
|
+
// A cycle needs files the resolver can name, which today means JavaScript
|
|
676
|
+
// and TypeScript; every other import rule reads any language spec-guard
|
|
677
|
+
// tokenizes. ADR-0011 has the reasons, language by language.
|
|
678
|
+
const cycles = assertion.kind === "assert-import-cycle";
|
|
679
|
+
const analysable = enumeration.files.filter((file) => cycles
|
|
680
|
+
? isGraphFile(file.relativePath)
|
|
681
|
+
: ANALYSABLE_EXTENSIONS.has(path.posix.extname(file.relativePath)));
|
|
682
|
+
if (analysable.length === 0 && missing === null && !options.allowEmptyScope && !assertion.allowEmpty) {
|
|
622
683
|
// For an import rule the bar is higher than "some file exists": a directory
|
|
623
684
|
// of YAML has nothing this can read, so a dependency claim about it is a
|
|
624
685
|
// claim about nothing.
|
|
@@ -628,35 +689,58 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
|
|
|
628
689
|
actual: 0,
|
|
629
690
|
message: enumeration.files.length === 0
|
|
630
691
|
? `no files were inspected, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`
|
|
631
|
-
:
|
|
692
|
+
: cycles
|
|
693
|
+
? `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})`
|
|
694
|
+
: `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})`,
|
|
632
695
|
matches: [],
|
|
633
696
|
durationMs: elapsed(startedAt),
|
|
634
697
|
};
|
|
635
698
|
}
|
|
636
699
|
const skipped = enumeration.files.length - analysable.length;
|
|
637
700
|
if (skipped > 0) {
|
|
638
|
-
warnings.push(
|
|
701
|
+
warnings.push(cycles
|
|
702
|
+
? `placed ${analysable.length} of ${enumeration.files.length} files in the import graph; ${skipped} ${skipped === 1 ? "is" : "are"} not JavaScript or TypeScript`
|
|
703
|
+
: `analysed ${analysable.length} of ${enumeration.files.length} files; ${skipped} ${skipped === 1 ? "is" : "are"} in a language whose imports spec-guard cannot read`);
|
|
639
704
|
}
|
|
640
|
-
const
|
|
641
|
-
const matches = [];
|
|
705
|
+
const analysed = [];
|
|
642
706
|
const unresolved = [];
|
|
643
707
|
for (const file of analysable) {
|
|
644
708
|
const analysis = await options.imports.analyze(file.absolutePath, file.relativePath);
|
|
645
709
|
for (const note of analysis.notes) {
|
|
646
710
|
unresolved.push(`${note.file}:${note.line} ${note.detail}`);
|
|
647
711
|
}
|
|
648
|
-
|
|
712
|
+
analysed.push({ file: file.relativePath, references: analysis.references });
|
|
713
|
+
}
|
|
714
|
+
if (unresolved.length > 0) {
|
|
715
|
+
warnings.push(`${unresolved.length} module reference${unresolved.length === 1 ? "" : "s"} could not be resolved statically`, ...unresolved.slice(0, options.maxSnippets).map((entry) => ` ${entry}`));
|
|
716
|
+
}
|
|
717
|
+
const read = {
|
|
718
|
+
analysed,
|
|
719
|
+
walked: enumeration.files.map((file) => file.relativePath),
|
|
720
|
+
targets: existingTargets,
|
|
721
|
+
unresolved: unresolved.length,
|
|
722
|
+
missing,
|
|
723
|
+
};
|
|
724
|
+
if (cycles)
|
|
725
|
+
return finishCycles(assertion, options, base, warnings, startedAt, read);
|
|
726
|
+
if (assertion.layers) {
|
|
727
|
+
return finishLayers(assertion, assertion.layers, options, base, warnings, startedAt, read);
|
|
728
|
+
}
|
|
729
|
+
const matchesModule = createExcludeMatcher(query.modules);
|
|
730
|
+
const matches = [];
|
|
731
|
+
for (const { file, references } of analysed) {
|
|
732
|
+
const hit = references.find((reference) => {
|
|
649
733
|
if (reference.typeOnly && !query.includeTypes)
|
|
650
734
|
return false;
|
|
651
735
|
// Both forms are tried: the resolved one so `module="app/db/**"` works
|
|
652
736
|
// everywhere, and the raw one so a Python or C# author can write the
|
|
653
737
|
// dotted path they see in their own source and still be understood.
|
|
654
|
-
return (matchesModule(resolveModule(reference.specifier, file
|
|
738
|
+
return (matchesModule(resolveModule(reference.specifier, file)) ||
|
|
655
739
|
matchesModule(reference.specifier));
|
|
656
740
|
});
|
|
657
741
|
if (hit) {
|
|
658
742
|
matches.push({
|
|
659
|
-
file
|
|
743
|
+
file,
|
|
660
744
|
line: hit.line,
|
|
661
745
|
column: hit.column,
|
|
662
746
|
text: `${IMPORT_VERBS[hit.kind]} ${hit.specifier}`,
|
|
@@ -664,31 +748,23 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
|
|
|
664
748
|
});
|
|
665
749
|
}
|
|
666
750
|
}
|
|
667
|
-
if (unresolved.length > 0) {
|
|
668
|
-
warnings.push(`${unresolved.length} module reference${unresolved.length === 1 ? "" : "s"} could not be resolved statically`, ...unresolved.slice(0, options.maxSnippets).map((entry) => ` ${entry}`));
|
|
669
|
-
}
|
|
670
|
-
const missingFailure = !options.allowMissingTargets && assertion.missingTargets.length > 0;
|
|
671
751
|
// One reference per file, so the file counts are the matches themselves.
|
|
672
752
|
const fileCounts = new Map(matches.map((match) => [match.file, 1]));
|
|
673
753
|
const { excluded, stale, shows } = applyBaseline(assertion.baseline, fileCounts);
|
|
674
754
|
const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
|
|
675
755
|
const actual = matches.length - excluded;
|
|
676
|
-
const strictFailure =
|
|
756
|
+
const strictFailure = read.missing !== null || (options.strictTargets && unresolved.length > 0);
|
|
677
757
|
const ok = satisfies(actual, assertion.bounds) && !strictFailure && !staleFailure;
|
|
678
758
|
return {
|
|
679
759
|
...base,
|
|
680
760
|
ok,
|
|
681
761
|
actual,
|
|
682
|
-
message:
|
|
683
|
-
|
|
684
|
-
: describeOutcome(assertion.bounds, actual, {
|
|
762
|
+
message: read.missing ??
|
|
763
|
+
describeOutcome(assertion.bounds, actual, {
|
|
685
764
|
excluded,
|
|
686
765
|
stale: staleFailure ? stale : [],
|
|
687
766
|
gaps: 0,
|
|
688
|
-
}) +
|
|
689
|
-
(options.strictTargets && unresolved.length > 0
|
|
690
|
-
? `; ${unresolved.length} reference${unresolved.length === 1 ? "" : "s"} could not be resolved`
|
|
691
|
-
: ""),
|
|
767
|
+
}) + strictSuffix(options, unresolved.length),
|
|
692
768
|
baselinedMatches: excluded,
|
|
693
769
|
staleBaseline: stale,
|
|
694
770
|
fileMatches: [...fileCounts].map(([file, count]) => ({ file, count })),
|
|
@@ -696,6 +772,127 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
|
|
|
696
772
|
durationMs: elapsed(startedAt),
|
|
697
773
|
};
|
|
698
774
|
}
|
|
775
|
+
/** What `--strict` adds to a message when references went unresolved. */
|
|
776
|
+
function strictSuffix(options, unresolved) {
|
|
777
|
+
return options.strictTargets && unresolved > 0
|
|
778
|
+
? `; ${unresolved} reference${unresolved === 1 ? "" : "s"} could not be resolved`
|
|
779
|
+
: "";
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Counts the import cycles in scope - components, not simple cycles.
|
|
783
|
+
*
|
|
784
|
+
* Each is shown as one concrete loop with the line of every import on it, at
|
|
785
|
+
* the location of the first. The count does not move when someone adds a
|
|
786
|
+
* second route around a loop that already exists, which is what makes it a
|
|
787
|
+
* number a spec can hold. See ADR-0011.
|
|
788
|
+
*/
|
|
789
|
+
function finishCycles(assertion, options, base, warnings, startedAt, read) {
|
|
790
|
+
const query = assertion.imports;
|
|
791
|
+
const scope = {
|
|
792
|
+
nodes: new Set(read.analysed.map((entry) => entry.file)),
|
|
793
|
+
walked: new Set(read.walked),
|
|
794
|
+
excluded: createExcludeMatcher(assertion.search.excludeGlobs),
|
|
795
|
+
covers: (relativePath) => relativePath !== ".." &&
|
|
796
|
+
!relativePath.startsWith("../") &&
|
|
797
|
+
read.targets.some((target) => target === "." || relativePath === target || relativePath.startsWith(`${target}/`)),
|
|
798
|
+
};
|
|
799
|
+
const graph = buildGraph(read.analysed, scope, query.includeTypes);
|
|
800
|
+
const loops = cyclicComponents(graph).map((component) => witness(component, graph.successors));
|
|
801
|
+
if (graph.unresolved.length > 0) {
|
|
802
|
+
const count = graph.unresolved.length;
|
|
803
|
+
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
|
|
804
|
+
.slice(0, options.maxSnippets)
|
|
805
|
+
.map(({ file, reference }) => ` ${file}:${reference.line} ${reference.specifier}`));
|
|
806
|
+
}
|
|
807
|
+
const gaps = read.unresolved + graph.unresolved.length;
|
|
808
|
+
const actual = loops.length;
|
|
809
|
+
const ok = satisfies(actual, assertion.bounds) &&
|
|
810
|
+
read.missing === null &&
|
|
811
|
+
!(options.strictTargets && gaps > 0);
|
|
812
|
+
const matches = loops.map((loop) => {
|
|
813
|
+
const hop = (at) => graph.via.get(edgeKey(loop[at], loop[at + 1]));
|
|
814
|
+
return {
|
|
815
|
+
file: loop[0],
|
|
816
|
+
line: hop(0).line,
|
|
817
|
+
column: hop(0).column,
|
|
818
|
+
// Every file on the loop with the line that leaves it, so the imports to
|
|
819
|
+
// change can be read off without opening each file to find them.
|
|
820
|
+
text: loop.map((file, at) => (at < loop.length - 1 ? `${file}:${hop(at).line}` : file)).join(" -> "),
|
|
821
|
+
count: 1,
|
|
822
|
+
};
|
|
823
|
+
});
|
|
824
|
+
return {
|
|
825
|
+
...base,
|
|
826
|
+
ok,
|
|
827
|
+
actual,
|
|
828
|
+
message: read.missing ??
|
|
829
|
+
describeOutcome(assertion.bounds, actual, { excluded: 0, stale: [], gaps: 0 }, CYCLES) +
|
|
830
|
+
strictSuffix(options, gaps),
|
|
831
|
+
matches: matches.slice(0, options.maxSnippets),
|
|
832
|
+
durationMs: elapsed(startedAt),
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Checks each file's imports against the layer order.
|
|
837
|
+
*
|
|
838
|
+
* Three things fail besides a violation, because each is a rule that would
|
|
839
|
+
* otherwise pass while checking less than it says: a layer that matches no
|
|
840
|
+
* file, a file two layers claim, and a missing target. See ADR-0011.
|
|
841
|
+
*/
|
|
842
|
+
function finishLayers(assertion, order, options, base, warnings, startedAt, read) {
|
|
843
|
+
const query = assertion.imports;
|
|
844
|
+
const report = checkLayers(read.analysed, order, query.includeTypes);
|
|
845
|
+
const quoted = (layers) => layers.map((layer) => `"${order[layer]}"`).join(" and ");
|
|
846
|
+
const empty = order.flatMap((_, layer) => (report.members[layer] === 0 ? [layer] : []));
|
|
847
|
+
const emptyAllowed = options.allowEmptyScope || assertion.allowEmpty;
|
|
848
|
+
const one = empty.length === 1;
|
|
849
|
+
const unmatched = `${one ? "layer" : "layers"} ${quoted(empty)} ${one ? "matches" : "match"} no file in scope`;
|
|
850
|
+
if (report.unassigned.length > 0) {
|
|
851
|
+
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"}`);
|
|
852
|
+
}
|
|
853
|
+
if (empty.length > 0 && emptyAllowed)
|
|
854
|
+
warnings.push(unmatched);
|
|
855
|
+
const fileCounts = new Map(report.violations.map((violation) => [violation.file, 1]));
|
|
856
|
+
const { excluded, stale, shows } = applyBaseline(assertion.baseline, fileCounts);
|
|
857
|
+
const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
|
|
858
|
+
const actual = report.violations.length - excluded;
|
|
859
|
+
const emptyFailure = empty.length > 0 && !emptyAllowed;
|
|
860
|
+
const ok = satisfies(actual, assertion.bounds) &&
|
|
861
|
+
read.missing === null &&
|
|
862
|
+
!staleFailure &&
|
|
863
|
+
!emptyFailure &&
|
|
864
|
+
report.ambiguous.length === 0 &&
|
|
865
|
+
!(options.strictTargets && read.unresolved > 0);
|
|
866
|
+
const message = read.missing ??
|
|
867
|
+
(emptyFailure
|
|
868
|
+
? `${unmatched}, so nothing is held to ${one ? "it" : "them"} (${EMPTY_SCOPE_HINT})`
|
|
869
|
+
: report.ambiguous.length > 0
|
|
870
|
+
? `${report.ambiguous
|
|
871
|
+
.slice(0, options.maxSnippets)
|
|
872
|
+
.map(({ file, layers }) => `${file} is in both ${quoted(layers)}`)
|
|
873
|
+
.join("; ")}; a file in two layers has no single rule to follow`
|
|
874
|
+
: describeOutcome(assertion.bounds, actual, { excluded, stale: staleFailure ? stale : [], gaps: 0 }, VIOLATING_FILES) + strictSuffix(options, read.unresolved));
|
|
875
|
+
return {
|
|
876
|
+
...base,
|
|
877
|
+
ok,
|
|
878
|
+
actual,
|
|
879
|
+
message,
|
|
880
|
+
baselinedMatches: excluded,
|
|
881
|
+
staleBaseline: stale,
|
|
882
|
+
fileMatches: [...fileCounts].map(([file, count]) => ({ file, count })),
|
|
883
|
+
matches: report.violations
|
|
884
|
+
.filter((violation) => shows(violation.file))
|
|
885
|
+
.slice(0, options.maxSnippets)
|
|
886
|
+
.map((violation) => ({
|
|
887
|
+
file: violation.file,
|
|
888
|
+
line: violation.reference.line,
|
|
889
|
+
column: violation.reference.column,
|
|
890
|
+
text: `${order[violation.from]} -> ${order[violation.to]}: ${IMPORT_VERBS[violation.reference.kind]} ${violation.reference.specifier}`,
|
|
891
|
+
count: 1,
|
|
892
|
+
})),
|
|
893
|
+
durationMs: elapsed(startedAt),
|
|
894
|
+
};
|
|
895
|
+
}
|
|
699
896
|
/** Executes a single resolved assertion. */
|
|
700
897
|
export async function executeAssertion(assertion, options) {
|
|
701
898
|
const prepared = await prepareAssertion(assertion, options);
|
|
@@ -722,34 +919,53 @@ export async function runSpecGuard(options) {
|
|
|
722
919
|
const allowMissingTargets = options.allowMissingTargets ?? false;
|
|
723
920
|
const allowEmptyScope = options.allowEmptyScope ?? false;
|
|
724
921
|
const scope = createScope(options.defaultSkips ?? true);
|
|
725
|
-
const
|
|
726
|
-
const
|
|
922
|
+
const specs = await readSpecs(options.patterns, root);
|
|
923
|
+
const specFiles = specs.files;
|
|
924
|
+
const excludeFiles = specExclusions(specFiles, options.includeSpecs ?? false);
|
|
727
925
|
const directives = [];
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
926
|
+
/** Parsed, validated, and then not run: see ADR-0010. */
|
|
927
|
+
const withheld = [];
|
|
928
|
+
const errors = [...specs.errors];
|
|
929
|
+
const inactiveSpecs = [];
|
|
930
|
+
for (const document of specs.documents) {
|
|
931
|
+
if (!document.inForce && !(options.ignoreStatus ?? false)) {
|
|
932
|
+
// A document is only ever out of force because of a status it declares.
|
|
933
|
+
const status = document.status;
|
|
934
|
+
withheld.push(...document.directives);
|
|
935
|
+
// Recorded even when it held no directives. "docs/adr/0011.md is a
|
|
936
|
+
// draft" is worth saying to someone wondering why their new rule has no
|
|
937
|
+
// effect, and a report that only mentions the documents it happened to
|
|
938
|
+
// find directives in cannot answer that.
|
|
939
|
+
inactiveSpecs.push({
|
|
940
|
+
file: document.relativeFile,
|
|
941
|
+
status: status.value,
|
|
942
|
+
label: status.label,
|
|
943
|
+
directives: document.directives.length,
|
|
736
944
|
});
|
|
737
|
-
return null;
|
|
738
|
-
});
|
|
739
|
-
if (source === null)
|
|
740
945
|
continue;
|
|
741
|
-
|
|
742
|
-
directives.push(...
|
|
743
|
-
errors.push(...parsed.errors);
|
|
946
|
+
}
|
|
947
|
+
directives.push(...document.directives);
|
|
744
948
|
}
|
|
745
949
|
const assertions = [];
|
|
746
950
|
for (const directive of directives) {
|
|
747
951
|
const resolved = resolveDirective(directive, { root, excludeFiles, scope });
|
|
748
952
|
if ("error" in resolved)
|
|
749
953
|
errors.push(resolved.error);
|
|
750
|
-
|
|
954
|
+
// Selection happens after resolution, so a directive that is not selected
|
|
955
|
+
// is still held to being well-formed - the same bargain ADR-0010 strikes
|
|
956
|
+
// for a document that is not in force.
|
|
957
|
+
else if (options.select?.(resolved.assertion) ?? true)
|
|
751
958
|
assertions.push(resolved.assertion);
|
|
752
959
|
}
|
|
960
|
+
// Not in force is not the same as not checked. A withheld directive is still
|
|
961
|
+
// held to being well-formed, so a draft's typo is found on the day it is
|
|
962
|
+
// written rather than on the day the ADR is accepted - which is the day
|
|
963
|
+
// everyone has already agreed the rule is right and stopped looking at it.
|
|
964
|
+
for (const directive of withheld) {
|
|
965
|
+
const resolved = resolveDirective(directive, { root, excludeFiles, scope });
|
|
966
|
+
if ("error" in resolved)
|
|
967
|
+
errors.push(resolved.error);
|
|
968
|
+
}
|
|
753
969
|
const engine = createCachedEngine(await resolveEngine(options.engine ?? "auto"));
|
|
754
970
|
const executeOptions = {
|
|
755
971
|
root,
|
|
@@ -826,11 +1042,13 @@ export async function runSpecGuard(options) {
|
|
|
826
1042
|
passed: results.length - failed,
|
|
827
1043
|
failed,
|
|
828
1044
|
skipped: assertions.length - results.length,
|
|
1045
|
+
inactive: withheld.length,
|
|
829
1046
|
},
|
|
830
1047
|
results,
|
|
831
1048
|
errors,
|
|
832
1049
|
warnings,
|
|
833
|
-
|
|
1050
|
+
inactiveSpecs,
|
|
1051
|
+
specFiles: specFiles.map((file) => specPath(root, file)),
|
|
834
1052
|
};
|
|
835
1053
|
}
|
|
836
1054
|
//# sourceMappingURL=runner.js.map
|