@sfqa/scar 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +863 -2
- package/dist/capture.d.ts +20 -0
- package/dist/capture.js +42 -0
- package/dist/cli.d.ts +80 -0
- package/dist/cli.js +622 -0
- package/dist/harvest.d.ts +82 -0
- package/dist/harvest.js +511 -0
- package/dist/hash.d.ts +1 -0
- package/dist/hash.js +10 -0
- package/dist/ignore.d.ts +17 -0
- package/dist/ignore.js +38 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +14 -0
- package/dist/local.d.ts +17 -0
- package/dist/local.js +163 -0
- package/dist/new-rule.d.ts +39 -0
- package/dist/new-rule.js +209 -0
- package/dist/prove.d.ts +37 -0
- package/dist/prove.js +158 -0
- package/dist/redact.d.ts +3 -0
- package/dist/redact.js +20 -0
- package/dist/report.d.ts +23 -0
- package/dist/report.js +137 -0
- package/dist/rules.d.ts +38 -0
- package/dist/rules.js +166 -0
- package/dist/scan.d.ts +38 -0
- package/dist/scan.js +404 -0
- package/dist/types.d.ts +77 -0
- package/dist/types.js +11 -0
- package/package.json +59 -4
- package/prompts/extract-rules.md +80 -0
- package/prompts/turn-incident-into-rule.md +82 -0
- package/rules/generic.json +518 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// packages/scar/src/cli.ts
|
|
3
|
+
// -----------------------------------------------------------------------------
|
|
4
|
+
// sfqa-scar: review code against a rule set extracted from past failures.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// sfqa-scar [options] [paths...]
|
|
8
|
+
//
|
|
9
|
+
// The default rule set ships with the package (`rules/generic.json`). Point
|
|
10
|
+
// `--rules` at your own rule set once you have extracted one from your repo's
|
|
11
|
+
// history; the detectors are data, so no code change is needed.
|
|
12
|
+
// -----------------------------------------------------------------------------
|
|
13
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { dirname, join, resolve } from 'node:path';
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
16
|
+
import { HarvestError, formatHarvestJson, formatHarvestText, harvest, } from './harvest.js';
|
|
17
|
+
import { DEFAULT_IGNORE_FILE, addIgnore, loadIgnoreLedger, saveIgnoreLedger, } from './ignore.js';
|
|
18
|
+
import { DEFAULT_CAPTURE_FILE, addCapture, loadCaptureQueue, saveCaptureQueue, } from './capture.js';
|
|
19
|
+
import { formatScaffold, ScaffoldError, scaffoldRule } from './new-rule.js';
|
|
20
|
+
import { DEFAULT_FIXTURE_ROOT, formatProof, ProveError, proveRules } from './prove.js';
|
|
21
|
+
import { buildReport, formatJson, formatMarkdown, formatText, shouldFail, } from './report.js';
|
|
22
|
+
import { RuleSetError, SEVERITIES, compileRuleSet, loadRuleSet } from './rules.js';
|
|
23
|
+
import { collectFiles, gitChangedFiles, scanFiles } from './scan.js';
|
|
24
|
+
export const DEFAULT_RULES = join(dirname(fileURLToPath(import.meta.url)), '..', 'rules', 'generic.json');
|
|
25
|
+
export class CliError extends Error {
|
|
26
|
+
constructor(message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'CliError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const FAIL_ON_VALUES = ['high', 'med', 'low', 'any'];
|
|
32
|
+
export const USAGE = `sfqa-scar - review code against past-failure patterns
|
|
33
|
+
|
|
34
|
+
The four things you do
|
|
35
|
+
sfqa-scar [options] [paths...] scan files or directories
|
|
36
|
+
sfqa-scar new --rule <sentence> --scope <dir-or-glob>
|
|
37
|
+
start a rule: writes the rule and both fixtures
|
|
38
|
+
sfqa-scar prove [--id ST-020] prove rules against their fixtures
|
|
39
|
+
sfqa-scar harvest --help find incidents that never became rules
|
|
40
|
+
|
|
41
|
+
Scan options
|
|
42
|
+
--base <ref> Review files changed vs a git ref instead of paths
|
|
43
|
+
--staged Review only staged files
|
|
44
|
+
--rules <file> Rule set to load (default: scar-tissue.json if it exists,
|
|
45
|
+
otherwise the packaged template)
|
|
46
|
+
--fail-on <sev> Exit 1 at this severity or higher: high|med|low|any (default: high)
|
|
47
|
+
--json Machine-readable JSON on stdout
|
|
48
|
+
--markdown Markdown table with relative paths, for a PR or a write-up
|
|
49
|
+
--max-per-rule <n> Max lines reported per rule per file (default: 5)
|
|
50
|
+
-h, --help This message
|
|
51
|
+
|
|
52
|
+
New rule options (sfqa-scar new)
|
|
53
|
+
--rule <sentence> The rule, imperative: what must not happen again
|
|
54
|
+
--scope <dir|glob> Where the rule may fire; repeatable. A plain directory means
|
|
55
|
+
"everything under it". The fixture is placed to match it.
|
|
56
|
+
Required: a rule with no place cannot be proved
|
|
57
|
+
--severity <sev> high|med|low (default: med - high is for lost data, a
|
|
58
|
+
leaked secret, a wrong record or a false green)
|
|
59
|
+
--id <id> Rule id (default: the next free one, e.g. ST-020)
|
|
60
|
+
--fixtures <dir> Fixture root (default: scar-fixtures)
|
|
61
|
+
|
|
62
|
+
Prove options (sfqa-scar prove)
|
|
63
|
+
--id <id> Prove only this rule; repeatable
|
|
64
|
+
--rules <file> Rule set to prove (same default as scan)
|
|
65
|
+
--fixtures <dir> Fixture root (default: scar-fixtures)
|
|
66
|
+
|
|
67
|
+
Run sfqa-scar harvest --help for the history and report options.
|
|
68
|
+
`;
|
|
69
|
+
export const HARVEST_USAGE = `sfqa-scar harvest - propose rules for incidents no rule claims
|
|
70
|
+
|
|
71
|
+
sfqa-scar harvest [options] read git history for incident-shaped commits
|
|
72
|
+
sfqa-scar harvest --staged read one staged change instead
|
|
73
|
+
|
|
74
|
+
Options
|
|
75
|
+
--since <date> Only read commits after this date (git --since)
|
|
76
|
+
--limit <n> Max commits read (default: 300)
|
|
77
|
+
--all Also show covered and dismissed incidents
|
|
78
|
+
--write <file> Write candidates as JSON, for review or a PR comment
|
|
79
|
+
--strict Exit 1 when any incident-shaped change has no rule
|
|
80
|
+
--staged Inspect the staged change instead of history
|
|
81
|
+
--message <text> Commit message for --staged
|
|
82
|
+
--message-file <f> Read the commit message from a file (commit-msg hook)
|
|
83
|
+
--force Treat a staged change as an incident without a keyword
|
|
84
|
+
--report <path> Read a Playwright JSON / JUnit XML report (repeatable)
|
|
85
|
+
--report-format <f> auto|playwright|junit (default: auto)
|
|
86
|
+
--ask Prompt to capture or ignore each staged candidate
|
|
87
|
+
--capture <id> Record a captured candidate in the capture queue
|
|
88
|
+
--ignore <id> Record a dismissal in the ignore ledger
|
|
89
|
+
--rules <file> Rule set to check candidates against
|
|
90
|
+
--ignore-file <f> Ledger path (default: scar-tissue.ignore.json)
|
|
91
|
+
--capture-file <f> Queue path (default: scar-tissue.candidates.json)
|
|
92
|
+
-h, --help This message
|
|
93
|
+
|
|
94
|
+
A candidate is a commit whose message says fix, revert, hotfix, flaky, timeout,
|
|
95
|
+
race, regression or credential, and that no rule in your rule set mentions.
|
|
96
|
+
`;
|
|
97
|
+
export function parseArgs(argv, defaultRules = DEFAULT_RULES) {
|
|
98
|
+
const options = {
|
|
99
|
+
rules: defaultRules,
|
|
100
|
+
rulesExplicit: false,
|
|
101
|
+
paths: [],
|
|
102
|
+
staged: false,
|
|
103
|
+
json: false,
|
|
104
|
+
markdown: false,
|
|
105
|
+
failOn: 'high',
|
|
106
|
+
maxPerRule: 5,
|
|
107
|
+
help: false,
|
|
108
|
+
};
|
|
109
|
+
for (let i = 0; i < argv.length; i++) {
|
|
110
|
+
const arg = argv[i];
|
|
111
|
+
if (arg === '--rules') {
|
|
112
|
+
options.rules = requireValue(argv, ++i, '--rules');
|
|
113
|
+
options.rulesExplicit = true;
|
|
114
|
+
}
|
|
115
|
+
else if (arg === '--base') {
|
|
116
|
+
options.base = requireValue(argv, ++i, '--base');
|
|
117
|
+
}
|
|
118
|
+
else if (arg === '--staged') {
|
|
119
|
+
options.staged = true;
|
|
120
|
+
}
|
|
121
|
+
else if (arg === '--json') {
|
|
122
|
+
options.json = true;
|
|
123
|
+
}
|
|
124
|
+
else if (arg === '--markdown') {
|
|
125
|
+
options.markdown = true;
|
|
126
|
+
}
|
|
127
|
+
else if (arg === '--fail-on') {
|
|
128
|
+
const value = requireValue(argv, ++i, '--fail-on');
|
|
129
|
+
if (!FAIL_ON_VALUES.includes(value)) {
|
|
130
|
+
throw new CliError(`--fail-on must be one of ${FAIL_ON_VALUES.join(', ')}`);
|
|
131
|
+
}
|
|
132
|
+
options.failOn = value;
|
|
133
|
+
}
|
|
134
|
+
else if (arg === '--max-per-rule') {
|
|
135
|
+
const value = Number(requireValue(argv, ++i, '--max-per-rule'));
|
|
136
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
137
|
+
throw new CliError('--max-per-rule must be a positive integer');
|
|
138
|
+
}
|
|
139
|
+
options.maxPerRule = value;
|
|
140
|
+
}
|
|
141
|
+
else if (arg === '-h' || arg === '--help') {
|
|
142
|
+
options.help = true;
|
|
143
|
+
}
|
|
144
|
+
else if (arg.startsWith('-')) {
|
|
145
|
+
throw new CliError(`unknown option ${arg}`);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
options.paths.push(arg);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return options;
|
|
152
|
+
}
|
|
153
|
+
function requireValue(argv, index, flag) {
|
|
154
|
+
const value = argv[index];
|
|
155
|
+
if (!value || value.startsWith('-')) {
|
|
156
|
+
throw new CliError(`${flag} requires a value`);
|
|
157
|
+
}
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
export function parseHarvestArgs(argv, defaultRules = DEFAULT_RULES) {
|
|
161
|
+
const options = {
|
|
162
|
+
rules: defaultRules,
|
|
163
|
+
rulesExplicit: false,
|
|
164
|
+
limit: 300,
|
|
165
|
+
json: false,
|
|
166
|
+
all: false,
|
|
167
|
+
strict: false,
|
|
168
|
+
help: false,
|
|
169
|
+
staged: false,
|
|
170
|
+
force: false,
|
|
171
|
+
reports: [],
|
|
172
|
+
reportFormat: 'auto',
|
|
173
|
+
ignoreFile: DEFAULT_IGNORE_FILE,
|
|
174
|
+
ignore: [],
|
|
175
|
+
captureFile: DEFAULT_CAPTURE_FILE,
|
|
176
|
+
capture: [],
|
|
177
|
+
ask: false,
|
|
178
|
+
};
|
|
179
|
+
for (let i = 0; i < argv.length; i++) {
|
|
180
|
+
const arg = argv[i];
|
|
181
|
+
if (arg === '--rules') {
|
|
182
|
+
options.rules = requireValue(argv, ++i, '--rules');
|
|
183
|
+
}
|
|
184
|
+
else if (arg === '--since') {
|
|
185
|
+
options.since = requireValue(argv, ++i, '--since');
|
|
186
|
+
}
|
|
187
|
+
else if (arg === '--limit') {
|
|
188
|
+
const value = Number(requireValue(argv, ++i, '--limit'));
|
|
189
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
190
|
+
throw new CliError('--limit must be a positive integer');
|
|
191
|
+
}
|
|
192
|
+
options.limit = value;
|
|
193
|
+
}
|
|
194
|
+
else if (arg === '--json') {
|
|
195
|
+
options.json = true;
|
|
196
|
+
}
|
|
197
|
+
else if (arg === '--all') {
|
|
198
|
+
options.all = true;
|
|
199
|
+
}
|
|
200
|
+
else if (arg === '--write') {
|
|
201
|
+
options.write = requireValue(argv, ++i, '--write');
|
|
202
|
+
}
|
|
203
|
+
else if (arg === '--strict') {
|
|
204
|
+
options.strict = true;
|
|
205
|
+
}
|
|
206
|
+
else if (arg === '--staged') {
|
|
207
|
+
options.staged = true;
|
|
208
|
+
}
|
|
209
|
+
else if (arg === '--message') {
|
|
210
|
+
options.message = requireValue(argv, ++i, '--message');
|
|
211
|
+
}
|
|
212
|
+
else if (arg === '--message-file') {
|
|
213
|
+
options.messageFile = requireValue(argv, ++i, '--message-file');
|
|
214
|
+
}
|
|
215
|
+
else if (arg === '--force') {
|
|
216
|
+
options.force = true;
|
|
217
|
+
}
|
|
218
|
+
else if (arg === '--report') {
|
|
219
|
+
options.reports.push(requireValue(argv, ++i, '--report'));
|
|
220
|
+
}
|
|
221
|
+
else if (arg === '--report-format') {
|
|
222
|
+
const value = requireValue(argv, ++i, '--report-format');
|
|
223
|
+
if (!['auto', 'playwright', 'junit'].includes(value)) {
|
|
224
|
+
throw new CliError('--report-format must be auto, playwright or junit');
|
|
225
|
+
}
|
|
226
|
+
options.reportFormat = value;
|
|
227
|
+
}
|
|
228
|
+
else if (arg === '--ignore-file') {
|
|
229
|
+
options.ignoreFile = requireValue(argv, ++i, '--ignore-file');
|
|
230
|
+
}
|
|
231
|
+
else if (arg === '--ignore') {
|
|
232
|
+
options.ignore.push(requireValue(argv, ++i, '--ignore'));
|
|
233
|
+
}
|
|
234
|
+
else if (arg === '--capture-file') {
|
|
235
|
+
options.captureFile = requireValue(argv, ++i, '--capture-file');
|
|
236
|
+
}
|
|
237
|
+
else if (arg === '--capture') {
|
|
238
|
+
options.capture.push(requireValue(argv, ++i, '--capture'));
|
|
239
|
+
}
|
|
240
|
+
else if (arg === '--ask') {
|
|
241
|
+
options.ask = true;
|
|
242
|
+
}
|
|
243
|
+
else if (arg === '-h' || arg === '--help') {
|
|
244
|
+
options.help = true;
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
throw new CliError(`unexpected argument ${arg} (harvest takes options only)`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return options;
|
|
251
|
+
}
|
|
252
|
+
async function defaultAsk(question) {
|
|
253
|
+
const { createInterface } = await import('node:readline/promises');
|
|
254
|
+
const reader = createInterface({ input: process.stdin, output: process.stdout });
|
|
255
|
+
try {
|
|
256
|
+
return (await reader.question(question)).trim().toLowerCase();
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
reader.close();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function readCommitMessage(cwd, file) {
|
|
263
|
+
try {
|
|
264
|
+
return readFileSync(resolve(cwd, file), 'utf8');
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** Strip the comment lines git puts in COMMIT_EDITMSG before it commits. */
|
|
271
|
+
function cleanMessage(message) {
|
|
272
|
+
if (!message)
|
|
273
|
+
return undefined;
|
|
274
|
+
return message
|
|
275
|
+
.split('\n')
|
|
276
|
+
.filter((line) => !line.startsWith('#'))
|
|
277
|
+
.join('\n')
|
|
278
|
+
.trim();
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Advisory command. Exit 0 by default even when it finds gaps, because a gap is
|
|
282
|
+
* information, not a failed scan. `--strict` makes a gap fail CI.
|
|
283
|
+
*
|
|
284
|
+
* `--ask` prompts only for a staged change, because that is the one moment the
|
|
285
|
+
* person still remembers the incident. History is reviewed in bulk instead.
|
|
286
|
+
*/
|
|
287
|
+
export async function runHarvest(options, cwd = process.cwd(), ask) {
|
|
288
|
+
if (options.help) {
|
|
289
|
+
console.log(HARVEST_USAGE);
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
|
292
|
+
const rulesPath = options.rulesExplicit ? options.rules : defaultRulePath(cwd);
|
|
293
|
+
let rules = [];
|
|
294
|
+
try {
|
|
295
|
+
rules = compileRuleSet(loadRuleSet(resolve(cwd, rulesPath)));
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
if (!(error instanceof RuleSetError))
|
|
299
|
+
throw error;
|
|
300
|
+
console.error(`sfqa-scar: ${error.message}`);
|
|
301
|
+
console.error('harvest: continuing with no existing rules');
|
|
302
|
+
}
|
|
303
|
+
const ignorePath = resolve(cwd, options.ignoreFile);
|
|
304
|
+
const ledger = loadIgnoreLedger(ignorePath);
|
|
305
|
+
let ledgerChanged = false;
|
|
306
|
+
const capturePath = resolve(cwd, options.captureFile);
|
|
307
|
+
const captures = loadCaptureQueue(capturePath);
|
|
308
|
+
let captureChanged = false;
|
|
309
|
+
const now = new Date().toISOString();
|
|
310
|
+
for (const entryFingerprint of options.ignore) {
|
|
311
|
+
addIgnore(ledger, {
|
|
312
|
+
fingerprint: entryFingerprint,
|
|
313
|
+
reason: 'ignored from the command line',
|
|
314
|
+
at: now,
|
|
315
|
+
});
|
|
316
|
+
ledgerChanged = true;
|
|
317
|
+
}
|
|
318
|
+
const message = cleanMessage(options.message ?? (options.messageFile ? readCommitMessage(cwd, options.messageFile) : undefined));
|
|
319
|
+
const result = harvest(rules, {
|
|
320
|
+
cwd,
|
|
321
|
+
since: options.since,
|
|
322
|
+
limit: options.limit,
|
|
323
|
+
includeCovered: options.all,
|
|
324
|
+
includeIgnored: options.all,
|
|
325
|
+
staged: options.staged,
|
|
326
|
+
message,
|
|
327
|
+
force: options.force,
|
|
328
|
+
reports: options.reports,
|
|
329
|
+
reportFormat: options.reportFormat,
|
|
330
|
+
ignoreLedger: ledger,
|
|
331
|
+
captureQueue: captures,
|
|
332
|
+
});
|
|
333
|
+
console.log(options.json ? formatHarvestJson(result) : formatHarvestText(result));
|
|
334
|
+
let accepted = [...result.candidates];
|
|
335
|
+
for (const entryFingerprint of options.capture) {
|
|
336
|
+
const candidate = result.candidates.find((item) => item.fingerprint === entryFingerprint);
|
|
337
|
+
addCapture(captures, {
|
|
338
|
+
fingerprint: entryFingerprint,
|
|
339
|
+
capturedAt: now,
|
|
340
|
+
...(candidate?.subject ? { subject: candidate.subject } : {}),
|
|
341
|
+
...(candidate?.files ? { files: candidate.files } : {}),
|
|
342
|
+
...(candidate ? { draft: candidate.draft } : {}),
|
|
343
|
+
});
|
|
344
|
+
captureChanged = true;
|
|
345
|
+
console.log(candidate
|
|
346
|
+
? `captured ${entryFingerprint} (${candidate.subject})`
|
|
347
|
+
: `captured ${entryFingerprint} with no draft in this run - re-run the same source to attach one`);
|
|
348
|
+
}
|
|
349
|
+
if (options.ask && options.staged && result.candidates.length > 0) {
|
|
350
|
+
const interactive = ask ?? (process.stdin.isTTY ? defaultAsk : undefined);
|
|
351
|
+
if (!interactive) {
|
|
352
|
+
console.log('\nnot a TTY; no prompt. Capture later with --capture <id>, dismiss with --ignore <id>.');
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
accepted = [];
|
|
356
|
+
for (const candidate of result.candidates) {
|
|
357
|
+
if (candidate.ignoredAt) {
|
|
358
|
+
accepted.push(candidate);
|
|
359
|
+
console.log(`\nalready dismissed ${candidate.fingerprint}; --all shows why.`);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (candidate.capturedAt) {
|
|
363
|
+
accepted.push(candidate);
|
|
364
|
+
console.log(`\nalready captured ${candidate.fingerprint} on ${candidate.capturedAt}.`);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
const answer = await interactive(`\ncapture a draft for ${candidate.shortHash} "${candidate.subject}"? [y]es / [i]gnore / [s]kip: `);
|
|
368
|
+
if (answer.startsWith('y')) {
|
|
369
|
+
accepted.push(candidate);
|
|
370
|
+
addCapture(captures, {
|
|
371
|
+
fingerprint: candidate.fingerprint,
|
|
372
|
+
capturedAt: new Date().toISOString(),
|
|
373
|
+
subject: candidate.subject,
|
|
374
|
+
files: candidate.files,
|
|
375
|
+
draft: candidate.draft,
|
|
376
|
+
});
|
|
377
|
+
captureChanged = true;
|
|
378
|
+
console.log(` captured ${candidate.fingerprint}`);
|
|
379
|
+
}
|
|
380
|
+
else if (answer.startsWith('i')) {
|
|
381
|
+
const reason = await interactive(' reason (optional): ');
|
|
382
|
+
addIgnore(ledger, {
|
|
383
|
+
fingerprint: candidate.fingerprint,
|
|
384
|
+
reason: reason || 'ignored at commit time',
|
|
385
|
+
at: new Date().toISOString(),
|
|
386
|
+
subject: candidate.subject,
|
|
387
|
+
});
|
|
388
|
+
ledgerChanged = true;
|
|
389
|
+
console.log(` ignored ${candidate.fingerprint} - not asked again`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
else if (options.ask && !options.staged) {
|
|
395
|
+
console.log('\n--ask only prompts for --staged. Use --write for a history review.');
|
|
396
|
+
}
|
|
397
|
+
if (ledgerChanged)
|
|
398
|
+
saveIgnoreLedger(ignorePath, ledger);
|
|
399
|
+
if (captureChanged) {
|
|
400
|
+
saveCaptureQueue(capturePath, captures);
|
|
401
|
+
console.log(`\ncaptured decisions in ${capturePath}`);
|
|
402
|
+
}
|
|
403
|
+
if (options.write) {
|
|
404
|
+
const target = resolve(cwd, options.write);
|
|
405
|
+
writeFileSync(target, `${formatHarvestJson({ ...result, candidates: accepted })}\n`, 'utf8');
|
|
406
|
+
console.log(`\nwrote ${target}`);
|
|
407
|
+
}
|
|
408
|
+
const gaps = [...accepted, ...result.localFailures];
|
|
409
|
+
return options.strict && gaps.length > 0 ? 1 : 0;
|
|
410
|
+
}
|
|
411
|
+
/** Local rule set if the repository has one, otherwise the packaged template. */
|
|
412
|
+
function defaultRulePath(cwd) {
|
|
413
|
+
return existsSync(resolve(cwd, 'scar-tissue.json')) ? 'scar-tissue.json' : DEFAULT_RULES;
|
|
414
|
+
}
|
|
415
|
+
/** A path as a person would type it: relative when it is under the working directory. */
|
|
416
|
+
function shownPath(cwd, absolute) {
|
|
417
|
+
const rel = absolute.startsWith(cwd) ? absolute.slice(cwd.length).replace(/^[\\/]/, '') : absolute;
|
|
418
|
+
return rel || absolute;
|
|
419
|
+
}
|
|
420
|
+
export function parseNewArgs(argv) {
|
|
421
|
+
const options = { scope: [], fixtures: DEFAULT_FIXTURE_ROOT, help: false };
|
|
422
|
+
for (let i = 0; i < argv.length; i++) {
|
|
423
|
+
const arg = argv[i];
|
|
424
|
+
if (arg === '--rule') {
|
|
425
|
+
options.rule = requireValue(argv, ++i, '--rule');
|
|
426
|
+
}
|
|
427
|
+
else if (arg === '--scope') {
|
|
428
|
+
options.scope.push(requireValue(argv, ++i, '--scope'));
|
|
429
|
+
}
|
|
430
|
+
else if (arg === '--severity') {
|
|
431
|
+
const value = requireValue(argv, ++i, '--severity');
|
|
432
|
+
if (!SEVERITIES.includes(value)) {
|
|
433
|
+
throw new CliError(`--severity must be one of ${SEVERITIES.join(', ')}`);
|
|
434
|
+
}
|
|
435
|
+
options.severity = value;
|
|
436
|
+
}
|
|
437
|
+
else if (arg === '--id') {
|
|
438
|
+
options.id = requireValue(argv, ++i, '--id');
|
|
439
|
+
}
|
|
440
|
+
else if (arg === '--rules') {
|
|
441
|
+
options.rules = requireValue(argv, ++i, '--rules');
|
|
442
|
+
}
|
|
443
|
+
else if (arg === '--fixtures') {
|
|
444
|
+
options.fixtures = requireValue(argv, ++i, '--fixtures');
|
|
445
|
+
}
|
|
446
|
+
else if (arg === '-h' || arg === '--help') {
|
|
447
|
+
options.help = true;
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
throw new CliError(`unexpected argument ${arg} (new takes options, e.g. --rule "..." --scope "src/**")`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return options;
|
|
454
|
+
}
|
|
455
|
+
/** Write one rule and its two fixtures. Returns 0; a bad request is a usage error. */
|
|
456
|
+
export function runNew(options, cwd = process.cwd()) {
|
|
457
|
+
if (options.help) {
|
|
458
|
+
console.log(USAGE);
|
|
459
|
+
return 0;
|
|
460
|
+
}
|
|
461
|
+
if (!options.rule) {
|
|
462
|
+
throw new CliError('new needs --rule: the rule in one sentence, e.g. --rule "Never pass options to isVisible()"');
|
|
463
|
+
}
|
|
464
|
+
const result = scaffoldRule({
|
|
465
|
+
title: options.rule,
|
|
466
|
+
scope: options.scope,
|
|
467
|
+
severity: options.severity,
|
|
468
|
+
id: options.id,
|
|
469
|
+
// `new` always writes a rule set you own. The packaged template is a
|
|
470
|
+
// read-only reference and, in an installed package, lives inside
|
|
471
|
+
// node_modules - appending to it would be lost on the next install.
|
|
472
|
+
rulesPath: options.rules ?? 'scar-tissue.json',
|
|
473
|
+
fixtureRoot: options.fixtures,
|
|
474
|
+
cwd,
|
|
475
|
+
});
|
|
476
|
+
console.log(formatScaffold(result));
|
|
477
|
+
return 0;
|
|
478
|
+
}
|
|
479
|
+
export function parseProveArgs(argv) {
|
|
480
|
+
const options = { ids: [], fixtures: DEFAULT_FIXTURE_ROOT, help: false };
|
|
481
|
+
for (let i = 0; i < argv.length; i++) {
|
|
482
|
+
const arg = argv[i];
|
|
483
|
+
if (arg === '--id') {
|
|
484
|
+
options.ids.push(requireValue(argv, ++i, '--id'));
|
|
485
|
+
}
|
|
486
|
+
else if (arg === '--rules') {
|
|
487
|
+
options.rules = requireValue(argv, ++i, '--rules');
|
|
488
|
+
}
|
|
489
|
+
else if (arg === '--fixtures') {
|
|
490
|
+
options.fixtures = requireValue(argv, ++i, '--fixtures');
|
|
491
|
+
}
|
|
492
|
+
else if (arg === '-h' || arg === '--help') {
|
|
493
|
+
options.help = true;
|
|
494
|
+
}
|
|
495
|
+
else if (arg.startsWith('-')) {
|
|
496
|
+
throw new CliError(`unexpected option ${arg}`);
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
options.ids.push(arg);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return options;
|
|
503
|
+
}
|
|
504
|
+
/** Prove rules against their fixtures. 0 all proven, 1 a proof failed, 2 bad input. */
|
|
505
|
+
export function runProve(options, cwd = process.cwd()) {
|
|
506
|
+
if (options.help) {
|
|
507
|
+
console.log(USAGE);
|
|
508
|
+
return 0;
|
|
509
|
+
}
|
|
510
|
+
const rulesPath = options.rules ?? defaultRulePath(cwd);
|
|
511
|
+
let ruleSet;
|
|
512
|
+
try {
|
|
513
|
+
ruleSet = loadRuleSet(resolve(cwd, rulesPath));
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
if (error instanceof RuleSetError) {
|
|
517
|
+
console.error(`sfqa-scar: ${error.message}`);
|
|
518
|
+
return 2;
|
|
519
|
+
}
|
|
520
|
+
throw error;
|
|
521
|
+
}
|
|
522
|
+
const result = proveRules(ruleSet.rules, resolve(cwd, options.fixtures), options.ids);
|
|
523
|
+
console.log(formatProof(result, shownPath(cwd, resolve(cwd, rulesPath)), ruleSet.rules.length, shownPath(cwd, resolve(cwd, options.fixtures))));
|
|
524
|
+
return result.passed ? 0 : 1;
|
|
525
|
+
}
|
|
526
|
+
/** Run the scanner. Returns a process exit code: 0 clean, 1 findings, 2 usage. */
|
|
527
|
+
export function run(options, cwd = process.cwd()) {
|
|
528
|
+
if (options.help) {
|
|
529
|
+
console.log(USAGE);
|
|
530
|
+
return 0;
|
|
531
|
+
}
|
|
532
|
+
// A repository with its own scar-tissue.json is scanning its own rules.
|
|
533
|
+
// Ignoring that file here but honouring it in `prove` is the single most
|
|
534
|
+
// confusing thing this tool could do: you add a rule, run the scan, and see
|
|
535
|
+
// nothing. So the same resolver is used by both verbs.
|
|
536
|
+
const rulesPath = options.rulesExplicit ? options.rules : defaultRulePath(cwd);
|
|
537
|
+
let ruleSet;
|
|
538
|
+
try {
|
|
539
|
+
ruleSet = loadRuleSet(resolve(cwd, rulesPath));
|
|
540
|
+
}
|
|
541
|
+
catch (error) {
|
|
542
|
+
if (error instanceof RuleSetError) {
|
|
543
|
+
console.error(`sfqa-scar: ${error.message}`);
|
|
544
|
+
return 2;
|
|
545
|
+
}
|
|
546
|
+
throw error;
|
|
547
|
+
}
|
|
548
|
+
const rules = compileRuleSet(ruleSet);
|
|
549
|
+
const targets = options.staged || options.base
|
|
550
|
+
? gitChangedFiles(cwd, options.base, options.staged)
|
|
551
|
+
: options.paths;
|
|
552
|
+
if (targets.length === 0)
|
|
553
|
+
targets.push('.');
|
|
554
|
+
const files = collectFiles(targets, cwd);
|
|
555
|
+
const result = scanFiles(cwd, files, rules, { maxPerRule: options.maxPerRule });
|
|
556
|
+
const report = buildReport(rulesPath, result.filesScanned, result.findings);
|
|
557
|
+
if (options.json && options.markdown) {
|
|
558
|
+
console.error('sfqa-scar: --json and --markdown are mutually exclusive');
|
|
559
|
+
return 2;
|
|
560
|
+
}
|
|
561
|
+
const rendered = options.json
|
|
562
|
+
? formatJson(report)
|
|
563
|
+
: options.markdown
|
|
564
|
+
? formatMarkdown(report)
|
|
565
|
+
: formatText(report, cwd);
|
|
566
|
+
console.log(rendered);
|
|
567
|
+
return shouldFail(result.findings, options.failOn) ? 1 : 0;
|
|
568
|
+
}
|
|
569
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
570
|
+
try {
|
|
571
|
+
if (argv[0] === 'harvest') {
|
|
572
|
+
return await runHarvest(parseHarvestArgs(argv.slice(1)));
|
|
573
|
+
}
|
|
574
|
+
if (argv[0] === 'new') {
|
|
575
|
+
return runNew(parseNewArgs(argv.slice(1)));
|
|
576
|
+
}
|
|
577
|
+
if (argv[0] === 'prove') {
|
|
578
|
+
return runProve(parseProveArgs(argv.slice(1)));
|
|
579
|
+
}
|
|
580
|
+
return run(parseArgs(argv));
|
|
581
|
+
}
|
|
582
|
+
catch (error) {
|
|
583
|
+
if (error instanceof CliError) {
|
|
584
|
+
console.error(`sfqa-scar: ${error.message}\n`);
|
|
585
|
+
console.error(USAGE);
|
|
586
|
+
return 2;
|
|
587
|
+
}
|
|
588
|
+
if (error instanceof HarvestError || error instanceof ScaffoldError || error instanceof ProveError) {
|
|
589
|
+
console.error(`sfqa-scar: ${error.message}`);
|
|
590
|
+
return 2;
|
|
591
|
+
}
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* True when this file is the process entry point.
|
|
597
|
+
*
|
|
598
|
+
* npm installs a bin as a symlink - `node_modules/.bin/sfqa-scar` pointing at
|
|
599
|
+
* `@sfqa/scar/dist/cli.js` - so argv[1] is the symlink and import.meta.url is
|
|
600
|
+
* the real file. Comparing them directly is false under `npx` and the CLI exits
|
|
601
|
+
* silently with status 0, which looks exactly like "found nothing". Compare
|
|
602
|
+
* real paths instead.
|
|
603
|
+
*/
|
|
604
|
+
function invokedDirectly() {
|
|
605
|
+
const entry = process.argv[1];
|
|
606
|
+
if (entry === undefined)
|
|
607
|
+
return false;
|
|
608
|
+
try {
|
|
609
|
+
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
return import.meta.url === pathToFileURL(entry).href;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (invokedDirectly()) {
|
|
616
|
+
void main().then((code) => {
|
|
617
|
+
process.exitCode = code;
|
|
618
|
+
}, (error) => {
|
|
619
|
+
console.error(error instanceof Error ? (error.stack ?? error.message) : String(error));
|
|
620
|
+
process.exitCode = 2;
|
|
621
|
+
});
|
|
622
|
+
}
|