@brighterly/lib-fe-codestyle 1.0.0 → 1.0.2
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/README.md +234 -178
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +181 -0
- package/dist/config/define-config.d.ts +11 -0
- package/dist/config/define-config.js +33 -0
- package/dist/config/load-config.d.ts +15 -0
- package/dist/config/load-config.js +52 -0
- package/dist/config/schema.d.ts +58 -0
- package/dist/config/schema.js +139 -0
- package/dist/{invariants → engine}/ast.d.ts +8 -0
- package/dist/{invariants → engine}/ast.js +51 -0
- package/dist/engine/docs.d.ts +14 -0
- package/dist/engine/docs.js +72 -0
- package/dist/engine/meta.d.ts +32 -0
- package/dist/engine/meta.js +47 -0
- package/dist/engine/migrate.d.ts +22 -0
- package/dist/engine/migrate.js +175 -0
- package/dist/engine/overrides.d.ts +36 -0
- package/dist/engine/overrides.js +92 -0
- package/dist/engine/registry.d.ts +35 -0
- package/dist/engine/registry.js +100 -0
- package/dist/engine/report.d.ts +23 -0
- package/dist/engine/report.js +87 -0
- package/dist/engine/run.d.ts +57 -0
- package/dist/engine/run.js +353 -0
- package/dist/engine/self-test.d.ts +33 -0
- package/dist/engine/self-test.js +141 -0
- package/dist/engine/types.d.ts +232 -0
- package/dist/{invariants → engine}/types.js +2 -1
- package/dist/{invariants → engine}/util.d.ts +31 -39
- package/dist/{invariants → engine}/util.js +59 -86
- package/dist/index.d.ts +16 -0
- package/dist/index.js +14 -0
- package/dist/rules/convention.d.ts +20 -0
- package/dist/{invariants/rules → rules}/convention.js +103 -16
- package/dist/rules/index.d.ts +28 -0
- package/dist/{invariants/rules → rules}/index.js +40 -32
- package/dist/{invariants/rules → rules}/regression.d.ts +1 -1
- package/dist/{invariants/rules → rules}/regression.js +26 -6
- package/dist/{invariants/rules → rules}/shared.d.ts +1 -1
- package/dist/validate/index.d.ts +1 -1
- package/dist/validate/index.js +1 -1
- package/package.json +8 -13
- package/dist/invariants/index.d.ts +0 -7
- package/dist/invariants/index.js +0 -5
- package/dist/invariants/rules/convention.d.ts +0 -18
- package/dist/invariants/rules/fixtures.d.ts +0 -17
- package/dist/invariants/rules/fixtures.js +0 -89
- package/dist/invariants/rules/index.d.ts +0 -55
- package/dist/invariants/rules/linter.d.ts +0 -10
- package/dist/invariants/rules/linter.js +0 -9
- package/dist/invariants/run.d.ts +0 -53
- package/dist/invariants/run.js +0 -746
- package/dist/invariants/self-test.d.ts +0 -46
- package/dist/invariants/self-test.js +0 -84
- package/dist/invariants/types.d.ts +0 -222
- /package/dist/{invariants/rules → rules}/shared.js +0 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `check()` — the runner on the 1.0 surface: load the config, build the registry, compile the
|
|
3
|
+
* overrides, pick the files and the rules, run them, apply suppressions and overrides to every
|
|
4
|
+
* finding, add the meta findings, self-test the repo's own rules, report.
|
|
5
|
+
*
|
|
6
|
+
* Exit codes:
|
|
7
|
+
* 0 — clean, or warnings only
|
|
8
|
+
* 1 — an error-level finding, or a failed self-test
|
|
9
|
+
* 2 — the tool itself failed (bad flags, no or bad config, a crashing rule)
|
|
10
|
+
*/
|
|
11
|
+
import type { RegisteredRule } from './registry.js';
|
|
12
|
+
import type { Level } from './types.js';
|
|
13
|
+
export interface CheckOptions {
|
|
14
|
+
/** Repo root. Passed, never derived — import.meta.url here resolves into node_modules. */
|
|
15
|
+
root: string;
|
|
16
|
+
argv: string[];
|
|
17
|
+
/** `YYYY-MM-DD`; defaults to today (UTC). Overrides and rules read it for `until` dates. */
|
|
18
|
+
today?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Defaults to setting `process.exitCode` — never `process.exit()`, which would cut stdout still
|
|
21
|
+
* draining into a pipe. Engine code always `return exit(...)`. Injectable so tests can collect the code.
|
|
22
|
+
*/
|
|
23
|
+
exit?: (code: number) => never;
|
|
24
|
+
/** The `--stdin` content. Defaults to reading fd 0. */
|
|
25
|
+
stdin?: () => string;
|
|
26
|
+
}
|
|
27
|
+
export interface CheckJson {
|
|
28
|
+
schemaVersion: 1;
|
|
29
|
+
ok: boolean;
|
|
30
|
+
scanned: number;
|
|
31
|
+
errors: number;
|
|
32
|
+
warnings: number;
|
|
33
|
+
rules: {
|
|
34
|
+
name: string;
|
|
35
|
+
level: Level;
|
|
36
|
+
title: string;
|
|
37
|
+
tags: string[];
|
|
38
|
+
owner: RegisteredRule['owner'];
|
|
39
|
+
}[];
|
|
40
|
+
findings: {
|
|
41
|
+
rule: string;
|
|
42
|
+
level: Level;
|
|
43
|
+
file: string;
|
|
44
|
+
line: number;
|
|
45
|
+
message: string;
|
|
46
|
+
}[];
|
|
47
|
+
skipped?: {
|
|
48
|
+
file: string;
|
|
49
|
+
reason: string;
|
|
50
|
+
};
|
|
51
|
+
/** Present whenever the self-test ran. */
|
|
52
|
+
selfTest?: {
|
|
53
|
+
ok: boolean;
|
|
54
|
+
failures: string[];
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export declare const check: (options: CheckOptions) => Promise<void>;
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `check()` — the runner on the 1.0 surface: load the config, build the registry, compile the
|
|
3
|
+
* overrides, pick the files and the rules, run them, apply suppressions and overrides to every
|
|
4
|
+
* finding, add the meta findings, self-test the repo's own rules, report.
|
|
5
|
+
*
|
|
6
|
+
* Exit codes:
|
|
7
|
+
* 0 — clean, or warnings only
|
|
8
|
+
* 1 — an error-level finding, or a failed self-test
|
|
9
|
+
* 2 — the tool itself failed (bad flags, no or bad config, a crashing rule)
|
|
10
|
+
*/
|
|
11
|
+
import { execSync } from 'node:child_process';
|
|
12
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
13
|
+
import { resolve } from 'node:path';
|
|
14
|
+
import { ConfigError } from '../config/define-config.js';
|
|
15
|
+
import { findConfig, loadConfig } from '../config/load-config.js';
|
|
16
|
+
import { metaRuleInfo, scanMeta } from './meta.js';
|
|
17
|
+
import { compileOverrides } from './overrides.js';
|
|
18
|
+
import { buildRegistry } from './registry.js';
|
|
19
|
+
import { printReport } from './report.js';
|
|
20
|
+
import { runSelfTest } from './self-test.js';
|
|
21
|
+
import { createReader, globToRegExp, matchesAny, parseIgnores, resolveFindingLevel, stripCodeComments, toRelative, todayUtc, walk, } from './util.js';
|
|
22
|
+
const USAGE = 'usage: codestyle check [--staged | --stdin --file=<path>] [--json] [--only=<a,b>] [--rule=<name>] [--tags=<a,b>] [--no-self-test] [--no-color]';
|
|
23
|
+
const SWITCHES = new Set(['--staged', '--stdin', '--json', '--no-self-test', '--no-color']);
|
|
24
|
+
const VALUES = ['file', 'rule', 'tags', 'only'];
|
|
25
|
+
/** Parse the flags, or name the first one that is not ours — a typo must not run a full scan. */
|
|
26
|
+
const parseArgs = (argv) => {
|
|
27
|
+
const values = {};
|
|
28
|
+
for (const arg of argv) {
|
|
29
|
+
if (SWITCHES.has(arg))
|
|
30
|
+
continue;
|
|
31
|
+
const key = VALUES.find(v => arg.startsWith(`--${v}=`));
|
|
32
|
+
if (!key)
|
|
33
|
+
return `unknown argument "${arg}"`;
|
|
34
|
+
values[key] = arg.slice(key.length + 3);
|
|
35
|
+
}
|
|
36
|
+
const list = (v) => (v ?? '').split(',').map(s => s.trim()).filter(Boolean);
|
|
37
|
+
return {
|
|
38
|
+
staged: argv.includes('--staged'),
|
|
39
|
+
stdin: argv.includes('--stdin'),
|
|
40
|
+
json: argv.includes('--json'),
|
|
41
|
+
selfTest: !argv.includes('--no-self-test'),
|
|
42
|
+
color: !argv.includes('--no-color'),
|
|
43
|
+
file: values.file ?? null,
|
|
44
|
+
// --rule= is the 0.5.x spelling of --only=, kept so consumer hooks need no change.
|
|
45
|
+
only: [...list(values.only), ...list(values.rule)],
|
|
46
|
+
tags: list(values.tags),
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
const isGlob = (p) => /[*?{]/.test(p);
|
|
50
|
+
/** `config.files` minus `config.ignores`: directories are walked, files taken, globs matched against the whole root. */
|
|
51
|
+
const fileSet = (root, config) => {
|
|
52
|
+
const ignoreTests = config.ignores.map(ig => (isGlob(ig)
|
|
53
|
+
? (rel) => globToRegExp(ig).test(rel)
|
|
54
|
+
: ig.includes('/') ? (rel) => rel === ig || rel.startsWith(`${ig}/`) : (rel) => rel.split('/').includes(ig)));
|
|
55
|
+
const ignored = (rel) => ignoreTests.some(t => t(rel));
|
|
56
|
+
const out = new Set();
|
|
57
|
+
let rootWalk = null;
|
|
58
|
+
for (const entry of config.files) {
|
|
59
|
+
if (isGlob(entry)) {
|
|
60
|
+
rootWalk ??= walk(root, '', ignored);
|
|
61
|
+
const re = globToRegExp(entry);
|
|
62
|
+
for (const f of rootWalk)
|
|
63
|
+
if (re.test(f))
|
|
64
|
+
out.add(f);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (ignored(entry))
|
|
68
|
+
continue;
|
|
69
|
+
const stat = statSync(resolve(root, entry), { throwIfNoEntry: false });
|
|
70
|
+
if (!stat)
|
|
71
|
+
continue;
|
|
72
|
+
if (stat.isDirectory())
|
|
73
|
+
for (const f of walk(root, entry, ignored))
|
|
74
|
+
out.add(f);
|
|
75
|
+
else
|
|
76
|
+
out.add(entry);
|
|
77
|
+
}
|
|
78
|
+
const covered = (rel) => config.files.some(e => (isGlob(e) ? globToRegExp(e).test(rel) : rel === e || rel.startsWith(`${e}/`)));
|
|
79
|
+
return { files: [...out], ignored, covered };
|
|
80
|
+
};
|
|
81
|
+
/** What this commit stages: present paths, and the deleted/renamed-away ones a `once` rule must still see. */
|
|
82
|
+
const stagedChanges = (root) => {
|
|
83
|
+
const present = [];
|
|
84
|
+
const gone = [];
|
|
85
|
+
for (const line of execSync('git diff --staged --name-status -M', { cwd: root, encoding: 'utf8' }).split('\n')) {
|
|
86
|
+
const [status = '', ...paths] = line.split('\t').map(s => s.trim());
|
|
87
|
+
const first = paths[0];
|
|
88
|
+
const last = paths[paths.length - 1];
|
|
89
|
+
if (!status || !first || !last)
|
|
90
|
+
continue;
|
|
91
|
+
if (status.startsWith('D'))
|
|
92
|
+
gone.push(first);
|
|
93
|
+
else if (status.startsWith('R')) {
|
|
94
|
+
gone.push(first);
|
|
95
|
+
present.push(last);
|
|
96
|
+
}
|
|
97
|
+
else
|
|
98
|
+
present.push(last);
|
|
99
|
+
}
|
|
100
|
+
return { present: present.filter(f => existsSync(resolve(root, f))), gone };
|
|
101
|
+
};
|
|
102
|
+
const inScope = (rule, file) => matchesAny(file, rule.scope) && !(rule.exclude && matchesAny(file, rule.exclude));
|
|
103
|
+
const IGNORE_SCAN = /\.(?:m?ts|vue|mjs|js)$/;
|
|
104
|
+
const LEVELS = ['off', 'warn', 'error'];
|
|
105
|
+
export const check = async (options) => {
|
|
106
|
+
const exit = options.exit ?? ((code) => { process.exitCode = code; });
|
|
107
|
+
const json = options.argv.includes('--json');
|
|
108
|
+
const fail = (message) => {
|
|
109
|
+
console.error(`codestyle: ${message}`);
|
|
110
|
+
// The JSON contract is total: a tool failure under --json still prints an object.
|
|
111
|
+
if (json)
|
|
112
|
+
console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: message }));
|
|
113
|
+
return exit(2);
|
|
114
|
+
};
|
|
115
|
+
const args = parseArgs(options.argv);
|
|
116
|
+
if (typeof args === 'string')
|
|
117
|
+
return fail(`${args}\n${USAGE}`);
|
|
118
|
+
if (args.stdin && !args.file)
|
|
119
|
+
return fail('--stdin requires --file=<repo-relative path>');
|
|
120
|
+
if (args.file && !args.stdin)
|
|
121
|
+
return fail('--file= is only meaningful with --stdin');
|
|
122
|
+
if (args.stdin && args.staged)
|
|
123
|
+
return fail('--stdin and --staged are exclusive');
|
|
124
|
+
try {
|
|
125
|
+
return await run(options, args, exit, fail);
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
if (e instanceof ConfigError)
|
|
129
|
+
return fail(e.message);
|
|
130
|
+
// Anything else is the tool failing (a config that throws while loading, a crash in the
|
|
131
|
+
// engine) — exit 2, never the 1 that means "an error-level finding". Keep the stack.
|
|
132
|
+
if (e instanceof Error && e.stack)
|
|
133
|
+
console.error(e.stack);
|
|
134
|
+
return fail(e instanceof Error ? e.message : String(e));
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
const run = async (options, args, exit, fail) => {
|
|
138
|
+
const { root } = options;
|
|
139
|
+
const today = options.today ?? todayUtc();
|
|
140
|
+
const config = await loadConfig(root);
|
|
141
|
+
const configFile = toRelative(root, findConfig(root));
|
|
142
|
+
const registry = buildRegistry(config);
|
|
143
|
+
const selected = registry.select({ tags: args.tags, only: args.only });
|
|
144
|
+
const narrowed = args.tags.length > 0 || args.only.length > 0;
|
|
145
|
+
/** A `ruleSources` entry is a directory prefix, matched at a `/` boundary. */
|
|
146
|
+
const isRuleSource = (f) => config.ruleSources.some(p => f === p || f.startsWith(p.endsWith('/') ? p : `${p}/`));
|
|
147
|
+
// ---------------------------------------------------------------- stdin
|
|
148
|
+
let stdinContent = null;
|
|
149
|
+
const stdinFile = args.stdin ? toRelative(root, args.file) : null;
|
|
150
|
+
if (args.stdin) {
|
|
151
|
+
if (!options.stdin && process.stdin.isTTY)
|
|
152
|
+
return fail('--stdin expects content on stdin, not a terminal');
|
|
153
|
+
try {
|
|
154
|
+
stdinContent = (options.stdin ?? (() => readFileSync(0, 'utf8')))();
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
return fail(`could not read stdin — ${e.message}`);
|
|
158
|
+
}
|
|
159
|
+
// Empty is a plumbing failure, not a clean file: the rules would invent findings about content never sent.
|
|
160
|
+
if (stdinContent === '')
|
|
161
|
+
return fail('--stdin received no content');
|
|
162
|
+
}
|
|
163
|
+
const baseRead = createReader(root);
|
|
164
|
+
const read = (rel) => (rel === stdinFile ? stdinContent : baseRead(rel));
|
|
165
|
+
// ---------------------------------------------------------------- files
|
|
166
|
+
const tree = fileSet(root, config);
|
|
167
|
+
// `allFiles` may gain the stdin path; the overrides keep the tree as it is on disk.
|
|
168
|
+
let allFiles = tree.files;
|
|
169
|
+
const compiled = compileOverrides(config, registry, { configFile, configSource: baseRead(configFile) ?? undefined, files: tree.files, today });
|
|
170
|
+
const rulesJson = registry.all.map(r => ({ name: r.rule.name, level: r.level, title: r.rule.title, tags: r.tags, owner: r.owner }));
|
|
171
|
+
/** A file this run will not check says so, in the shape the caller asked for — never a bare PASS. */
|
|
172
|
+
const skip = (file, reason) => {
|
|
173
|
+
if (args.json) {
|
|
174
|
+
const json = { schemaVersion: 1, ok: true, scanned: 0, errors: 0, warnings: 0, rules: rulesJson, findings: [], skipped: { file, reason } };
|
|
175
|
+
console.log(JSON.stringify(json, null, 2));
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
console.log(` not checked: ${file} — ${reason}`);
|
|
179
|
+
}
|
|
180
|
+
return exit(0);
|
|
181
|
+
};
|
|
182
|
+
/** Under `files` and not under `ignores` (the path or any directory above it) — the tree's own gate. */
|
|
183
|
+
const inTree = (f) => tree.covered(f) && !f.split('/').some((_, i, parts) => tree.ignored(parts.slice(0, i + 1).join('/')));
|
|
184
|
+
let candidates = allFiles;
|
|
185
|
+
let triggers = allFiles;
|
|
186
|
+
let selfTestDue = args.selfTest && !args.staged && !args.stdin && !narrowed;
|
|
187
|
+
/** stdin on a path no selected rule scopes: only the meta scans run, and a clean file is skipped. */
|
|
188
|
+
let metaOnly = false;
|
|
189
|
+
if (stdinFile !== null) {
|
|
190
|
+
// The path or any directory above it may be ignored.
|
|
191
|
+
if (stdinFile.split('/').some((_, i, parts) => tree.ignored(parts.slice(0, i + 1).join('/'))))
|
|
192
|
+
return skip(stdinFile, `under ignores in ${configFile}`);
|
|
193
|
+
if (!tree.covered(stdinFile))
|
|
194
|
+
return skip(stdinFile, `not under files in ${configFile}`);
|
|
195
|
+
metaOnly = !selected.some(r => inScope(r.rule, stdinFile));
|
|
196
|
+
candidates = [stdinFile];
|
|
197
|
+
// A once rule in stdin mode runs when the one file passes its gate, and reads the piped content.
|
|
198
|
+
triggers = candidates;
|
|
199
|
+
if (!allFiles.includes(stdinFile))
|
|
200
|
+
allFiles = [...allFiles, stdinFile];
|
|
201
|
+
}
|
|
202
|
+
else if (args.staged) {
|
|
203
|
+
let staged;
|
|
204
|
+
try {
|
|
205
|
+
staged = stagedChanges(root);
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
return fail(`could not read the staged file list — ${e.message}`);
|
|
209
|
+
}
|
|
210
|
+
const treeSet = new Set(allFiles);
|
|
211
|
+
candidates = staged.present.filter(f => treeSet.has(f));
|
|
212
|
+
// Deleted paths are no candidates, but still a change to the tree a once rule reads. Present
|
|
213
|
+
// and deleted alike pass the tree's gate, so a once rule fires the same in every mode.
|
|
214
|
+
const touched = [...staged.present, ...staged.gone];
|
|
215
|
+
triggers = touched.filter(inTree);
|
|
216
|
+
// Deleting a rule or its proof is exactly when the self-test must run.
|
|
217
|
+
selfTestDue = args.selfTest && touched.some(isRuleSource);
|
|
218
|
+
}
|
|
219
|
+
// A narrowed run describes its own reach: the files its rules can see.
|
|
220
|
+
if (narrowed && !args.stdin)
|
|
221
|
+
candidates = candidates.filter(f => selected.some(r => inScope(r.rule, f)));
|
|
222
|
+
// ---------------------------------------------------------------- suppressions
|
|
223
|
+
const empty = { suppressions: new Map(), unreasoned: [], comments: [], legacy: false };
|
|
224
|
+
const ignoreCache = new Map();
|
|
225
|
+
/** One parse per file; rule sources are exempt (their fixture strings carry ignore comments on purpose). */
|
|
226
|
+
const ignoresFor = (file) => {
|
|
227
|
+
let parsed = ignoreCache.get(file);
|
|
228
|
+
if (parsed)
|
|
229
|
+
return parsed;
|
|
230
|
+
const src = isRuleSource(file) ? null : read(file);
|
|
231
|
+
parsed = src === null || !src.includes('-ignore:') ? empty : parseIgnores(src);
|
|
232
|
+
if (parsed.legacy)
|
|
233
|
+
console.error(`codestyle: ${file} uses "invariant-ignore:" — rename it to "codestyle-ignore:" (the old spelling is accepted through 1.x)`);
|
|
234
|
+
ignoreCache.set(file, parsed);
|
|
235
|
+
return parsed;
|
|
236
|
+
};
|
|
237
|
+
// ---------------------------------------------------------------- rules
|
|
238
|
+
const findings = [];
|
|
239
|
+
const settings = config.settings;
|
|
240
|
+
const strippedCache = new Map();
|
|
241
|
+
const stripped = (file, source) => {
|
|
242
|
+
if (!strippedCache.has(file))
|
|
243
|
+
strippedCache.set(file, stripCodeComments(source, file));
|
|
244
|
+
return strippedCache.get(file);
|
|
245
|
+
};
|
|
246
|
+
/** Returns an error message when a finding is malformed — the caller turns it into a rule crash. */
|
|
247
|
+
const accept = (entry, rule, raw) => {
|
|
248
|
+
for (const v of raw) {
|
|
249
|
+
if (v.level !== undefined && !LEVELS.includes(v.level))
|
|
250
|
+
return `rule ${rule.name} produced an invalid level "${String(v.level)}"`;
|
|
251
|
+
if (!rule.noIgnore && ignoresFor(v.file).suppressions.get(v.line)?.has(rule.name))
|
|
252
|
+
continue;
|
|
253
|
+
// A `rules` entry is the base level and a ceiling over the finding's own; without one, the finding's own level wins.
|
|
254
|
+
const level = compiled.resolve(v, rule.name, resolveFindingLevel(rule.level, entry.configured, v.level));
|
|
255
|
+
if (level === 'off')
|
|
256
|
+
continue;
|
|
257
|
+
findings.push({ ...v, rule: rule.name, level });
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
};
|
|
261
|
+
// A custom rule whose scope finds nothing in the tree never runs — say so (not a finding).
|
|
262
|
+
if (!args.staged && !args.stdin) {
|
|
263
|
+
for (const entry of selected) {
|
|
264
|
+
if (entry.owner === 'custom' && !tree.files.some(f => inScope(entry.rule, f))) {
|
|
265
|
+
console.error(`codestyle: rule ${entry.rule.name} matches no file under files — check files/ignores`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
for (const entry of metaOnly ? [] : selected) {
|
|
270
|
+
const rule = entry.rule;
|
|
271
|
+
const overrides = compiled.forRule(rule.name);
|
|
272
|
+
if (rule.once) {
|
|
273
|
+
if (!triggers.some(f => inScope(rule, f)))
|
|
274
|
+
continue;
|
|
275
|
+
let raw;
|
|
276
|
+
try {
|
|
277
|
+
raw = (await rule.check({ file: rule.scope[0], source: '', code: '', read, allFiles, settings, overrides, today })) ?? [];
|
|
278
|
+
}
|
|
279
|
+
catch (e) {
|
|
280
|
+
return fail(`rule ${rule.name} crashed — ${e.message}`);
|
|
281
|
+
}
|
|
282
|
+
// At write time only findings about the written path count; `subject` names it for a registry finding.
|
|
283
|
+
if (stdinFile !== null)
|
|
284
|
+
raw = raw.filter(v => (v.subject ?? v.file) === stdinFile);
|
|
285
|
+
const bad = accept(entry, rule, raw);
|
|
286
|
+
if (bad)
|
|
287
|
+
return fail(bad);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
for (const file of candidates) {
|
|
291
|
+
if (!inScope(rule, file))
|
|
292
|
+
continue;
|
|
293
|
+
const source = read(file);
|
|
294
|
+
if (source === null)
|
|
295
|
+
continue;
|
|
296
|
+
try {
|
|
297
|
+
// Always await: a Promise-returning check whose author forgot `async` must not be iterated as a Promise.
|
|
298
|
+
const raw = await rule.check({ file, source, code: stripped(file, source), read, allFiles, settings, overrides, today });
|
|
299
|
+
const bad = accept(entry, rule, raw ?? []);
|
|
300
|
+
if (bad)
|
|
301
|
+
return fail(bad);
|
|
302
|
+
}
|
|
303
|
+
catch (e) {
|
|
304
|
+
return fail(`rule ${rule.name} crashed on ${file} — ${e.message}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// ---------------------------------------------------------------- meta + config problems
|
|
309
|
+
const metaFiles = candidates.filter(f => IGNORE_SCAN.test(f) && !isRuleSource(f));
|
|
310
|
+
for (const v of scanMeta({ files: metaFiles, ignoresFor, registry, configFile })) {
|
|
311
|
+
// An override may lower a meta rule too (IG0 for a legacy file).
|
|
312
|
+
const level = compiled.resolve(v, v.rule, 'error');
|
|
313
|
+
if (level !== 'off')
|
|
314
|
+
findings.push({ ...v, level });
|
|
315
|
+
}
|
|
316
|
+
// Config problems are properties of the config, not of the file a hook is checking.
|
|
317
|
+
if (!args.stdin)
|
|
318
|
+
findings.push(...compiled.problems);
|
|
319
|
+
if (metaOnly && findings.length === 0)
|
|
320
|
+
return skip(stdinFile, 'no selected rule scopes this path');
|
|
321
|
+
// ---------------------------------------------------------------- self-test (the repo's own rules)
|
|
322
|
+
let selfTest;
|
|
323
|
+
if (selfTestDue) {
|
|
324
|
+
const own = registry.all.filter(r => r.owner === 'custom').map(r => r.rule);
|
|
325
|
+
const { ok, failures } = await runSelfTest({ rules: own, settings, today });
|
|
326
|
+
selfTest = { ok, failures };
|
|
327
|
+
for (const f of failures)
|
|
328
|
+
console.error(`codestyle: self-test: ${f}`);
|
|
329
|
+
}
|
|
330
|
+
const selfTestOk = selfTest?.ok ?? true;
|
|
331
|
+
// ---------------------------------------------------------------- report
|
|
332
|
+
const errors = findings.filter(f => f.level === 'error').length;
|
|
333
|
+
const warnings = findings.filter(f => f.level === 'warn').length;
|
|
334
|
+
const ok = errors === 0 && selfTestOk;
|
|
335
|
+
if (args.json) {
|
|
336
|
+
const json = {
|
|
337
|
+
schemaVersion: 1,
|
|
338
|
+
ok,
|
|
339
|
+
scanned: candidates.length,
|
|
340
|
+
errors,
|
|
341
|
+
warnings,
|
|
342
|
+
rules: rulesJson,
|
|
343
|
+
findings: findings.map(f => ({ rule: f.rule, level: f.level, file: f.file, line: f.line, message: f.message })),
|
|
344
|
+
...(selfTest ? { selfTest } : {}),
|
|
345
|
+
};
|
|
346
|
+
console.log(JSON.stringify(json, null, 2));
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
const color = args.color && Boolean(process.stdout.isTTY) && !process.env.NO_COLOR && process.env.TERM !== 'dumb';
|
|
350
|
+
printReport(findings, registry, metaRuleInfo, { color, scanned: candidates.length, selfTestFailed: !selfTestOk });
|
|
351
|
+
}
|
|
352
|
+
return exit(ok ? 0 : 1);
|
|
353
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generic half of the self-test: prove every rule still fires on a violating fixture
|
|
3
|
+
* and stays quiet on a clean one.
|
|
4
|
+
*
|
|
5
|
+
* This exists because a rule that has silently stopped matching finds nothing — which is
|
|
6
|
+
* indistinguishable from a clean codebase. The scan alone can print PASS while a rule is
|
|
7
|
+
* dead.
|
|
8
|
+
*
|
|
9
|
+
* Only the harness lives here. The proofs travel on the rule objects themselves — `fixtures`
|
|
10
|
+
* (single-file cases), `world` (a virtual repo) or `verify` (a programmatic proof) — so a
|
|
11
|
+
* consumer's rules prove themselves exactly like the package's.
|
|
12
|
+
*/
|
|
13
|
+
import type { FixtureCase, Rule, RuleName, SharedRuleSettings } from './types.js';
|
|
14
|
+
/** A rule may declare one case or an array (multi-check rules need one `bad` per check). */
|
|
15
|
+
export type Fixture = FixtureCase | FixtureCase[];
|
|
16
|
+
export interface SelfTestInput {
|
|
17
|
+
rules: Rule[];
|
|
18
|
+
/** The date fixture contexts see, and world contexts unless the world pins its own. Default: today (UTC). */
|
|
19
|
+
today?: string;
|
|
20
|
+
/** The consumer's settings, handed to every proof context. Default `{}`. */
|
|
21
|
+
settings?: SharedRuleSettings & Record<string, string>;
|
|
22
|
+
}
|
|
23
|
+
export interface SelfTestResult {
|
|
24
|
+
ok: boolean;
|
|
25
|
+
failures: string[];
|
|
26
|
+
/** Names of the rules whose proof ran, in input order. */
|
|
27
|
+
checked: RuleName[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Prove every rule still fires on a known violation and stays quiet on a clean case, through
|
|
31
|
+
* whichever proof the rule carries. A rule with none is itself a failure.
|
|
32
|
+
*/
|
|
33
|
+
export declare const runSelfTest: (input: SelfTestInput) => Promise<SelfTestResult>;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generic half of the self-test: prove every rule still fires on a violating fixture
|
|
3
|
+
* and stays quiet on a clean one.
|
|
4
|
+
*
|
|
5
|
+
* This exists because a rule that has silently stopped matching finds nothing — which is
|
|
6
|
+
* indistinguishable from a clean codebase. The scan alone can print PASS while a rule is
|
|
7
|
+
* dead.
|
|
8
|
+
*
|
|
9
|
+
* Only the harness lives here. The proofs travel on the rule objects themselves — `fixtures`
|
|
10
|
+
* (single-file cases), `world` (a virtual repo) or `verify` (a programmatic proof) — so a
|
|
11
|
+
* consumer's rules prove themselves exactly like the package's.
|
|
12
|
+
*/
|
|
13
|
+
import { matchesAny, stripCodeComments, todayUtc } from './util.js';
|
|
14
|
+
const callCheck = async (rule, ctx) => (await rule.check(ctx)) ?? [];
|
|
15
|
+
/** Run one rule against a synthetic single-file context: the tree is that one file. */
|
|
16
|
+
const runOnFile = (rule, file, source, settings, today) => callCheck(rule, {
|
|
17
|
+
file,
|
|
18
|
+
source,
|
|
19
|
+
code: stripCodeComments(source, file),
|
|
20
|
+
read: (p) => (p === file ? source : null),
|
|
21
|
+
allFiles: [file],
|
|
22
|
+
settings,
|
|
23
|
+
overrides: [],
|
|
24
|
+
today,
|
|
25
|
+
});
|
|
26
|
+
const asList = (v) => (Array.isArray(v) ? v : [v]);
|
|
27
|
+
/** The 0.5.x grammar: `bad` must produce >=1 finding, `good` none, `excluded` proves the exclude glob. */
|
|
28
|
+
const proveFixtures = async (rule, cases, settings, today) => {
|
|
29
|
+
const failures = [];
|
|
30
|
+
// Declared, not fired: a dead snippet reports once, on its own line. `misplaced`
|
|
31
|
+
// mutes the two floors — an out-of-scope path already has its own message and
|
|
32
|
+
// the floors would only tell the author to add cases they already declared.
|
|
33
|
+
let declaredBad = false;
|
|
34
|
+
let declaredGood = false;
|
|
35
|
+
let misplaced = false;
|
|
36
|
+
for (const fx of cases) {
|
|
37
|
+
if (fx.excluded) {
|
|
38
|
+
if (!matchesAny(fx.file, rule.scope))
|
|
39
|
+
failures.push(`${rule.name}: ${fx.file} is declared excluded but is not in the rule's scope (${rule.scope.join(', ')}) — the exclusion is doing no work`);
|
|
40
|
+
if (!rule.exclude || !matchesAny(fx.file, rule.exclude))
|
|
41
|
+
failures.push(`${rule.name}: ${fx.file} is declared excluded but the rule's exclude list (${(rule.exclude ?? []).join(', ') || 'none'}) does not match it`);
|
|
42
|
+
if (fx.bad !== undefined)
|
|
43
|
+
failures.push(`${rule.name}: ${fx.file} is excluded — the runner never checks it, so a 'bad' snippet proves nothing; drop it`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (!matchesAny(fx.file, rule.scope)) {
|
|
47
|
+
failures.push(`${rule.name}: fixture path ${fx.file} is outside the rule's own scope (${rule.scope.join(', ')})`);
|
|
48
|
+
misplaced = true;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (fx.bad !== undefined) {
|
|
52
|
+
declaredBad = true;
|
|
53
|
+
const bad = await runOnFile(rule, fx.file, fx.bad, settings, today);
|
|
54
|
+
if (bad.length === 0)
|
|
55
|
+
failures.push(`${rule.name}: violating fixture at ${fx.file} produced no finding — the check is a no-op:\n${fx.bad}`);
|
|
56
|
+
}
|
|
57
|
+
if (fx.good !== undefined) {
|
|
58
|
+
declaredGood = true;
|
|
59
|
+
const good = await runOnFile(rule, fx.file, fx.good, settings, today);
|
|
60
|
+
if (good.length > 0)
|
|
61
|
+
failures.push(`${rule.name}: clean fixture at ${fx.file} produced ${good.length} false positive(s): ${good.map(g => g.message).join('; ')}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!misplaced && !declaredGood)
|
|
65
|
+
failures.push(`${rule.name}: no case proves the rule stays quiet — at least one 'good' snippet is required`);
|
|
66
|
+
if (!misplaced && !declaredBad)
|
|
67
|
+
failures.push(`${rule.name}: no case declares a 'bad' snippet — an 'excluded' case never runs the check, and a 'good'-only fixture cannot prove the rule still matches`);
|
|
68
|
+
return failures;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* A virtual repo, gated exactly like the runner: only files in `scope` minus `exclude` count.
|
|
72
|
+
* A `once` rule runs once over the whole world; a file rule runs over every world file in
|
|
73
|
+
* scope. A `finding` world with nothing in scope is no proof — production would never run it.
|
|
74
|
+
*/
|
|
75
|
+
const proveWorld = async (rule, world, settings, today) => {
|
|
76
|
+
const paths = Object.keys(world.files);
|
|
77
|
+
const read = (p) => world.files[p] ?? null;
|
|
78
|
+
const inScope = paths.filter(f => matchesAny(f, rule.scope) && !(rule.exclude && matchesAny(f, rule.exclude)));
|
|
79
|
+
const date = world.today ?? today;
|
|
80
|
+
if (inScope.length === 0)
|
|
81
|
+
return world.expect === 'finding' ? [`${rule.name}: world has no file in scope`] : [];
|
|
82
|
+
const findings = [];
|
|
83
|
+
if (rule.once) {
|
|
84
|
+
// Shaped like the runner's once context.
|
|
85
|
+
findings.push(...await callCheck(rule, { file: rule.scope[0], source: '', code: '', read, allFiles: paths, settings, overrides: [], today: date }));
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
for (const file of inScope) {
|
|
89
|
+
const source = world.files[file] ?? '';
|
|
90
|
+
findings.push(...await callCheck(rule, { file, source, code: stripCodeComments(source, file), read, allFiles: paths, settings, overrides: [], today: date }));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const listed = findings.map(f => `${f.file}:${f.line} ${f.message}`).join('; ');
|
|
94
|
+
if (world.expect === 'silence')
|
|
95
|
+
return findings.length ? [`${rule.name}: world expected silence but produced ${findings.length} finding(s): ${listed}`] : [];
|
|
96
|
+
if (findings.length === 0)
|
|
97
|
+
return [`${rule.name}: world expected a finding and produced none — the check is a no-op`];
|
|
98
|
+
if (world.match && !findings.some(f => world.match.test(f.message)))
|
|
99
|
+
return [`${rule.name}: world produced findings but none matched ${world.match}: ${listed}`];
|
|
100
|
+
return [];
|
|
101
|
+
};
|
|
102
|
+
/** A programmatic proof: every returned string is a failure. */
|
|
103
|
+
const proveVerify = async (rule, verify) => (await verify()).map(msg => `${rule.name}: ${msg}`);
|
|
104
|
+
/**
|
|
105
|
+
* Prove every rule still fires on a known violation and stays quiet on a clean case, through
|
|
106
|
+
* whichever proof the rule carries. A rule with none is itself a failure.
|
|
107
|
+
*/
|
|
108
|
+
export const runSelfTest = async (input) => {
|
|
109
|
+
const settings = input.settings ?? {};
|
|
110
|
+
const today = input.today ?? todayUtc();
|
|
111
|
+
const failures = [];
|
|
112
|
+
const checked = [];
|
|
113
|
+
for (const rule of input.rules) {
|
|
114
|
+
// A JS consumer can hand over a rule the type would have refused.
|
|
115
|
+
const proof = rule;
|
|
116
|
+
const forms = ['fixtures', 'world', 'verify'].filter(k => proof[k] !== undefined);
|
|
117
|
+
if (forms.length === 0) {
|
|
118
|
+
failures.push(`${rule.name}: no proof — add fixtures, world or verify`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (forms.length > 1) {
|
|
122
|
+
failures.push(`${rule.name}: carries more than one proof form — keep fixtures, world or verify`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
// A throwing check or a rejecting verify fails THIS rule; the rest still get proven.
|
|
126
|
+
try {
|
|
127
|
+
if (proof.fixtures)
|
|
128
|
+
failures.push(...await proveFixtures(rule, asList(proof.fixtures), settings, today));
|
|
129
|
+
else if (proof.world)
|
|
130
|
+
for (const w of asList(proof.world))
|
|
131
|
+
failures.push(...await proveWorld(rule, w, settings, today));
|
|
132
|
+
else if (proof.verify)
|
|
133
|
+
failures.push(...await proveVerify(rule, proof.verify));
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
failures.push(`${rule.name}: proof crashed — ${e instanceof Error ? e.message : String(e)}`);
|
|
137
|
+
}
|
|
138
|
+
checked.push(rule.name);
|
|
139
|
+
}
|
|
140
|
+
return { ok: failures.length === 0, failures, checked };
|
|
141
|
+
};
|