@descent-vtt/spec-guard 0.7.0 → 0.9.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 +147 -0
- package/README.md +224 -27
- package/dist/cli.d.ts +37 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +275 -59
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +66 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +165 -0
- package/dist/config.js.map +1 -0
- package/dist/engine.d.ts +22 -8
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +118 -67
- package/dist/engine.js.map +1 -1
- package/dist/facts.d.ts +106 -0
- package/dist/facts.d.ts.map +1 -0
- package/dist/facts.js +188 -0
- package/dist/facts.js.map +1 -0
- package/dist/glob.d.ts +17 -21
- package/dist/glob.d.ts.map +1 -1
- package/dist/glob.js +13 -24
- package/dist/glob.js.map +1 -1
- package/dist/imports.d.ts +6 -1
- package/dist/imports.d.ts.map +1 -1
- package/dist/imports.js +9 -5
- package/dist/imports.js.map +1 -1
- package/dist/index.d.ts +20 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -5
- package/dist/index.js.map +1 -1
- package/dist/io.d.ts +51 -0
- package/dist/io.d.ts.map +1 -0
- package/dist/io.js +44 -0
- package/dist/io.js.map +1 -0
- package/dist/mcp.d.ts +11 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +23 -18
- package/dist/mcp.js.map +1 -1
- package/dist/memo.d.ts +53 -0
- package/dist/memo.d.ts.map +1 -0
- package/dist/memo.js +67 -0
- package/dist/memo.js.map +1 -0
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +17 -0
- package/dist/parser.js.map +1 -1
- package/dist/query.d.ts +5 -2
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +12 -3
- package/dist/query.js.map +1 -1
- package/dist/reporter.d.ts +10 -0
- package/dist/reporter.d.ts.map +1 -1
- package/dist/reporter.js +33 -4
- package/dist/reporter.js.map +1 -1
- package/dist/rules.d.ts +12 -1
- package/dist/rules.d.ts.map +1 -1
- package/dist/rules.js +45 -1
- package/dist/rules.js.map +1 -1
- package/dist/runner.d.ts +42 -2
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +261 -76
- package/dist/runner.js.map +1 -1
- package/dist/specs.d.ts +3 -1
- package/dist/specs.d.ts.map +1 -1
- package/dist/specs.js +6 -5
- package/dist/specs.js.map +1 -1
- package/dist/structure.d.ts +104 -0
- package/dist/structure.d.ts.map +1 -0
- package/dist/structure.js +254 -0
- package/dist/structure.js.map +1 -0
- package/dist/types.d.ts +50 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/watch.d.ts +125 -0
- package/dist/watch.d.ts.map +1 -0
- package/dist/watch.js +321 -0
- package/dist/watch.js.map +1 -0
- package/package.json +8 -2
package/dist/runner.js
CHANGED
|
@@ -5,15 +5,16 @@
|
|
|
5
5
|
* number, an unknown boolean or a path escaping the root is an error, never a
|
|
6
6
|
* silently-passing assertion. A spec that lies is worse than no spec at all.
|
|
7
7
|
*/
|
|
8
|
-
import { promises as fs } from "node:fs";
|
|
9
8
|
import path from "node:path";
|
|
10
9
|
import { comparePaths, createCachedEngine, enumerateCandidates, passKey, resolveEngine, runSearches, ANY_FILE_PROBE, ROOT_TARGETS, } from "./engine.js";
|
|
11
10
|
import { createExcludeMatcher, toPosix } from "./glob.js";
|
|
11
|
+
import { nodeIo } from "./io.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
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
|
/**
|
|
@@ -242,7 +243,7 @@ export function applyBaseline(entries, fileCounts) {
|
|
|
242
243
|
* answer. Without the cache the probe measured about 0.9 ms per assertion,
|
|
243
244
|
* which is small until a spec has two hundred of them.
|
|
244
245
|
*/
|
|
245
|
-
export function createScopeProbe() {
|
|
246
|
+
export function createScopeProbe(io = nodeIo) {
|
|
246
247
|
const cache = new Map();
|
|
247
248
|
return (request) => {
|
|
248
249
|
const key = JSON.stringify([
|
|
@@ -255,7 +256,7 @@ export function createScopeProbe() {
|
|
|
255
256
|
if (!pending) {
|
|
256
257
|
// The walk abandons at the first file it finds, so proving a scope is
|
|
257
258
|
// populated costs a couple of directory reads rather than a traversal.
|
|
258
|
-
pending = enumerateCandidates(request, ANY_FILE_PROBE).then((enumeration) => enumeration.files.length > 0);
|
|
259
|
+
pending = enumerateCandidates(request, ANY_FILE_PROBE, io).then((enumeration) => enumeration.files.length > 0);
|
|
259
260
|
cache.set(key, pending);
|
|
260
261
|
}
|
|
261
262
|
return pending;
|
|
@@ -294,7 +295,6 @@ export function resolveDirective(directive, context) {
|
|
|
294
295
|
targets: [],
|
|
295
296
|
files,
|
|
296
297
|
bounds: { min: files.length, max: files.length },
|
|
297
|
-
missingTargets: [],
|
|
298
298
|
// A file list is never empty here: resolution rejects that above.
|
|
299
299
|
allowEmpty: true,
|
|
300
300
|
baseline: [],
|
|
@@ -307,9 +307,23 @@ export function resolveDirective(directive, context) {
|
|
|
307
307
|
// itself and names no subject; layers name theirs in `order`.
|
|
308
308
|
const graphKind = kind === "assert-import-cycle" || kind === "assert-layers";
|
|
309
309
|
const isImportKind = perModule || graphKind;
|
|
310
|
-
|
|
310
|
+
// ADR-0013: a structure rule names its subject by naming its claim, and
|
|
311
|
+
// names exactly one. Two claims in one directive would count two different
|
|
312
|
+
// things in one number.
|
|
313
|
+
const structureKind = kind === "assert-structure";
|
|
314
|
+
const claims = STRUCTURE_CLAIMS.filter((claim) => attributes[claim] !== undefined);
|
|
315
|
+
if (structureKind && claims.length !== 1) {
|
|
316
|
+
return fail(claims.length === 0
|
|
317
|
+
? '@assert-structure requires one of pattern="...", required="..." or partner="...".'
|
|
318
|
+
: `@assert-structure makes one claim per directive, got ${claims.join(" and ")}.`);
|
|
319
|
+
}
|
|
320
|
+
const subject = structureKind
|
|
321
|
+
? claims[0]
|
|
322
|
+
: kind === "assert-layers" ? "order" : perModule ? "module" : "symbol";
|
|
311
323
|
const symbol = attributes[subject] ?? "";
|
|
312
|
-
|
|
324
|
+
// A list attribute is empty when it lists nothing, whatever its length; a
|
|
325
|
+
// symbol of one space is a symbol.
|
|
326
|
+
if (kind !== "assert-import-cycle" && (structureKind ? splitList(symbol).length : symbol.length) === 0) {
|
|
313
327
|
return fail(`@${kind} requires a non-empty ${subject}="..." attribute.`);
|
|
314
328
|
}
|
|
315
329
|
const allowEmpty = parseBoolean(attributes["allow-empty"], "allow-empty");
|
|
@@ -332,7 +346,7 @@ export function resolveDirective(directive, context) {
|
|
|
332
346
|
const rawTargets = splitList(attributes["target"]);
|
|
333
347
|
const targets = (rawTargets.length > 0 ? rawTargets : ROOT_TARGETS).map((target) => normalizeTarget(target, context.root, "target"));
|
|
334
348
|
const bounds = {};
|
|
335
|
-
if (kind === "assert-absence" || kind === "assert-import-absence" || graphKind) {
|
|
349
|
+
if (kind === "assert-absence" || kind === "assert-import-absence" || graphKind || structureKind) {
|
|
336
350
|
if (attributes["expected"] !== undefined &&
|
|
337
351
|
attributes["max"] !== undefined) {
|
|
338
352
|
return fail(`@${kind} accepts either expected="..." or max="...", not both.`);
|
|
@@ -371,6 +385,16 @@ export function resolveDirective(directive, context) {
|
|
|
371
385
|
}
|
|
372
386
|
}
|
|
373
387
|
}
|
|
388
|
+
if (structureKind) {
|
|
389
|
+
return resolveStructure(directive, subject, splitList(symbol), {
|
|
390
|
+
targets,
|
|
391
|
+
bounds,
|
|
392
|
+
allowEmpty,
|
|
393
|
+
baseline,
|
|
394
|
+
ratchet,
|
|
395
|
+
scope: context.scope ?? DEFAULT_SCOPE,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
374
398
|
if (isImportKind) {
|
|
375
399
|
const scope = targets.join(", ");
|
|
376
400
|
const excludeGlobs = splitList(attributes["exclude"]);
|
|
@@ -423,7 +447,6 @@ export function resolveDirective(directive, context) {
|
|
|
423
447
|
},
|
|
424
448
|
imports: { modules: perModule ? splitList(symbol) : [], includeTypes },
|
|
425
449
|
...(kind === "assert-layers" ? { layers } : {}),
|
|
426
|
-
missingTargets: [],
|
|
427
450
|
allowEmpty,
|
|
428
451
|
baseline,
|
|
429
452
|
ratchet,
|
|
@@ -466,7 +489,6 @@ export function resolveDirective(directive, context) {
|
|
|
466
489
|
files: [],
|
|
467
490
|
bounds,
|
|
468
491
|
search,
|
|
469
|
-
missingTargets: [],
|
|
470
492
|
allowEmpty,
|
|
471
493
|
baseline,
|
|
472
494
|
ratchet,
|
|
@@ -477,6 +499,80 @@ export function resolveDirective(directive, context) {
|
|
|
477
499
|
return fail(error instanceof Error ? error.message : String(error));
|
|
478
500
|
}
|
|
479
501
|
}
|
|
502
|
+
const STRUCTURE_CLAIMS = ["pattern", "required", "partner"];
|
|
503
|
+
/** What each structure claim counts. */
|
|
504
|
+
const STRUCTURE_UNITS = {
|
|
505
|
+
pattern: { one: "misnamed file", many: "misnamed files" },
|
|
506
|
+
required: { one: "directory missing an entry", many: "directories missing an entry" },
|
|
507
|
+
partner: { one: "file without a partner", many: "files without a partner" },
|
|
508
|
+
};
|
|
509
|
+
/**
|
|
510
|
+
* The rest of an `@assert-structure` directive, once what every rule shares is
|
|
511
|
+
* read. Throws for a directive that cannot mean what it says, which
|
|
512
|
+
* `resolveDirective` reports as the directive's error.
|
|
513
|
+
*/
|
|
514
|
+
function resolveStructure(directive, claim, values, shared) {
|
|
515
|
+
const { attributes } = directive;
|
|
516
|
+
const required = claim === "required";
|
|
517
|
+
const globs = splitList(attributes["glob"]);
|
|
518
|
+
const excludeGlobs = splitList(attributes["exclude"]);
|
|
519
|
+
// Two attributes that would otherwise be read and ignored, which is a rule
|
|
520
|
+
// saying something it does not check.
|
|
521
|
+
if (attributes["dirs"] !== undefined && !required) {
|
|
522
|
+
throw new Error(`Attribute "dirs" chooses the directories of required="...", and this directive claims ${claim}="...".`);
|
|
523
|
+
}
|
|
524
|
+
if (required && globs.length > 0) {
|
|
525
|
+
throw new Error(`Attribute "glob" chooses files, and required="..." is about directories; dirs="..." chooses those.`);
|
|
526
|
+
}
|
|
527
|
+
const dirs = attributes["dirs"]?.trim().replace(/^\.\//, "").replace(/\/+$/, "");
|
|
528
|
+
if (dirs === "")
|
|
529
|
+
throw new Error(`Attribute "dirs" must not be empty.`);
|
|
530
|
+
const issue = values
|
|
531
|
+
.map((value) => (required ? requiredEntryIssue(value) : claim === "partner" ? partnerTemplateIssue(value) : null))
|
|
532
|
+
.find((found) => found !== null);
|
|
533
|
+
if (issue)
|
|
534
|
+
throw new Error(issue);
|
|
535
|
+
const scope = shared.targets.join(", ");
|
|
536
|
+
const matching = globs.length > 0 ? ` matching ${globs.join(", ")}` : "";
|
|
537
|
+
const claimed = claim === "pattern"
|
|
538
|
+
? `files in ${scope}${matching} must be named ${values.join(" or ")}`
|
|
539
|
+
: claim === "partner"
|
|
540
|
+
? `files in ${scope}${matching} must each have a partner ${values.join(" or ")}`
|
|
541
|
+
: dirs === undefined
|
|
542
|
+
? `${scope} must contain ${values.join(", ")}`
|
|
543
|
+
: `directories matching ${dirs} under ${scope} must contain ${values.join(", ")}`;
|
|
544
|
+
const allowance = shared.bounds.max === 0 ? "" : `, with ${describeBounds(shared.bounds, STRUCTURE_UNITS[claim])}`;
|
|
545
|
+
const except = excludeGlobs.length > 0 ? ` (excluding ${excludeGlobs.join(", ")})` : "";
|
|
546
|
+
return {
|
|
547
|
+
assertion: {
|
|
548
|
+
kind: directive.kind,
|
|
549
|
+
location: directive.location,
|
|
550
|
+
description: `${claimed}${allowance}${except}`,
|
|
551
|
+
reason: attributes["reason"],
|
|
552
|
+
targets: shared.targets,
|
|
553
|
+
files: [],
|
|
554
|
+
bounds: shared.bounds,
|
|
555
|
+
search: {
|
|
556
|
+
regex: false,
|
|
557
|
+
word: false,
|
|
558
|
+
ignoreCase: false,
|
|
559
|
+
globs,
|
|
560
|
+
excludeGlobs,
|
|
561
|
+
// Nothing is read, so there is no comment to leave out.
|
|
562
|
+
ignoreComments: false,
|
|
563
|
+
scope: shared.scope,
|
|
564
|
+
// The spec files are in scope. Every other rule leaves them out so a
|
|
565
|
+
// text rule does not find its own directive, and a structure rule reads
|
|
566
|
+
// no text - while a rule about what the ADRs are called is about them.
|
|
567
|
+
excludeFiles: new Set(),
|
|
568
|
+
},
|
|
569
|
+
structure: dirs === undefined ? { claim, values } : { claim, values, dirs },
|
|
570
|
+
allowEmpty: shared.allowEmpty,
|
|
571
|
+
baseline: shared.baseline,
|
|
572
|
+
ratchet: shared.ratchet,
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
}
|
|
480
576
|
/**
|
|
481
577
|
* The spec files a search must not count, as absolute paths.
|
|
482
578
|
*
|
|
@@ -486,8 +582,23 @@ export function resolveDirective(directive, context) {
|
|
|
486
582
|
export function specExclusions(specFiles, includeSpecs) {
|
|
487
583
|
return new Set(includeSpecs ? undefined : specFiles.map((file) => path.resolve(file)));
|
|
488
584
|
}
|
|
489
|
-
async function pathExists(candidate) {
|
|
490
|
-
return (await
|
|
585
|
+
async function pathExists(io, candidate) {
|
|
586
|
+
return (await io.stat(candidate)) !== null;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* The targets that exist and the ones that do not, in the order written.
|
|
590
|
+
*
|
|
591
|
+
* Returned rather than recorded on the assertion, which is what execution used
|
|
592
|
+
* to do: the second execution of a rule then named each missing target twice.
|
|
593
|
+
* A watch session executes its rules again and again (ADR-0014).
|
|
594
|
+
*/
|
|
595
|
+
async function partitionTargets(targets, options) {
|
|
596
|
+
const existing = [];
|
|
597
|
+
const missing = [];
|
|
598
|
+
for (const target of targets) {
|
|
599
|
+
(await pathExists(options.io, path.resolve(options.root, target)) ? existing : missing).push(target);
|
|
600
|
+
}
|
|
601
|
+
return { existing, missing };
|
|
491
602
|
}
|
|
492
603
|
/**
|
|
493
604
|
* Resolves everything about an assertion that does not need a search, and
|
|
@@ -505,6 +616,7 @@ async function prepareAssertion(assertion, options) {
|
|
|
505
616
|
targets: assertion.targets,
|
|
506
617
|
files: assertion.files,
|
|
507
618
|
bounds: assertion.bounds,
|
|
619
|
+
claim: assertion.structure?.claim,
|
|
508
620
|
warnings,
|
|
509
621
|
commentMatches: 0,
|
|
510
622
|
unclassifiedFiles: 0,
|
|
@@ -516,7 +628,7 @@ async function prepareAssertion(assertion, options) {
|
|
|
516
628
|
if (assertion.kind === "assert-present") {
|
|
517
629
|
const missing = [];
|
|
518
630
|
for (const file of assertion.files) {
|
|
519
|
-
if (!(await pathExists(path.resolve(options.root, file))))
|
|
631
|
+
if (!(await pathExists(options.io, path.resolve(options.root, file))))
|
|
520
632
|
missing.push(file);
|
|
521
633
|
}
|
|
522
634
|
const actual = assertion.files.length - missing.length;
|
|
@@ -534,22 +646,19 @@ async function prepareAssertion(assertion, options) {
|
|
|
534
646
|
if (assertion.imports) {
|
|
535
647
|
return executeImportAssertion(assertion, options, base, warnings, startedAt);
|
|
536
648
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
if (await pathExists(path.resolve(options.root, target)))
|
|
540
|
-
existingTargets.push(target);
|
|
541
|
-
else
|
|
542
|
-
assertion.missingTargets.push(target);
|
|
649
|
+
if (assertion.structure) {
|
|
650
|
+
return executeStructureAssertion(assertion, assertion.structure, options, base, warnings, startedAt);
|
|
543
651
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
652
|
+
const { existing: existingTargets, missing } = await partitionTargets(assertion.targets, options);
|
|
653
|
+
const notFound = missingTargets(missing);
|
|
654
|
+
if (missing.length > 0)
|
|
655
|
+
warnings.push(notFound.warning);
|
|
656
|
+
if (!options.allowMissingTargets && missing.length > 0) {
|
|
548
657
|
return {
|
|
549
658
|
...base,
|
|
550
659
|
ok: false,
|
|
551
660
|
actual: 0,
|
|
552
|
-
message:
|
|
661
|
+
message: notFound.failure,
|
|
553
662
|
matches: [],
|
|
554
663
|
durationMs: elapsed(startedAt),
|
|
555
664
|
};
|
|
@@ -617,6 +726,72 @@ async function prepareAssertion(assertion, options) {
|
|
|
617
726
|
};
|
|
618
727
|
return nothingLeft ? finish(NOTHING_SEARCHED) : { request, finish };
|
|
619
728
|
}
|
|
729
|
+
/**
|
|
730
|
+
* What a report says about targets that are not there: a warning always, and a
|
|
731
|
+
* failure unless `--allow-missing-targets` tolerates it. One place for the
|
|
732
|
+
* words, since every kind of rule that has a target says them.
|
|
733
|
+
*/
|
|
734
|
+
function missingTargets(missing) {
|
|
735
|
+
const one = missing.length === 1;
|
|
736
|
+
return {
|
|
737
|
+
warning: `target path${one ? "" : "s"} not found: ${missing.join(", ")}`,
|
|
738
|
+
failure: `target path${one ? " does" : "s do"} not exist: ${missing.join(", ")}`,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Holds a structure rule to the tree, by name. ADR-0013.
|
|
743
|
+
*
|
|
744
|
+
* Four things fail before any count is read, because each is a rule that would
|
|
745
|
+
* otherwise pass without checking what it says: a missing target, a `required`
|
|
746
|
+
* target that is a file, a partner template that names the file itself, and a
|
|
747
|
+
* scope holding nothing.
|
|
748
|
+
*/
|
|
749
|
+
async function executeStructureAssertion(assertion, query, options, base, warnings, startedAt) {
|
|
750
|
+
const search = assertion.search;
|
|
751
|
+
const check = await checkStructure(query, {
|
|
752
|
+
targets: assertion.targets,
|
|
753
|
+
globs: search.globs,
|
|
754
|
+
excludeGlobs: search.excludeGlobs,
|
|
755
|
+
scope: search.scope,
|
|
756
|
+
}, options.tree);
|
|
757
|
+
const notFound = missingTargets(check.missing);
|
|
758
|
+
if (check.missing.length > 0)
|
|
759
|
+
warnings.push(notFound.warning);
|
|
760
|
+
const files = check.notDirectories;
|
|
761
|
+
const self = check.selfPartner;
|
|
762
|
+
const failure = !options.allowMissingTargets && check.missing.length > 0
|
|
763
|
+
? notFound.failure
|
|
764
|
+
: files.length > 0
|
|
765
|
+
? `required="..." is about directories, and ${files.length === 1 ? "this target is a file" : "these targets are files"}: ${files.join(", ")}`
|
|
766
|
+
: self !== undefined
|
|
767
|
+
? `partner template ${self.template} names ${self.file} itself, so every file would be its own partner`
|
|
768
|
+
: check.inspected === 0 && !options.allowEmptyScope && !assertion.allowEmpty
|
|
769
|
+
? `no ${query.claim === "required" ? "directories were selected" : "files were inspected"}, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`
|
|
770
|
+
: null;
|
|
771
|
+
const fileCounts = new Map(check.violations.map((violation) => [violation.path, 1]));
|
|
772
|
+
const { excluded, stale, shows } = applyBaseline(assertion.baseline, fileCounts);
|
|
773
|
+
const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
|
|
774
|
+
const gaps = check.scope.skipped.length;
|
|
775
|
+
const strictFailure = options.strictTargets && gaps > 0;
|
|
776
|
+
const actual = check.violations.length - excluded;
|
|
777
|
+
return {
|
|
778
|
+
...base,
|
|
779
|
+
ok: failure === null && satisfies(actual, assertion.bounds) && !staleFailure && !strictFailure,
|
|
780
|
+
actual,
|
|
781
|
+
message: failure ??
|
|
782
|
+
describeOutcome(assertion.bounds, actual, { excluded, stale: staleFailure ? stale : [], gaps: strictFailure ? gaps : 0 }, STRUCTURE_UNITS[query.claim]),
|
|
783
|
+
baselinedMatches: excluded,
|
|
784
|
+
staleBaseline: stale,
|
|
785
|
+
fileMatches: [...fileCounts].map(([file, count]) => ({ file, count })),
|
|
786
|
+
// No line and no column: what is wrong is the path itself.
|
|
787
|
+
matches: check.violations
|
|
788
|
+
.filter((violation) => shows(violation.path))
|
|
789
|
+
.slice(0, options.maxSnippets)
|
|
790
|
+
.map((violation) => ({ file: violation.path, line: 0, column: 0, text: violation.text, count: 1 })),
|
|
791
|
+
scope: check.scope,
|
|
792
|
+
durationMs: elapsed(startedAt),
|
|
793
|
+
};
|
|
794
|
+
}
|
|
620
795
|
const NOTHING_SEARCHED = {
|
|
621
796
|
count: 0,
|
|
622
797
|
commentMatches: 0,
|
|
@@ -643,16 +818,10 @@ const IMPORT_VERBS = {
|
|
|
643
818
|
*/
|
|
644
819
|
async function executeImportAssertion(assertion, options, base, warnings, startedAt) {
|
|
645
820
|
const query = assertion.imports;
|
|
646
|
-
const existingTargets =
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
else
|
|
651
|
-
assertion.missingTargets.push(target);
|
|
652
|
-
}
|
|
653
|
-
if (assertion.missingTargets.length > 0) {
|
|
654
|
-
warnings.push(`target path${assertion.missingTargets.length === 1 ? "" : "s"} not found: ${assertion.missingTargets.join(", ")}`);
|
|
655
|
-
}
|
|
821
|
+
const { existing: existingTargets, missing: absent } = await partitionTargets(assertion.targets, options);
|
|
822
|
+
const notFound = missingTargets(absent);
|
|
823
|
+
if (absent.length > 0)
|
|
824
|
+
warnings.push(notFound.warning);
|
|
656
825
|
// No symbol: an import assertion never runs a text search, and the walk does
|
|
657
826
|
// not depend on one. This used to pass `symbol: ""`, an invented value that
|
|
658
827
|
// reached nothing and that no test could therefore be wrong about.
|
|
@@ -665,12 +834,12 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
|
|
|
665
834
|
root: options.root,
|
|
666
835
|
targets: existingTargets,
|
|
667
836
|
options: assertion.search,
|
|
668
|
-
});
|
|
837
|
+
}, undefined, options.io);
|
|
669
838
|
// Why a missing target fails the assertion, or null when it does not. Decided
|
|
670
839
|
// before scope, so a rule whose only target is gone says so rather than that
|
|
671
840
|
// its scope is empty - which is true, and not the thing to fix.
|
|
672
|
-
const missing = !options.allowMissingTargets &&
|
|
673
|
-
?
|
|
841
|
+
const missing = !options.allowMissingTargets && absent.length > 0
|
|
842
|
+
? notFound.failure
|
|
674
843
|
: null;
|
|
675
844
|
// A cycle needs files the resolver can name, which today means JavaScript
|
|
676
845
|
// and TypeScript; every other import rule reads any language spec-guard
|
|
@@ -910,18 +1079,15 @@ export async function executeAssertion(assertion, options) {
|
|
|
910
1079
|
export function batchConcurrency(requested, batches) {
|
|
911
1080
|
return Math.max(1, Math.min(requested, batches));
|
|
912
1081
|
}
|
|
913
|
-
/**
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
const allowEmptyScope = options.allowEmptyScope ?? false;
|
|
1082
|
+
/**
|
|
1083
|
+
* Resolves what a set of specs asks for, reading nothing.
|
|
1084
|
+
*
|
|
1085
|
+
* The first half of `runSpecGuard`, moved out so that a watch session plans
|
|
1086
|
+
* each run the way a run does rather than by a copy of it (ADR-0014).
|
|
1087
|
+
*/
|
|
1088
|
+
export function planRun(specs, root, options) {
|
|
921
1089
|
const scope = createScope(options.defaultSkips ?? true);
|
|
922
|
-
const
|
|
923
|
-
const specFiles = specs.files;
|
|
924
|
-
const excludeFiles = specExclusions(specFiles, options.includeSpecs ?? false);
|
|
1090
|
+
const excludeFiles = specExclusions(specs.files, options.includeSpecs ?? false);
|
|
925
1091
|
const directives = [];
|
|
926
1092
|
/** Parsed, validated, and then not run: see ADR-0010. */
|
|
927
1093
|
const withheld = [];
|
|
@@ -966,16 +1132,59 @@ export async function runSpecGuard(options) {
|
|
|
966
1132
|
if ("error" in resolved)
|
|
967
1133
|
errors.push(resolved.error);
|
|
968
1134
|
}
|
|
1135
|
+
return { root, specFiles: specs.files, assertions, withheld: withheld.length, errors, inactiveSpecs };
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* A run's report, from its plan and the results of the rules it executed.
|
|
1139
|
+
*
|
|
1140
|
+
* The last part of `runSpecGuard`, shared with a watch session for the same
|
|
1141
|
+
* reason as `planRun`.
|
|
1142
|
+
*/
|
|
1143
|
+
export function reportRun(plan, results, engine, startedAt) {
|
|
1144
|
+
const warnings = engine.fallbacks.map((message) => `ripgrep failed, fell back to the JavaScript engine (${message})`);
|
|
1145
|
+
// Errors arrive in two waves (parse, then resolve); readers expect file order.
|
|
1146
|
+
const errors = [...plan.errors].sort((a, b) => a.location.relativeFile === b.location.relativeFile
|
|
1147
|
+
? a.location.line - b.location.line
|
|
1148
|
+
: comparePaths(a.location.relativeFile, b.location.relativeFile));
|
|
1149
|
+
const failed = results.filter((result) => !result.ok).length;
|
|
1150
|
+
return {
|
|
1151
|
+
ok: failed === 0 && errors.length === 0,
|
|
1152
|
+
root: plan.root,
|
|
1153
|
+
engine: warnings.length > 0 ? "javascript" : engine.name,
|
|
1154
|
+
durationMs: elapsed(startedAt),
|
|
1155
|
+
summary: {
|
|
1156
|
+
specs: plan.specFiles.length,
|
|
1157
|
+
total: results.length,
|
|
1158
|
+
passed: results.length - failed,
|
|
1159
|
+
failed,
|
|
1160
|
+
skipped: plan.assertions.length - results.length,
|
|
1161
|
+
inactive: plan.withheld,
|
|
1162
|
+
},
|
|
1163
|
+
results,
|
|
1164
|
+
errors,
|
|
1165
|
+
warnings,
|
|
1166
|
+
inactiveSpecs: plan.inactiveSpecs,
|
|
1167
|
+
specFiles: plan.specFiles.map((file) => specPath(plan.root, file)),
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
/** Reads, parses and executes every directive found in the given spec files. */
|
|
1171
|
+
export async function runSpecGuard(options) {
|
|
1172
|
+
const startedAt = performance.now();
|
|
1173
|
+
const root = path.resolve(options.root ?? process.cwd());
|
|
1174
|
+
const plan = planRun(await readSpecs(options.patterns, root), root, options);
|
|
1175
|
+
const assertions = plan.assertions;
|
|
969
1176
|
const engine = createCachedEngine(await resolveEngine(options.engine ?? "auto"));
|
|
970
1177
|
const executeOptions = {
|
|
971
1178
|
root,
|
|
1179
|
+
io: nodeIo,
|
|
972
1180
|
engine,
|
|
973
|
-
allowMissingTargets,
|
|
974
|
-
strictTargets,
|
|
975
|
-
allowEmptyScope,
|
|
976
|
-
maxSnippets,
|
|
1181
|
+
allowMissingTargets: options.allowMissingTargets ?? false,
|
|
1182
|
+
strictTargets: options.strictTargets ?? false,
|
|
1183
|
+
allowEmptyScope: options.allowEmptyScope ?? false,
|
|
1184
|
+
maxSnippets: options.maxSnippets ?? DEFAULT_MAX_SNIPPETS,
|
|
977
1185
|
imports: createImportIndex(),
|
|
978
1186
|
hasFiles: createScopeProbe(),
|
|
1187
|
+
tree: createTreeIndex(root),
|
|
979
1188
|
};
|
|
980
1189
|
const results = [];
|
|
981
1190
|
if (options.failFast) {
|
|
@@ -1025,30 +1234,6 @@ export async function runSpecGuard(options) {
|
|
|
1025
1234
|
};
|
|
1026
1235
|
await Promise.all(Array.from({ length: concurrency }, worker));
|
|
1027
1236
|
}
|
|
1028
|
-
|
|
1029
|
-
// Errors arrive in two waves (parse, then resolve); readers expect file order.
|
|
1030
|
-
errors.sort((a, b) => a.location.relativeFile === b.location.relativeFile
|
|
1031
|
-
? a.location.line - b.location.line
|
|
1032
|
-
: comparePaths(a.location.relativeFile, b.location.relativeFile));
|
|
1033
|
-
const failed = results.filter((result) => !result.ok).length;
|
|
1034
|
-
return {
|
|
1035
|
-
ok: failed === 0 && errors.length === 0,
|
|
1036
|
-
root,
|
|
1037
|
-
engine: warnings.length > 0 ? "javascript" : engine.name,
|
|
1038
|
-
durationMs: elapsed(startedAt),
|
|
1039
|
-
summary: {
|
|
1040
|
-
specs: specFiles.length,
|
|
1041
|
-
total: results.length,
|
|
1042
|
-
passed: results.length - failed,
|
|
1043
|
-
failed,
|
|
1044
|
-
skipped: assertions.length - results.length,
|
|
1045
|
-
inactive: withheld.length,
|
|
1046
|
-
},
|
|
1047
|
-
results,
|
|
1048
|
-
errors,
|
|
1049
|
-
warnings,
|
|
1050
|
-
inactiveSpecs,
|
|
1051
|
-
specFiles: specFiles.map((file) => specPath(root, file)),
|
|
1052
|
-
};
|
|
1237
|
+
return reportRun(plan, results, engine, startedAt);
|
|
1053
1238
|
}
|
|
1054
1239
|
//# sourceMappingURL=runner.js.map
|