@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/scan.js
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
// packages/scar/src/scan.ts
|
|
2
|
+
// -----------------------------------------------------------------------------
|
|
3
|
+
// File discovery and detection. The scanner is intentionally boring: it reads
|
|
4
|
+
// text, applies declarative detectors, and reports where each one fired.
|
|
5
|
+
//
|
|
6
|
+
// Two exemption scopes, both deliberate:
|
|
7
|
+
// - fileExemptSignals: a mitigation anywhere in the file clears the file
|
|
8
|
+
// (e.g. a Promise.race guarding a toast wait)
|
|
9
|
+
// - fileExemptLines: one line is individually safe (e.g. an empty env
|
|
10
|
+
// fallback); sibling hardcoded literals in the same file still fire
|
|
11
|
+
// -----------------------------------------------------------------------------
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
14
|
+
import { basename, extname, join, relative, resolve, sep } from 'node:path';
|
|
15
|
+
export const SKIP_DIRS = new Set([
|
|
16
|
+
'node_modules',
|
|
17
|
+
'.git',
|
|
18
|
+
'.features-gen',
|
|
19
|
+
'test-results',
|
|
20
|
+
'test-results1',
|
|
21
|
+
'test-artifacts',
|
|
22
|
+
'allure-results',
|
|
23
|
+
'playwright-report',
|
|
24
|
+
'html-report',
|
|
25
|
+
'.local',
|
|
26
|
+
]);
|
|
27
|
+
const MAX_BYTES = 1_000_000;
|
|
28
|
+
const BINARY_EXT = new Set([
|
|
29
|
+
'.png',
|
|
30
|
+
'.jpg',
|
|
31
|
+
'.jpeg',
|
|
32
|
+
'.gif',
|
|
33
|
+
'.zip',
|
|
34
|
+
'.xlsx',
|
|
35
|
+
'.pdf',
|
|
36
|
+
'.ico',
|
|
37
|
+
'.woff',
|
|
38
|
+
'.woff2',
|
|
39
|
+
'.ttf',
|
|
40
|
+
'.mp4',
|
|
41
|
+
'.webm',
|
|
42
|
+
]);
|
|
43
|
+
/** Normalise to forward slashes so globs behave the same on every platform. */
|
|
44
|
+
export function toPosix(path) {
|
|
45
|
+
return path.split(sep).join('/');
|
|
46
|
+
}
|
|
47
|
+
export function relPosix(root, path) {
|
|
48
|
+
return toPosix(relative(root, path));
|
|
49
|
+
}
|
|
50
|
+
export function walk(dir, output = []) {
|
|
51
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
52
|
+
if (entry.isDirectory()) {
|
|
53
|
+
if (!SKIP_DIRS.has(entry.name))
|
|
54
|
+
walk(join(dir, entry.name), output);
|
|
55
|
+
}
|
|
56
|
+
else if (entry.isFile()) {
|
|
57
|
+
output.push(join(dir, entry.name));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return output;
|
|
61
|
+
}
|
|
62
|
+
/** Expand CLI path arguments into absolute file paths. */
|
|
63
|
+
export function collectFiles(targets, cwd) {
|
|
64
|
+
const files = [];
|
|
65
|
+
for (const target of targets) {
|
|
66
|
+
const absolute = resolve(cwd, target);
|
|
67
|
+
let stat;
|
|
68
|
+
try {
|
|
69
|
+
stat = statSync(absolute);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (stat.isDirectory())
|
|
75
|
+
files.push(...walk(absolute));
|
|
76
|
+
else if (stat.isFile())
|
|
77
|
+
files.push(absolute);
|
|
78
|
+
}
|
|
79
|
+
return files;
|
|
80
|
+
}
|
|
81
|
+
export function gitChangedFiles(cwd, baseRef, staged = false) {
|
|
82
|
+
const args = ['diff', '--name-only', '--relative'];
|
|
83
|
+
if (staged)
|
|
84
|
+
args.push('--cached');
|
|
85
|
+
else if (baseRef)
|
|
86
|
+
args.push(baseRef);
|
|
87
|
+
const output = execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
88
|
+
return output
|
|
89
|
+
.split('\n')
|
|
90
|
+
.map((line) => line.trim())
|
|
91
|
+
.filter(Boolean);
|
|
92
|
+
}
|
|
93
|
+
/** True when the path is covered by .gitignore (exit 0 from git check-ignore). */
|
|
94
|
+
export function isGitIgnored(cwd, relPath) {
|
|
95
|
+
try {
|
|
96
|
+
execFileSync('git', ['check-ignore', '-q', relPath], { cwd, stdio: 'ignore' });
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The same answer for many paths in one process.
|
|
105
|
+
*
|
|
106
|
+
* `isGitIgnored` spawns git per path. On a 1,400-file repository that is the
|
|
107
|
+
* entire runtime of a scan; batching takes it from ~30s to well under a second.
|
|
108
|
+
* Paths are fed NUL-separated so a space or a newline in a filename cannot
|
|
109
|
+
* split the input.
|
|
110
|
+
*/
|
|
111
|
+
export function gitIgnoredSet(cwd, relPaths) {
|
|
112
|
+
const result = new Set();
|
|
113
|
+
if (relPaths.length === 0)
|
|
114
|
+
return result;
|
|
115
|
+
let stdout;
|
|
116
|
+
try {
|
|
117
|
+
stdout = execFileSync('git', ['check-ignore', '--stdin', '-z'], {
|
|
118
|
+
cwd,
|
|
119
|
+
input: `${relPaths.join('\0')}\0`,
|
|
120
|
+
encoding: 'utf8',
|
|
121
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
// Exit 1 means "none of these are ignored", which is not an error.
|
|
126
|
+
const output = error.stdout ?? '';
|
|
127
|
+
if (!output)
|
|
128
|
+
return result;
|
|
129
|
+
stdout = output;
|
|
130
|
+
}
|
|
131
|
+
for (const entry of stdout.split('\0'))
|
|
132
|
+
if (entry)
|
|
133
|
+
result.add(entry);
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
function isComment(line) {
|
|
137
|
+
const trimmed = line.trimStart();
|
|
138
|
+
return (trimmed.startsWith('//') ||
|
|
139
|
+
trimmed.startsWith('/*') ||
|
|
140
|
+
trimmed.startsWith('*') ||
|
|
141
|
+
(trimmed.startsWith('#') && !trimmed.startsWith('#!')));
|
|
142
|
+
}
|
|
143
|
+
function lineOf(content, index) {
|
|
144
|
+
return content.slice(0, index).split('\n').length;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* A line pattern is matched against the line plus the next three lines, but a
|
|
148
|
+
* finding is only reported when the match starts on this line. That is how a
|
|
149
|
+
* guard written on the following line - `.isVisible({ timeout: 5000 })` then
|
|
150
|
+
* `.catch(() => false)` - reads as a deliberate probe instead of a silent
|
|
151
|
+
* assertion, without teaching the rule about JavaScript.
|
|
152
|
+
*/
|
|
153
|
+
function matchesOnLine(re, lines, index) {
|
|
154
|
+
const line = lines[index];
|
|
155
|
+
const match = re.exec(lines.slice(index, index + 4).join('\n'));
|
|
156
|
+
return match !== null && match.index < line.length;
|
|
157
|
+
}
|
|
158
|
+
function isExemptLine(rule, line) {
|
|
159
|
+
return rule._exemptLines.length > 0 && rule._exemptLines.some((re) => re.test(line));
|
|
160
|
+
}
|
|
161
|
+
function spansLines(re) {
|
|
162
|
+
return /\[\\s\\S\]|\\n/.test(re.source);
|
|
163
|
+
}
|
|
164
|
+
function quotedLiterals(content) {
|
|
165
|
+
const literals = [];
|
|
166
|
+
const pattern = /(['"])([^'"\n]{2,})\1/g;
|
|
167
|
+
let match;
|
|
168
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
169
|
+
const start = match.index;
|
|
170
|
+
const end = start + match[0].length;
|
|
171
|
+
literals.push({
|
|
172
|
+
value: match[2],
|
|
173
|
+
line: lineOf(content, start),
|
|
174
|
+
before: content.slice(Math.max(0, start - 1), start),
|
|
175
|
+
prefix: content.slice(Math.max(0, start - 4), start),
|
|
176
|
+
after: content.slice(end, end + 1),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return literals;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Not every quoted string is a picklist label. Three shapes produced false
|
|
183
|
+
* positives in the repository this rule was mined from, and all three are code,
|
|
184
|
+
* not labels:
|
|
185
|
+
*
|
|
186
|
+
* 'input[name="FirstName"]' - a value inside an attribute selector
|
|
187
|
+
* 'Email': 'email' - an object key, and its value
|
|
188
|
+
* cond ? 'email' : 'name' - the branches of a ternary
|
|
189
|
+
* 'First Name': 'firstName' - a camelCase identifier
|
|
190
|
+
*
|
|
191
|
+
* A lowercase single word is kept: `['web']` where the canonical value is `Web`
|
|
192
|
+
* is exactly the drift this rule exists to catch, and there is no way to tell it
|
|
193
|
+
* from a lowercase value without the surrounding type information.
|
|
194
|
+
*/
|
|
195
|
+
function looksLikeLabel(literal) {
|
|
196
|
+
if (literal.after === ':')
|
|
197
|
+
return false;
|
|
198
|
+
// `[` is an array, so only the attribute-operator characters count here.
|
|
199
|
+
if (/[=~|^$*]/.test(literal.before))
|
|
200
|
+
return false;
|
|
201
|
+
// A value after a colon or a question mark is a map value or a ternary
|
|
202
|
+
// branch, not a label. `['web', 'Event']` is untouched - an array element is
|
|
203
|
+
// exactly where a drifted picklist value lives.
|
|
204
|
+
if (/[?:]\s*$/.test(literal.prefix))
|
|
205
|
+
return false;
|
|
206
|
+
if (/^[a-z][A-Za-z0-9]*[A-Z]/.test(literal.value))
|
|
207
|
+
return false;
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
function normaliseLabel(value) {
|
|
211
|
+
return value.toLowerCase().replace(/[\s_-]+/g, '');
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Label drift: a quoted literal that is the same label as a canonical one but
|
|
215
|
+
* differs in case, spacing or hyphenation. This is the generic form of "copy
|
|
216
|
+
* the picklist label verbatim" - the canonical list is supplied by the org.
|
|
217
|
+
*/
|
|
218
|
+
function labelFindings(rule, content, lines, relPath, severity, maxPerRule, ignored) {
|
|
219
|
+
const findings = [];
|
|
220
|
+
if (rule._labels.length === 0)
|
|
221
|
+
return findings;
|
|
222
|
+
const flush = (value, line, canonical, kind) => {
|
|
223
|
+
const lineText = lines[line - 1] ?? '';
|
|
224
|
+
if (isComment(lineText) || isExemptLine(rule, lineText))
|
|
225
|
+
return;
|
|
226
|
+
findings.push({
|
|
227
|
+
rule: { ...rule, severity },
|
|
228
|
+
relPath,
|
|
229
|
+
line,
|
|
230
|
+
snippet: `${lineText.trim().slice(0, 140)}${ignored ? ' [gitignored - informational]' : ''}`,
|
|
231
|
+
meta: `${kind}: "${value}" should be "${canonical}"`,
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
const literals = quotedLiterals(content);
|
|
235
|
+
outer: for (const labelRule of rule._labels) {
|
|
236
|
+
for (const canonical of labelRule.canonical) {
|
|
237
|
+
for (const literal of literals) {
|
|
238
|
+
if (findings.length >= maxPerRule)
|
|
239
|
+
break outer;
|
|
240
|
+
if (!looksLikeLabel(literal))
|
|
241
|
+
continue;
|
|
242
|
+
if (literal.value === canonical)
|
|
243
|
+
continue;
|
|
244
|
+
if (literal.value.toLowerCase() === canonical.toLowerCase()) {
|
|
245
|
+
flush(literal.value, literal.line, canonical, 'case drift');
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
if (normaliseLabel(literal.value) === normaliseLabel(canonical)) {
|
|
249
|
+
flush(literal.value, literal.line, canonical, 'spacing/hyphen drift');
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return findings;
|
|
256
|
+
}
|
|
257
|
+
export function lintContent(relPath, content, rules, options = {}) {
|
|
258
|
+
const maxPerRule = options.maxPerRule ?? 5;
|
|
259
|
+
const ignored = options.ignored ?? false;
|
|
260
|
+
const findings = [];
|
|
261
|
+
const file = basename(relPath);
|
|
262
|
+
const lines = content.split('\n');
|
|
263
|
+
for (const rule of rules) {
|
|
264
|
+
const gated = rule._include.length > 0 && !rule._include.some((re) => re.test(relPath));
|
|
265
|
+
if (gated || rule._exclude.some((re) => re.test(relPath)))
|
|
266
|
+
continue;
|
|
267
|
+
const severity = ignored ? 'low' : rule.severity;
|
|
268
|
+
// Filename-based rules (e.g. a tracked .env file).
|
|
269
|
+
for (const fileNameRule of rule._fileName) {
|
|
270
|
+
if (fileNameRule.re.test(file)) {
|
|
271
|
+
findings.push({
|
|
272
|
+
rule: { ...rule, severity },
|
|
273
|
+
relPath,
|
|
274
|
+
line: 0,
|
|
275
|
+
snippet: `(file name "${file}" matched /${fileNameRule.re.source}/)${ignored ? ' [gitignored - informational]' : ''}`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const fileExempt = rule._exempt.length > 0 && rule._exempt.some((re) => re.test(content));
|
|
280
|
+
// Per-line patterns. A pattern containing [\s\S] or \n is written to span
|
|
281
|
+
// lines, so it can never match inside one line; split those out and scan
|
|
282
|
+
// them against the whole content, mapping each match back to its line.
|
|
283
|
+
if (rule._line.length > 0 && !fileExempt) {
|
|
284
|
+
const singleLine = rule._line.filter((re) => !spansLines(re));
|
|
285
|
+
const multiLine = rule._line.filter(spansLines);
|
|
286
|
+
let hits = 0;
|
|
287
|
+
for (let i = 0; i < lines.length && hits < maxPerRule; i++) {
|
|
288
|
+
if (isComment(lines[i]) || isExemptLine(rule, lines[i]))
|
|
289
|
+
continue;
|
|
290
|
+
if (singleLine.some((re) => matchesOnLine(re, lines, i))) {
|
|
291
|
+
findings.push({
|
|
292
|
+
rule: { ...rule, severity },
|
|
293
|
+
relPath,
|
|
294
|
+
line: i + 1,
|
|
295
|
+
snippet: `${lines[i].trim().slice(0, 160)}${ignored ? ' [gitignored - informational]' : ''}`,
|
|
296
|
+
});
|
|
297
|
+
hits++;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
for (const re of multiLine) {
|
|
301
|
+
if (hits >= maxPerRule)
|
|
302
|
+
break;
|
|
303
|
+
const global = new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`);
|
|
304
|
+
let match;
|
|
305
|
+
while ((match = global.exec(content)) !== null && hits < maxPerRule) {
|
|
306
|
+
const line = lineOf(content, match.index);
|
|
307
|
+
const lineText = lines[line - 1] ?? '';
|
|
308
|
+
if (!isComment(lineText) && !isExemptLine(rule, lineText)) {
|
|
309
|
+
findings.push({
|
|
310
|
+
rule: { ...rule, severity },
|
|
311
|
+
relPath,
|
|
312
|
+
line,
|
|
313
|
+
snippet: `${match[0].trim().slice(0, 160)}${ignored ? ' [gitignored - informational]' : ''}`,
|
|
314
|
+
});
|
|
315
|
+
hits++;
|
|
316
|
+
}
|
|
317
|
+
if (match.index === global.lastIndex)
|
|
318
|
+
global.lastIndex++;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Numeric-range rules: capture group 1 must fall in [min, max].
|
|
323
|
+
if (!fileExempt) {
|
|
324
|
+
for (const numeric of rule._numeric) {
|
|
325
|
+
numeric.re.lastIndex = 0;
|
|
326
|
+
let hits = findings.filter((finding) => finding.rule.id === rule.id).length;
|
|
327
|
+
let match;
|
|
328
|
+
while ((match = numeric.re.exec(content)) !== null && hits < maxPerRule) {
|
|
329
|
+
const value = Number(match[1]);
|
|
330
|
+
if (Number.isFinite(value) && value >= numeric.min && value <= numeric.max) {
|
|
331
|
+
const lineStart = content.lastIndexOf('\n', match.index) + 1;
|
|
332
|
+
const lineText = content.slice(lineStart, match.index + match[0].length + 40);
|
|
333
|
+
if (isComment(lineText) || isExemptLine(rule, lineText))
|
|
334
|
+
continue;
|
|
335
|
+
findings.push({
|
|
336
|
+
rule: { ...rule, severity },
|
|
337
|
+
relPath,
|
|
338
|
+
line: lineOf(content, match.index),
|
|
339
|
+
snippet: match[0].trim().slice(0, 160),
|
|
340
|
+
meta: `value ${value} outside safe range (${numeric.min}-${numeric.max})`,
|
|
341
|
+
});
|
|
342
|
+
hits++;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// Label drift: exact display labels supplied by the org.
|
|
348
|
+
if (!fileExempt) {
|
|
349
|
+
findings.push(...labelFindings(rule, content, lines, relPath, severity, maxPerRule, ignored));
|
|
350
|
+
}
|
|
351
|
+
// Required-signal rules: fire once when NO required signal exists.
|
|
352
|
+
if (!fileExempt &&
|
|
353
|
+
rule._required.length > 0 &&
|
|
354
|
+
!rule._required.some((re) => re.test(content))) {
|
|
355
|
+
findings.push({
|
|
356
|
+
rule: { ...rule, severity },
|
|
357
|
+
relPath,
|
|
358
|
+
line: 0,
|
|
359
|
+
snippet: `(file passed rule scope but contains none of: ${(rule.match.fileRequiredSignals ?? []).join(', ')})`,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return findings;
|
|
364
|
+
}
|
|
365
|
+
/** Read and lint every file, skipping binaries, huge files and ignored dirs. */
|
|
366
|
+
export function scanFiles(cwd, files, rules, options = {}) {
|
|
367
|
+
const findings = [];
|
|
368
|
+
let filesScanned = 0;
|
|
369
|
+
// Lint first, ask git only about files that actually matched something. The
|
|
370
|
+
// ignore ledger is cosmetic (it downgrades a finding and marks it), so paying
|
|
371
|
+
// for it on 1,400 clean files was pure waste.
|
|
372
|
+
const matched = [];
|
|
373
|
+
for (const absolute of files) {
|
|
374
|
+
const rel = relPosix(cwd, absolute);
|
|
375
|
+
if (SKIP_DIRS.has(rel.split('/')[0]))
|
|
376
|
+
continue;
|
|
377
|
+
if (BINARY_EXT.has(extname(absolute)))
|
|
378
|
+
continue;
|
|
379
|
+
let stat;
|
|
380
|
+
try {
|
|
381
|
+
stat = statSync(absolute);
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (stat.size > MAX_BYTES)
|
|
387
|
+
continue;
|
|
388
|
+
const content = readFileSync(absolute, 'utf8');
|
|
389
|
+
filesScanned++;
|
|
390
|
+
if (lintContent(rel, content, rules, { maxPerRule: options.maxPerRule }).length > 0) {
|
|
391
|
+
matched.push({ rel, content });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const ignored = options.isIgnored
|
|
395
|
+
? new Set(matched.filter((entry) => options.isIgnored?.(entry.rel)).map((entry) => entry.rel))
|
|
396
|
+
: gitIgnoredSet(cwd, matched.map((entry) => entry.rel));
|
|
397
|
+
for (const entry of matched) {
|
|
398
|
+
findings.push(...lintContent(entry.rel, entry.content, rules, {
|
|
399
|
+
maxPerRule: options.maxPerRule,
|
|
400
|
+
ignored: ignored.has(entry.rel),
|
|
401
|
+
}));
|
|
402
|
+
}
|
|
403
|
+
return { filesScanned, findings };
|
|
404
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export type Severity = 'high' | 'med' | 'low';
|
|
2
|
+
export interface RuleOrigin {
|
|
3
|
+
/** Optional commit or PR in the adopting repository. */
|
|
4
|
+
commit?: string | null;
|
|
5
|
+
pr?: string | null;
|
|
6
|
+
/** What class of incident produced the rule. Never client data. */
|
|
7
|
+
summary: string;
|
|
8
|
+
}
|
|
9
|
+
export interface FileNameRule {
|
|
10
|
+
/** Regex tested against the file's base name (e.g. `^\.env$`). */
|
|
11
|
+
pattern: string;
|
|
12
|
+
}
|
|
13
|
+
export interface NumericRule {
|
|
14
|
+
pattern: string;
|
|
15
|
+
flags?: string;
|
|
16
|
+
/** Capture group 1 must fall in [min, max] for the rule to fire. */
|
|
17
|
+
min: number;
|
|
18
|
+
max: number;
|
|
19
|
+
}
|
|
20
|
+
export interface LabelRule {
|
|
21
|
+
/**
|
|
22
|
+
* Canonical display labels, exactly as the org renders them. A quoted
|
|
23
|
+
* literal that matches one of these case-insensitively, or after removing
|
|
24
|
+
* spaces and hyphens, is drift and fires the rule.
|
|
25
|
+
*/
|
|
26
|
+
canonical: string[];
|
|
27
|
+
}
|
|
28
|
+
export interface RuleMatch {
|
|
29
|
+
pathInclude?: string[];
|
|
30
|
+
pathExclude?: string[];
|
|
31
|
+
/**
|
|
32
|
+
* Matched against the line plus the next three lines, and reported only when
|
|
33
|
+
* the match starts on that line. A guard written on the following line -
|
|
34
|
+
* `.isVisible({ timeout: 5000 })` then `.catch(() => false)` - therefore reads
|
|
35
|
+
* as a deliberate probe, not a finding.
|
|
36
|
+
*/
|
|
37
|
+
linePatterns?: string[];
|
|
38
|
+
numericRules?: NumericRule[];
|
|
39
|
+
fileNameRules?: FileNameRule[];
|
|
40
|
+
/** Fire when NONE of these are present anywhere in the file. */
|
|
41
|
+
fileRequiredSignals?: string[];
|
|
42
|
+
/** Clear the whole file when ANY of these are present. */
|
|
43
|
+
fileExemptSignals?: string[];
|
|
44
|
+
/** Clear a single line when ANY of these match it. */
|
|
45
|
+
fileExemptLines?: string[];
|
|
46
|
+
labelRules?: LabelRule[];
|
|
47
|
+
}
|
|
48
|
+
export interface ScarRule {
|
|
49
|
+
id: string;
|
|
50
|
+
rule: string;
|
|
51
|
+
context: string;
|
|
52
|
+
origin: RuleOrigin;
|
|
53
|
+
severity: Severity;
|
|
54
|
+
detection_pattern: string;
|
|
55
|
+
match: RuleMatch;
|
|
56
|
+
recommendation: string;
|
|
57
|
+
severity_note?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface RuleSet {
|
|
60
|
+
version: number;
|
|
61
|
+
description?: string;
|
|
62
|
+
generated_from_repo?: string;
|
|
63
|
+
rules: ScarRule[];
|
|
64
|
+
}
|
|
65
|
+
export interface Finding {
|
|
66
|
+
rule: ScarRule;
|
|
67
|
+
relPath: string;
|
|
68
|
+
/** 1-based; 0 means the finding is about the file itself. */
|
|
69
|
+
line: number;
|
|
70
|
+
snippet: string;
|
|
71
|
+
meta?: string;
|
|
72
|
+
}
|
|
73
|
+
export interface SeverityCounts {
|
|
74
|
+
high: number;
|
|
75
|
+
med: number;
|
|
76
|
+
low: number;
|
|
77
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// packages/scar/src/types.ts
|
|
2
|
+
// -----------------------------------------------------------------------------
|
|
3
|
+
// The rule-set contract. A rule is data, not code: the detector is a small,
|
|
4
|
+
// declarative object so a team can regenerate the rule set from their own git
|
|
5
|
+
// history without touching this package.
|
|
6
|
+
//
|
|
7
|
+
// Nothing in here names an org, a user, a label or a project. The shipped
|
|
8
|
+
// `rules/generic.json` is a template: its example value lists are meant to be
|
|
9
|
+
// replaced with the org's own canonical labels.
|
|
10
|
+
// -----------------------------------------------------------------------------
|
|
11
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sfqa/scar",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Review code against a Salesforce test suite's scar tissue: past failures, encoded as declarative rules.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Vaishali Desarda",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/desardavaishali/sfqa-scar.git",
|
|
10
|
+
"directory": "packages/scar"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/desardavaishali/sfqa-scar/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/desardavaishali/sfqa-scar#readme",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"main": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"bin": {
|
|
23
|
+
"sfqa-scar": "dist/cli.js"
|
|
24
|
+
},
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./rules/generic.json": "./rules/generic.json",
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"rules",
|
|
36
|
+
"prompts",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"CHANGELOG.md"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "node scripts/build.mjs",
|
|
43
|
+
"test": "tsx --test test/scar.test.ts test/harvest.test.ts",
|
|
44
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
45
|
+
"verify": "node scripts/verify.mjs",
|
|
46
|
+
"validate:scar": "node ../../scar-validation/run.mjs"
|
|
47
|
+
},
|
|
48
|
+
"sideEffects": false,
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"keywords": [
|
|
53
|
+
"salesforce",
|
|
54
|
+
"scar-tissue",
|
|
55
|
+
"code-review",
|
|
56
|
+
"lint",
|
|
57
|
+
"testing",
|
|
58
|
+
"qa",
|
|
59
|
+
"playwright"
|
|
60
|
+
]
|
|
61
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Extract your own scar tissue
|
|
2
|
+
|
|
3
|
+
A reusable prompt for any capable LLM agent that can read your repository's git
|
|
4
|
+
history. It produces a rule set compatible with `@sfqa/scar`.
|
|
5
|
+
|
|
6
|
+
Run it once per repository, review the output (every rule should cite a commit
|
|
7
|
+
you can verify), keep the JSON next to your code, and re-run it after every
|
|
8
|
+
postmortem or hotfix so the rule set grows with the team.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
PROMPT (copy everything below this line):
|
|
13
|
+
|
|
14
|
+
You are mining a git repository for "scar tissue": hard-won rules derived from
|
|
15
|
+
real past failures - bugs, flaky tests, reverts, hotfixes, leaked secrets, data
|
|
16
|
+
corruption incidents.
|
|
17
|
+
|
|
18
|
+
1. Explore history exhaustively:
|
|
19
|
+
- `git log --oneline --all`, then read every commit whose message contains
|
|
20
|
+
fix, revert, hotfix, flaky, race, timeout, workaround, credentials,
|
|
21
|
+
rollback.
|
|
22
|
+
- For each candidate, read the diff (`git show <hash>`) and any linked PR
|
|
23
|
+
description, ADR or postmortem doc.
|
|
24
|
+
2. Extract only lessons with DIRECT evidence in a specific commit or incident
|
|
25
|
+
doc. Do not invent generic best practices; if you cannot cite a hash, drop
|
|
26
|
+
the lesson.
|
|
27
|
+
3. For each lesson emit ONE JSON object with exactly these keys:
|
|
28
|
+
|
|
29
|
+
- `"id"`: stable slug matching `^[A-Z]+-\d+$`, e.g. `"ST-013"`.
|
|
30
|
+
- `"rule"`: one-line guardrail (what future automation must or must not do).
|
|
31
|
+
- `"context"`: which objects, flows, files or classes it applies to.
|
|
32
|
+
- `"origin"`: `{ "commit": "<hash>", "pr": "<id or null>",
|
|
33
|
+
"summary": "1-2 sentences on what went wrong and the cost" }`.
|
|
34
|
+
- `"severity"`: `"high" | "med" | "low"` - how bad is violating it today
|
|
35
|
+
(data corruption / leaked secrets / wrong records = high;
|
|
36
|
+
flaky-but-recovers = med; cosmetic = low).
|
|
37
|
+
- `"detection_pattern"`: what a reviewer should look for in new code to
|
|
38
|
+
catch a repeat - be concrete about syntax, not vibes.
|
|
39
|
+
- `"match"`: the machine-executable detector:
|
|
40
|
+
{
|
|
41
|
+
"pathInclude": ["glob", ...], // optional scope gate
|
|
42
|
+
"pathExclude": ["glob", ...], // optional
|
|
43
|
+
"fileNameRules": [{ "pattern": "regex" }], // optional
|
|
44
|
+
"linePatterns": ["regex", ...], // per-line (use [\s\S] to span lines)
|
|
45
|
+
"numericRules": [{ "pattern": "regex-with-(group)", "min": n, "max": m, "flags": "gm" }],
|
|
46
|
+
"labelRules": [{ "canonical": ["Exact Label", ...] }], // exact-label drift
|
|
47
|
+
"fileRequiredSignals": ["regex", ...], // fire if NONE present
|
|
48
|
+
"fileExemptSignals": ["regex", ...], // suppress the file if ANY present
|
|
49
|
+
"fileExemptLines": ["regex", ...] // suppress a single line if ANY present
|
|
50
|
+
}
|
|
51
|
+
Regexes are JavaScript RegExp source. A leading `(?i)` means
|
|
52
|
+
case-insensitive. Prefer few high-precision patterns over many noisy ones;
|
|
53
|
+
use `pathInclude` to scope rules to where the failure class lives.
|
|
54
|
+
- `"recommendation"`: a concrete fix, ideally describing how the origin
|
|
55
|
+
commit solved it.
|
|
56
|
+
|
|
57
|
+
4. Output: a single JSON document
|
|
58
|
+
`{ "version": 1, "generated_from_repo": "<name>", "rules": [ ... ] }`.
|
|
59
|
+
5. Self-check before finishing:
|
|
60
|
+
- Every rule's origin commit exists (`git cat-file -e <hash>^{commit}`).
|
|
61
|
+
- Each regex compiles against JavaScript `RegExp`.
|
|
62
|
+
- No regex or prose contains a real secret, customer value, person's name or
|
|
63
|
+
client-specific label. Labels belong in `labelRules.canonical`, not in a
|
|
64
|
+
hardcoded "known-bad" blocklist.
|
|
65
|
+
- Every `labelRules.canonical` list contains the org's exact rendered labels.
|
|
66
|
+
- At least one rule is a documented gap if you cannot write a detector; do
|
|
67
|
+
not invent one that can never fire.
|
|
68
|
+
- Rules that scope paths use globs the scanner supports (`**`, `*`, `?`).
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Reviewing the output
|
|
73
|
+
|
|
74
|
+
- Run the packaged proof harness shape against your own rule set: a fixture per
|
|
75
|
+
executable rule must make it fire, and a clean fixture set must produce zero
|
|
76
|
+
findings. If a rule has a detector it cannot fire, the proof failed.
|
|
77
|
+
- Delete any rule whose origin you cannot defend. A rule set is a claim about
|
|
78
|
+
your history, not a style guide.
|
|
79
|
+
- Re-run extraction after a major incident. Rules that stop mattering should be
|
|
80
|
+
removed, not left as noise.
|