@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/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
+ }