@sfqa/scar 0.0.0-stage → 0.1.0

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/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
+ }
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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.