@descent-vtt/spec-guard 0.3.0 → 0.5.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 +125 -0
- package/README.md +292 -49
- package/dist/cli.d.ts +9 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +46 -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 +40 -14
- package/dist/comments.js.map +1 -1
- package/dist/engine.d.ts +52 -49
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +185 -259
- package/dist/engine.js.map +1 -1
- package/dist/glob.d.ts +19 -6
- package/dist/glob.d.ts.map +1 -1
- package/dist/glob.js +14 -28
- 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 +36 -4
- package/dist/imports.js.map +1 -1
- package/dist/index.d.ts +15 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -6
- package/dist/index.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +28 -23
- package/dist/parser.js.map +1 -1
- package/dist/polyglot.d.ts +129 -0
- package/dist/polyglot.d.ts.map +1 -0
- package/dist/polyglot.js +544 -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 +187 -4
- package/dist/reporter.js.map +1 -1
- package/dist/runner.d.ts +47 -3
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +371 -105
- package/dist/runner.js.map +1 -1
- package/dist/scope.d.ts +108 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +115 -0
- package/dist/scope.js.map +1 -0
- package/dist/text.d.ts +26 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +69 -0
- package/dist/text.js.map +1 -0
- package/dist/types.d.ts +87 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -6
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/runner.js
CHANGED
|
@@ -5,16 +5,17 @@
|
|
|
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
|
|
9
|
-
import path from
|
|
10
|
-
import { createCachedEngine, enumerateCandidates, resolveEngine, runSearches, } from
|
|
11
|
-
import { createExcludeMatcher, expandSpecPatterns, toPosix } from
|
|
12
|
-
import { ANALYSABLE_EXTENSIONS, createImportIndex,
|
|
13
|
-
import { parseDirectives } from
|
|
8
|
+
import { promises as fs } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { createCachedEngine, enumerateCandidates, resolveEngine, runSearches, ANY_FILE_PROBE, } from "./engine.js";
|
|
11
|
+
import { createExcludeMatcher, expandSpecPatterns, toPosix } from "./glob.js";
|
|
12
|
+
import { ANALYSABLE_EXTENSIONS, createImportIndex, resolveModule, } from "./imports.js";
|
|
13
|
+
import { parseDirectives } from "./parser.js";
|
|
14
|
+
import { createScope, DEFAULT_SCOPE, EMPTY_LEDGER, UNCERTAIN_REASONS, } from "./scope.js";
|
|
14
15
|
export const DEFAULT_MAX_SNIPPETS = 5;
|
|
15
16
|
export const DEFAULT_CONCURRENCY = 8;
|
|
16
|
-
const TRUE_VALUES = new Set([
|
|
17
|
-
const FALSE_VALUES = new Set([
|
|
17
|
+
const TRUE_VALUES = new Set(["true", "1", "yes", "on"]);
|
|
18
|
+
const FALSE_VALUES = new Set(["false", "0", "no", "off"]);
|
|
18
19
|
function parseBoolean(value, attribute) {
|
|
19
20
|
if (value === undefined)
|
|
20
21
|
return false;
|
|
@@ -55,53 +56,193 @@ function normalizeTarget(target, root, attribute) {
|
|
|
55
56
|
}
|
|
56
57
|
const absolute = path.resolve(root, target);
|
|
57
58
|
const relative = path.relative(root, absolute);
|
|
58
|
-
if (relative.startsWith(
|
|
59
|
+
if (relative.startsWith("..")) {
|
|
59
60
|
throw new Error(`Attribute "${attribute}" escapes the root directory: "${target}".`);
|
|
60
61
|
}
|
|
61
|
-
return toPosix(relative) ||
|
|
62
|
+
return toPosix(relative) || ".";
|
|
62
63
|
}
|
|
63
64
|
function plural(count) {
|
|
64
|
-
return count === 1 ?
|
|
65
|
+
return count === 1 ? "" : "es";
|
|
65
66
|
}
|
|
66
67
|
function describeBounds(bounds) {
|
|
67
68
|
const { min, max } = bounds;
|
|
68
69
|
if (min !== undefined && max !== undefined) {
|
|
69
|
-
return min === max
|
|
70
|
+
return min === max
|
|
71
|
+
? `exactly ${min} match${plural(min)}`
|
|
72
|
+
: `between ${min} and ${max} matches`;
|
|
70
73
|
}
|
|
71
74
|
if (min !== undefined)
|
|
72
75
|
return `at least ${min} match${plural(min)}`;
|
|
73
76
|
if (max !== undefined)
|
|
74
|
-
return max === 0 ?
|
|
77
|
+
return max === 0 ? "no matches" : `at most ${max} match${plural(max)}`;
|
|
75
78
|
/* c8 ignore next */
|
|
76
|
-
return
|
|
79
|
+
return "any number of matches";
|
|
77
80
|
}
|
|
78
81
|
/** Prose form used in the assertion description ("must appear at most 3 times"). */
|
|
79
82
|
function describeExpectation(bounds) {
|
|
80
83
|
const { min, max } = bounds;
|
|
81
|
-
const times = (value) => `${value} time${value === 1 ?
|
|
84
|
+
const times = (value) => `${value} time${value === 1 ? "" : "s"}`;
|
|
82
85
|
if (min !== undefined && max !== undefined) {
|
|
83
|
-
return min === max
|
|
86
|
+
return min === max
|
|
87
|
+
? `must appear exactly ${times(min)}`
|
|
88
|
+
: `must appear between ${min} and ${max} times`;
|
|
84
89
|
}
|
|
85
90
|
if (min !== undefined)
|
|
86
91
|
return `must appear at least ${times(min)}`;
|
|
87
92
|
if (max === 0)
|
|
88
|
-
return
|
|
93
|
+
return "must not appear";
|
|
89
94
|
/* c8 ignore next */
|
|
90
|
-
return max === undefined
|
|
95
|
+
return max === undefined
|
|
96
|
+
? "may appear any number of times"
|
|
97
|
+
: `must appear at most ${times(max)}`;
|
|
91
98
|
}
|
|
92
99
|
/** Prose for an import claim: "must not import", "must import at least 2 files". */
|
|
93
100
|
function describeImportExpectation(bounds) {
|
|
94
101
|
const { min, max } = bounds;
|
|
95
|
-
const files = (value) => `${value} file${value === 1 ?
|
|
102
|
+
const files = (value) => `${value} file${value === 1 ? "" : "s"}`;
|
|
96
103
|
if (max === 0 && min === undefined)
|
|
97
|
-
return
|
|
104
|
+
return "must not import";
|
|
98
105
|
if (min !== undefined && max !== undefined) {
|
|
99
|
-
return min === max
|
|
106
|
+
return min === max
|
|
107
|
+
? `must import from exactly ${files(min)}`
|
|
108
|
+
: `must import from between ${min} and ${max} files`;
|
|
100
109
|
}
|
|
101
110
|
if (min !== undefined)
|
|
102
111
|
return `must import from at least ${files(min)}`;
|
|
103
112
|
/* c8 ignore next */
|
|
104
|
-
return max === undefined
|
|
113
|
+
return max === undefined
|
|
114
|
+
? "may import"
|
|
115
|
+
: `must import from at most ${files(max)}`;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* What a report says when an assertion covered nothing.
|
|
119
|
+
*
|
|
120
|
+
* The fourth silent false green, after the three ADR-0007 closed. A rule whose
|
|
121
|
+
* scope holds no files passes every time, forever, and reads in the report
|
|
122
|
+
* exactly like a rule that inspected a thousand files and found nothing. The
|
|
123
|
+
* usual causes are a `glob` that matches no extension in the tree, an `exclude`
|
|
124
|
+
* that swallowed the target, or a directory that has since been emptied.
|
|
125
|
+
*
|
|
126
|
+
* Deliberately no file count alongside it: under ripgrep spec-guard never
|
|
127
|
+
* enumerates the tree, so "inspected 412 files" would be a number only one of
|
|
128
|
+
* the two engines could produce. "Nothing at all" is the same answer in both,
|
|
129
|
+
* and it is the only part that changes an outcome.
|
|
130
|
+
*/
|
|
131
|
+
const EMPTY_SCOPE_HINT = 'add allow-empty="true" if that is expected';
|
|
132
|
+
/**
|
|
133
|
+
* The one-line explanation under a result.
|
|
134
|
+
*
|
|
135
|
+
* Built from parts rather than written per branch so that a run with a
|
|
136
|
+
* baseline, an unreadable file and a stale entry says all three things instead
|
|
137
|
+
* of whichever one the branch order happened to reach first.
|
|
138
|
+
*/
|
|
139
|
+
function describeOutcome(bounds, actual, extras) {
|
|
140
|
+
const parts = [`expected ${describeBounds(bounds)}, found ${actual}`];
|
|
141
|
+
if (extras.excluded > 0) {
|
|
142
|
+
parts.push(`${extras.excluded} more ${extras.excluded === 1 ? "is" : "are"} on the baseline`);
|
|
143
|
+
}
|
|
144
|
+
if (extras.gaps > 0) {
|
|
145
|
+
parts.push(`${extras.gaps} file${extras.gaps === 1 ? "" : "s"} could not be inspected`);
|
|
146
|
+
}
|
|
147
|
+
if (extras.stale.length > 0) {
|
|
148
|
+
// Naming the entries matters more than counting them: the fix is to delete
|
|
149
|
+
// exactly these lines from the spec, and a reader should not have to work
|
|
150
|
+
// out which.
|
|
151
|
+
const listed = extras.stale
|
|
152
|
+
.map((entry) => entry.found === 0
|
|
153
|
+
? `${entry.path} (no longer matches)`
|
|
154
|
+
: `${entry.path} (declares ${entry.declared}, found ${entry.found})`)
|
|
155
|
+
.join(", ");
|
|
156
|
+
parts.push(`the baseline is out of date and must be pruned: ${listed}`);
|
|
157
|
+
}
|
|
158
|
+
return parts.join("; ");
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Reads a `baseline="..."` attribute into entries.
|
|
162
|
+
*
|
|
163
|
+
* Each item is a path, optionally `path:count`; a bare path means one match.
|
|
164
|
+
* The separator is a colon because a path here is always relative and
|
|
165
|
+
* POSIX-shaped, so it cannot contain one - the same reasoning that lets
|
|
166
|
+
* `target` split on whitespace.
|
|
167
|
+
*/
|
|
168
|
+
function parseBaseline(value, root) {
|
|
169
|
+
const entries = [];
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
for (const item of splitList(value)) {
|
|
172
|
+
const separator = item.lastIndexOf(":");
|
|
173
|
+
const rawPath = separator === -1 ? item : item.slice(0, separator);
|
|
174
|
+
const rawCount = separator === -1 ? "1" : item.slice(separator + 1);
|
|
175
|
+
const declared = parseCount(rawCount, "baseline");
|
|
176
|
+
if (declared === 0) {
|
|
177
|
+
throw new Error(`Baseline entry "${item}" declares 0 matches. Remove the entry instead; a baseline lists violations that exist.`);
|
|
178
|
+
}
|
|
179
|
+
const normalized = normalizeTarget(rawPath, root, "baseline");
|
|
180
|
+
if (seen.has(normalized)) {
|
|
181
|
+
throw new Error(`Baseline lists "${normalized}" twice.`);
|
|
182
|
+
}
|
|
183
|
+
seen.add(normalized);
|
|
184
|
+
entries.push({ path: normalized, declared });
|
|
185
|
+
}
|
|
186
|
+
return entries;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Subtracts the declared debt from what was found.
|
|
190
|
+
*
|
|
191
|
+
* Two questions, and the ratchet needs both. Matches inside a baselined file
|
|
192
|
+
* stop counting *up to the declared number* - so a file allowed two violations
|
|
193
|
+
* that grows a third reports one, not three, and the failure points at the
|
|
194
|
+
* change rather than at the history. And a file that no longer has as many as
|
|
195
|
+
* it declares is a stale entry: the debt was paid and the ledger still claims
|
|
196
|
+
* it, which is a spec asserting something untrue about the code.
|
|
197
|
+
*/
|
|
198
|
+
export function applyBaseline(entries, fileCounts) {
|
|
199
|
+
let excluded = 0;
|
|
200
|
+
const stale = [];
|
|
201
|
+
const declaredBy = new Map(entries.map((entry) => [entry.path, entry.declared]));
|
|
202
|
+
for (const entry of entries) {
|
|
203
|
+
const found = fileCounts.get(entry.path) ?? 0;
|
|
204
|
+
excluded += Math.min(found, entry.declared);
|
|
205
|
+
if (found < entry.declared)
|
|
206
|
+
stale.push({ path: entry.path, declared: entry.declared, found });
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
excluded,
|
|
210
|
+
stale,
|
|
211
|
+
// Which files a failure should show. A file inside its allowance is not
|
|
212
|
+
// what went wrong, and printing it points the reader at the history
|
|
213
|
+
// instead of at the change - the first version of this did exactly that,
|
|
214
|
+
// and reported a new violation by quoting a twelve-year-old one. A file
|
|
215
|
+
// *over* its allowance still shows, because the excess is a real
|
|
216
|
+
// violation living in it.
|
|
217
|
+
shows: (file) => (fileCounts.get(file) ?? 0) > (declaredBy.get(file) ?? 0),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Per-run cache for "does this scope contain anything at all".
|
|
222
|
+
*
|
|
223
|
+
* The same shape as the import index, and for the same reason: a document with
|
|
224
|
+
* twenty assertions over `src/` asks this question twenty times and it has one
|
|
225
|
+
* answer. Without the cache the probe measured about 0.9 ms per assertion,
|
|
226
|
+
* which is small until a spec has two hundred of them.
|
|
227
|
+
*/
|
|
228
|
+
export function createScopeProbe() {
|
|
229
|
+
const cache = new Map();
|
|
230
|
+
return (request) => {
|
|
231
|
+
const key = JSON.stringify([
|
|
232
|
+
request.root,
|
|
233
|
+
request.targets,
|
|
234
|
+
request.options.globs,
|
|
235
|
+
request.options.excludeGlobs,
|
|
236
|
+
]);
|
|
237
|
+
let pending = cache.get(key);
|
|
238
|
+
if (!pending) {
|
|
239
|
+
// The walk abandons at the first file it finds, so proving a scope is
|
|
240
|
+
// populated costs a couple of directory reads rather than a traversal.
|
|
241
|
+
pending = enumerateCandidates(request, ANY_FILE_PROBE).then((enumeration) => enumeration.files.length > 0);
|
|
242
|
+
cache.set(key, pending);
|
|
243
|
+
}
|
|
244
|
+
return pending;
|
|
245
|
+
};
|
|
105
246
|
}
|
|
106
247
|
function satisfies(count, bounds) {
|
|
107
248
|
if (bounds.min !== undefined && count < bounds.min)
|
|
@@ -117,9 +258,9 @@ export function resolveDirective(directive, context) {
|
|
|
117
258
|
error: { location, raw: directive.raw, message },
|
|
118
259
|
});
|
|
119
260
|
try {
|
|
120
|
-
const reason = attributes[
|
|
121
|
-
if (kind ===
|
|
122
|
-
const files = splitList(attributes[
|
|
261
|
+
const reason = attributes["reason"];
|
|
262
|
+
if (kind === "assert-present") {
|
|
263
|
+
const files = splitList(attributes["file"]).map((file) => normalizeTarget(file, context.root, "file"));
|
|
123
264
|
if (files.length === 0) {
|
|
124
265
|
return fail('@assert-present requires a file="..." attribute.');
|
|
125
266
|
}
|
|
@@ -127,65 +268,92 @@ export function resolveDirective(directive, context) {
|
|
|
127
268
|
assertion: {
|
|
128
269
|
kind,
|
|
129
270
|
location,
|
|
130
|
-
description: `${files.join(
|
|
271
|
+
description: `${files.join(", ")} must exist`,
|
|
131
272
|
reason,
|
|
132
273
|
targets: [],
|
|
133
274
|
files,
|
|
134
275
|
bounds: { min: files.length, max: files.length },
|
|
135
276
|
missingTargets: [],
|
|
277
|
+
// A file list is never empty here: resolution rejects that above.
|
|
278
|
+
allowEmpty: true,
|
|
279
|
+
baseline: [],
|
|
280
|
+
ratchet: "two-sided",
|
|
136
281
|
},
|
|
137
282
|
};
|
|
138
283
|
}
|
|
139
|
-
const isImportKind = kind ===
|
|
140
|
-
const subject = isImportKind ?
|
|
284
|
+
const isImportKind = kind === "assert-import-absence" || kind === "assert-import-count";
|
|
285
|
+
const subject = isImportKind ? "module" : "symbol";
|
|
141
286
|
const symbol = attributes[subject];
|
|
142
287
|
if (symbol === undefined || symbol.length === 0) {
|
|
143
288
|
return fail(`@${kind} requires a non-empty ${subject}="..." attribute.`);
|
|
144
289
|
}
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
290
|
+
const allowEmpty = parseBoolean(attributes["allow-empty"], "allow-empty");
|
|
291
|
+
// `baseline` and `ratchet` reach here only on the absence kinds: the parser
|
|
292
|
+
// rejects them elsewhere, because "these known files may violate" means
|
|
293
|
+
// nothing for an assertion that is not forbidding anything.
|
|
294
|
+
const baseline = parseBaseline(attributes["baseline"], context.root);
|
|
295
|
+
const rawRatchet = (attributes["ratchet"] ?? "two-sided").trim().toLowerCase();
|
|
296
|
+
if (rawRatchet !== "two-sided" && rawRatchet !== "one-way") {
|
|
297
|
+
return fail(`Attribute "ratchet" must be two-sided or one-way, got "${attributes["ratchet"]}".`);
|
|
148
298
|
}
|
|
149
|
-
|
|
150
|
-
|
|
299
|
+
if (baseline.length === 0 && attributes["ratchet"] !== undefined) {
|
|
300
|
+
return fail(`Attribute "ratchet" needs a baseline="..." to ratchet.`);
|
|
301
|
+
}
|
|
302
|
+
const ratchet = rawRatchet;
|
|
303
|
+
const comments = (attributes["comments"] ?? "ignore").trim().toLowerCase();
|
|
304
|
+
if (comments !== "ignore" && comments !== "include") {
|
|
305
|
+
return fail(`Attribute "comments" must be ignore or include, got "${attributes["comments"]}".`);
|
|
306
|
+
}
|
|
307
|
+
const rawTargets = splitList(attributes["target"]);
|
|
308
|
+
const targets = (rawTargets.length > 0 ? rawTargets : ["."]).map((target) => normalizeTarget(target, context.root, "target"));
|
|
151
309
|
const bounds = {};
|
|
152
|
-
if (kind ===
|
|
153
|
-
if (attributes[
|
|
310
|
+
if (kind === "assert-absence" || kind === "assert-import-absence") {
|
|
311
|
+
if (attributes["expected"] !== undefined &&
|
|
312
|
+
attributes["max"] !== undefined) {
|
|
154
313
|
return fail(`@${kind} accepts either expected="..." or max="...", not both.`);
|
|
155
314
|
}
|
|
156
|
-
const limit = attributes[
|
|
157
|
-
bounds.max =
|
|
315
|
+
const limit = attributes["expected"] ?? attributes["max"];
|
|
316
|
+
bounds.max =
|
|
317
|
+
limit === undefined
|
|
318
|
+
? 0
|
|
319
|
+
: parseCount(limit, attributes["expected"] !== undefined ? "expected" : "max");
|
|
158
320
|
}
|
|
159
321
|
else {
|
|
160
|
-
const expected = attributes[
|
|
322
|
+
const expected = attributes["expected"];
|
|
161
323
|
if (expected !== undefined) {
|
|
162
|
-
if (attributes[
|
|
324
|
+
if (attributes["min"] !== undefined ||
|
|
325
|
+
attributes["max"] !== undefined) {
|
|
163
326
|
return fail(`@${kind} accepts either expected="..." or min/max, not both.`);
|
|
164
327
|
}
|
|
165
|
-
const value = parseCount(expected,
|
|
328
|
+
const value = parseCount(expected, "expected");
|
|
166
329
|
bounds.min = value;
|
|
167
330
|
bounds.max = value;
|
|
168
331
|
}
|
|
169
332
|
else {
|
|
170
|
-
if (attributes[
|
|
333
|
+
if (attributes["min"] === undefined &&
|
|
334
|
+
attributes["max"] === undefined) {
|
|
171
335
|
return fail(`@${kind} requires expected="...", min="..." or max="...".`);
|
|
172
336
|
}
|
|
173
|
-
if (attributes[
|
|
174
|
-
bounds.min = parseCount(attributes[
|
|
175
|
-
if (attributes[
|
|
176
|
-
bounds.max = parseCount(attributes[
|
|
177
|
-
if (bounds.min !== undefined &&
|
|
337
|
+
if (attributes["min"] !== undefined)
|
|
338
|
+
bounds.min = parseCount(attributes["min"], "min");
|
|
339
|
+
if (attributes["max"] !== undefined)
|
|
340
|
+
bounds.max = parseCount(attributes["max"], "max");
|
|
341
|
+
if (bounds.min !== undefined &&
|
|
342
|
+
bounds.max !== undefined &&
|
|
343
|
+
bounds.min > bounds.max) {
|
|
178
344
|
return fail(`min="${bounds.min}" is greater than max="${bounds.max}".`);
|
|
179
345
|
}
|
|
180
346
|
}
|
|
181
347
|
}
|
|
182
348
|
if (isImportKind) {
|
|
183
|
-
const scope = targets.join(
|
|
184
|
-
const excludeGlobs = splitList(attributes[
|
|
185
|
-
const except = excludeGlobs.length > 0
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
349
|
+
const scope = targets.join(", ");
|
|
350
|
+
const excludeGlobs = splitList(attributes["exclude"]);
|
|
351
|
+
const except = excludeGlobs.length > 0
|
|
352
|
+
? ` (excluding ${excludeGlobs.join(", ")})`
|
|
353
|
+
: "";
|
|
354
|
+
const includeTypes = (attributes["types"] ?? "include").trim().toLowerCase() !== "ignore";
|
|
355
|
+
if (!["include", "ignore"].includes((attributes["types"] ?? "include").trim().toLowerCase())) {
|
|
356
|
+
return fail(`Attribute "types" must be include or ignore, got "${attributes["types"]}".`);
|
|
189
357
|
}
|
|
190
358
|
return {
|
|
191
359
|
assertion: {
|
|
@@ -207,20 +375,25 @@ export function resolveDirective(directive, context) {
|
|
|
207
375
|
// text search. It is set true because it is true: the tokenizer
|
|
208
376
|
// reads imports, so a module named in a comment was never a match.
|
|
209
377
|
ignoreComments: true,
|
|
378
|
+
scope: context.scope ?? DEFAULT_SCOPE,
|
|
210
379
|
excludeFiles: context.excludeFiles,
|
|
211
380
|
},
|
|
212
381
|
imports: { modules: splitList(symbol), includeTypes },
|
|
213
382
|
missingTargets: [],
|
|
383
|
+
allowEmpty,
|
|
384
|
+
baseline,
|
|
385
|
+
ratchet,
|
|
214
386
|
},
|
|
215
387
|
};
|
|
216
388
|
}
|
|
217
389
|
const search = {
|
|
218
|
-
regex: parseBoolean(attributes[
|
|
219
|
-
word: parseBoolean(attributes[
|
|
220
|
-
ignoreCase: parseBoolean(attributes[
|
|
221
|
-
globs: splitList(attributes[
|
|
222
|
-
excludeGlobs: splitList(attributes[
|
|
223
|
-
ignoreComments: comments !==
|
|
390
|
+
regex: parseBoolean(attributes["regex"], "regex"),
|
|
391
|
+
word: parseBoolean(attributes["word"], "word"),
|
|
392
|
+
ignoreCase: parseBoolean(attributes["ignore-case"], "ignore-case"),
|
|
393
|
+
globs: splitList(attributes["glob"]),
|
|
394
|
+
excludeGlobs: splitList(attributes["exclude"]),
|
|
395
|
+
ignoreComments: comments !== "include",
|
|
396
|
+
scope: context.scope ?? DEFAULT_SCOPE,
|
|
224
397
|
excludeFiles: context.excludeFiles,
|
|
225
398
|
};
|
|
226
399
|
if (search.regex) {
|
|
@@ -231,8 +404,10 @@ export function resolveDirective(directive, context) {
|
|
|
231
404
|
return fail(`Invalid regular expression: ${error instanceof Error ? error.message : String(error)}`);
|
|
232
405
|
}
|
|
233
406
|
}
|
|
234
|
-
const scope = targets.join(
|
|
235
|
-
const except = search.excludeGlobs.length > 0
|
|
407
|
+
const scope = targets.join(", ");
|
|
408
|
+
const except = search.excludeGlobs.length > 0
|
|
409
|
+
? ` (excluding ${search.excludeGlobs.join(", ")})`
|
|
410
|
+
: "";
|
|
236
411
|
return {
|
|
237
412
|
assertion: {
|
|
238
413
|
kind,
|
|
@@ -245,6 +420,9 @@ export function resolveDirective(directive, context) {
|
|
|
245
420
|
bounds,
|
|
246
421
|
search,
|
|
247
422
|
missingTargets: [],
|
|
423
|
+
allowEmpty,
|
|
424
|
+
baseline,
|
|
425
|
+
ratchet,
|
|
248
426
|
},
|
|
249
427
|
};
|
|
250
428
|
}
|
|
@@ -274,8 +452,12 @@ async function prepareAssertion(assertion, options) {
|
|
|
274
452
|
warnings,
|
|
275
453
|
commentMatches: 0,
|
|
276
454
|
unclassifiedFiles: 0,
|
|
455
|
+
scope: EMPTY_LEDGER,
|
|
456
|
+
baselinedMatches: 0,
|
|
457
|
+
staleBaseline: [],
|
|
458
|
+
fileMatches: [],
|
|
277
459
|
};
|
|
278
|
-
if (assertion.kind ===
|
|
460
|
+
if (assertion.kind === "assert-present") {
|
|
279
461
|
const missing = [];
|
|
280
462
|
for (const file of assertion.files) {
|
|
281
463
|
if (!(await pathExists(path.resolve(options.root, file))))
|
|
@@ -287,8 +469,8 @@ async function prepareAssertion(assertion, options) {
|
|
|
287
469
|
ok: missing.length === 0,
|
|
288
470
|
actual,
|
|
289
471
|
message: missing.length === 0
|
|
290
|
-
? `all ${assertion.files.length} referenced ${assertion.files.length === 1 ?
|
|
291
|
-
: `missing: ${missing.join(
|
|
472
|
+
? `all ${assertion.files.length} referenced ${assertion.files.length === 1 ? "path exists" : "paths exist"}`
|
|
473
|
+
: `missing: ${missing.join(", ")}`,
|
|
292
474
|
matches: [],
|
|
293
475
|
durationMs: performance.now() - startedAt,
|
|
294
476
|
};
|
|
@@ -304,40 +486,82 @@ async function prepareAssertion(assertion, options) {
|
|
|
304
486
|
assertion.missingTargets.push(target);
|
|
305
487
|
}
|
|
306
488
|
if (assertion.missingTargets.length > 0) {
|
|
307
|
-
warnings.push(`target path${assertion.missingTargets.length === 1 ?
|
|
489
|
+
warnings.push(`target path${assertion.missingTargets.length === 1 ? "" : "s"} not found: ${assertion.missingTargets.join(", ")}`);
|
|
308
490
|
}
|
|
309
491
|
if (!options.allowMissingTargets && assertion.missingTargets.length > 0) {
|
|
310
492
|
return {
|
|
311
493
|
...base,
|
|
312
494
|
ok: false,
|
|
313
495
|
actual: 0,
|
|
314
|
-
message: `target path${assertion.missingTargets.length === 1 ?
|
|
496
|
+
message: `target path${assertion.missingTargets.length === 1 ? " does" : "s do"} not exist: ${assertion.missingTargets.join(", ")}`,
|
|
315
497
|
matches: [],
|
|
316
498
|
durationMs: performance.now() - startedAt,
|
|
317
499
|
};
|
|
318
500
|
}
|
|
501
|
+
const request = {
|
|
502
|
+
root: options.root,
|
|
503
|
+
symbol: assertion.symbol,
|
|
504
|
+
targets: existingTargets,
|
|
505
|
+
options: assertion.search,
|
|
506
|
+
};
|
|
507
|
+
if (!options.allowEmptyScope && !assertion.allowEmpty && !(await options.hasFiles(request))) {
|
|
508
|
+
{
|
|
509
|
+
return {
|
|
510
|
+
...base,
|
|
511
|
+
ok: false,
|
|
512
|
+
actual: 0,
|
|
513
|
+
message: `no files were inspected, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`,
|
|
514
|
+
matches: [],
|
|
515
|
+
durationMs: performance.now() - startedAt,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
}
|
|
319
519
|
return {
|
|
320
|
-
request
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
520
|
+
request,
|
|
521
|
+
finish: (search) => {
|
|
522
|
+
// A file that could not be read, or whose bytes are not text but did
|
|
523
|
+
// contain the symbol, is a hole in the answer rather than a detail of it.
|
|
524
|
+
// The count is still reported, because it is still true of everything
|
|
525
|
+
// that was read; --strict is for runs where "true of what we read" is not
|
|
526
|
+
// good enough.
|
|
527
|
+
const gaps = search.scope.skipped.filter((entry) => UNCERTAIN_REASONS.has(entry.reason));
|
|
528
|
+
const strictFailure = options.strictTargets && gaps.length > 0;
|
|
529
|
+
const { excluded, stale, shows } = applyBaseline(assertion.baseline, search.fileCounts);
|
|
530
|
+
const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
|
|
531
|
+
const actual = search.count - excluded;
|
|
532
|
+
return {
|
|
533
|
+
...base,
|
|
534
|
+
ok: satisfies(actual, assertion.bounds) && !strictFailure && !staleFailure,
|
|
535
|
+
actual,
|
|
536
|
+
message: describeOutcome(assertion.bounds, actual, {
|
|
537
|
+
excluded,
|
|
538
|
+
stale: staleFailure ? stale : [],
|
|
539
|
+
gaps: strictFailure ? gaps.length : 0,
|
|
540
|
+
}),
|
|
541
|
+
baselinedMatches: excluded,
|
|
542
|
+
staleBaseline: stale,
|
|
543
|
+
fileMatches: [...search.fileCounts].map(([file, count]) => ({ file, count })),
|
|
544
|
+
matches: search.matches.filter((match) => shows(match.file)).slice(0, options.maxSnippets),
|
|
545
|
+
// Carried as numbers, not prose, so the reporter can total them across a
|
|
546
|
+
// run and JSON consumers can act on them.
|
|
547
|
+
commentMatches: search.commentMatches,
|
|
548
|
+
unclassifiedFiles: search.unclassifiedFiles,
|
|
549
|
+
scope: search.scope,
|
|
550
|
+
engine: search.engine,
|
|
551
|
+
durationMs: performance.now() - startedAt,
|
|
552
|
+
};
|
|
325
553
|
},
|
|
326
|
-
finish: (search) => ({
|
|
327
|
-
...base,
|
|
328
|
-
ok: satisfies(search.count, assertion.bounds),
|
|
329
|
-
actual: search.count,
|
|
330
|
-
message: `expected ${describeBounds(assertion.bounds)}, found ${search.count}`,
|
|
331
|
-
matches: search.matches.slice(0, options.maxSnippets),
|
|
332
|
-
// Carried as numbers, not prose, so the reporter can total them across a
|
|
333
|
-
// run and JSON consumers can act on them.
|
|
334
|
-
commentMatches: search.commentMatches,
|
|
335
|
-
unclassifiedFiles: search.unclassifiedFiles,
|
|
336
|
-
engine: search.engine,
|
|
337
|
-
durationMs: performance.now() - startedAt,
|
|
338
|
-
}),
|
|
339
554
|
};
|
|
340
555
|
}
|
|
556
|
+
/** How each kind of reference is spelled in a report snippet. */
|
|
557
|
+
const IMPORT_VERBS = {
|
|
558
|
+
import: "import",
|
|
559
|
+
export: "export",
|
|
560
|
+
require: "require",
|
|
561
|
+
"dynamic-import": "import",
|
|
562
|
+
use: "use",
|
|
563
|
+
using: "using",
|
|
564
|
+
};
|
|
341
565
|
/**
|
|
342
566
|
* Counts the files in scope that depend on the requested module.
|
|
343
567
|
*
|
|
@@ -355,18 +579,33 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
|
|
|
355
579
|
assertion.missingTargets.push(target);
|
|
356
580
|
}
|
|
357
581
|
if (assertion.missingTargets.length > 0) {
|
|
358
|
-
warnings.push(`target path${assertion.missingTargets.length === 1 ?
|
|
582
|
+
warnings.push(`target path${assertion.missingTargets.length === 1 ? "" : "s"} not found: ${assertion.missingTargets.join(", ")}`);
|
|
359
583
|
}
|
|
360
584
|
const enumeration = await enumerateCandidates({
|
|
361
585
|
root: options.root,
|
|
362
|
-
symbol:
|
|
586
|
+
symbol: "",
|
|
363
587
|
targets: existingTargets,
|
|
364
588
|
options: assertion.search,
|
|
365
589
|
});
|
|
366
590
|
const analysable = enumeration.files.filter((file) => ANALYSABLE_EXTENSIONS.has(path.posix.extname(file.relativePath)));
|
|
591
|
+
if (analysable.length === 0 && !options.allowEmptyScope && !assertion.allowEmpty) {
|
|
592
|
+
// For an import rule the bar is higher than "some file exists": a directory
|
|
593
|
+
// of YAML has nothing this can read, so a dependency claim about it is a
|
|
594
|
+
// claim about nothing.
|
|
595
|
+
return {
|
|
596
|
+
...base,
|
|
597
|
+
ok: false,
|
|
598
|
+
actual: 0,
|
|
599
|
+
message: enumeration.files.length === 0
|
|
600
|
+
? `no files were inspected, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`
|
|
601
|
+
: `none of the ${enumeration.files.length} files here are in a language whose imports spec-guard can read, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`,
|
|
602
|
+
matches: [],
|
|
603
|
+
durationMs: performance.now() - startedAt,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
367
606
|
const skipped = enumeration.files.length - analysable.length;
|
|
368
607
|
if (skipped > 0) {
|
|
369
|
-
warnings.push(`analysed ${analysable.length} of ${enumeration.files.length} files; ${skipped} are
|
|
608
|
+
warnings.push(`analysed ${analysable.length} of ${enumeration.files.length} files; ${skipped} ${skipped === 1 ? "is" : "are"} in a language whose imports spec-guard cannot read`);
|
|
370
609
|
}
|
|
371
610
|
const matchesModule = createExcludeMatcher(query.modules);
|
|
372
611
|
const matches = [];
|
|
@@ -379,41 +618,58 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
|
|
|
379
618
|
const hit = analysis.references.find((reference) => {
|
|
380
619
|
if (reference.typeOnly && !query.includeTypes)
|
|
381
620
|
return false;
|
|
382
|
-
|
|
621
|
+
// Both forms are tried: the resolved one so `module="app/db/**"` works
|
|
622
|
+
// everywhere, and the raw one so a Python or C# author can write the
|
|
623
|
+
// dotted path they see in their own source and still be understood.
|
|
624
|
+
return (matchesModule(resolveModule(reference.specifier, file.relativePath)) ||
|
|
625
|
+
matchesModule(reference.specifier));
|
|
383
626
|
});
|
|
384
627
|
if (hit) {
|
|
385
628
|
matches.push({
|
|
386
629
|
file: file.relativePath,
|
|
387
630
|
line: hit.line,
|
|
388
631
|
column: hit.column,
|
|
389
|
-
text: `${hit.kind
|
|
632
|
+
text: `${IMPORT_VERBS[hit.kind]} ${hit.specifier}`,
|
|
390
633
|
count: 1,
|
|
391
634
|
});
|
|
392
635
|
}
|
|
393
636
|
}
|
|
394
637
|
if (unresolved.length > 0) {
|
|
395
|
-
warnings.push(`${unresolved.length} module reference${unresolved.length === 1 ?
|
|
638
|
+
warnings.push(`${unresolved.length} module reference${unresolved.length === 1 ? "" : "s"} could not be resolved statically`, ...unresolved.slice(0, options.maxSnippets).map((entry) => ` ${entry}`));
|
|
396
639
|
}
|
|
397
640
|
const missingFailure = !options.allowMissingTargets && assertion.missingTargets.length > 0;
|
|
641
|
+
// One reference per file, so the file counts are the matches themselves.
|
|
642
|
+
const fileCounts = new Map(matches.map((match) => [match.file, 1]));
|
|
643
|
+
const { excluded, stale, shows } = applyBaseline(assertion.baseline, fileCounts);
|
|
644
|
+
const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
|
|
645
|
+
const actual = matches.length - excluded;
|
|
398
646
|
const strictFailure = missingFailure || (options.strictTargets && unresolved.length > 0);
|
|
399
|
-
const ok = satisfies(
|
|
647
|
+
const ok = satisfies(actual, assertion.bounds) && !strictFailure && !staleFailure;
|
|
400
648
|
return {
|
|
401
649
|
...base,
|
|
402
650
|
ok,
|
|
403
|
-
actual
|
|
651
|
+
actual,
|
|
404
652
|
message: missingFailure
|
|
405
|
-
? `target path${assertion.missingTargets.length === 1 ?
|
|
406
|
-
:
|
|
407
|
-
|
|
408
|
-
:
|
|
409
|
-
|
|
653
|
+
? `target path${assertion.missingTargets.length === 1 ? " does" : "s do"} not exist: ${assertion.missingTargets.join(", ")}`
|
|
654
|
+
: describeOutcome(assertion.bounds, actual, {
|
|
655
|
+
excluded,
|
|
656
|
+
stale: staleFailure ? stale : [],
|
|
657
|
+
gaps: 0,
|
|
658
|
+
}) +
|
|
659
|
+
(options.strictTargets && unresolved.length > 0
|
|
660
|
+
? `; ${unresolved.length} reference${unresolved.length === 1 ? "" : "s"} could not be resolved`
|
|
661
|
+
: ""),
|
|
662
|
+
baselinedMatches: excluded,
|
|
663
|
+
staleBaseline: stale,
|
|
664
|
+
fileMatches: [...fileCounts].map(([file, count]) => ({ file, count })),
|
|
665
|
+
matches: matches.filter((match) => shows(match.file)).slice(0, options.maxSnippets),
|
|
410
666
|
durationMs: performance.now() - startedAt,
|
|
411
667
|
};
|
|
412
668
|
}
|
|
413
669
|
/** Executes a single resolved assertion. */
|
|
414
670
|
export async function executeAssertion(assertion, options) {
|
|
415
671
|
const prepared = await prepareAssertion(assertion, options);
|
|
416
|
-
if (!(
|
|
672
|
+
if (!("request" in prepared))
|
|
417
673
|
return prepared;
|
|
418
674
|
return prepared.finish(await options.engine.search(prepared.request));
|
|
419
675
|
}
|
|
@@ -424,7 +680,13 @@ export async function executeAssertion(assertion, options) {
|
|
|
424
680
|
*/
|
|
425
681
|
function groupKey(request) {
|
|
426
682
|
const { options } = request;
|
|
427
|
-
return JSON.stringify([
|
|
683
|
+
return JSON.stringify([
|
|
684
|
+
request.targets,
|
|
685
|
+
options.regex,
|
|
686
|
+
options.word,
|
|
687
|
+
options.ignoreCase,
|
|
688
|
+
options.globs,
|
|
689
|
+
]);
|
|
428
690
|
}
|
|
429
691
|
/** Reads, parses and executes every directive found in the given spec files. */
|
|
430
692
|
export async function runSpecGuard(options) {
|
|
@@ -433,16 +695,18 @@ export async function runSpecGuard(options) {
|
|
|
433
695
|
const maxSnippets = options.maxSnippets ?? DEFAULT_MAX_SNIPPETS;
|
|
434
696
|
const strictTargets = options.strictTargets ?? false;
|
|
435
697
|
const allowMissingTargets = options.allowMissingTargets ?? false;
|
|
698
|
+
const allowEmptyScope = options.allowEmptyScope ?? false;
|
|
699
|
+
const scope = createScope(options.defaultSkips ?? true);
|
|
436
700
|
const specFiles = await expandSpecPatterns(options.patterns, root);
|
|
437
701
|
const excludeFiles = new Set(options.includeSpecs ? [] : specFiles.map((file) => path.resolve(file)));
|
|
438
702
|
const directives = [];
|
|
439
703
|
const errors = [];
|
|
440
704
|
for (const file of specFiles) {
|
|
441
705
|
const relativeFile = toPosix(path.relative(root, file)) || toPosix(file);
|
|
442
|
-
const source = await fs.readFile(file,
|
|
706
|
+
const source = await fs.readFile(file, "utf8").catch((error) => {
|
|
443
707
|
errors.push({
|
|
444
708
|
location: { file, relativeFile, line: 1, column: 1 },
|
|
445
|
-
raw:
|
|
709
|
+
raw: "",
|
|
446
710
|
message: `Unable to read spec file: ${error instanceof Error ? error.message : String(error)}`,
|
|
447
711
|
});
|
|
448
712
|
return null;
|
|
@@ -455,20 +719,22 @@ export async function runSpecGuard(options) {
|
|
|
455
719
|
}
|
|
456
720
|
const assertions = [];
|
|
457
721
|
for (const directive of directives) {
|
|
458
|
-
const resolved = resolveDirective(directive, { root, excludeFiles });
|
|
459
|
-
if (
|
|
722
|
+
const resolved = resolveDirective(directive, { root, excludeFiles, scope });
|
|
723
|
+
if ("error" in resolved)
|
|
460
724
|
errors.push(resolved.error);
|
|
461
725
|
else
|
|
462
726
|
assertions.push(resolved.assertion);
|
|
463
727
|
}
|
|
464
|
-
const engine = createCachedEngine(await resolveEngine(options.engine ??
|
|
728
|
+
const engine = createCachedEngine(await resolveEngine(options.engine ?? "auto"));
|
|
465
729
|
const executeOptions = {
|
|
466
730
|
root,
|
|
467
731
|
engine,
|
|
468
732
|
allowMissingTargets,
|
|
469
733
|
strictTargets,
|
|
734
|
+
allowEmptyScope,
|
|
470
735
|
maxSnippets,
|
|
471
736
|
imports: createImportIndex(),
|
|
737
|
+
hasFiles: createScopeProbe(),
|
|
472
738
|
};
|
|
473
739
|
const results = new Array(assertions.length);
|
|
474
740
|
if (options.failFast) {
|
|
@@ -486,7 +752,7 @@ export async function runSpecGuard(options) {
|
|
|
486
752
|
const prepared = await Promise.all(assertions.map((assertion) => prepareAssertion(assertion, executeOptions)));
|
|
487
753
|
const groups = new Map();
|
|
488
754
|
prepared.forEach((entry, index) => {
|
|
489
|
-
if (!(
|
|
755
|
+
if (!("request" in entry)) {
|
|
490
756
|
results[index] = entry;
|
|
491
757
|
return;
|
|
492
758
|
}
|
|
@@ -525,7 +791,7 @@ export async function runSpecGuard(options) {
|
|
|
525
791
|
return {
|
|
526
792
|
ok: failed === 0 && errors.length === 0,
|
|
527
793
|
root,
|
|
528
|
-
engine: warnings.length > 0 ?
|
|
794
|
+
engine: warnings.length > 0 ? "javascript" : engine.name,
|
|
529
795
|
durationMs: performance.now() - startedAt,
|
|
530
796
|
summary: {
|
|
531
797
|
specs: specFiles.length,
|