@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.
@@ -0,0 +1,82 @@
1
+ import { type CaptureQueue } from './capture.js';
2
+ import { type IgnoreLedger } from './ignore.js';
3
+ import { type LocalFailure, type ReportFormat } from './local.js';
4
+ import type { CompiledRule } from './rules.js';
5
+ import type { ScarRule } from './types.js';
6
+ /** Commit messages that usually mean "something broke and was repaired". */
7
+ export declare const INCIDENT_KEYWORDS: RegExp;
8
+ export declare class HarvestError extends Error {
9
+ constructor(message: string);
10
+ }
11
+ export interface HarvestOptions {
12
+ cwd?: string;
13
+ /** Passed to `git log --since`. */
14
+ since?: string;
15
+ /** Max commits inspected. Defaults to 300. */
16
+ limit?: number;
17
+ keywords?: RegExp;
18
+ /** Include incidents already covered by an existing rule. */
19
+ includeCovered?: boolean;
20
+ /** Also return candidates the ignore ledger dismissed. */
21
+ includeIgnored?: boolean;
22
+ /** Inspect the staged diff instead of git history. */
23
+ staged?: boolean;
24
+ /** Commit message, required for staged mode unless `force` is set. */
25
+ message?: string;
26
+ /** Treat a staged change as incident-shaped even without a keyword message. */
27
+ force?: boolean;
28
+ /** Local test report files or directories (Playwright JSON / JUnit XML). */
29
+ reports?: readonly string[];
30
+ reportFormat?: ReportFormat;
31
+ ignoreLedger?: IgnoreLedger;
32
+ captureQueue?: CaptureQueue;
33
+ }
34
+ export interface HarvestCandidate {
35
+ commit: string;
36
+ shortHash: string;
37
+ date: string;
38
+ subject: string;
39
+ files: string[];
40
+ removed: string[];
41
+ added: string[];
42
+ pathInclude: string[];
43
+ linePatterns: string[];
44
+ confidence: 'high' | 'medium' | 'low';
45
+ executable: boolean;
46
+ coveredBy: string[];
47
+ fingerprint: string;
48
+ ignoredAt?: string;
49
+ ignoreReason?: string;
50
+ capturedAt?: string;
51
+ draft: ScarRule;
52
+ }
53
+ export interface HarvestResult {
54
+ commits: number;
55
+ incidents: number;
56
+ candidates: HarvestCandidate[];
57
+ covered: HarvestCandidate[];
58
+ localFailures: LocalFailure[];
59
+ ignored: number;
60
+ captured: number;
61
+ staged: boolean;
62
+ }
63
+ /**
64
+ * Find incident-shaped changes that no existing rule accounts for: fixed
65
+ * commits in history, the change staged right now, or repeated failures in a
66
+ * local test report. Reads only. Writes nothing.
67
+ */
68
+ export declare function harvest(rules: readonly CompiledRule[], options?: HarvestOptions): HarvestResult;
69
+ export declare function formatHarvestText(result: HarvestResult): string;
70
+ export interface HarvestReport {
71
+ tool: 'sfqa-scar';
72
+ command: 'harvest';
73
+ staged: boolean;
74
+ commits: number;
75
+ incidents: number;
76
+ candidates: HarvestCandidate[];
77
+ covered: HarvestCandidate[];
78
+ localFailures: LocalFailure[];
79
+ ignored: number;
80
+ captured: number;
81
+ }
82
+ export declare function formatHarvestJson(result: HarvestResult): string;
@@ -0,0 +1,511 @@
1
+ // packages/scar/src/harvest.ts
2
+ // -----------------------------------------------------------------------------
3
+ // Rule discovery. The scanner enforces rules that already exist; harvest finds
4
+ // the incidents that never became rules and hands back a draft for each one.
5
+ //
6
+ // Two sources: git history (failures the team already fixed) and local test
7
+ // reports (failures it is living with now). Deterministic on purpose: it never
8
+ // writes a rule at scan time. A person (or a model working for one) still
9
+ // approves the draft, because a rule that cannot cite an incident is a style
10
+ // opinion.
11
+ // -----------------------------------------------------------------------------
12
+ import { execFileSync } from 'node:child_process';
13
+ import { basename } from 'node:path';
14
+ import { fingerprint } from './hash.js';
15
+ import { findCapture } from './capture.js';
16
+ import { findIgnore } from './ignore.js';
17
+ import { readLocalReports } from './local.js';
18
+ import { isSecretShaped, redactLine } from './redact.js';
19
+ /** Commit messages that usually mean "something broke and was repaired". */
20
+ export const INCIDENT_KEYWORDS = /\b(fix|fixed|fixes|fixing|revert|reverted|hotfix|flaky|flake|race|timeout|workaround|credential|credentials|secret|secrets|rollback|regression|bug|defect|patch)\b/i;
21
+ const CODE_EXTENSIONS = new Set([
22
+ '.ts',
23
+ '.tsx',
24
+ '.js',
25
+ '.jsx',
26
+ '.mjs',
27
+ '.cjs',
28
+ '.py',
29
+ '.java',
30
+ '.rb',
31
+ '.go',
32
+ '.cs',
33
+ '.php',
34
+ '.kt',
35
+ '.swift',
36
+ '.scala',
37
+ '.feature',
38
+ '.json',
39
+ '.yml',
40
+ '.yaml',
41
+ '.sql',
42
+ '.sh',
43
+ ]);
44
+ const FIELD = '\x1f';
45
+ const RECORD = '\x1e';
46
+ export class HarvestError extends Error {
47
+ constructor(message) {
48
+ super(message);
49
+ this.name = 'HarvestError';
50
+ }
51
+ }
52
+ function git(cwd, args) {
53
+ try {
54
+ return execFileSync('git', args, {
55
+ cwd,
56
+ encoding: 'utf8',
57
+ maxBuffer: 32 * 1024 * 1024,
58
+ });
59
+ }
60
+ catch (error) {
61
+ const detail = error instanceof Error ? error.message : String(error);
62
+ throw new HarvestError(`git ${args.join(' ')} failed: ${detail}`);
63
+ }
64
+ }
65
+ function readCommits(cwd, options) {
66
+ const limit = options.limit ?? 300;
67
+ const format = `%H${FIELD}%h${FIELD}%ad${FIELD}%s${FIELD}%b${RECORD}`;
68
+ const args = ['log', '--no-merges', '--date=short', `--format=${format}`, '-n', String(limit)];
69
+ if (options.since)
70
+ args.push(`--since=${options.since}`);
71
+ return git(cwd, args)
72
+ .split(RECORD)
73
+ .map((record) => record.trim())
74
+ .filter(Boolean)
75
+ .map((record) => {
76
+ const [hash, shortHash, date, subject, body] = record.split(FIELD);
77
+ return {
78
+ hash: hash ?? '',
79
+ shortHash: shortHash ?? '',
80
+ date: date ?? '',
81
+ subject: subject ?? '',
82
+ body: body ?? '',
83
+ };
84
+ })
85
+ .filter((commit) => commit.hash.length > 0);
86
+ }
87
+ function parseDiff(out) {
88
+ const files = new Set();
89
+ const removed = [];
90
+ const added = [];
91
+ for (const line of out.split('\n')) {
92
+ if (line.startsWith('+++ b/')) {
93
+ files.add(line.slice(6).trim());
94
+ }
95
+ else if (line.startsWith('--- a/')) {
96
+ continue;
97
+ }
98
+ else if (line.startsWith('+') && !line.startsWith('+++')) {
99
+ added.push(line.slice(1));
100
+ }
101
+ else if (line.startsWith('-') && !line.startsWith('---')) {
102
+ removed.push(line.slice(1));
103
+ }
104
+ }
105
+ return { files: [...files], removed, added };
106
+ }
107
+ function readDiff(cwd, hash) {
108
+ return parseDiff(git(cwd, ['show', '--format=', '--unified=0', '--no-color', '--no-ext-diff', hash]));
109
+ }
110
+ function readStagedDiff(cwd) {
111
+ return parseDiff(git(cwd, ['diff', '--cached', '--unified=0', '--no-color', '--no-ext-diff']));
112
+ }
113
+ function isCodeFile(file) {
114
+ const dot = file.lastIndexOf('.');
115
+ return dot !== -1 && CODE_EXTENSIONS.has(file.slice(dot).toLowerCase());
116
+ }
117
+ function isComment(line) {
118
+ const trimmed = line.trimStart();
119
+ return (trimmed.startsWith('//') ||
120
+ trimmed.startsWith('/*') ||
121
+ trimmed.startsWith('*') ||
122
+ (trimmed.startsWith('#') && !trimmed.startsWith('#!')));
123
+ }
124
+ function meaningful(lines) {
125
+ return lines
126
+ .map((line) => line.trim())
127
+ .filter((line) => line.length > 0)
128
+ .filter((line) => !isComment(line))
129
+ .filter((line) => !/^[{}()[\];,]+$/.test(line))
130
+ .filter((line) => !/^(import|export\s+\{|export\s+default|from)\b/.test(line));
131
+ }
132
+ function escapeRegex(value) {
133
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
134
+ }
135
+ /** Turn a literal token into a regex, allowing flexible whitespace. */
136
+ function toRegex(token) {
137
+ return token
138
+ .trim()
139
+ .split(/\s+/)
140
+ .map(escapeRegex)
141
+ .join('\\s*');
142
+ }
143
+ const CALL = /[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*\s*\(/g;
144
+ const LITERAL = /(['"])([^'"\n]{2,})\1/g;
145
+ const GENERIC_CALLS = new Set([
146
+ 'expect(',
147
+ 'it(',
148
+ 'describe(',
149
+ 'test(',
150
+ 'console.log(',
151
+ 'console.error(',
152
+ 'require(',
153
+ 'import(',
154
+ 'assert(',
155
+ 'beforeEach(',
156
+ 'afterEach(',
157
+ 'beforeAll(',
158
+ 'afterAll(',
159
+ ]);
160
+ function tokensIn(line) {
161
+ const tokens = [];
162
+ for (const match of line.matchAll(CALL)) {
163
+ const raw = match[0].replace(/\s*\.\s*/g, '.').replace(/\s+/g, ' ');
164
+ const value = raw.endsWith('(') ? raw : `${raw}(`;
165
+ if (GENERIC_CALLS.has(value))
166
+ continue;
167
+ const score = value.length + (value.includes('.') ? 10 : 0) + 5;
168
+ tokens.push({ value, score });
169
+ }
170
+ // A literal on a credential line is a value, not a pattern. Proposing it
171
+ // would write the secret into the rule file, which is the opposite of the fix.
172
+ if (!isSecretShaped(line)) {
173
+ for (const match of line.matchAll(LITERAL)) {
174
+ const value = `${match[1]}${match[2]}${match[1]}`;
175
+ tokens.push({ value, score: value.length + 3 });
176
+ }
177
+ }
178
+ return tokens;
179
+ }
180
+ function commonDir(files) {
181
+ const dirs = files.map((file) => {
182
+ const slash = file.lastIndexOf('/');
183
+ return slash === -1 ? '' : file.slice(0, slash);
184
+ });
185
+ if (dirs.length === 0 || dirs.some((dir) => dir.length === 0))
186
+ return null;
187
+ const parts = dirs.map((dir) => dir.split('/'));
188
+ const first = parts[0];
189
+ let end = first.length;
190
+ for (const part of parts.slice(1)) {
191
+ let i = 0;
192
+ while (i < end && i < part.length && part[i] === first[i])
193
+ i++;
194
+ end = i;
195
+ }
196
+ return end === 0 ? null : first.slice(0, end).join('/');
197
+ }
198
+ function propose(diff) {
199
+ const dir = commonDir(diff.files);
200
+ const pathInclude = dir
201
+ ? [`${dir}/**`]
202
+ : [...new Set(diff.files.map((file) => `**/${basename(file)}`))];
203
+ const removed = meaningful(diff.removed);
204
+ const addedText = diff.added.map(redactLine).join('\n');
205
+ const ranked = removed
206
+ .flatMap((line) => tokensIn(line))
207
+ .filter((token) => !addedText.includes(token.value))
208
+ .sort((a, b) => b.score - a.score);
209
+ const seen = new Set();
210
+ const linePatterns = [];
211
+ for (const token of ranked) {
212
+ if (linePatterns.length >= 2)
213
+ break;
214
+ if (seen.has(token.value))
215
+ continue;
216
+ seen.add(token.value);
217
+ linePatterns.push(toRegex(token.value));
218
+ }
219
+ const best = ranked[0]?.score ?? 0;
220
+ const confidence = best >= 20 ? 'high' : best >= 8 ? 'medium' : 'low';
221
+ return {
222
+ pathInclude: pathInclude.length > 0 ? pathInclude : ['**/*'],
223
+ linePatterns,
224
+ confidence,
225
+ executable: linePatterns.length > 0,
226
+ };
227
+ }
228
+ function nextRuleNumber(rules) {
229
+ let highest = 0;
230
+ for (const rule of rules) {
231
+ const match = /^[A-Z]+-(\d+)$/.exec(rule.id);
232
+ if (match)
233
+ highest = Math.max(highest, Number(match[1]));
234
+ }
235
+ return highest + 1;
236
+ }
237
+ function draftRule(commit, diff, proposal, id, originCommit) {
238
+ const firstAdded = meaningful(diff.added).map(redactLine)[0];
239
+ const ref = commit.shortHash === 'staged' ? 'the staged change' : commit.shortHash;
240
+ return {
241
+ id,
242
+ rule: `Prevent a repeat of: ${commit.subject}`,
243
+ context: `Files changed in ${ref} (${diff.files.slice(0, 3).join(', ') || 'no files'})`,
244
+ origin: { commit: originCommit, summary: commit.subject },
245
+ severity: 'med',
246
+ detection_pattern: proposal.executable
247
+ ? `Lines matching the pattern removed in ${ref}`
248
+ : `TODO: no detector inferred; read the diff for ${ref}`,
249
+ match: {
250
+ pathInclude: proposal.pathInclude,
251
+ ...(proposal.linePatterns.length > 0 ? { linePatterns: proposal.linePatterns } : {}),
252
+ },
253
+ recommendation: firstAdded
254
+ ? `Adopt the fix from ${ref}: ${firstAdded}`
255
+ : `Review the change in ${ref} and write the guardrail.`,
256
+ };
257
+ }
258
+ function coveredBy(commit, diff, rules) {
259
+ const ids = new Set();
260
+ for (const rule of rules) {
261
+ const origin = rule.origin?.commit;
262
+ if (origin &&
263
+ (commit.hash === origin ||
264
+ commit.hash.startsWith(origin) ||
265
+ commit.shortHash === origin ||
266
+ (origin.length >= 7 && commit.hash.startsWith(origin.slice(0, 7))))) {
267
+ ids.add(rule.id);
268
+ }
269
+ for (const line of diff.removed) {
270
+ for (const re of rule._line) {
271
+ re.lastIndex = 0;
272
+ if (re.test(line)) {
273
+ ids.add(rule.id);
274
+ break;
275
+ }
276
+ }
277
+ }
278
+ for (const file of diff.files) {
279
+ const base = basename(file);
280
+ for (const fileRule of rule._fileName) {
281
+ fileRule.re.lastIndex = 0;
282
+ if (fileRule.re.test(base))
283
+ ids.add(rule.id);
284
+ }
285
+ }
286
+ }
287
+ return [...ids];
288
+ }
289
+ function stagedCommit(options) {
290
+ const message = (options.message ?? '').trim();
291
+ if (!message && !options.force)
292
+ return [];
293
+ if (message && !(options.keywords ?? INCIDENT_KEYWORDS).test(message) && !options.force) {
294
+ return [];
295
+ }
296
+ const [subject, ...rest] = message.split('\n');
297
+ return [
298
+ {
299
+ hash: 'staged',
300
+ shortHash: 'staged',
301
+ date: new Date().toISOString().slice(0, 10),
302
+ subject: subject || 'staged change',
303
+ body: rest.join('\n'),
304
+ },
305
+ ];
306
+ }
307
+ function candidateFingerprint(commit, diff) {
308
+ if (commit.shortHash !== 'staged')
309
+ return `commit:${commit.hash}`;
310
+ return `staged:${fingerprint([
311
+ commit.subject,
312
+ [...diff.files].sort().join(','),
313
+ diff.removed.map(redactLine).join('\n'),
314
+ ])}`;
315
+ }
316
+ /**
317
+ * Find incident-shaped changes that no existing rule accounts for: fixed
318
+ * commits in history, the change staged right now, or repeated failures in a
319
+ * local test report. Reads only. Writes nothing.
320
+ */
321
+ export function harvest(rules, options = {}) {
322
+ const cwd = options.cwd ?? process.cwd();
323
+ const keywords = options.keywords ?? INCIDENT_KEYWORDS;
324
+ const ledger = options.ignoreLedger;
325
+ const capture = options.captureQueue;
326
+ const includeIgnored = options.includeIgnored ?? false;
327
+ let commits = [];
328
+ if (options.staged) {
329
+ commits = stagedCommit(options);
330
+ }
331
+ else {
332
+ try {
333
+ commits = readCommits(cwd, options);
334
+ }
335
+ catch (error) {
336
+ // A reports-only run outside a git checkout is still useful.
337
+ if (!options.reports?.length)
338
+ throw error;
339
+ }
340
+ }
341
+ const stagedDiff = options.staged && commits.length > 0 ? readStagedDiff(cwd) : null;
342
+ let incidents = 0;
343
+ let ignored = 0;
344
+ let captured = 0;
345
+ let nextId = nextRuleNumber(rules);
346
+ const candidates = [];
347
+ const covered = [];
348
+ for (const commit of commits) {
349
+ if (!keywords.test(`${commit.subject}\n${commit.body}`) && !options.force)
350
+ continue;
351
+ incidents++;
352
+ const diff = stagedDiff ?? readDiff(cwd, commit.hash);
353
+ if (!diff.files.some(isCodeFile))
354
+ continue;
355
+ const matched = coveredBy(commit, diff, rules);
356
+ if (matched.length > 0 && !options.includeCovered)
357
+ continue;
358
+ const dismissed = ledger ? findIgnore(ledger, candidateFingerprint(commit, diff)) : undefined;
359
+ if (dismissed && !includeIgnored) {
360
+ ignored++;
361
+ continue;
362
+ }
363
+ const pending = capture ? findCapture(capture, candidateFingerprint(commit, diff)) : undefined;
364
+ if (pending)
365
+ captured++;
366
+ const proposal = propose(diff);
367
+ const candidate = {
368
+ commit: commit.hash,
369
+ shortHash: commit.shortHash,
370
+ date: commit.date,
371
+ subject: commit.subject,
372
+ files: diff.files,
373
+ removed: meaningful(diff.removed).map(redactLine).slice(0, 8),
374
+ added: meaningful(diff.added).map(redactLine).slice(0, 8),
375
+ pathInclude: proposal.pathInclude,
376
+ linePatterns: proposal.linePatterns,
377
+ confidence: proposal.confidence,
378
+ executable: proposal.executable,
379
+ coveredBy: matched,
380
+ fingerprint: candidateFingerprint(commit, diff),
381
+ ...(dismissed ? { ignoredAt: dismissed.at, ignoreReason: dismissed.reason } : {}),
382
+ ...(pending ? { capturedAt: pending.capturedAt } : {}),
383
+ draft: draftRule(commit, diff, proposal, `ST-${String(nextId++).padStart(3, '0')}`, options.staged ? null : commit.shortHash),
384
+ };
385
+ if (matched.length > 0)
386
+ covered.push(candidate);
387
+ else
388
+ candidates.push(candidate);
389
+ }
390
+ let localFailures = [];
391
+ if (options.reports && options.reports.length > 0) {
392
+ localFailures = readLocalReports(options.reports, options.reportFormat ?? 'auto', cwd)
393
+ .map((failure) => {
394
+ const dismissed = ledger ? findIgnore(ledger, failure.fingerprint) : undefined;
395
+ if (dismissed && !includeIgnored) {
396
+ ignored++;
397
+ return null;
398
+ }
399
+ return dismissed
400
+ ? { ...failure, ignoredAt: dismissed.at, ignoreReason: dismissed.reason }
401
+ : failure;
402
+ })
403
+ .filter((failure) => failure !== null);
404
+ }
405
+ return {
406
+ commits: commits.length,
407
+ incidents,
408
+ candidates,
409
+ covered,
410
+ localFailures,
411
+ ignored,
412
+ captured,
413
+ staged: options.staged ?? false,
414
+ };
415
+ }
416
+ export function formatHarvestText(result) {
417
+ const lines = [];
418
+ const scope = result.staged
419
+ ? 'staged change'
420
+ : `${result.commits} commit(s) read`;
421
+ const local = result.localFailures.length > 0 ? `, ${result.localFailures.length} local failure(s)` : '';
422
+ const ignored = result.ignored > 0 ? ` (${result.ignored} ignored)` : '';
423
+ const captured = result.captured > 0 ? ` (${result.captured} captured)` : '';
424
+ lines.push(`sfqa-scar harvest - ${scope}, ${result.incidents} incident-shaped, ${result.candidates.length} without a rule${local}${ignored}${captured}`);
425
+ result.candidates.forEach((candidate, index) => {
426
+ lines.push('');
427
+ lines.push(`CANDIDATE ${index + 1}/${result.candidates.length}`);
428
+ lines.push(` commit : ${candidate.shortHash} ${candidate.date}`);
429
+ lines.push(` subject: ${candidate.subject}`);
430
+ lines.push(` files : ${candidate.files.join(', ') || '(none)'}`);
431
+ if (candidate.removed.length > 0) {
432
+ lines.push(` removed: ${candidate.removed.slice(0, 3).join('\n ')}`);
433
+ }
434
+ if (candidate.added.length > 0) {
435
+ lines.push(` added : ${candidate.added.slice(0, 3).join('\n ')}`);
436
+ }
437
+ lines.push(` detect : ${candidate.linePatterns.length > 0 ? candidate.linePatterns.join(' | ') : '(none inferred - manual review)'} [confidence ${candidate.confidence}]`);
438
+ lines.push(` scope : ${candidate.pathInclude.join(', ')}`);
439
+ lines.push(` id : ${candidate.fingerprint}`);
440
+ if (candidate.ignoredAt) {
441
+ lines.push(` ignored: ${candidate.ignoreReason ?? 'dismissed'} (${candidate.ignoredAt})`);
442
+ }
443
+ if (candidate.capturedAt) {
444
+ lines.push(` captured: yes, still waiting for a rule (${candidate.capturedAt})`);
445
+ }
446
+ lines.push(' draft :');
447
+ for (const line of JSON.stringify(candidate.draft, null, 2).split('\n')) {
448
+ lines.push(` ${line}`);
449
+ }
450
+ });
451
+ result.localFailures.forEach((failure, index) => {
452
+ lines.push('');
453
+ lines.push(`LOCAL FAILURE ${index + 1}/${result.localFailures.length}`);
454
+ lines.push(` test : ${failure.test}`);
455
+ if (failure.file)
456
+ lines.push(` file : ${failure.file}`);
457
+ lines.push(` status : ${failure.status} [${failure.occurrences} occurrence(s)]`);
458
+ const message = failure.message.split('\n').slice(0, 3).join('\n ');
459
+ lines.push(` error : ${message}`);
460
+ if (failure.ignoredAt) {
461
+ lines.push(` ignored: ${failure.ignoreReason ?? 'dismissed'} (${failure.ignoredAt})`);
462
+ }
463
+ lines.push(' next : find the source line behind this failure, then write one rule with two fixtures');
464
+ lines.push(` id : ${failure.fingerprint}`);
465
+ });
466
+ if (result.covered.length > 0) {
467
+ lines.push('');
468
+ lines.push(`COVERED (already accounted for by an existing rule): ${result.covered.length}`);
469
+ for (const candidate of result.covered) {
470
+ lines.push(` ${candidate.shortHash} ${candidate.coveredBy.join(', ')} ${candidate.subject}`);
471
+ }
472
+ }
473
+ lines.push('');
474
+ const hasGaps = result.candidates.length > 0 || result.localFailures.length > 0;
475
+ if (!hasGaps) {
476
+ lines.push('Nothing unclaimed. Every incident-shaped change has a rule.');
477
+ }
478
+ else {
479
+ if (result.candidates.length > 0) {
480
+ lines.push('Nothing was written. Review each draft, delete what you cannot defend,');
481
+ lines.push('then prove it with a positive fixture (must fire) and a clean fixture (must not).');
482
+ }
483
+ if (result.localFailures.length > 0) {
484
+ lines.push('Local failures are briefs, not drafts: a runtime error is not yet a source pattern.');
485
+ lines.push('Dismiss one with: sfqa-scar harvest --ignore <id> (recorded in scar-tissue.ignore.json)');
486
+ }
487
+ if (result.ignored > 0) {
488
+ lines.push(`${result.ignored} candidate(s) dismissed in scar-tissue.ignore.json; use --all to see them.`);
489
+ }
490
+ if (result.captured > 0) {
491
+ lines.push(`${result.captured} candidate(s) captured in scar-tissue.candidates.json and not asked again;`);
492
+ lines.push('move each draft into the rule set to clear it.');
493
+ }
494
+ }
495
+ return lines.join('\n');
496
+ }
497
+ export function formatHarvestJson(result) {
498
+ const report = {
499
+ tool: 'sfqa-scar',
500
+ command: 'harvest',
501
+ staged: result.staged,
502
+ commits: result.commits,
503
+ incidents: result.incidents,
504
+ candidates: result.candidates,
505
+ covered: result.covered,
506
+ localFailures: result.localFailures,
507
+ ignored: result.ignored,
508
+ captured: result.captured,
509
+ };
510
+ return JSON.stringify(report, null, 2);
511
+ }
package/dist/hash.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function fingerprint(parts: readonly string[]): string;
package/dist/hash.js ADDED
@@ -0,0 +1,10 @@
1
+ // packages/scar/src/hash.ts
2
+ // -----------------------------------------------------------------------------
3
+ // Stable fingerprints for the ignore ledger. A decision to ignore a candidate
4
+ // must survive a reboot, a rebase and a different shell, so it is keyed on
5
+ // content, not on a process id or a line number.
6
+ // -----------------------------------------------------------------------------
7
+ import { createHash } from 'node:crypto';
8
+ export function fingerprint(parts) {
9
+ return createHash('sha256').update(parts.join('\n')).digest('hex').slice(0, 16);
10
+ }
@@ -0,0 +1,17 @@
1
+ export declare const DEFAULT_IGNORE_FILE = "scar-tissue.ignore.json";
2
+ export interface IgnoreEntry {
3
+ /** Content fingerprint of the candidate that was dismissed. */
4
+ fingerprint: string;
5
+ reason: string;
6
+ at: string;
7
+ subject?: string;
8
+ }
9
+ export interface IgnoreLedger {
10
+ version: number;
11
+ ignored: IgnoreEntry[];
12
+ }
13
+ export declare function emptyLedger(): IgnoreLedger;
14
+ export declare function loadIgnoreLedger(path: string): IgnoreLedger;
15
+ export declare function saveIgnoreLedger(path: string, ledger: IgnoreLedger): void;
16
+ export declare function findIgnore(ledger: IgnoreLedger, entryFingerprint: string): IgnoreEntry | undefined;
17
+ export declare function addIgnore(ledger: IgnoreLedger, entry: IgnoreEntry): void;
package/dist/ignore.js ADDED
@@ -0,0 +1,38 @@
1
+ // packages/scar/src/ignore.ts
2
+ // -----------------------------------------------------------------------------
3
+ // The ignore ledger. When a person says "do not ask me about this again", that
4
+ // decision is data in the repository, reviewed like code. It is not a hidden
5
+ // cache, so it can be audited, diffed and reversed.
6
+ // -----------------------------------------------------------------------------
7
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { dirname } from 'node:path';
9
+ export const DEFAULT_IGNORE_FILE = 'scar-tissue.ignore.json';
10
+ export function emptyLedger() {
11
+ return { version: 1, ignored: [] };
12
+ }
13
+ export function loadIgnoreLedger(path) {
14
+ try {
15
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
16
+ const ignored = Array.isArray(raw.ignored)
17
+ ? raw.ignored.filter((entry) => Boolean(entry) && typeof entry.fingerprint === 'string')
18
+ : [];
19
+ return { version: 1, ignored };
20
+ }
21
+ catch {
22
+ return emptyLedger();
23
+ }
24
+ }
25
+ export function saveIgnoreLedger(path, ledger) {
26
+ mkdirSync(dirname(path), { recursive: true });
27
+ writeFileSync(path, `${JSON.stringify(ledger, null, 2)}\n`, 'utf8');
28
+ }
29
+ export function findIgnore(ledger, entryFingerprint) {
30
+ return ledger.ignored.find((entry) => entry.fingerprint === entryFingerprint);
31
+ }
32
+ export function addIgnore(ledger, entry) {
33
+ const existing = ledger.ignored.findIndex((item) => item.fingerprint === entry.fingerprint);
34
+ if (existing === -1)
35
+ ledger.ignored.push(entry);
36
+ else
37
+ ledger.ignored[existing] = { ...ledger.ignored[existing], ...entry };
38
+ }
@@ -0,0 +1,13 @@
1
+ export * from './types.js';
2
+ export * from './rules.js';
3
+ export * from './scan.js';
4
+ export * from './report.js';
5
+ export * from './redact.js';
6
+ export * from './hash.js';
7
+ export * from './ignore.js';
8
+ export * from './capture.js';
9
+ export * from './local.js';
10
+ export * from './harvest.js';
11
+ export * from './cli.js';
12
+ export * from './prove.js';
13
+ export * from './new-rule.js';