@descent-vtt/spec-guard 0.6.0 → 0.8.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 +132 -8
- package/README.md +191 -9
- package/dist/cli.d.ts +17 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +127 -7
- package/dist/cli.js.map +1 -1
- package/dist/glob.d.ts +25 -2
- package/dist/glob.d.ts.map +1 -1
- package/dist/glob.js +26 -9
- package/dist/glob.js.map +1 -1
- package/dist/index.d.ts +17 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +117 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +545 -0
- package/dist/mcp.js.map +1 -0
- package/dist/parser.d.ts +25 -0
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +71 -15
- 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 +211 -0
- package/dist/query.js.map +1 -0
- package/dist/reporter.d.ts.map +1 -1
- package/dist/reporter.js +13 -4
- package/dist/reporter.js.map +1 -1
- package/dist/rules.d.ts +119 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +197 -0
- package/dist/rules.js.map +1 -0
- package/dist/runner.d.ts +19 -0
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +289 -97
- 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/structure.d.ts +104 -0
- package/dist/structure.d.ts.map +1 -0
- package/dist/structure.js +253 -0
- package/dist/structure.js.map +1 -0
- package/dist/types.d.ts +33 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/runner.js
CHANGED
|
@@ -8,12 +8,13 @@
|
|
|
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
12
|
import { buildGraph, cyclicComponents, edgeKey, isGraphFile, witness, } from "./graph.js";
|
|
13
13
|
import { ANALYSABLE_EXTENSIONS, createImportIndex, resolveModule, } from "./imports.js";
|
|
14
14
|
import { checkLayers } from "./layers.js";
|
|
15
|
-
import {
|
|
15
|
+
import { readSpecs, specPath } from "./specs.js";
|
|
16
16
|
import { createScope, DEFAULT_SCOPE, EMPTY_LEDGER, } from "./scope.js";
|
|
17
|
+
import { checkStructure, createTreeIndex, partnerTemplateIssue, requiredEntryIssue, } from "./structure.js";
|
|
17
18
|
export const DEFAULT_MAX_SNIPPETS = 5;
|
|
18
19
|
export const DEFAULT_CONCURRENCY = 8;
|
|
19
20
|
/**
|
|
@@ -307,9 +308,23 @@ export function resolveDirective(directive, context) {
|
|
|
307
308
|
// itself and names no subject; layers name theirs in `order`.
|
|
308
309
|
const graphKind = kind === "assert-import-cycle" || kind === "assert-layers";
|
|
309
310
|
const isImportKind = perModule || graphKind;
|
|
310
|
-
|
|
311
|
+
// ADR-0013: a structure rule names its subject by naming its claim, and
|
|
312
|
+
// names exactly one. Two claims in one directive would count two different
|
|
313
|
+
// things in one number.
|
|
314
|
+
const structureKind = kind === "assert-structure";
|
|
315
|
+
const claims = STRUCTURE_CLAIMS.filter((claim) => attributes[claim] !== undefined);
|
|
316
|
+
if (structureKind && claims.length !== 1) {
|
|
317
|
+
return fail(claims.length === 0
|
|
318
|
+
? '@assert-structure requires one of pattern="...", required="..." or partner="...".'
|
|
319
|
+
: `@assert-structure makes one claim per directive, got ${claims.join(" and ")}.`);
|
|
320
|
+
}
|
|
321
|
+
const subject = structureKind
|
|
322
|
+
? claims[0]
|
|
323
|
+
: kind === "assert-layers" ? "order" : perModule ? "module" : "symbol";
|
|
311
324
|
const symbol = attributes[subject] ?? "";
|
|
312
|
-
|
|
325
|
+
// A list attribute is empty when it lists nothing, whatever its length; a
|
|
326
|
+
// symbol of one space is a symbol.
|
|
327
|
+
if (kind !== "assert-import-cycle" && (structureKind ? splitList(symbol).length : symbol.length) === 0) {
|
|
313
328
|
return fail(`@${kind} requires a non-empty ${subject}="..." attribute.`);
|
|
314
329
|
}
|
|
315
330
|
const allowEmpty = parseBoolean(attributes["allow-empty"], "allow-empty");
|
|
@@ -332,7 +347,7 @@ export function resolveDirective(directive, context) {
|
|
|
332
347
|
const rawTargets = splitList(attributes["target"]);
|
|
333
348
|
const targets = (rawTargets.length > 0 ? rawTargets : ROOT_TARGETS).map((target) => normalizeTarget(target, context.root, "target"));
|
|
334
349
|
const bounds = {};
|
|
335
|
-
if (kind === "assert-absence" || kind === "assert-import-absence" || graphKind) {
|
|
350
|
+
if (kind === "assert-absence" || kind === "assert-import-absence" || graphKind || structureKind) {
|
|
336
351
|
if (attributes["expected"] !== undefined &&
|
|
337
352
|
attributes["max"] !== undefined) {
|
|
338
353
|
return fail(`@${kind} accepts either expected="..." or max="...", not both.`);
|
|
@@ -371,6 +386,16 @@ export function resolveDirective(directive, context) {
|
|
|
371
386
|
}
|
|
372
387
|
}
|
|
373
388
|
}
|
|
389
|
+
if (structureKind) {
|
|
390
|
+
return resolveStructure(directive, subject, splitList(symbol), {
|
|
391
|
+
targets,
|
|
392
|
+
bounds,
|
|
393
|
+
allowEmpty,
|
|
394
|
+
baseline,
|
|
395
|
+
ratchet,
|
|
396
|
+
scope: context.scope ?? DEFAULT_SCOPE,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
374
399
|
if (isImportKind) {
|
|
375
400
|
const scope = targets.join(", ");
|
|
376
401
|
const excludeGlobs = splitList(attributes["exclude"]);
|
|
@@ -477,6 +502,90 @@ export function resolveDirective(directive, context) {
|
|
|
477
502
|
return fail(error instanceof Error ? error.message : String(error));
|
|
478
503
|
}
|
|
479
504
|
}
|
|
505
|
+
const STRUCTURE_CLAIMS = ["pattern", "required", "partner"];
|
|
506
|
+
/** What each structure claim counts. */
|
|
507
|
+
const STRUCTURE_UNITS = {
|
|
508
|
+
pattern: { one: "misnamed file", many: "misnamed files" },
|
|
509
|
+
required: { one: "directory missing an entry", many: "directories missing an entry" },
|
|
510
|
+
partner: { one: "file without a partner", many: "files without a partner" },
|
|
511
|
+
};
|
|
512
|
+
/**
|
|
513
|
+
* The rest of an `@assert-structure` directive, once what every rule shares is
|
|
514
|
+
* read. Throws for a directive that cannot mean what it says, which
|
|
515
|
+
* `resolveDirective` reports as the directive's error.
|
|
516
|
+
*/
|
|
517
|
+
function resolveStructure(directive, claim, values, shared) {
|
|
518
|
+
const { attributes } = directive;
|
|
519
|
+
const required = claim === "required";
|
|
520
|
+
const globs = splitList(attributes["glob"]);
|
|
521
|
+
const excludeGlobs = splitList(attributes["exclude"]);
|
|
522
|
+
// Two attributes that would otherwise be read and ignored, which is a rule
|
|
523
|
+
// saying something it does not check.
|
|
524
|
+
if (attributes["dirs"] !== undefined && !required) {
|
|
525
|
+
throw new Error(`Attribute "dirs" chooses the directories of required="...", and this directive claims ${claim}="...".`);
|
|
526
|
+
}
|
|
527
|
+
if (required && globs.length > 0) {
|
|
528
|
+
throw new Error(`Attribute "glob" chooses files, and required="..." is about directories; dirs="..." chooses those.`);
|
|
529
|
+
}
|
|
530
|
+
const dirs = attributes["dirs"]?.trim().replace(/^\.\//, "").replace(/\/+$/, "");
|
|
531
|
+
if (dirs === "")
|
|
532
|
+
throw new Error(`Attribute "dirs" must not be empty.`);
|
|
533
|
+
const issue = values
|
|
534
|
+
.map((value) => (required ? requiredEntryIssue(value) : claim === "partner" ? partnerTemplateIssue(value) : null))
|
|
535
|
+
.find((found) => found !== null);
|
|
536
|
+
if (issue)
|
|
537
|
+
throw new Error(issue);
|
|
538
|
+
const scope = shared.targets.join(", ");
|
|
539
|
+
const matching = globs.length > 0 ? ` matching ${globs.join(", ")}` : "";
|
|
540
|
+
const claimed = claim === "pattern"
|
|
541
|
+
? `files in ${scope}${matching} must be named ${values.join(" or ")}`
|
|
542
|
+
: claim === "partner"
|
|
543
|
+
? `files in ${scope}${matching} must each have a partner ${values.join(" or ")}`
|
|
544
|
+
: dirs === undefined
|
|
545
|
+
? `${scope} must contain ${values.join(", ")}`
|
|
546
|
+
: `directories matching ${dirs} under ${scope} must contain ${values.join(", ")}`;
|
|
547
|
+
const allowance = shared.bounds.max === 0 ? "" : `, with ${describeBounds(shared.bounds, STRUCTURE_UNITS[claim])}`;
|
|
548
|
+
const except = excludeGlobs.length > 0 ? ` (excluding ${excludeGlobs.join(", ")})` : "";
|
|
549
|
+
return {
|
|
550
|
+
assertion: {
|
|
551
|
+
kind: directive.kind,
|
|
552
|
+
location: directive.location,
|
|
553
|
+
description: `${claimed}${allowance}${except}`,
|
|
554
|
+
reason: attributes["reason"],
|
|
555
|
+
targets: shared.targets,
|
|
556
|
+
files: [],
|
|
557
|
+
bounds: shared.bounds,
|
|
558
|
+
search: {
|
|
559
|
+
regex: false,
|
|
560
|
+
word: false,
|
|
561
|
+
ignoreCase: false,
|
|
562
|
+
globs,
|
|
563
|
+
excludeGlobs,
|
|
564
|
+
// Nothing is read, so there is no comment to leave out.
|
|
565
|
+
ignoreComments: false,
|
|
566
|
+
scope: shared.scope,
|
|
567
|
+
// The spec files are in scope. Every other rule leaves them out so a
|
|
568
|
+
// text rule does not find its own directive, and a structure rule reads
|
|
569
|
+
// no text - while a rule about what the ADRs are called is about them.
|
|
570
|
+
excludeFiles: new Set(),
|
|
571
|
+
},
|
|
572
|
+
structure: dirs === undefined ? { claim, values } : { claim, values, dirs },
|
|
573
|
+
missingTargets: [],
|
|
574
|
+
allowEmpty: shared.allowEmpty,
|
|
575
|
+
baseline: shared.baseline,
|
|
576
|
+
ratchet: shared.ratchet,
|
|
577
|
+
},
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* The spec files a search must not count, as absolute paths.
|
|
582
|
+
*
|
|
583
|
+
* A spec names the symbol it forbids, so without this every absence rule would
|
|
584
|
+
* find itself. Empty under `--include-specs`.
|
|
585
|
+
*/
|
|
586
|
+
export function specExclusions(specFiles, includeSpecs) {
|
|
587
|
+
return new Set(includeSpecs ? undefined : specFiles.map((file) => path.resolve(file)));
|
|
588
|
+
}
|
|
480
589
|
async function pathExists(candidate) {
|
|
481
590
|
return (await fs.stat(candidate).catch(() => null)) !== null;
|
|
482
591
|
}
|
|
@@ -496,6 +605,7 @@ async function prepareAssertion(assertion, options) {
|
|
|
496
605
|
targets: assertion.targets,
|
|
497
606
|
files: assertion.files,
|
|
498
607
|
bounds: assertion.bounds,
|
|
608
|
+
claim: assertion.structure?.claim,
|
|
499
609
|
warnings,
|
|
500
610
|
commentMatches: 0,
|
|
501
611
|
unclassifiedFiles: 0,
|
|
@@ -525,6 +635,9 @@ async function prepareAssertion(assertion, options) {
|
|
|
525
635
|
if (assertion.imports) {
|
|
526
636
|
return executeImportAssertion(assertion, options, base, warnings, startedAt);
|
|
527
637
|
}
|
|
638
|
+
if (assertion.structure) {
|
|
639
|
+
return executeStructureAssertion(assertion, assertion.structure, options, base, warnings, startedAt);
|
|
640
|
+
}
|
|
528
641
|
const existingTargets = [];
|
|
529
642
|
for (const target of assertion.targets) {
|
|
530
643
|
if (await pathExists(path.resolve(options.root, target)))
|
|
@@ -532,15 +645,15 @@ async function prepareAssertion(assertion, options) {
|
|
|
532
645
|
else
|
|
533
646
|
assertion.missingTargets.push(target);
|
|
534
647
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
648
|
+
const notFound = missingTargets(assertion.missingTargets);
|
|
649
|
+
if (assertion.missingTargets.length > 0)
|
|
650
|
+
warnings.push(notFound.warning);
|
|
538
651
|
if (!options.allowMissingTargets && assertion.missingTargets.length > 0) {
|
|
539
652
|
return {
|
|
540
653
|
...base,
|
|
541
654
|
ok: false,
|
|
542
655
|
actual: 0,
|
|
543
|
-
message:
|
|
656
|
+
message: notFound.failure,
|
|
544
657
|
matches: [],
|
|
545
658
|
durationMs: elapsed(startedAt),
|
|
546
659
|
};
|
|
@@ -551,61 +664,138 @@ async function prepareAssertion(assertion, options) {
|
|
|
551
664
|
targets: existingTargets,
|
|
552
665
|
options: assertion.search,
|
|
553
666
|
};
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
667
|
+
// Every target missing, and that tolerated, leaves nothing to search - and
|
|
668
|
+
// must not be handed to the engine as an empty target list, which the engine
|
|
669
|
+
// reads, by contract, as the root. It used to be: a rule on a deleted
|
|
670
|
+
// `src/auth` searched the whole repository, where `min="1"` could pass on a
|
|
671
|
+
// test file. Found by holding `spec-guard query` to the walk; see ADR-0012.
|
|
672
|
+
const nothingLeft = existingTargets.length === 0;
|
|
673
|
+
if (!options.allowEmptyScope && !assertion.allowEmpty && (nothingLeft || !(await options.hasFiles(request)))) {
|
|
674
|
+
return {
|
|
675
|
+
...base,
|
|
676
|
+
ok: false,
|
|
677
|
+
actual: 0,
|
|
678
|
+
message: `no files were inspected, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`,
|
|
679
|
+
matches: [],
|
|
680
|
+
durationMs: elapsed(startedAt),
|
|
681
|
+
};
|
|
565
682
|
}
|
|
683
|
+
const finish = (search) => {
|
|
684
|
+
// A file that could not be read, or whose bytes are not text but did
|
|
685
|
+
// contain the symbol, is a hole in the answer rather than a detail of it.
|
|
686
|
+
// The count is still reported, because it is still true of everything
|
|
687
|
+
// that was read; --strict is for runs where "true of what we read" is not
|
|
688
|
+
// good enough.
|
|
689
|
+
//
|
|
690
|
+
// No filter for uncertainty here any more. The ledger only ever holds
|
|
691
|
+
// gaps: the walk drops a policy skip before recording it, and the only
|
|
692
|
+
// other reasons the scanner adds are "unreadable" and "binary". Filtering
|
|
693
|
+
// again was a second statement of an invariant that is enforced where it
|
|
694
|
+
// is created, and being a no-op, nothing could tell it from its absence.
|
|
695
|
+
const gaps = search.scope.skipped;
|
|
696
|
+
const strictFailure = options.strictTargets && gaps.length > 0;
|
|
697
|
+
const { excluded, stale, shows } = applyBaseline(assertion.baseline, search.fileCounts);
|
|
698
|
+
const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
|
|
699
|
+
const actual = search.count - excluded;
|
|
700
|
+
return {
|
|
701
|
+
...base,
|
|
702
|
+
ok: satisfies(actual, assertion.bounds) && !strictFailure && !staleFailure,
|
|
703
|
+
actual,
|
|
704
|
+
message: describeOutcome(assertion.bounds, actual, {
|
|
705
|
+
excluded,
|
|
706
|
+
stale: staleFailure ? stale : [],
|
|
707
|
+
gaps: strictFailure ? gaps.length : 0,
|
|
708
|
+
}),
|
|
709
|
+
baselinedMatches: excluded,
|
|
710
|
+
staleBaseline: stale,
|
|
711
|
+
fileMatches: [...search.fileCounts].map(([file, count]) => ({ file, count })),
|
|
712
|
+
matches: search.matches.filter((match) => shows(match.file)).slice(0, options.maxSnippets),
|
|
713
|
+
// Carried as numbers, not prose, so the reporter can total them across a
|
|
714
|
+
// run and JSON consumers can act on them.
|
|
715
|
+
commentMatches: search.commentMatches,
|
|
716
|
+
unclassifiedFiles: search.unclassifiedFiles,
|
|
717
|
+
scope: search.scope,
|
|
718
|
+
engine: search.engine,
|
|
719
|
+
durationMs: elapsed(startedAt),
|
|
720
|
+
};
|
|
721
|
+
};
|
|
722
|
+
return nothingLeft ? finish(NOTHING_SEARCHED) : { request, finish };
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* What a report says about targets that are not there: a warning always, and a
|
|
726
|
+
* failure unless `--allow-missing-targets` tolerates it. One place for the
|
|
727
|
+
* words, since every kind of rule that has a target says them.
|
|
728
|
+
*/
|
|
729
|
+
function missingTargets(missing) {
|
|
730
|
+
const one = missing.length === 1;
|
|
566
731
|
return {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
// A file that could not be read, or whose bytes are not text but did
|
|
570
|
-
// contain the symbol, is a hole in the answer rather than a detail of it.
|
|
571
|
-
// The count is still reported, because it is still true of everything
|
|
572
|
-
// that was read; --strict is for runs where "true of what we read" is not
|
|
573
|
-
// good enough.
|
|
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;
|
|
581
|
-
const strictFailure = options.strictTargets && gaps.length > 0;
|
|
582
|
-
const { excluded, stale, shows } = applyBaseline(assertion.baseline, search.fileCounts);
|
|
583
|
-
const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
|
|
584
|
-
const actual = search.count - excluded;
|
|
585
|
-
return {
|
|
586
|
-
...base,
|
|
587
|
-
ok: satisfies(actual, assertion.bounds) && !strictFailure && !staleFailure,
|
|
588
|
-
actual,
|
|
589
|
-
message: describeOutcome(assertion.bounds, actual, {
|
|
590
|
-
excluded,
|
|
591
|
-
stale: staleFailure ? stale : [],
|
|
592
|
-
gaps: strictFailure ? gaps.length : 0,
|
|
593
|
-
}),
|
|
594
|
-
baselinedMatches: excluded,
|
|
595
|
-
staleBaseline: stale,
|
|
596
|
-
fileMatches: [...search.fileCounts].map(([file, count]) => ({ file, count })),
|
|
597
|
-
matches: search.matches.filter((match) => shows(match.file)).slice(0, options.maxSnippets),
|
|
598
|
-
// Carried as numbers, not prose, so the reporter can total them across a
|
|
599
|
-
// run and JSON consumers can act on them.
|
|
600
|
-
commentMatches: search.commentMatches,
|
|
601
|
-
unclassifiedFiles: search.unclassifiedFiles,
|
|
602
|
-
scope: search.scope,
|
|
603
|
-
engine: search.engine,
|
|
604
|
-
durationMs: elapsed(startedAt),
|
|
605
|
-
};
|
|
606
|
-
},
|
|
732
|
+
warning: `target path${one ? "" : "s"} not found: ${missing.join(", ")}`,
|
|
733
|
+
failure: `target path${one ? " does" : "s do"} not exist: ${missing.join(", ")}`,
|
|
607
734
|
};
|
|
608
735
|
}
|
|
736
|
+
/**
|
|
737
|
+
* Holds a structure rule to the tree, by name. ADR-0013.
|
|
738
|
+
*
|
|
739
|
+
* Four things fail before any count is read, because each is a rule that would
|
|
740
|
+
* otherwise pass without checking what it says: a missing target, a `required`
|
|
741
|
+
* target that is a file, a partner template that names the file itself, and a
|
|
742
|
+
* scope holding nothing.
|
|
743
|
+
*/
|
|
744
|
+
async function executeStructureAssertion(assertion, query, options, base, warnings, startedAt) {
|
|
745
|
+
const search = assertion.search;
|
|
746
|
+
const check = await checkStructure(query, {
|
|
747
|
+
targets: assertion.targets,
|
|
748
|
+
globs: search.globs,
|
|
749
|
+
excludeGlobs: search.excludeGlobs,
|
|
750
|
+
scope: search.scope,
|
|
751
|
+
}, options.tree);
|
|
752
|
+
assertion.missingTargets.push(...check.missing);
|
|
753
|
+
const notFound = missingTargets(check.missing);
|
|
754
|
+
if (check.missing.length > 0)
|
|
755
|
+
warnings.push(notFound.warning);
|
|
756
|
+
const files = check.notDirectories;
|
|
757
|
+
const self = check.selfPartner;
|
|
758
|
+
const failure = !options.allowMissingTargets && check.missing.length > 0
|
|
759
|
+
? notFound.failure
|
|
760
|
+
: files.length > 0
|
|
761
|
+
? `required="..." is about directories, and ${files.length === 1 ? "this target is a file" : "these targets are files"}: ${files.join(", ")}`
|
|
762
|
+
: self !== undefined
|
|
763
|
+
? `partner template ${self.template} names ${self.file} itself, so every file would be its own partner`
|
|
764
|
+
: check.inspected === 0 && !options.allowEmptyScope && !assertion.allowEmpty
|
|
765
|
+
? `no ${query.claim === "required" ? "directories were selected" : "files were inspected"}, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`
|
|
766
|
+
: null;
|
|
767
|
+
const fileCounts = new Map(check.violations.map((violation) => [violation.path, 1]));
|
|
768
|
+
const { excluded, stale, shows } = applyBaseline(assertion.baseline, fileCounts);
|
|
769
|
+
const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
|
|
770
|
+
const gaps = check.scope.skipped.length;
|
|
771
|
+
const strictFailure = options.strictTargets && gaps > 0;
|
|
772
|
+
const actual = check.violations.length - excluded;
|
|
773
|
+
return {
|
|
774
|
+
...base,
|
|
775
|
+
ok: failure === null && satisfies(actual, assertion.bounds) && !staleFailure && !strictFailure,
|
|
776
|
+
actual,
|
|
777
|
+
message: failure ??
|
|
778
|
+
describeOutcome(assertion.bounds, actual, { excluded, stale: staleFailure ? stale : [], gaps: strictFailure ? gaps : 0 }, STRUCTURE_UNITS[query.claim]),
|
|
779
|
+
baselinedMatches: excluded,
|
|
780
|
+
staleBaseline: stale,
|
|
781
|
+
fileMatches: [...fileCounts].map(([file, count]) => ({ file, count })),
|
|
782
|
+
// No line and no column: what is wrong is the path itself.
|
|
783
|
+
matches: check.violations
|
|
784
|
+
.filter((violation) => shows(violation.path))
|
|
785
|
+
.slice(0, options.maxSnippets)
|
|
786
|
+
.map((violation) => ({ file: violation.path, line: 0, column: 0, text: violation.text, count: 1 })),
|
|
787
|
+
scope: check.scope,
|
|
788
|
+
durationMs: elapsed(startedAt),
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
const NOTHING_SEARCHED = {
|
|
792
|
+
count: 0,
|
|
793
|
+
commentMatches: 0,
|
|
794
|
+
unclassifiedFiles: 0,
|
|
795
|
+
matches: [],
|
|
796
|
+
fileCounts: new Map(),
|
|
797
|
+
scope: EMPTY_LEDGER,
|
|
798
|
+
};
|
|
609
799
|
/** How each kind of reference is spelled in a report snippet. */
|
|
610
800
|
const IMPORT_VERBS = {
|
|
611
801
|
import: "import",
|
|
@@ -631,17 +821,28 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
|
|
|
631
821
|
else
|
|
632
822
|
assertion.missingTargets.push(target);
|
|
633
823
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
824
|
+
const notFound = missingTargets(assertion.missingTargets);
|
|
825
|
+
if (assertion.missingTargets.length > 0)
|
|
826
|
+
warnings.push(notFound.warning);
|
|
637
827
|
// No symbol: an import assertion never runs a text search, and the walk does
|
|
638
828
|
// not depend on one. This used to pass `symbol: ""`, an invented value that
|
|
639
829
|
// reached nothing and that no test could therefore be wrong about.
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
830
|
+
//
|
|
831
|
+
// And no walk at all when every target is missing: an empty target list is
|
|
832
|
+
// the root to the engine, as the text rules above found out.
|
|
833
|
+
const enumeration = existingTargets.length === 0
|
|
834
|
+
? { files: [] }
|
|
835
|
+
: await enumerateCandidates({
|
|
836
|
+
root: options.root,
|
|
837
|
+
targets: existingTargets,
|
|
838
|
+
options: assertion.search,
|
|
839
|
+
});
|
|
840
|
+
// Why a missing target fails the assertion, or null when it does not. Decided
|
|
841
|
+
// before scope, so a rule whose only target is gone says so rather than that
|
|
842
|
+
// its scope is empty - which is true, and not the thing to fix.
|
|
843
|
+
const missing = !options.allowMissingTargets && assertion.missingTargets.length > 0
|
|
844
|
+
? notFound.failure
|
|
845
|
+
: null;
|
|
645
846
|
// A cycle needs files the resolver can name, which today means JavaScript
|
|
646
847
|
// and TypeScript; every other import rule reads any language spec-guard
|
|
647
848
|
// tokenizes. ADR-0011 has the reasons, language by language.
|
|
@@ -649,7 +850,7 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
|
|
|
649
850
|
const analysable = enumeration.files.filter((file) => cycles
|
|
650
851
|
? isGraphFile(file.relativePath)
|
|
651
852
|
: ANALYSABLE_EXTENSIONS.has(path.posix.extname(file.relativePath)));
|
|
652
|
-
if (analysable.length === 0 && !options.allowEmptyScope && !assertion.allowEmpty) {
|
|
853
|
+
if (analysable.length === 0 && missing === null && !options.allowEmptyScope && !assertion.allowEmpty) {
|
|
653
854
|
// For an import rule the bar is higher than "some file exists": a directory
|
|
654
855
|
// of YAML has nothing this can read, so a dependency claim about it is a
|
|
655
856
|
// claim about nothing.
|
|
@@ -689,9 +890,7 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
|
|
|
689
890
|
walked: enumeration.files.map((file) => file.relativePath),
|
|
690
891
|
targets: existingTargets,
|
|
691
892
|
unresolved: unresolved.length,
|
|
692
|
-
missing
|
|
693
|
-
? `target path${assertion.missingTargets.length === 1 ? " does" : "s do"} not exist: ${assertion.missingTargets.join(", ")}`
|
|
694
|
-
: null,
|
|
893
|
+
missing,
|
|
695
894
|
};
|
|
696
895
|
if (cycles)
|
|
697
896
|
return finishCycles(assertion, options, base, warnings, startedAt, read);
|
|
@@ -891,50 +1090,42 @@ export async function runSpecGuard(options) {
|
|
|
891
1090
|
const allowMissingTargets = options.allowMissingTargets ?? false;
|
|
892
1091
|
const allowEmptyScope = options.allowEmptyScope ?? false;
|
|
893
1092
|
const scope = createScope(options.defaultSkips ?? true);
|
|
894
|
-
const
|
|
895
|
-
const
|
|
1093
|
+
const specs = await readSpecs(options.patterns, root);
|
|
1094
|
+
const specFiles = specs.files;
|
|
1095
|
+
const excludeFiles = specExclusions(specFiles, options.includeSpecs ?? false);
|
|
896
1096
|
const directives = [];
|
|
897
1097
|
/** Parsed, validated, and then not run: see ADR-0010. */
|
|
898
1098
|
const withheld = [];
|
|
899
|
-
const errors = [];
|
|
1099
|
+
const errors = [...specs.errors];
|
|
900
1100
|
const inactiveSpecs = [];
|
|
901
|
-
for (const
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
raw: "",
|
|
907
|
-
message: `Unable to read spec file: ${error instanceof Error ? error.message : String(error)}`,
|
|
908
|
-
});
|
|
909
|
-
return null;
|
|
910
|
-
});
|
|
911
|
-
if (source === null)
|
|
912
|
-
continue;
|
|
913
|
-
const parsed = parseDirectives(source, { file, relativeFile });
|
|
914
|
-
errors.push(...parsed.errors);
|
|
915
|
-
const status = parsed.status;
|
|
916
|
-
if (status && !status.active && !(options.ignoreStatus ?? false)) {
|
|
917
|
-
withheld.push(...parsed.directives);
|
|
1101
|
+
for (const document of specs.documents) {
|
|
1102
|
+
if (!document.inForce && !(options.ignoreStatus ?? false)) {
|
|
1103
|
+
// A document is only ever out of force because of a status it declares.
|
|
1104
|
+
const status = document.status;
|
|
1105
|
+
withheld.push(...document.directives);
|
|
918
1106
|
// Recorded even when it held no directives. "docs/adr/0011.md is a
|
|
919
1107
|
// draft" is worth saying to someone wondering why their new rule has no
|
|
920
1108
|
// effect, and a report that only mentions the documents it happened to
|
|
921
1109
|
// find directives in cannot answer that.
|
|
922
1110
|
inactiveSpecs.push({
|
|
923
|
-
file: relativeFile,
|
|
1111
|
+
file: document.relativeFile,
|
|
924
1112
|
status: status.value,
|
|
925
1113
|
label: status.label,
|
|
926
|
-
directives:
|
|
1114
|
+
directives: document.directives.length,
|
|
927
1115
|
});
|
|
928
1116
|
continue;
|
|
929
1117
|
}
|
|
930
|
-
directives.push(...
|
|
1118
|
+
directives.push(...document.directives);
|
|
931
1119
|
}
|
|
932
1120
|
const assertions = [];
|
|
933
1121
|
for (const directive of directives) {
|
|
934
1122
|
const resolved = resolveDirective(directive, { root, excludeFiles, scope });
|
|
935
1123
|
if ("error" in resolved)
|
|
936
1124
|
errors.push(resolved.error);
|
|
937
|
-
|
|
1125
|
+
// Selection happens after resolution, so a directive that is not selected
|
|
1126
|
+
// is still held to being well-formed - the same bargain ADR-0010 strikes
|
|
1127
|
+
// for a document that is not in force.
|
|
1128
|
+
else if (options.select?.(resolved.assertion) ?? true)
|
|
938
1129
|
assertions.push(resolved.assertion);
|
|
939
1130
|
}
|
|
940
1131
|
// Not in force is not the same as not checked. A withheld directive is still
|
|
@@ -956,6 +1147,7 @@ export async function runSpecGuard(options) {
|
|
|
956
1147
|
maxSnippets,
|
|
957
1148
|
imports: createImportIndex(),
|
|
958
1149
|
hasFiles: createScopeProbe(),
|
|
1150
|
+
tree: createTreeIndex(root),
|
|
959
1151
|
};
|
|
960
1152
|
const results = [];
|
|
961
1153
|
if (options.failFast) {
|
|
@@ -1028,7 +1220,7 @@ export async function runSpecGuard(options) {
|
|
|
1028
1220
|
errors,
|
|
1029
1221
|
warnings,
|
|
1030
1222
|
inactiveSpecs,
|
|
1031
|
-
specFiles: specFiles.map((file) =>
|
|
1223
|
+
specFiles: specFiles.map((file) => specPath(root, file)),
|
|
1032
1224
|
};
|
|
1033
1225
|
}
|
|
1034
1226
|
//# sourceMappingURL=runner.js.map
|