@descent-vtt/spec-guard 0.4.0 → 0.5.1
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 +283 -150
- package/README.md +868 -644
- package/dist/cli.d.ts +8 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +40 -7
- package/dist/cli.js.map +1 -1
- package/dist/comments.d.ts +23 -4
- package/dist/comments.d.ts.map +1 -1
- package/dist/comments.js +75 -27
- package/dist/comments.js.map +1 -1
- package/dist/engine.d.ts +126 -6
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +301 -154
- 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/imports.d.ts +28 -4
- package/dist/imports.d.ts.map +1 -1
- package/dist/imports.js +46 -8
- package/dist/imports.js.map +1 -1
- package/dist/index.d.ts +11 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/parser.d.ts +12 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +39 -26
- package/dist/parser.js.map +1 -1
- package/dist/polyglot.d.ts +139 -0
- package/dist/polyglot.d.ts.map +1 -0
- package/dist/polyglot.js +548 -0
- package/dist/polyglot.js.map +1 -0
- package/dist/reporter.d.ts +41 -0
- package/dist/reporter.d.ts.map +1 -1
- package/dist/reporter.js +154 -1
- package/dist/reporter.js.map +1 -1
- package/dist/runner.d.ts +54 -2
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +321 -73
- package/dist/runner.js.map +1 -1
- package/dist/text.d.ts +34 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +80 -0
- package/dist/text.js.map +1 -0
- package/dist/types.d.ts +76 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +73 -73
package/dist/runner.js
CHANGED
|
@@ -7,13 +7,24 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { promises as fs } from "node:fs";
|
|
9
9
|
import path from "node:path";
|
|
10
|
-
import { createCachedEngine, enumerateCandidates, resolveEngine, runSearches, } 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 { ANALYSABLE_EXTENSIONS, createImportIndex,
|
|
12
|
+
import { ANALYSABLE_EXTENSIONS, createImportIndex, resolveModule, } from "./imports.js";
|
|
13
13
|
import { parseDirectives } from "./parser.js";
|
|
14
|
-
import { createScope, DEFAULT_SCOPE, EMPTY_LEDGER,
|
|
14
|
+
import { createScope, DEFAULT_SCOPE, EMPTY_LEDGER, } from "./scope.js";
|
|
15
15
|
export const DEFAULT_MAX_SNIPPETS = 5;
|
|
16
16
|
export const DEFAULT_CONCURRENCY = 8;
|
|
17
|
+
/**
|
|
18
|
+
* Milliseconds between two `performance.now()` marks.
|
|
19
|
+
*
|
|
20
|
+
* Seven results carry a duration and each used to subtract inline, which is
|
|
21
|
+
* seven chances to write the operands the wrong way round and report a negative
|
|
22
|
+
* time - and seven copies of one subtraction that nothing could pin down,
|
|
23
|
+
* because "a plausible number of milliseconds" is not an assertion.
|
|
24
|
+
*/
|
|
25
|
+
export function elapsed(from, to = performance.now()) {
|
|
26
|
+
return to - from;
|
|
27
|
+
}
|
|
17
28
|
const TRUE_VALUES = new Set(["true", "1", "yes", "on"]);
|
|
18
29
|
const FALSE_VALUES = new Set(["false", "0", "no", "off"]);
|
|
19
30
|
function parseBoolean(value, attribute) {
|
|
@@ -44,13 +55,19 @@ function parseCount(value, attribute) {
|
|
|
44
55
|
function splitList(value) {
|
|
45
56
|
if (value === undefined)
|
|
46
57
|
return [];
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
58
|
+
// Taking the items rather than splitting on the separators. Split needed a
|
|
59
|
+
// `+` on the separator class, a trim and a length filter, and any two of the
|
|
60
|
+
// three made the third unnecessary - so none of them could be tested. What is
|
|
61
|
+
// wanted is "the runs of non-separator characters", and that is one pattern.
|
|
62
|
+
return value.match(/[^,\s]+/g) ?? [];
|
|
51
63
|
}
|
|
52
64
|
/** Rejects absolute paths and any `..` escape out of the root. */
|
|
53
65
|
function normalizeTarget(target, root, attribute) {
|
|
66
|
+
// The second test is not redundant with the first: `path.isAbsolute` follows
|
|
67
|
+
// the host platform, so a Windows drive path is absolute on Windows and an
|
|
68
|
+
// ordinary relative name on Linux. A spec naming `C:/repo/src` has to be
|
|
69
|
+
// refused on both, or the same document means two different things. It is
|
|
70
|
+
// anchored, because a colon partway along a path is just a character.
|
|
54
71
|
if (path.isAbsolute(target) || /^[a-zA-Z]:[\\/]/.test(target)) {
|
|
55
72
|
throw new Error(`Attribute "${attribute}" must be relative to --root, got "${target}".`);
|
|
56
73
|
}
|
|
@@ -75,7 +92,6 @@ function describeBounds(bounds) {
|
|
|
75
92
|
return `at least ${min} match${plural(min)}`;
|
|
76
93
|
if (max !== undefined)
|
|
77
94
|
return max === 0 ? "no matches" : `at most ${max} match${plural(max)}`;
|
|
78
|
-
/* c8 ignore next */
|
|
79
95
|
return "any number of matches";
|
|
80
96
|
}
|
|
81
97
|
/** Prose form used in the assertion description ("must appear at most 3 times"). */
|
|
@@ -91,10 +107,10 @@ function describeExpectation(bounds) {
|
|
|
91
107
|
return `must appear at least ${times(min)}`;
|
|
92
108
|
if (max === 0)
|
|
93
109
|
return "must not appear";
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
110
|
+
// No branch for "neither bound": this is only ever called from
|
|
111
|
+
// `resolveDirective`, which refuses a directive that names none. Carrying a
|
|
112
|
+
// fourth case here meant carrying a string no run could print.
|
|
113
|
+
return `must appear at most ${times(max ?? 0)}`;
|
|
98
114
|
}
|
|
99
115
|
/** Prose for an import claim: "must not import", "must import at least 2 files". */
|
|
100
116
|
function describeImportExpectation(bounds) {
|
|
@@ -109,17 +125,149 @@ function describeImportExpectation(bounds) {
|
|
|
109
125
|
}
|
|
110
126
|
if (min !== undefined)
|
|
111
127
|
return `must import from at least ${files(min)}`;
|
|
112
|
-
|
|
113
|
-
return max
|
|
114
|
-
? "may import"
|
|
115
|
-
: `must import from at most ${files(max)}`;
|
|
128
|
+
// As above: `resolveDirective` never produces an unbounded import claim.
|
|
129
|
+
return `must import from at most ${files(max ?? 0)}`;
|
|
116
130
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
131
|
+
/**
|
|
132
|
+
* What a report says when an assertion covered nothing.
|
|
133
|
+
*
|
|
134
|
+
* The fourth silent false green, after the three ADR-0007 closed. A rule whose
|
|
135
|
+
* scope holds no files passes every time, forever, and reads in the report
|
|
136
|
+
* exactly like a rule that inspected a thousand files and found nothing. The
|
|
137
|
+
* usual causes are a `glob` that matches no extension in the tree, an `exclude`
|
|
138
|
+
* that swallowed the target, or a directory that has since been emptied.
|
|
139
|
+
*
|
|
140
|
+
* Deliberately no file count alongside it: under ripgrep spec-guard never
|
|
141
|
+
* enumerates the tree, so "inspected 412 files" would be a number only one of
|
|
142
|
+
* the two engines could produce. "Nothing at all" is the same answer in both,
|
|
143
|
+
* and it is the only part that changes an outcome.
|
|
144
|
+
*/
|
|
145
|
+
const EMPTY_SCOPE_HINT = 'add allow-empty="true" if that is expected';
|
|
146
|
+
/**
|
|
147
|
+
* The one-line explanation under a result.
|
|
148
|
+
*
|
|
149
|
+
* Built from parts rather than written per branch so that a run with a
|
|
150
|
+
* baseline, an unreadable file and a stale entry says all three things instead
|
|
151
|
+
* of whichever one the branch order happened to reach first.
|
|
152
|
+
*/
|
|
153
|
+
function describeOutcome(bounds, actual, extras) {
|
|
154
|
+
const parts = [`expected ${describeBounds(bounds)}, found ${actual}`];
|
|
155
|
+
if (extras.excluded > 0) {
|
|
156
|
+
parts.push(`${extras.excluded} more ${extras.excluded === 1 ? "is" : "are"} on the baseline`);
|
|
157
|
+
}
|
|
158
|
+
if (extras.gaps > 0) {
|
|
159
|
+
parts.push(`${extras.gaps} file${extras.gaps === 1 ? "" : "s"} could not be inspected`);
|
|
160
|
+
}
|
|
161
|
+
if (extras.stale.length > 0) {
|
|
162
|
+
// Naming the entries matters more than counting them: the fix is to delete
|
|
163
|
+
// exactly these lines from the spec, and a reader should not have to work
|
|
164
|
+
// out which.
|
|
165
|
+
const listed = extras.stale
|
|
166
|
+
.map((entry) => entry.found === 0
|
|
167
|
+
? `${entry.path} (no longer matches)`
|
|
168
|
+
: `${entry.path} (declares ${entry.declared}, found ${entry.found})`)
|
|
169
|
+
.join(", ");
|
|
170
|
+
parts.push(`the baseline is out of date and must be pruned: ${listed}`);
|
|
171
|
+
}
|
|
172
|
+
return parts.join("; ");
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Reads a `baseline="..."` attribute into entries.
|
|
176
|
+
*
|
|
177
|
+
* Each item is a path, optionally `path:count`; a bare path means one match.
|
|
178
|
+
* The separator is a colon because a path here is always relative and
|
|
179
|
+
* POSIX-shaped, so it cannot contain one - the same reasoning that lets
|
|
180
|
+
* `target` split on whitespace.
|
|
181
|
+
*/
|
|
182
|
+
function parseBaseline(value, root) {
|
|
183
|
+
const entries = [];
|
|
184
|
+
const seen = new Set();
|
|
185
|
+
for (const item of splitList(value)) {
|
|
186
|
+
const separator = item.lastIndexOf(":");
|
|
187
|
+
const rawPath = separator === -1 ? item : item.slice(0, separator);
|
|
188
|
+
const rawCount = separator === -1 ? "1" : item.slice(separator + 1);
|
|
189
|
+
const declared = parseCount(rawCount, "baseline");
|
|
190
|
+
if (declared === 0) {
|
|
191
|
+
throw new Error(`Baseline entry "${item}" declares 0 matches. Remove the entry instead; a baseline lists violations that exist.`);
|
|
192
|
+
}
|
|
193
|
+
const normalized = normalizeTarget(rawPath, root, "baseline");
|
|
194
|
+
if (seen.has(normalized)) {
|
|
195
|
+
throw new Error(`Baseline lists "${normalized}" twice.`);
|
|
196
|
+
}
|
|
197
|
+
seen.add(normalized);
|
|
198
|
+
entries.push({ path: normalized, declared });
|
|
199
|
+
}
|
|
200
|
+
return entries;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Subtracts the declared debt from what was found.
|
|
204
|
+
*
|
|
205
|
+
* Two questions, and the ratchet needs both. Matches inside a baselined file
|
|
206
|
+
* stop counting *up to the declared number* - so a file allowed two violations
|
|
207
|
+
* that grows a third reports one, not three, and the failure points at the
|
|
208
|
+
* change rather than at the history. And a file that no longer has as many as
|
|
209
|
+
* it declares is a stale entry: the debt was paid and the ledger still claims
|
|
210
|
+
* it, which is a spec asserting something untrue about the code.
|
|
211
|
+
*/
|
|
212
|
+
export function applyBaseline(entries, fileCounts) {
|
|
213
|
+
let excluded = 0;
|
|
214
|
+
const stale = [];
|
|
215
|
+
const declaredBy = new Map(entries.map((entry) => [entry.path, entry.declared]));
|
|
216
|
+
for (const entry of entries) {
|
|
217
|
+
const found = fileCounts.get(entry.path) ?? 0;
|
|
218
|
+
excluded += Math.min(found, entry.declared);
|
|
219
|
+
if (found < entry.declared)
|
|
220
|
+
stale.push({ path: entry.path, declared: entry.declared, found });
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
excluded,
|
|
224
|
+
stale,
|
|
225
|
+
// Which files a failure should show. A file inside its allowance is not
|
|
226
|
+
// what went wrong, and printing it points the reader at the history
|
|
227
|
+
// instead of at the change - the first version of this did exactly that,
|
|
228
|
+
// and reported a new violation by quoting a twelve-year-old one. A file
|
|
229
|
+
// *over* its allowance still shows, because the excess is a real
|
|
230
|
+
// violation living in it.
|
|
231
|
+
shows: (file) => (fileCounts.get(file) ?? 0) > (declaredBy.get(file) ?? 0),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Per-run cache for "does this scope contain anything at all".
|
|
236
|
+
*
|
|
237
|
+
* The same shape as the import index, and for the same reason: a document with
|
|
238
|
+
* twenty assertions over `src/` asks this question twenty times and it has one
|
|
239
|
+
* answer. Without the cache the probe measured about 0.9 ms per assertion,
|
|
240
|
+
* which is small until a spec has two hundred of them.
|
|
241
|
+
*/
|
|
242
|
+
export function createScopeProbe() {
|
|
243
|
+
const cache = new Map();
|
|
244
|
+
return (request) => {
|
|
245
|
+
const key = JSON.stringify([
|
|
246
|
+
request.root,
|
|
247
|
+
request.targets,
|
|
248
|
+
request.options.globs,
|
|
249
|
+
request.options.excludeGlobs,
|
|
250
|
+
]);
|
|
251
|
+
let pending = cache.get(key);
|
|
252
|
+
if (!pending) {
|
|
253
|
+
// The walk abandons at the first file it finds, so proving a scope is
|
|
254
|
+
// populated costs a couple of directory reads rather than a traversal.
|
|
255
|
+
pending = enumerateCandidates(request, ANY_FILE_PROBE).then((enumeration) => enumeration.files.length > 0);
|
|
256
|
+
cache.set(key, pending);
|
|
257
|
+
}
|
|
258
|
+
return pending;
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Whether a count is inside the bounds. An absent bound is no bound.
|
|
263
|
+
*
|
|
264
|
+
* Written as defaults rather than as two `!== undefined` guards. Those guards
|
|
265
|
+
* could not change an answer - `count < undefined` is false for every count, so
|
|
266
|
+
* the comparison already admitted everything - which meant two conditions in
|
|
267
|
+
* the middle of the pass/fail decision that no test could hold in place.
|
|
268
|
+
*/
|
|
269
|
+
function satisfies(count, { min = 0, max = Number.POSITIVE_INFINITY }) {
|
|
270
|
+
return count >= min && count <= max;
|
|
123
271
|
}
|
|
124
272
|
/** Turns one directive into an executable assertion, or an error. */
|
|
125
273
|
export function resolveDirective(directive, context) {
|
|
@@ -144,6 +292,10 @@ export function resolveDirective(directive, context) {
|
|
|
144
292
|
files,
|
|
145
293
|
bounds: { min: files.length, max: files.length },
|
|
146
294
|
missingTargets: [],
|
|
295
|
+
// A file list is never empty here: resolution rejects that above.
|
|
296
|
+
allowEmpty: true,
|
|
297
|
+
baseline: [],
|
|
298
|
+
ratchet: "two-sided",
|
|
147
299
|
},
|
|
148
300
|
};
|
|
149
301
|
}
|
|
@@ -153,12 +305,25 @@ export function resolveDirective(directive, context) {
|
|
|
153
305
|
if (symbol === undefined || symbol.length === 0) {
|
|
154
306
|
return fail(`@${kind} requires a non-empty ${subject}="..." attribute.`);
|
|
155
307
|
}
|
|
308
|
+
const allowEmpty = parseBoolean(attributes["allow-empty"], "allow-empty");
|
|
309
|
+
// `baseline` and `ratchet` reach here only on the absence kinds: the parser
|
|
310
|
+
// rejects them elsewhere, because "these known files may violate" means
|
|
311
|
+
// nothing for an assertion that is not forbidding anything.
|
|
312
|
+
const baseline = parseBaseline(attributes["baseline"], context.root);
|
|
313
|
+
const rawRatchet = (attributes["ratchet"] ?? "two-sided").trim().toLowerCase();
|
|
314
|
+
if (rawRatchet !== "two-sided" && rawRatchet !== "one-way") {
|
|
315
|
+
return fail(`Attribute "ratchet" must be two-sided or one-way, got "${attributes["ratchet"]}".`);
|
|
316
|
+
}
|
|
317
|
+
if (baseline.length === 0 && attributes["ratchet"] !== undefined) {
|
|
318
|
+
return fail(`Attribute "ratchet" needs a baseline="..." to ratchet.`);
|
|
319
|
+
}
|
|
320
|
+
const ratchet = rawRatchet;
|
|
156
321
|
const comments = (attributes["comments"] ?? "ignore").trim().toLowerCase();
|
|
157
322
|
if (comments !== "ignore" && comments !== "include") {
|
|
158
323
|
return fail(`Attribute "comments" must be ignore or include, got "${attributes["comments"]}".`);
|
|
159
324
|
}
|
|
160
325
|
const rawTargets = splitList(attributes["target"]);
|
|
161
|
-
const targets = (rawTargets.length > 0 ? rawTargets :
|
|
326
|
+
const targets = (rawTargets.length > 0 ? rawTargets : ROOT_TARGETS).map((target) => normalizeTarget(target, context.root, "target"));
|
|
162
327
|
const bounds = {};
|
|
163
328
|
if (kind === "assert-absence" || kind === "assert-import-absence") {
|
|
164
329
|
if (attributes["expected"] !== undefined &&
|
|
@@ -191,9 +356,10 @@ export function resolveDirective(directive, context) {
|
|
|
191
356
|
bounds.min = parseCount(attributes["min"], "min");
|
|
192
357
|
if (attributes["max"] !== undefined)
|
|
193
358
|
bounds.max = parseCount(attributes["max"], "max");
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
359
|
+
// Defaults rather than presence checks, for the same reason as
|
|
360
|
+
// `satisfies`: `min > undefined` is false whatever min is, so the two
|
|
361
|
+
// `!== undefined` guards this used to carry could not decide anything.
|
|
362
|
+
if ((bounds.min ?? 0) > (bounds.max ?? Number.POSITIVE_INFINITY)) {
|
|
197
363
|
return fail(`min="${bounds.min}" is greater than max="${bounds.max}".`);
|
|
198
364
|
}
|
|
199
365
|
}
|
|
@@ -233,6 +399,9 @@ export function resolveDirective(directive, context) {
|
|
|
233
399
|
},
|
|
234
400
|
imports: { modules: splitList(symbol), includeTypes },
|
|
235
401
|
missingTargets: [],
|
|
402
|
+
allowEmpty,
|
|
403
|
+
baseline,
|
|
404
|
+
ratchet,
|
|
236
405
|
},
|
|
237
406
|
};
|
|
238
407
|
}
|
|
@@ -251,7 +420,10 @@ export function resolveDirective(directive, context) {
|
|
|
251
420
|
new RegExp(symbol);
|
|
252
421
|
}
|
|
253
422
|
catch (error) {
|
|
254
|
-
|
|
423
|
+
// No prefix of our own. V8's message already begins "Invalid regular
|
|
424
|
+
// expression: /(/: ...", so adding one produced the phrase twice in a
|
|
425
|
+
// row and pushed the pattern itself off the useful part of the line.
|
|
426
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
255
427
|
}
|
|
256
428
|
}
|
|
257
429
|
const scope = targets.join(", ");
|
|
@@ -270,6 +442,9 @@ export function resolveDirective(directive, context) {
|
|
|
270
442
|
bounds,
|
|
271
443
|
search,
|
|
272
444
|
missingTargets: [],
|
|
445
|
+
allowEmpty,
|
|
446
|
+
baseline,
|
|
447
|
+
ratchet,
|
|
273
448
|
},
|
|
274
449
|
};
|
|
275
450
|
}
|
|
@@ -300,6 +475,9 @@ async function prepareAssertion(assertion, options) {
|
|
|
300
475
|
commentMatches: 0,
|
|
301
476
|
unclassifiedFiles: 0,
|
|
302
477
|
scope: EMPTY_LEDGER,
|
|
478
|
+
baselinedMatches: 0,
|
|
479
|
+
staleBaseline: [],
|
|
480
|
+
fileMatches: [],
|
|
303
481
|
};
|
|
304
482
|
if (assertion.kind === "assert-present") {
|
|
305
483
|
const missing = [];
|
|
@@ -316,7 +494,7 @@ async function prepareAssertion(assertion, options) {
|
|
|
316
494
|
? `all ${assertion.files.length} referenced ${assertion.files.length === 1 ? "path exists" : "paths exist"}`
|
|
317
495
|
: `missing: ${missing.join(", ")}`,
|
|
318
496
|
matches: [],
|
|
319
|
-
durationMs:
|
|
497
|
+
durationMs: elapsed(startedAt),
|
|
320
498
|
};
|
|
321
499
|
}
|
|
322
500
|
if (assertion.imports) {
|
|
@@ -339,43 +517,79 @@ async function prepareAssertion(assertion, options) {
|
|
|
339
517
|
actual: 0,
|
|
340
518
|
message: `target path${assertion.missingTargets.length === 1 ? " does" : "s do"} not exist: ${assertion.missingTargets.join(", ")}`,
|
|
341
519
|
matches: [],
|
|
342
|
-
durationMs:
|
|
520
|
+
durationMs: elapsed(startedAt),
|
|
343
521
|
};
|
|
344
522
|
}
|
|
523
|
+
const request = {
|
|
524
|
+
root: options.root,
|
|
525
|
+
symbol: assertion.symbol,
|
|
526
|
+
targets: existingTargets,
|
|
527
|
+
options: assertion.search,
|
|
528
|
+
};
|
|
529
|
+
if (!options.allowEmptyScope && !assertion.allowEmpty && !(await options.hasFiles(request))) {
|
|
530
|
+
{
|
|
531
|
+
return {
|
|
532
|
+
...base,
|
|
533
|
+
ok: false,
|
|
534
|
+
actual: 0,
|
|
535
|
+
message: `no files were inspected, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`,
|
|
536
|
+
matches: [],
|
|
537
|
+
durationMs: elapsed(startedAt),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
}
|
|
345
541
|
return {
|
|
346
|
-
request
|
|
347
|
-
root: options.root,
|
|
348
|
-
symbol: assertion.symbol,
|
|
349
|
-
targets: existingTargets,
|
|
350
|
-
options: assertion.search,
|
|
351
|
-
},
|
|
542
|
+
request,
|
|
352
543
|
finish: (search) => {
|
|
353
544
|
// A file that could not be read, or whose bytes are not text but did
|
|
354
545
|
// contain the symbol, is a hole in the answer rather than a detail of it.
|
|
355
546
|
// The count is still reported, because it is still true of everything
|
|
356
547
|
// that was read; --strict is for runs where "true of what we read" is not
|
|
357
548
|
// good enough.
|
|
358
|
-
|
|
549
|
+
//
|
|
550
|
+
// No filter for uncertainty here any more. The ledger only ever holds
|
|
551
|
+
// gaps: the walk drops a policy skip before recording it, and the only
|
|
552
|
+
// other reasons the scanner adds are "unreadable" and "binary". Filtering
|
|
553
|
+
// again was a second statement of an invariant that is enforced where it
|
|
554
|
+
// is created, and being a no-op, nothing could tell it from its absence.
|
|
555
|
+
const gaps = search.scope.skipped;
|
|
359
556
|
const strictFailure = options.strictTargets && gaps.length > 0;
|
|
557
|
+
const { excluded, stale, shows } = applyBaseline(assertion.baseline, search.fileCounts);
|
|
558
|
+
const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
|
|
559
|
+
const actual = search.count - excluded;
|
|
360
560
|
return {
|
|
361
561
|
...base,
|
|
362
|
-
ok: satisfies(
|
|
363
|
-
actual
|
|
364
|
-
message:
|
|
365
|
-
|
|
366
|
-
:
|
|
367
|
-
|
|
562
|
+
ok: satisfies(actual, assertion.bounds) && !strictFailure && !staleFailure,
|
|
563
|
+
actual,
|
|
564
|
+
message: describeOutcome(assertion.bounds, actual, {
|
|
565
|
+
excluded,
|
|
566
|
+
stale: staleFailure ? stale : [],
|
|
567
|
+
gaps: strictFailure ? gaps.length : 0,
|
|
568
|
+
}),
|
|
569
|
+
baselinedMatches: excluded,
|
|
570
|
+
staleBaseline: stale,
|
|
571
|
+
fileMatches: [...search.fileCounts].map(([file, count]) => ({ file, count })),
|
|
572
|
+
matches: search.matches.filter((match) => shows(match.file)).slice(0, options.maxSnippets),
|
|
368
573
|
// Carried as numbers, not prose, so the reporter can total them across a
|
|
369
574
|
// run and JSON consumers can act on them.
|
|
370
575
|
commentMatches: search.commentMatches,
|
|
371
576
|
unclassifiedFiles: search.unclassifiedFiles,
|
|
372
577
|
scope: search.scope,
|
|
373
578
|
engine: search.engine,
|
|
374
|
-
durationMs:
|
|
579
|
+
durationMs: elapsed(startedAt),
|
|
375
580
|
};
|
|
376
581
|
},
|
|
377
582
|
};
|
|
378
583
|
}
|
|
584
|
+
/** How each kind of reference is spelled in a report snippet. */
|
|
585
|
+
const IMPORT_VERBS = {
|
|
586
|
+
import: "import",
|
|
587
|
+
export: "export",
|
|
588
|
+
require: "require",
|
|
589
|
+
"dynamic-import": "import",
|
|
590
|
+
use: "use",
|
|
591
|
+
using: "using",
|
|
592
|
+
};
|
|
379
593
|
/**
|
|
380
594
|
* Counts the files in scope that depend on the requested module.
|
|
381
595
|
*
|
|
@@ -395,16 +609,33 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
|
|
|
395
609
|
if (assertion.missingTargets.length > 0) {
|
|
396
610
|
warnings.push(`target path${assertion.missingTargets.length === 1 ? "" : "s"} not found: ${assertion.missingTargets.join(", ")}`);
|
|
397
611
|
}
|
|
612
|
+
// No symbol: an import assertion never runs a text search, and the walk does
|
|
613
|
+
// not depend on one. This used to pass `symbol: ""`, an invented value that
|
|
614
|
+
// reached nothing and that no test could therefore be wrong about.
|
|
398
615
|
const enumeration = await enumerateCandidates({
|
|
399
616
|
root: options.root,
|
|
400
|
-
symbol: "",
|
|
401
617
|
targets: existingTargets,
|
|
402
618
|
options: assertion.search,
|
|
403
619
|
});
|
|
404
620
|
const analysable = enumeration.files.filter((file) => ANALYSABLE_EXTENSIONS.has(path.posix.extname(file.relativePath)));
|
|
621
|
+
if (analysable.length === 0 && !options.allowEmptyScope && !assertion.allowEmpty) {
|
|
622
|
+
// For an import rule the bar is higher than "some file exists": a directory
|
|
623
|
+
// of YAML has nothing this can read, so a dependency claim about it is a
|
|
624
|
+
// claim about nothing.
|
|
625
|
+
return {
|
|
626
|
+
...base,
|
|
627
|
+
ok: false,
|
|
628
|
+
actual: 0,
|
|
629
|
+
message: enumeration.files.length === 0
|
|
630
|
+
? `no files were inspected, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`
|
|
631
|
+
: `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
|
+
matches: [],
|
|
633
|
+
durationMs: elapsed(startedAt),
|
|
634
|
+
};
|
|
635
|
+
}
|
|
405
636
|
const skipped = enumeration.files.length - analysable.length;
|
|
406
637
|
if (skipped > 0) {
|
|
407
|
-
warnings.push(`analysed ${analysable.length} of ${enumeration.files.length} files; ${skipped} are
|
|
638
|
+
warnings.push(`analysed ${analysable.length} of ${enumeration.files.length} files; ${skipped} ${skipped === 1 ? "is" : "are"} in a language whose imports spec-guard cannot read`);
|
|
408
639
|
}
|
|
409
640
|
const matchesModule = createExcludeMatcher(query.modules);
|
|
410
641
|
const matches = [];
|
|
@@ -417,14 +648,18 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
|
|
|
417
648
|
const hit = analysis.references.find((reference) => {
|
|
418
649
|
if (reference.typeOnly && !query.includeTypes)
|
|
419
650
|
return false;
|
|
420
|
-
|
|
651
|
+
// Both forms are tried: the resolved one so `module="app/db/**"` works
|
|
652
|
+
// everywhere, and the raw one so a Python or C# author can write the
|
|
653
|
+
// dotted path they see in their own source and still be understood.
|
|
654
|
+
return (matchesModule(resolveModule(reference.specifier, file.relativePath)) ||
|
|
655
|
+
matchesModule(reference.specifier));
|
|
421
656
|
});
|
|
422
657
|
if (hit) {
|
|
423
658
|
matches.push({
|
|
424
659
|
file: file.relativePath,
|
|
425
660
|
line: hit.line,
|
|
426
661
|
column: hit.column,
|
|
427
|
-
text: `${hit.kind
|
|
662
|
+
text: `${IMPORT_VERBS[hit.kind]} ${hit.specifier}`,
|
|
428
663
|
count: 1,
|
|
429
664
|
});
|
|
430
665
|
}
|
|
@@ -433,19 +668,32 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
|
|
|
433
668
|
warnings.push(`${unresolved.length} module reference${unresolved.length === 1 ? "" : "s"} could not be resolved statically`, ...unresolved.slice(0, options.maxSnippets).map((entry) => ` ${entry}`));
|
|
434
669
|
}
|
|
435
670
|
const missingFailure = !options.allowMissingTargets && assertion.missingTargets.length > 0;
|
|
671
|
+
// One reference per file, so the file counts are the matches themselves.
|
|
672
|
+
const fileCounts = new Map(matches.map((match) => [match.file, 1]));
|
|
673
|
+
const { excluded, stale, shows } = applyBaseline(assertion.baseline, fileCounts);
|
|
674
|
+
const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
|
|
675
|
+
const actual = matches.length - excluded;
|
|
436
676
|
const strictFailure = missingFailure || (options.strictTargets && unresolved.length > 0);
|
|
437
|
-
const ok = satisfies(
|
|
677
|
+
const ok = satisfies(actual, assertion.bounds) && !strictFailure && !staleFailure;
|
|
438
678
|
return {
|
|
439
679
|
...base,
|
|
440
680
|
ok,
|
|
441
|
-
actual
|
|
681
|
+
actual,
|
|
442
682
|
message: missingFailure
|
|
443
683
|
? `target path${assertion.missingTargets.length === 1 ? " does" : "s do"} not exist: ${assertion.missingTargets.join(", ")}`
|
|
444
|
-
:
|
|
445
|
-
|
|
446
|
-
:
|
|
447
|
-
|
|
448
|
-
|
|
684
|
+
: describeOutcome(assertion.bounds, actual, {
|
|
685
|
+
excluded,
|
|
686
|
+
stale: staleFailure ? stale : [],
|
|
687
|
+
gaps: 0,
|
|
688
|
+
}) +
|
|
689
|
+
(options.strictTargets && unresolved.length > 0
|
|
690
|
+
? `; ${unresolved.length} reference${unresolved.length === 1 ? "" : "s"} could not be resolved`
|
|
691
|
+
: ""),
|
|
692
|
+
baselinedMatches: excluded,
|
|
693
|
+
staleBaseline: stale,
|
|
694
|
+
fileMatches: [...fileCounts].map(([file, count]) => ({ file, count })),
|
|
695
|
+
matches: matches.filter((match) => shows(match.file)).slice(0, options.maxSnippets),
|
|
696
|
+
durationMs: elapsed(startedAt),
|
|
449
697
|
};
|
|
450
698
|
}
|
|
451
699
|
/** Executes a single resolved assertion. */
|
|
@@ -456,19 +704,14 @@ export async function executeAssertion(assertion, options) {
|
|
|
456
704
|
return prepared.finish(await options.engine.search(prepared.request));
|
|
457
705
|
}
|
|
458
706
|
/**
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
707
|
+
* How many workers to run over the batches.
|
|
708
|
+
*
|
|
709
|
+
* At least one, so an empty run still terminates; never more than there are
|
|
710
|
+
* batches, so a spec with three groups does not start eight workers to find
|
|
711
|
+
* five of them nothing to do.
|
|
462
712
|
*/
|
|
463
|
-
function
|
|
464
|
-
|
|
465
|
-
return JSON.stringify([
|
|
466
|
-
request.targets,
|
|
467
|
-
options.regex,
|
|
468
|
-
options.word,
|
|
469
|
-
options.ignoreCase,
|
|
470
|
-
options.globs,
|
|
471
|
-
]);
|
|
713
|
+
export function batchConcurrency(requested, batches) {
|
|
714
|
+
return Math.max(1, Math.min(requested, batches));
|
|
472
715
|
}
|
|
473
716
|
/** Reads, parses and executes every directive found in the given spec files. */
|
|
474
717
|
export async function runSpecGuard(options) {
|
|
@@ -477,6 +720,7 @@ export async function runSpecGuard(options) {
|
|
|
477
720
|
const maxSnippets = options.maxSnippets ?? DEFAULT_MAX_SNIPPETS;
|
|
478
721
|
const strictTargets = options.strictTargets ?? false;
|
|
479
722
|
const allowMissingTargets = options.allowMissingTargets ?? false;
|
|
723
|
+
const allowEmptyScope = options.allowEmptyScope ?? false;
|
|
480
724
|
const scope = createScope(options.defaultSkips ?? true);
|
|
481
725
|
const specFiles = await expandSpecPatterns(options.patterns, root);
|
|
482
726
|
const excludeFiles = new Set(options.includeSpecs ? [] : specFiles.map((file) => path.resolve(file)));
|
|
@@ -512,10 +756,12 @@ export async function runSpecGuard(options) {
|
|
|
512
756
|
engine,
|
|
513
757
|
allowMissingTargets,
|
|
514
758
|
strictTargets,
|
|
759
|
+
allowEmptyScope,
|
|
515
760
|
maxSnippets,
|
|
516
761
|
imports: createImportIndex(),
|
|
762
|
+
hasFiles: createScopeProbe(),
|
|
517
763
|
};
|
|
518
|
-
const results =
|
|
764
|
+
const results = [];
|
|
519
765
|
if (options.failFast) {
|
|
520
766
|
// Fail-fast trades throughput for an early exit, so it runs unbatched.
|
|
521
767
|
for (let index = 0; index < assertions.length; index++) {
|
|
@@ -535,7 +781,11 @@ export async function runSpecGuard(options) {
|
|
|
535
781
|
results[index] = entry;
|
|
536
782
|
return;
|
|
537
783
|
}
|
|
538
|
-
|
|
784
|
+
// The engine's own definition of "these can share one pass", rather than
|
|
785
|
+
// a second list here that had drifted four fields behind it. This is
|
|
786
|
+
// where most of spec-guard's speed comes from: a spec with twenty
|
|
787
|
+
// assertions over `src/` costs one ripgrep pass, not twenty.
|
|
788
|
+
const key = passKey(entry.request);
|
|
539
789
|
const group = groups.get(key);
|
|
540
790
|
if (group)
|
|
541
791
|
group.push({ index, pending: entry });
|
|
@@ -543,7 +793,7 @@ export async function runSpecGuard(options) {
|
|
|
543
793
|
groups.set(key, [{ index, pending: entry }]);
|
|
544
794
|
});
|
|
545
795
|
const batches = [...groups.values()];
|
|
546
|
-
const concurrency =
|
|
796
|
+
const concurrency = batchConcurrency(options.concurrency ?? DEFAULT_CONCURRENCY, batches.length);
|
|
547
797
|
let cursor = 0;
|
|
548
798
|
const worker = async () => {
|
|
549
799
|
while (cursor < batches.length) {
|
|
@@ -563,15 +813,13 @@ export async function runSpecGuard(options) {
|
|
|
563
813
|
// Errors arrive in two waves (parse, then resolve); readers expect file order.
|
|
564
814
|
errors.sort((a, b) => a.location.relativeFile === b.location.relativeFile
|
|
565
815
|
? a.location.line - b.location.line
|
|
566
|
-
: a.location.relativeFile
|
|
567
|
-
? -1
|
|
568
|
-
: 1);
|
|
816
|
+
: comparePaths(a.location.relativeFile, b.location.relativeFile));
|
|
569
817
|
const failed = results.filter((result) => !result.ok).length;
|
|
570
818
|
return {
|
|
571
819
|
ok: failed === 0 && errors.length === 0,
|
|
572
820
|
root,
|
|
573
821
|
engine: warnings.length > 0 ? "javascript" : engine.name,
|
|
574
|
-
durationMs:
|
|
822
|
+
durationMs: elapsed(startedAt),
|
|
575
823
|
summary: {
|
|
576
824
|
specs: specFiles.length,
|
|
577
825
|
total: results.length,
|