@descent-vtt/spec-guard 0.1.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/LICENSE +21 -0
- package/README.md +393 -0
- package/bin/spec-guard.js +24 -0
- package/dist/cli.d.ts +43 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +243 -0
- package/dist/cli.js.map +1 -0
- package/dist/engine.d.ts +80 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +539 -0
- package/dist/engine.js.map +1 -0
- package/dist/glob.d.ts +60 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +238 -0
- package/dist/glob.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/parser.d.ts +33 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +180 -0
- package/dist/parser.js.map +1 -0
- package/dist/reporter.d.ts +45 -0
- package/dist/reporter.d.ts.map +1 -0
- package/dist/reporter.js +198 -0
- package/dist/reporter.js.map +1 -0
- package/dist/runner.d.ts +54 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +387 -0
- package/dist/runner.js.map +1 -0
- package/dist/types.d.ts +136 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/package.json +72 -0
package/dist/reporter.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal and JSON reporting.
|
|
3
|
+
*
|
|
4
|
+
* The failure block is the whole product: a spec author who broke an invariant
|
|
5
|
+
* should be able to fix it without opening a single file, so every failure
|
|
6
|
+
* carries the spec location, the expectation, the observed count and real
|
|
7
|
+
* snippets from the offending code.
|
|
8
|
+
*/
|
|
9
|
+
// Raw SGR codes - a colour library is not worth a dependency here.
|
|
10
|
+
const ESC = String.fromCharCode(27);
|
|
11
|
+
const ANSI = {
|
|
12
|
+
reset: `${ESC}[0m`,
|
|
13
|
+
bold: `${ESC}[1m`,
|
|
14
|
+
dim: `${ESC}[2m`,
|
|
15
|
+
red: `${ESC}[31m`,
|
|
16
|
+
green: `${ESC}[32m`,
|
|
17
|
+
yellow: `${ESC}[33m`,
|
|
18
|
+
blue: `${ESC}[34m`,
|
|
19
|
+
magenta: `${ESC}[35m`,
|
|
20
|
+
cyan: `${ESC}[36m`,
|
|
21
|
+
gray: `${ESC}[90m`,
|
|
22
|
+
};
|
|
23
|
+
/** Creates a `paint(text, style)` helper honouring the color setting. */
|
|
24
|
+
export function createPainter(color) {
|
|
25
|
+
if (!color)
|
|
26
|
+
return (text) => text;
|
|
27
|
+
return (text, ...styles) => styles.length === 0 ? text : `${styles.map((style) => ANSI[style]).join('')}${text}${ANSI.reset}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Colour is on only when the stream is a TTY and nobody asked otherwise.
|
|
31
|
+
* Honours the NO_COLOR and FORCE_COLOR conventions.
|
|
32
|
+
*/
|
|
33
|
+
export function shouldUseColor(stream, flag, env = process.env) {
|
|
34
|
+
if (flag !== undefined)
|
|
35
|
+
return flag;
|
|
36
|
+
if (env['NO_COLOR'])
|
|
37
|
+
return false;
|
|
38
|
+
if (env['FORCE_COLOR'] && env['FORCE_COLOR'] !== '0')
|
|
39
|
+
return true;
|
|
40
|
+
return Boolean(stream.isTTY);
|
|
41
|
+
}
|
|
42
|
+
/** Legacy Windows consoles render box-drawing glyphs poorly; degrade to ASCII. */
|
|
43
|
+
export function shouldUseAscii(env = process.env, platform = process.platform) {
|
|
44
|
+
if (platform !== 'win32')
|
|
45
|
+
return false;
|
|
46
|
+
return !env['WT_SESSION'] && !env['TERM'] && !env['TERM_PROGRAM'];
|
|
47
|
+
}
|
|
48
|
+
function symbols(ascii) {
|
|
49
|
+
return ascii
|
|
50
|
+
? { pass: '+', fail: 'x', warn: '!', more: '...' }
|
|
51
|
+
: { pass: '✔', fail: '✖', warn: '⚠', more: '…' };
|
|
52
|
+
}
|
|
53
|
+
function formatDuration(ms) {
|
|
54
|
+
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(2)}s`;
|
|
55
|
+
}
|
|
56
|
+
function countLabel(count, noun) {
|
|
57
|
+
if (count === 1)
|
|
58
|
+
return `${count} ${noun}`;
|
|
59
|
+
// "match" -> "matches", "spec" -> "specs".
|
|
60
|
+
return `${count} ${noun}${/(?:s|x|z|ch|sh)$/.test(noun) ? 'es' : 's'}`;
|
|
61
|
+
}
|
|
62
|
+
function formatLocation(result) {
|
|
63
|
+
return `${result.location.relativeFile}:${result.location.line}`;
|
|
64
|
+
}
|
|
65
|
+
function formatFailure(result, paint, glyphs, maxSnippets) {
|
|
66
|
+
const lines = [];
|
|
67
|
+
lines.push(`${paint(glyphs.fail, 'red', 'bold')} ${paint(formatLocation(result), 'bold')} ${paint(`@${result.kind}`, 'magenta')}`);
|
|
68
|
+
lines.push(` ${result.description}`);
|
|
69
|
+
lines.push(` ${paint(result.message, 'red')}`);
|
|
70
|
+
if (result.reason)
|
|
71
|
+
lines.push(` ${paint(`reason: ${result.reason}`, 'dim')}`);
|
|
72
|
+
for (const warning of result.warnings) {
|
|
73
|
+
lines.push(` ${paint(`${glyphs.warn} ${warning}`, 'yellow')}`);
|
|
74
|
+
}
|
|
75
|
+
const shown = result.matches.slice(0, maxSnippets);
|
|
76
|
+
for (const match of shown) {
|
|
77
|
+
const where = paint(`${match.file}:${match.line}:${match.column}`, 'cyan');
|
|
78
|
+
lines.push(` ${where} ${paint(match.text.trim(), 'gray')}`);
|
|
79
|
+
}
|
|
80
|
+
const remaining = result.actual - shown.reduce((total, match) => total + match.count, 0);
|
|
81
|
+
if (remaining > 0) {
|
|
82
|
+
lines.push(` ${paint(`${glyphs.more} ${countLabel(remaining, 'more match')} not shown`, 'dim')}`);
|
|
83
|
+
}
|
|
84
|
+
return lines;
|
|
85
|
+
}
|
|
86
|
+
function formatPass(result, paint, glyphs) {
|
|
87
|
+
const detail = result.kind === 'assert-present'
|
|
88
|
+
? result.files.join(', ')
|
|
89
|
+
: `"${result.symbol}" ${paint(`(${countLabel(result.actual, 'match')})`, 'dim')} in ${result.targets.join(', ')}`;
|
|
90
|
+
return `${paint(glyphs.pass, 'green')} ${paint(formatLocation(result), 'dim')} ${paint(`@${result.kind}`, 'dim')} ${detail}`;
|
|
91
|
+
}
|
|
92
|
+
function formatError(error, paint, glyphs) {
|
|
93
|
+
const lines = [
|
|
94
|
+
`${paint(glyphs.warn, 'yellow', 'bold')} ${paint(formatLocation(error), 'bold')} ${paint('invalid directive', 'yellow')}`,
|
|
95
|
+
` ${error.message}`,
|
|
96
|
+
];
|
|
97
|
+
if (error.raw)
|
|
98
|
+
lines.push(` ${paint(error.raw.split('\n')[0], 'dim')}`);
|
|
99
|
+
return lines;
|
|
100
|
+
}
|
|
101
|
+
/** Renders the full human-readable report. */
|
|
102
|
+
export function formatReport(report, options, maxSnippets = 5) {
|
|
103
|
+
const paint = createPainter(options.color);
|
|
104
|
+
const glyphs = symbols(options.ascii ?? false);
|
|
105
|
+
const lines = [];
|
|
106
|
+
const failures = report.results.filter((result) => !result.ok);
|
|
107
|
+
const passes = report.results.filter((result) => result.ok);
|
|
108
|
+
// The engine label is only meaningful once something was actually searched.
|
|
109
|
+
const searched = report.results.some((result) => result.engine !== undefined);
|
|
110
|
+
const headline = [
|
|
111
|
+
countLabel(report.summary.specs, 'spec'),
|
|
112
|
+
countLabel(report.summary.total, 'assertion'),
|
|
113
|
+
...(searched ? [report.engine] : []),
|
|
114
|
+
].join(' · ');
|
|
115
|
+
lines.push(`${paint('spec-guard', 'bold', 'blue')} ${paint(headline, 'dim')}`);
|
|
116
|
+
lines.push('');
|
|
117
|
+
if (options.verbose) {
|
|
118
|
+
for (const result of passes)
|
|
119
|
+
lines.push(formatPass(result, paint, glyphs));
|
|
120
|
+
if (passes.length > 0)
|
|
121
|
+
lines.push('');
|
|
122
|
+
}
|
|
123
|
+
for (const warning of report.warnings) {
|
|
124
|
+
lines.push(`${paint(glyphs.warn, 'yellow')} ${paint(warning, 'yellow')}`);
|
|
125
|
+
}
|
|
126
|
+
if (report.warnings.length > 0)
|
|
127
|
+
lines.push('');
|
|
128
|
+
if (options.verbose) {
|
|
129
|
+
for (const result of passes) {
|
|
130
|
+
for (const warning of result.warnings) {
|
|
131
|
+
lines.push(`${paint(glyphs.warn, 'yellow')} ${paint(`${formatLocation(result)} ${warning}`, 'yellow')}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const error of report.errors) {
|
|
136
|
+
lines.push(...formatError(error, paint, glyphs));
|
|
137
|
+
lines.push('');
|
|
138
|
+
}
|
|
139
|
+
for (const failure of failures) {
|
|
140
|
+
lines.push(...formatFailure(failure, paint, glyphs, maxSnippets));
|
|
141
|
+
lines.push('');
|
|
142
|
+
}
|
|
143
|
+
const parts = [
|
|
144
|
+
paint(`${report.summary.passed} passed`, 'green'),
|
|
145
|
+
failures.length > 0 ? paint(`${report.summary.failed} failed`, 'red', 'bold') : null,
|
|
146
|
+
report.errors.length > 0 ? paint(`${report.errors.length} invalid`, 'yellow') : null,
|
|
147
|
+
report.summary.skipped > 0 ? paint(`${report.summary.skipped} skipped`, 'dim') : null,
|
|
148
|
+
paint(formatDuration(report.durationMs), 'dim'),
|
|
149
|
+
].filter((part) => part !== null);
|
|
150
|
+
lines.push(parts.join(paint(' · ', 'dim')));
|
|
151
|
+
if (report.ok) {
|
|
152
|
+
lines.push(paint(`${glyphs.pass} every spec assertion holds`, 'green'));
|
|
153
|
+
}
|
|
154
|
+
return lines.join('\n');
|
|
155
|
+
}
|
|
156
|
+
/** Machine-readable output for CI consumers. */
|
|
157
|
+
export function formatJson(report) {
|
|
158
|
+
return JSON.stringify({
|
|
159
|
+
ok: report.ok,
|
|
160
|
+
root: report.root,
|
|
161
|
+
engine: report.engine,
|
|
162
|
+
durationMs: Math.round(report.durationMs * 1000) / 1000,
|
|
163
|
+
summary: report.summary,
|
|
164
|
+
specFiles: report.specFiles,
|
|
165
|
+
results: report.results.map((result) => ({
|
|
166
|
+
ok: result.ok,
|
|
167
|
+
kind: result.kind,
|
|
168
|
+
spec: {
|
|
169
|
+
file: result.location.relativeFile,
|
|
170
|
+
line: result.location.line,
|
|
171
|
+
column: result.location.column,
|
|
172
|
+
},
|
|
173
|
+
description: result.description,
|
|
174
|
+
message: result.message,
|
|
175
|
+
reason: result.reason,
|
|
176
|
+
symbol: result.symbol,
|
|
177
|
+
targets: result.targets,
|
|
178
|
+
files: result.files,
|
|
179
|
+
bounds: result.bounds,
|
|
180
|
+
actual: result.actual,
|
|
181
|
+
matches: result.matches,
|
|
182
|
+
warnings: result.warnings,
|
|
183
|
+
engine: result.engine,
|
|
184
|
+
durationMs: Math.round(result.durationMs * 1000) / 1000,
|
|
185
|
+
})),
|
|
186
|
+
errors: report.errors.map((error) => ({
|
|
187
|
+
spec: {
|
|
188
|
+
file: error.location.relativeFile,
|
|
189
|
+
line: error.location.line,
|
|
190
|
+
column: error.location.column,
|
|
191
|
+
},
|
|
192
|
+
message: error.message,
|
|
193
|
+
raw: error.raw,
|
|
194
|
+
})),
|
|
195
|
+
warnings: report.warnings,
|
|
196
|
+
}, null, 2);
|
|
197
|
+
}
|
|
198
|
+
//# sourceMappingURL=reporter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reporter.js","sourceRoot":"","sources":["../src/reporter.ts"],"names":[],"mappings":"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"}
|
package/dist/runner.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directive -> Assertion -> Result.
|
|
3
|
+
*
|
|
4
|
+
* Resolution is strict and happens before any I/O: a directive with a bad
|
|
5
|
+
* number, an unknown boolean or a path escaping the root is an error, never a
|
|
6
|
+
* silently-passing assertion. A spec that lies is worse than no spec at all.
|
|
7
|
+
*/
|
|
8
|
+
import { type Engine, type EnginePreference } from './engine.js';
|
|
9
|
+
import type { Assertion, AssertionResult, Directive, DirectiveError, RunReport } from './types.js';
|
|
10
|
+
export declare const DEFAULT_MAX_SNIPPETS = 5;
|
|
11
|
+
export declare const DEFAULT_CONCURRENCY = 8;
|
|
12
|
+
export interface RunOptions {
|
|
13
|
+
/** Glob patterns / paths of the Markdown specs to execute. */
|
|
14
|
+
patterns: readonly string[];
|
|
15
|
+
/** Root of the codebase being asserted about. Defaults to cwd. */
|
|
16
|
+
root?: string;
|
|
17
|
+
/** Engine preference. `auto` uses ripgrep when available. */
|
|
18
|
+
engine?: EnginePreference;
|
|
19
|
+
/** Stop at the first failing assertion. */
|
|
20
|
+
failFast?: boolean;
|
|
21
|
+
/** Treat a target path that does not exist as a failure instead of a warning. */
|
|
22
|
+
strictTargets?: boolean;
|
|
23
|
+
/** Count matches inside the spec files themselves (off by default). */
|
|
24
|
+
includeSpecs?: boolean;
|
|
25
|
+
/** Max concurrent assertions. */
|
|
26
|
+
concurrency?: number;
|
|
27
|
+
/** Max snippets kept per failing assertion. */
|
|
28
|
+
maxSnippets?: number;
|
|
29
|
+
}
|
|
30
|
+
export interface RunResult extends RunReport {
|
|
31
|
+
/** Spec files that were executed, relative to root. */
|
|
32
|
+
specFiles: string[];
|
|
33
|
+
}
|
|
34
|
+
export interface ResolveContext {
|
|
35
|
+
root: string;
|
|
36
|
+
excludeFiles: ReadonlySet<string>;
|
|
37
|
+
}
|
|
38
|
+
/** Turns one directive into an executable assertion, or an error. */
|
|
39
|
+
export declare function resolveDirective(directive: Directive, context: ResolveContext): {
|
|
40
|
+
assertion: Assertion;
|
|
41
|
+
} | {
|
|
42
|
+
error: DirectiveError;
|
|
43
|
+
};
|
|
44
|
+
export interface ExecuteOptions {
|
|
45
|
+
root: string;
|
|
46
|
+
engine: Engine;
|
|
47
|
+
strictTargets: boolean;
|
|
48
|
+
maxSnippets: number;
|
|
49
|
+
}
|
|
50
|
+
/** Executes a single resolved assertion. */
|
|
51
|
+
export declare function executeAssertion(assertion: Assertion, options: ExecuteOptions): Promise<AssertionResult>;
|
|
52
|
+
/** Reads, parses and executes every directive found in the given spec files. */
|
|
53
|
+
export declare function runSpecGuard(options: RunOptions): Promise<RunResult>;
|
|
54
|
+
//# sourceMappingURL=runner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAKH,OAAO,EAIL,KAAK,MAAM,EACX,KAAK,gBAAgB,EAEtB,MAAM,aAAa,CAAC;AAGrB,OAAO,KAAK,EACV,SAAS,EACT,eAAe,EAEf,SAAS,EACT,cAAc,EACd,SAAS,EAGV,MAAM,YAAY,CAAC;AAEpB,eAAO,MAAM,oBAAoB,IAAI,CAAC;AACtC,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,MAAM,WAAW,UAAU;IACzB,8DAA8D;IAC9D,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,kEAAkE;IAClE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6DAA6D;IAC7D,MAAM,CAAC,EAAE,gBAAgB,CAAC;IAC1B,2CAA2C;IAC3C,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,iFAAiF;IACjF,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,uEAAuE;IACvE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,iCAAiC;IACjC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+CAA+C;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,SAAU,SAAQ,SAAS;IAC1C,uDAAuD;IACvD,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AA4ED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;CACnC;AAED,qEAAqE;AACrE,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,SAAS,EACpB,OAAO,EAAE,cAAc,GACtB;IAAE,SAAS,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,cAAc,CAAA;CAAE,CAoGtD;AAMD,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,OAAO,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;CACrB;AAiGD,4CAA4C;AAC5C,wBAAsB,gBAAgB,CAAC,SAAS,EAAE,SAAS,EAAE,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,eAAe,CAAC,CAI9G;AAYD,gFAAgF;AAChF,wBAAsB,YAAY,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAqH1E"}
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directive -> Assertion -> Result.
|
|
3
|
+
*
|
|
4
|
+
* Resolution is strict and happens before any I/O: a directive with a bad
|
|
5
|
+
* number, an unknown boolean or a path escaping the root is an error, never a
|
|
6
|
+
* silently-passing assertion. A spec that lies is worse than no spec at all.
|
|
7
|
+
*/
|
|
8
|
+
import { promises as fs } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { createCachedEngine, resolveEngine, runSearches, } from './engine.js';
|
|
11
|
+
import { expandSpecPatterns, toPosix } from './glob.js';
|
|
12
|
+
import { parseDirectives } from './parser.js';
|
|
13
|
+
export const DEFAULT_MAX_SNIPPETS = 5;
|
|
14
|
+
export const DEFAULT_CONCURRENCY = 8;
|
|
15
|
+
const TRUE_VALUES = new Set(['true', '1', 'yes', 'on']);
|
|
16
|
+
const FALSE_VALUES = new Set(['false', '0', 'no', 'off']);
|
|
17
|
+
function parseBoolean(value, attribute) {
|
|
18
|
+
if (value === undefined)
|
|
19
|
+
return false;
|
|
20
|
+
const normalized = value.trim().toLowerCase();
|
|
21
|
+
if (TRUE_VALUES.has(normalized))
|
|
22
|
+
return true;
|
|
23
|
+
if (FALSE_VALUES.has(normalized))
|
|
24
|
+
return false;
|
|
25
|
+
throw new Error(`Attribute "${attribute}" must be true or false, got "${value}".`);
|
|
26
|
+
}
|
|
27
|
+
function parseCount(value, attribute) {
|
|
28
|
+
const normalized = value.trim();
|
|
29
|
+
if (!/^\d+$/.test(normalized)) {
|
|
30
|
+
throw new Error(`Attribute "${attribute}" must be a non-negative integer, got "${value}".`);
|
|
31
|
+
}
|
|
32
|
+
return Number.parseInt(normalized, 10);
|
|
33
|
+
}
|
|
34
|
+
function splitList(value) {
|
|
35
|
+
if (value === undefined)
|
|
36
|
+
return [];
|
|
37
|
+
return value
|
|
38
|
+
.split(',')
|
|
39
|
+
.map((item) => item.trim())
|
|
40
|
+
.filter((item) => item.length > 0);
|
|
41
|
+
}
|
|
42
|
+
/** Rejects absolute paths and any `..` escape out of the root. */
|
|
43
|
+
function normalizeTarget(target, root, attribute) {
|
|
44
|
+
if (path.isAbsolute(target) || /^[a-zA-Z]:[\\/]/.test(target)) {
|
|
45
|
+
throw new Error(`Attribute "${attribute}" must be relative to --root, got "${target}".`);
|
|
46
|
+
}
|
|
47
|
+
const absolute = path.resolve(root, target);
|
|
48
|
+
const relative = path.relative(root, absolute);
|
|
49
|
+
if (relative.startsWith('..')) {
|
|
50
|
+
throw new Error(`Attribute "${attribute}" escapes the root directory: "${target}".`);
|
|
51
|
+
}
|
|
52
|
+
return toPosix(relative) || '.';
|
|
53
|
+
}
|
|
54
|
+
function plural(count) {
|
|
55
|
+
return count === 1 ? '' : 'es';
|
|
56
|
+
}
|
|
57
|
+
function describeBounds(bounds) {
|
|
58
|
+
const { min, max } = bounds;
|
|
59
|
+
if (min !== undefined && max !== undefined) {
|
|
60
|
+
return min === max ? `exactly ${min} match${plural(min)}` : `between ${min} and ${max} matches`;
|
|
61
|
+
}
|
|
62
|
+
if (min !== undefined)
|
|
63
|
+
return `at least ${min} match${plural(min)}`;
|
|
64
|
+
if (max !== undefined)
|
|
65
|
+
return max === 0 ? 'no matches' : `at most ${max} match${plural(max)}`;
|
|
66
|
+
/* c8 ignore next */
|
|
67
|
+
return 'any number of matches';
|
|
68
|
+
}
|
|
69
|
+
/** Prose form used in the assertion description ("must appear at most 3 times"). */
|
|
70
|
+
function describeExpectation(bounds) {
|
|
71
|
+
const { min, max } = bounds;
|
|
72
|
+
const times = (value) => `${value} time${value === 1 ? '' : 's'}`;
|
|
73
|
+
if (min !== undefined && max !== undefined) {
|
|
74
|
+
return min === max ? `must appear exactly ${times(min)}` : `must appear between ${min} and ${max} times`;
|
|
75
|
+
}
|
|
76
|
+
if (min !== undefined)
|
|
77
|
+
return `must appear at least ${times(min)}`;
|
|
78
|
+
if (max === 0)
|
|
79
|
+
return 'must not appear';
|
|
80
|
+
/* c8 ignore next */
|
|
81
|
+
return max === undefined ? 'may appear any number of times' : `must appear at most ${times(max)}`;
|
|
82
|
+
}
|
|
83
|
+
function satisfies(count, bounds) {
|
|
84
|
+
if (bounds.min !== undefined && count < bounds.min)
|
|
85
|
+
return false;
|
|
86
|
+
if (bounds.max !== undefined && count > bounds.max)
|
|
87
|
+
return false;
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
/** Turns one directive into an executable assertion, or an error. */
|
|
91
|
+
export function resolveDirective(directive, context) {
|
|
92
|
+
const { attributes, kind, location } = directive;
|
|
93
|
+
const fail = (message) => ({
|
|
94
|
+
error: { location, raw: directive.raw, message },
|
|
95
|
+
});
|
|
96
|
+
try {
|
|
97
|
+
const reason = attributes['reason'];
|
|
98
|
+
if (kind === 'assert-present') {
|
|
99
|
+
const files = splitList(attributes['file']).map((file) => normalizeTarget(file, context.root, 'file'));
|
|
100
|
+
if (files.length === 0) {
|
|
101
|
+
return fail('@assert-present requires a file="..." attribute.');
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
assertion: {
|
|
105
|
+
kind,
|
|
106
|
+
location,
|
|
107
|
+
description: `${files.join(', ')} must exist`,
|
|
108
|
+
reason,
|
|
109
|
+
targets: [],
|
|
110
|
+
files,
|
|
111
|
+
bounds: { min: files.length, max: files.length },
|
|
112
|
+
missingTargets: [],
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const symbol = attributes['symbol'];
|
|
117
|
+
if (symbol === undefined || symbol.length === 0) {
|
|
118
|
+
return fail(`@${kind} requires a non-empty symbol="..." attribute.`);
|
|
119
|
+
}
|
|
120
|
+
const rawTargets = splitList(attributes['target']);
|
|
121
|
+
const targets = (rawTargets.length > 0 ? rawTargets : ['.']).map((target) => normalizeTarget(target, context.root, 'target'));
|
|
122
|
+
const bounds = {};
|
|
123
|
+
if (kind === 'assert-absence') {
|
|
124
|
+
if (attributes['expected'] !== undefined && attributes['max'] !== undefined) {
|
|
125
|
+
return fail('@assert-absence accepts either expected="..." or max="...", not both.');
|
|
126
|
+
}
|
|
127
|
+
const limit = attributes['expected'] ?? attributes['max'];
|
|
128
|
+
bounds.max = limit === undefined ? 0 : parseCount(limit, attributes['expected'] !== undefined ? 'expected' : 'max');
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
const expected = attributes['expected'];
|
|
132
|
+
if (expected !== undefined) {
|
|
133
|
+
if (attributes['min'] !== undefined || attributes['max'] !== undefined) {
|
|
134
|
+
return fail('@assert-count accepts either expected="..." or min/max, not both.');
|
|
135
|
+
}
|
|
136
|
+
const value = parseCount(expected, 'expected');
|
|
137
|
+
bounds.min = value;
|
|
138
|
+
bounds.max = value;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
if (attributes['min'] === undefined && attributes['max'] === undefined) {
|
|
142
|
+
return fail('@assert-count requires expected="...", min="..." or max="...".');
|
|
143
|
+
}
|
|
144
|
+
if (attributes['min'] !== undefined)
|
|
145
|
+
bounds.min = parseCount(attributes['min'], 'min');
|
|
146
|
+
if (attributes['max'] !== undefined)
|
|
147
|
+
bounds.max = parseCount(attributes['max'], 'max');
|
|
148
|
+
if (bounds.min !== undefined && bounds.max !== undefined && bounds.min > bounds.max) {
|
|
149
|
+
return fail(`min="${bounds.min}" is greater than max="${bounds.max}".`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const search = {
|
|
154
|
+
regex: parseBoolean(attributes['regex'], 'regex'),
|
|
155
|
+
word: parseBoolean(attributes['word'], 'word'),
|
|
156
|
+
ignoreCase: parseBoolean(attributes['ignore-case'], 'ignore-case'),
|
|
157
|
+
globs: splitList(attributes['glob']),
|
|
158
|
+
excludeFiles: context.excludeFiles,
|
|
159
|
+
};
|
|
160
|
+
if (search.regex) {
|
|
161
|
+
try {
|
|
162
|
+
new RegExp(symbol);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
return fail(`Invalid regular expression: ${error instanceof Error ? error.message : String(error)}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const scope = targets.join(', ');
|
|
169
|
+
return {
|
|
170
|
+
assertion: {
|
|
171
|
+
kind,
|
|
172
|
+
location,
|
|
173
|
+
description: `"${symbol}" ${describeExpectation(bounds)} in ${scope}`,
|
|
174
|
+
reason,
|
|
175
|
+
symbol,
|
|
176
|
+
targets,
|
|
177
|
+
files: [],
|
|
178
|
+
bounds,
|
|
179
|
+
search,
|
|
180
|
+
missingTargets: [],
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async function pathExists(candidate) {
|
|
189
|
+
return (await fs.stat(candidate).catch(() => null)) !== null;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Resolves everything about an assertion that does not need a search, and
|
|
193
|
+
* returns either a finished result or the search still to run.
|
|
194
|
+
*/
|
|
195
|
+
async function prepareAssertion(assertion, options) {
|
|
196
|
+
const startedAt = performance.now();
|
|
197
|
+
const warnings = [];
|
|
198
|
+
const base = {
|
|
199
|
+
kind: assertion.kind,
|
|
200
|
+
location: assertion.location,
|
|
201
|
+
description: assertion.description,
|
|
202
|
+
reason: assertion.reason,
|
|
203
|
+
symbol: assertion.symbol,
|
|
204
|
+
targets: assertion.targets,
|
|
205
|
+
files: assertion.files,
|
|
206
|
+
bounds: assertion.bounds,
|
|
207
|
+
warnings,
|
|
208
|
+
};
|
|
209
|
+
if (assertion.kind === 'assert-present') {
|
|
210
|
+
const missing = [];
|
|
211
|
+
for (const file of assertion.files) {
|
|
212
|
+
if (!(await pathExists(path.resolve(options.root, file))))
|
|
213
|
+
missing.push(file);
|
|
214
|
+
}
|
|
215
|
+
const actual = assertion.files.length - missing.length;
|
|
216
|
+
return {
|
|
217
|
+
...base,
|
|
218
|
+
ok: missing.length === 0,
|
|
219
|
+
actual,
|
|
220
|
+
message: missing.length === 0
|
|
221
|
+
? `all ${assertion.files.length} referenced ${assertion.files.length === 1 ? 'path exists' : 'paths exist'}`
|
|
222
|
+
: `missing: ${missing.join(', ')}`,
|
|
223
|
+
matches: [],
|
|
224
|
+
durationMs: performance.now() - startedAt,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const existingTargets = [];
|
|
228
|
+
for (const target of assertion.targets) {
|
|
229
|
+
if (await pathExists(path.resolve(options.root, target)))
|
|
230
|
+
existingTargets.push(target);
|
|
231
|
+
else
|
|
232
|
+
assertion.missingTargets.push(target);
|
|
233
|
+
}
|
|
234
|
+
if (assertion.missingTargets.length > 0) {
|
|
235
|
+
warnings.push(`target path${assertion.missingTargets.length === 1 ? '' : 's'} not found: ${assertion.missingTargets.join(', ')}`);
|
|
236
|
+
}
|
|
237
|
+
if (options.strictTargets && assertion.missingTargets.length > 0) {
|
|
238
|
+
return {
|
|
239
|
+
...base,
|
|
240
|
+
ok: false,
|
|
241
|
+
actual: 0,
|
|
242
|
+
message: `target path${assertion.missingTargets.length === 1 ? ' does' : 's do'} not exist: ${assertion.missingTargets.join(', ')}`,
|
|
243
|
+
matches: [],
|
|
244
|
+
durationMs: performance.now() - startedAt,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
request: {
|
|
249
|
+
root: options.root,
|
|
250
|
+
symbol: assertion.symbol,
|
|
251
|
+
targets: existingTargets,
|
|
252
|
+
options: assertion.search,
|
|
253
|
+
},
|
|
254
|
+
finish: (search) => ({
|
|
255
|
+
...base,
|
|
256
|
+
ok: satisfies(search.count, assertion.bounds),
|
|
257
|
+
actual: search.count,
|
|
258
|
+
message: `expected ${describeBounds(assertion.bounds)}, found ${search.count}`,
|
|
259
|
+
matches: search.matches.slice(0, options.maxSnippets),
|
|
260
|
+
engine: search.engine,
|
|
261
|
+
durationMs: performance.now() - startedAt,
|
|
262
|
+
}),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
/** Executes a single resolved assertion. */
|
|
266
|
+
export async function executeAssertion(assertion, options) {
|
|
267
|
+
const prepared = await prepareAssertion(assertion, options);
|
|
268
|
+
if (!('request' in prepared))
|
|
269
|
+
return prepared;
|
|
270
|
+
return prepared.finish(await options.engine.search(prepared.request));
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Groups searches that can share a single pass over the tree: same targets,
|
|
274
|
+
* same flags. This is where most of spec-guard's speed comes from - a spec with
|
|
275
|
+
* twenty assertions over `src/` costs one ripgrep pass, not twenty.
|
|
276
|
+
*/
|
|
277
|
+
function groupKey(request) {
|
|
278
|
+
const { options } = request;
|
|
279
|
+
return JSON.stringify([request.targets, options.regex, options.word, options.ignoreCase, options.globs]);
|
|
280
|
+
}
|
|
281
|
+
/** Reads, parses and executes every directive found in the given spec files. */
|
|
282
|
+
export async function runSpecGuard(options) {
|
|
283
|
+
const startedAt = performance.now();
|
|
284
|
+
const root = path.resolve(options.root ?? process.cwd());
|
|
285
|
+
const maxSnippets = options.maxSnippets ?? DEFAULT_MAX_SNIPPETS;
|
|
286
|
+
const strictTargets = options.strictTargets ?? false;
|
|
287
|
+
const specFiles = await expandSpecPatterns(options.patterns, root);
|
|
288
|
+
const excludeFiles = new Set(options.includeSpecs ? [] : specFiles.map((file) => path.resolve(file)));
|
|
289
|
+
const directives = [];
|
|
290
|
+
const errors = [];
|
|
291
|
+
for (const file of specFiles) {
|
|
292
|
+
const relativeFile = toPosix(path.relative(root, file)) || toPosix(file);
|
|
293
|
+
const source = await fs.readFile(file, 'utf8').catch((error) => {
|
|
294
|
+
errors.push({
|
|
295
|
+
location: { file, relativeFile, line: 1, column: 1 },
|
|
296
|
+
raw: '',
|
|
297
|
+
message: `Unable to read spec file: ${error instanceof Error ? error.message : String(error)}`,
|
|
298
|
+
});
|
|
299
|
+
return null;
|
|
300
|
+
});
|
|
301
|
+
if (source === null)
|
|
302
|
+
continue;
|
|
303
|
+
const parsed = parseDirectives(source, { file, relativeFile });
|
|
304
|
+
directives.push(...parsed.directives);
|
|
305
|
+
errors.push(...parsed.errors);
|
|
306
|
+
}
|
|
307
|
+
const assertions = [];
|
|
308
|
+
for (const directive of directives) {
|
|
309
|
+
const resolved = resolveDirective(directive, { root, excludeFiles });
|
|
310
|
+
if ('error' in resolved)
|
|
311
|
+
errors.push(resolved.error);
|
|
312
|
+
else
|
|
313
|
+
assertions.push(resolved.assertion);
|
|
314
|
+
}
|
|
315
|
+
const engine = createCachedEngine(await resolveEngine(options.engine ?? 'auto'));
|
|
316
|
+
const executeOptions = { root, engine, strictTargets, maxSnippets };
|
|
317
|
+
const results = new Array(assertions.length);
|
|
318
|
+
if (options.failFast) {
|
|
319
|
+
// Fail-fast trades throughput for an early exit, so it runs unbatched.
|
|
320
|
+
for (let index = 0; index < assertions.length; index++) {
|
|
321
|
+
const result = await executeAssertion(assertions[index], executeOptions);
|
|
322
|
+
results[index] = result;
|
|
323
|
+
if (!result.ok) {
|
|
324
|
+
results.length = index + 1;
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
const prepared = await Promise.all(assertions.map((assertion) => prepareAssertion(assertion, executeOptions)));
|
|
331
|
+
const groups = new Map();
|
|
332
|
+
prepared.forEach((entry, index) => {
|
|
333
|
+
if (!('request' in entry)) {
|
|
334
|
+
results[index] = entry;
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const key = groupKey(entry.request);
|
|
338
|
+
const group = groups.get(key);
|
|
339
|
+
if (group)
|
|
340
|
+
group.push({ index, pending: entry });
|
|
341
|
+
else
|
|
342
|
+
groups.set(key, [{ index, pending: entry }]);
|
|
343
|
+
});
|
|
344
|
+
const batches = [...groups.values()];
|
|
345
|
+
const concurrency = Math.max(1, Math.min(options.concurrency ?? DEFAULT_CONCURRENCY, batches.length || 1));
|
|
346
|
+
let cursor = 0;
|
|
347
|
+
const worker = async () => {
|
|
348
|
+
while (cursor < batches.length) {
|
|
349
|
+
const batch = batches[cursor++];
|
|
350
|
+
/* c8 ignore next -- cursor is bounded by batches.length */
|
|
351
|
+
if (!batch)
|
|
352
|
+
return;
|
|
353
|
+
const searches = await runSearches(engine, batch.map((entry) => entry.pending.request));
|
|
354
|
+
batch.forEach((entry, position) => {
|
|
355
|
+
results[entry.index] = entry.pending.finish(searches[position]);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
await Promise.all(Array.from({ length: concurrency }, worker));
|
|
360
|
+
}
|
|
361
|
+
const warnings = engine.fallbacks.map((message) => `ripgrep failed, fell back to the JavaScript engine (${message})`);
|
|
362
|
+
// Errors arrive in two waves (parse, then resolve); readers expect file order.
|
|
363
|
+
errors.sort((a, b) => a.location.relativeFile === b.location.relativeFile
|
|
364
|
+
? a.location.line - b.location.line
|
|
365
|
+
: a.location.relativeFile < b.location.relativeFile
|
|
366
|
+
? -1
|
|
367
|
+
: 1);
|
|
368
|
+
const failed = results.filter((result) => !result.ok).length;
|
|
369
|
+
return {
|
|
370
|
+
ok: failed === 0 && errors.length === 0,
|
|
371
|
+
root,
|
|
372
|
+
engine: warnings.length > 0 ? 'javascript' : engine.name,
|
|
373
|
+
durationMs: performance.now() - startedAt,
|
|
374
|
+
summary: {
|
|
375
|
+
specs: specFiles.length,
|
|
376
|
+
total: results.length,
|
|
377
|
+
passed: results.length - failed,
|
|
378
|
+
failed,
|
|
379
|
+
skipped: assertions.length - results.length,
|
|
380
|
+
},
|
|
381
|
+
results,
|
|
382
|
+
errors,
|
|
383
|
+
warnings,
|
|
384
|
+
specFiles: specFiles.map((file) => toPosix(path.relative(root, file)) || toPosix(file)),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
//# sourceMappingURL=runner.js.map
|