@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +292 -49
  3. package/dist/cli.d.ts +9 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +46 -7
  6. package/dist/cli.js.map +1 -1
  7. package/dist/comments.d.ts +23 -4
  8. package/dist/comments.d.ts.map +1 -1
  9. package/dist/comments.js +40 -14
  10. package/dist/comments.js.map +1 -1
  11. package/dist/engine.d.ts +52 -49
  12. package/dist/engine.d.ts.map +1 -1
  13. package/dist/engine.js +185 -259
  14. package/dist/engine.js.map +1 -1
  15. package/dist/glob.d.ts +19 -6
  16. package/dist/glob.d.ts.map +1 -1
  17. package/dist/glob.js +14 -28
  18. package/dist/glob.js.map +1 -1
  19. package/dist/imports.d.ts +28 -4
  20. package/dist/imports.d.ts.map +1 -1
  21. package/dist/imports.js +36 -4
  22. package/dist/imports.js.map +1 -1
  23. package/dist/index.d.ts +15 -10
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +9 -6
  26. package/dist/index.js.map +1 -1
  27. package/dist/parser.d.ts.map +1 -1
  28. package/dist/parser.js +28 -23
  29. package/dist/parser.js.map +1 -1
  30. package/dist/polyglot.d.ts +129 -0
  31. package/dist/polyglot.d.ts.map +1 -0
  32. package/dist/polyglot.js +544 -0
  33. package/dist/polyglot.js.map +1 -0
  34. package/dist/reporter.d.ts +41 -0
  35. package/dist/reporter.d.ts.map +1 -1
  36. package/dist/reporter.js +187 -4
  37. package/dist/reporter.js.map +1 -1
  38. package/dist/runner.d.ts +47 -3
  39. package/dist/runner.d.ts.map +1 -1
  40. package/dist/runner.js +371 -105
  41. package/dist/runner.js.map +1 -1
  42. package/dist/scope.d.ts +108 -0
  43. package/dist/scope.d.ts.map +1 -0
  44. package/dist/scope.js +115 -0
  45. package/dist/scope.js.map +1 -0
  46. package/dist/text.d.ts +26 -0
  47. package/dist/text.d.ts.map +1 -0
  48. package/dist/text.js +69 -0
  49. package/dist/text.js.map +1 -0
  50. package/dist/types.d.ts +87 -0
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/types.js +0 -6
  53. package/dist/types.js.map +1 -1
  54. 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 'node:fs';
9
- import path from 'node:path';
10
- import { createCachedEngine, enumerateCandidates, resolveEngine, runSearches, } from './engine.js';
11
- import { createExcludeMatcher, expandSpecPatterns, toPosix } from './glob.js';
12
- import { ANALYSABLE_EXTENSIONS, createImportIndex, resolveSpecifier, } from './imports.js';
13
- import { parseDirectives } from './parser.js';
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(['true', '1', 'yes', 'on']);
17
- const FALSE_VALUES = new Set(['false', '0', 'no', 'off']);
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 ? '' : 'es';
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 ? `exactly ${min} match${plural(min)}` : `between ${min} and ${max} matches`;
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 ? 'no matches' : `at most ${max} match${plural(max)}`;
77
+ return max === 0 ? "no matches" : `at most ${max} match${plural(max)}`;
75
78
  /* c8 ignore next */
76
- return 'any number of matches';
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 ? '' : 's'}`;
84
+ const times = (value) => `${value} time${value === 1 ? "" : "s"}`;
82
85
  if (min !== undefined && max !== undefined) {
83
- return min === max ? `must appear exactly ${times(min)}` : `must appear between ${min} and ${max} times`;
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 'must not appear';
93
+ return "must not appear";
89
94
  /* c8 ignore next */
90
- return max === undefined ? 'may appear any number of times' : `must appear at most ${times(max)}`;
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 ? '' : 's'}`;
102
+ const files = (value) => `${value} file${value === 1 ? "" : "s"}`;
96
103
  if (max === 0 && min === undefined)
97
- return 'must not import';
104
+ return "must not import";
98
105
  if (min !== undefined && max !== undefined) {
99
- return min === max ? `must import from exactly ${files(min)}` : `must import from between ${min} and ${max} files`;
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 ? 'may import' : `must import from at most ${files(max)}`;
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['reason'];
121
- if (kind === 'assert-present') {
122
- const files = splitList(attributes['file']).map((file) => normalizeTarget(file, context.root, 'file'));
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(', ')} must exist`,
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 === 'assert-import-absence' || kind === 'assert-import-count';
140
- const subject = isImportKind ? 'module' : 'symbol';
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 comments = (attributes['comments'] ?? 'ignore').trim().toLowerCase();
146
- if (comments !== 'ignore' && comments !== 'include') {
147
- return fail(`Attribute "comments" must be ignore or include, got "${attributes['comments']}".`);
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
- const rawTargets = splitList(attributes['target']);
150
- const targets = (rawTargets.length > 0 ? rawTargets : ['.']).map((target) => normalizeTarget(target, context.root, 'target'));
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 === 'assert-absence' || kind === 'assert-import-absence') {
153
- if (attributes['expected'] !== undefined && attributes['max'] !== undefined) {
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['expected'] ?? attributes['max'];
157
- bounds.max = limit === undefined ? 0 : parseCount(limit, attributes['expected'] !== undefined ? 'expected' : '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['expected'];
322
+ const expected = attributes["expected"];
161
323
  if (expected !== undefined) {
162
- if (attributes['min'] !== undefined || attributes['max'] !== undefined) {
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, '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['min'] === undefined && attributes['max'] === undefined) {
333
+ if (attributes["min"] === undefined &&
334
+ attributes["max"] === undefined) {
171
335
  return fail(`@${kind} requires expected="...", min="..." or max="...".`);
172
336
  }
173
- if (attributes['min'] !== undefined)
174
- bounds.min = parseCount(attributes['min'], 'min');
175
- if (attributes['max'] !== undefined)
176
- bounds.max = parseCount(attributes['max'], 'max');
177
- if (bounds.min !== undefined && bounds.max !== undefined && bounds.min > bounds.max) {
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['exclude']);
185
- const except = excludeGlobs.length > 0 ? ` (excluding ${excludeGlobs.join(', ')})` : '';
186
- const includeTypes = (attributes['types'] ?? 'include').trim().toLowerCase() !== 'ignore';
187
- if (!['include', 'ignore'].includes((attributes['types'] ?? 'include').trim().toLowerCase())) {
188
- return fail(`Attribute "types" must be include or ignore, got "${attributes['types']}".`);
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['regex'], 'regex'),
219
- word: parseBoolean(attributes['word'], 'word'),
220
- ignoreCase: parseBoolean(attributes['ignore-case'], 'ignore-case'),
221
- globs: splitList(attributes['glob']),
222
- excludeGlobs: splitList(attributes['exclude']),
223
- ignoreComments: comments !== 'include',
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 ? ` (excluding ${search.excludeGlobs.join(', ')})` : '';
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 === 'assert-present') {
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 ? 'path exists' : 'paths exist'}`
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 ? '' : 's'} not found: ${assertion.missingTargets.join(', ')}`);
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 ? ' does' : 's do'} not exist: ${assertion.missingTargets.join(', ')}`,
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
- root: options.root,
322
- symbol: assertion.symbol,
323
- targets: existingTargets,
324
- options: assertion.search,
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 ? '' : 's'} not found: ${assertion.missingTargets.join(', ')}`);
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 not JavaScript or TypeScript`);
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
- return matchesModule(resolveSpecifier(reference.specifier, file.relativePath));
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 === 'export' ? 'export' : 'import'} ${hit.specifier}`,
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 ? '' : 's'} could not be resolved statically`, ...unresolved.slice(0, options.maxSnippets).map((entry) => ` ${entry}`));
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(matches.length, assertion.bounds) && !strictFailure;
647
+ const ok = satisfies(actual, assertion.bounds) && !strictFailure && !staleFailure;
400
648
  return {
401
649
  ...base,
402
650
  ok,
403
- actual: matches.length,
651
+ actual,
404
652
  message: missingFailure
405
- ? `target path${assertion.missingTargets.length === 1 ? ' does' : 's do'} not exist: ${assertion.missingTargets.join(', ')}`
406
- : strictFailure
407
- ? `expected ${describeBounds(assertion.bounds)}, found ${matches.length}, and ${unresolved.length} reference(s) could not be resolved`
408
- : `expected ${describeBounds(assertion.bounds)}, found ${matches.length}`,
409
- matches: matches.slice(0, options.maxSnippets),
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 (!('request' in prepared))
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([request.targets, options.regex, options.word, options.ignoreCase, options.globs]);
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, 'utf8').catch((error) => {
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 ('error' in resolved)
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 ?? 'auto'));
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 (!('request' in entry)) {
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 ? 'javascript' : engine.name,
794
+ engine: warnings.length > 0 ? "javascript" : engine.name,
529
795
  durationMs: performance.now() - startedAt,
530
796
  summary: {
531
797
  specs: specFiles.length,