claude-git-hooks 2.61.2 → 2.67.3
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 +329 -3
- package/README.md +34 -0
- package/lib/commands/analyze-diff.js +26 -23
- package/lib/commands/analyze.js +3 -1
- package/lib/commands/back-merge.js +39 -33
- package/lib/commands/bump-version.js +52 -90
- package/lib/commands/close-release.js +25 -19
- package/lib/commands/create-pr.js +152 -83
- package/lib/commands/create-release.js +19 -13
- package/lib/commands/help.js +13 -3
- package/lib/defaults.json +5 -0
- package/lib/hooks/pre-commit.js +112 -32
- package/lib/messages/library-warnings.js +128 -10
- package/lib/utils/analysis-engine.js +7 -3
- package/lib/utils/claude-client.js +6 -1
- package/lib/utils/config-registry.js +1 -0
- package/lib/utils/git-tag-manager.js +104 -0
- package/lib/utils/judge.js +2 -1
- package/lib/utils/library-resolver.js +50 -0
- package/lib/utils/prompt-builder.js +15 -0
- package/lib/utils/skill-registry/catalogue.js +74 -0
- package/lib/utils/skill-registry/feedback-writer.js +196 -0
- package/lib/utils/skill-registry/index.js +254 -0
- package/lib/utils/skill-registry/parser.js +311 -0
- package/lib/utils/skill-registry/resume.js +81 -0
- package/lib/utils/skill-registry/runner.js +265 -0
- package/lib/utils/version-manager.js +37 -18
- package/package.json +2 -1
- package/templates/CLAUDE_ANALYSIS_PROMPT.md +2 -1
- package/templates/config.advanced.example.json +25 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: skill-registry/parser.js
|
|
3
|
+
* Purpose: Parse @mscope/automation-skills improvement-registry.md files into
|
|
4
|
+
* a structured rule index that the pre-commit hook can run grep
|
|
5
|
+
* patterns against.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: the mscope skill repo
|
|
8
|
+
* (https://github.com/mscope-S-L/automation-skills) maintains canonical
|
|
9
|
+
* antipattern catalogues (JBE-NNN for backend, UIK-NNN/STR-NNN/etc. for
|
|
10
|
+
* frontend). Each registry entry includes a `Verify:` grep command that
|
|
11
|
+
* detects the antipattern. We parse those entries here so the pre-commit
|
|
12
|
+
* hook can run them against staged files automatically — turning the
|
|
13
|
+
* skill from descriptive (lives in docs) into prescriptive (blocks bad
|
|
14
|
+
* commits with the documented rule ID + fix).
|
|
15
|
+
*
|
|
16
|
+
* Skill repo location resolution (in order):
|
|
17
|
+
* 1. CLAUDE_HOOKS_SKILL_REPO env var
|
|
18
|
+
* 2. AUTOMATION_SKILLS_REPO env var (matches the skill repo's own convention)
|
|
19
|
+
* 3. ~/.claude/skills/automation-standards/.installed-version → walks back
|
|
20
|
+
* to find the source clone (when installed via `automation-skills install`)
|
|
21
|
+
* 4. Returns null — the caller decides whether to skip silently or warn
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFileSync, existsSync } from 'fs';
|
|
25
|
+
import { dirname, join } from 'path';
|
|
26
|
+
|
|
27
|
+
const SKILL_PATHS = [
|
|
28
|
+
{
|
|
29
|
+
key: 'backend',
|
|
30
|
+
relPath: 'skills/backend/automation-standards-backend/docs/improvement-registry.md',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
key: 'frontend',
|
|
34
|
+
relPath: 'skills/frontend/automation-standards/docs/improvement-registry.md',
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Single source of truth for file-extension → skill-scope mapping.
|
|
40
|
+
* Adding a new extension here propagates to every consumer (runner,
|
|
41
|
+
* feedback-writer, future modules). Avoids the duplicate-map drift the
|
|
42
|
+
* reviewer flagged on the v1 PR.
|
|
43
|
+
*/
|
|
44
|
+
export const SCOPE_BY_EXT = Object.freeze({
|
|
45
|
+
// Backend (Java + SQL — both owned by the backend skill)
|
|
46
|
+
'.java': 'backend',
|
|
47
|
+
'.sql': 'backend',
|
|
48
|
+
// Frontend (React / styling)
|
|
49
|
+
'.jsx': 'frontend',
|
|
50
|
+
'.tsx': 'frontend',
|
|
51
|
+
'.js': 'frontend',
|
|
52
|
+
'.ts': 'frontend',
|
|
53
|
+
'.css': 'frontend',
|
|
54
|
+
'.scss': 'frontend',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the skill repo root.
|
|
59
|
+
*
|
|
60
|
+
* Resolution order:
|
|
61
|
+
* 1. `CLAUDE_HOOKS_SKILL_REPO` env var (most explicit per-machine override).
|
|
62
|
+
* 2. `AUTOMATION_SKILLS_REPO` env var (matches the skill repo's own
|
|
63
|
+
* CLI convention; lets a teammate set one variable for both repos).
|
|
64
|
+
* 3. Sibling of the working repo: if the hook is running on a commit in
|
|
65
|
+
* e.g. `<parent>/mscope-gateway/`, check `<parent>/automation-skills/`.
|
|
66
|
+
* This is the mscope canonical layout (all repos as siblings under one
|
|
67
|
+
* parent, folder name matching the GitHub repo name) and requires no
|
|
68
|
+
* env-var setup for teammates following convention.
|
|
69
|
+
* 4. Return null — silent skip in the hook. The skill is value-add,
|
|
70
|
+
* not a hard dep of claude-git-hooks.
|
|
71
|
+
*
|
|
72
|
+
* No hardcoded absolute paths and no homedir-relative guesses (those
|
|
73
|
+
* leaked personal layouts and never matched on the canonical mscope
|
|
74
|
+
* `<parent>/mscope-*` setup — see PR #180 review comment 4).
|
|
75
|
+
*
|
|
76
|
+
* @param {string} [workingRepoRoot] - Absolute path of the repo whose
|
|
77
|
+
* commit triggered the hook (passed from pre-commit.js).
|
|
78
|
+
* @returns {string|null} Absolute path of the skill repo, or null.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveSkillRepoRoot(workingRepoRoot = null) {
|
|
81
|
+
const candidates = [
|
|
82
|
+
process.env.CLAUDE_HOOKS_SKILL_REPO,
|
|
83
|
+
process.env.AUTOMATION_SKILLS_REPO,
|
|
84
|
+
].filter(Boolean);
|
|
85
|
+
|
|
86
|
+
for (const c of candidates) {
|
|
87
|
+
if (isSkillRepoRoot(c)) return c;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (workingRepoRoot) {
|
|
91
|
+
const parent = dirname(workingRepoRoot);
|
|
92
|
+
const guess = join(parent, 'automation-skills');
|
|
93
|
+
if (isSkillRepoRoot(guess)) return guess;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isSkillRepoRoot(dir) {
|
|
100
|
+
if (!dir) return false;
|
|
101
|
+
try {
|
|
102
|
+
const pkgPath = join(dir, 'package.json');
|
|
103
|
+
if (!existsSync(pkgPath)) return false;
|
|
104
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
105
|
+
return pkg.name === '@mscope/automation-skills';
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Load all rule entries from both registries. Returns:
|
|
113
|
+
* [
|
|
114
|
+
* { id: 'JBE-001', severity: 'high', symptom: '…',
|
|
115
|
+
* fix: '…', verify: 'grep -rn …', scope: 'backend', skillRel: '…' },
|
|
116
|
+
* …
|
|
117
|
+
* ]
|
|
118
|
+
* If the skill repo isn't found, returns []. Caller can decide how to surface.
|
|
119
|
+
*/
|
|
120
|
+
export function loadRegistry(skillRepoRoot = null, workingRepoRoot = null) {
|
|
121
|
+
const root = skillRepoRoot || resolveSkillRepoRoot(workingRepoRoot);
|
|
122
|
+
if (!root) return { rules: [], skillRoot: null };
|
|
123
|
+
|
|
124
|
+
const rules = [];
|
|
125
|
+
for (const { key, relPath } of SKILL_PATHS) {
|
|
126
|
+
const fullPath = join(root, relPath);
|
|
127
|
+
if (!existsSync(fullPath)) continue;
|
|
128
|
+
const content = readFileSync(fullPath, 'utf8');
|
|
129
|
+
const parsed = parseRegistryMarkdown(content, key, relPath);
|
|
130
|
+
rules.push(...parsed);
|
|
131
|
+
}
|
|
132
|
+
return { rules, skillRoot: root };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parse a single registry markdown file. Returns an array of rule objects.
|
|
137
|
+
* Each registry entry follows the shape:
|
|
138
|
+
*
|
|
139
|
+
* ### <RULE-ID> — <title>
|
|
140
|
+
* **Severity:** <emoji + label>
|
|
141
|
+
* **Symptom:** <description or fenced code>
|
|
142
|
+
* **Fix:** <description or fenced code>
|
|
143
|
+
* **Verify:** <inline command, or fenced code with `grep -rn …`>
|
|
144
|
+
* **Seen in:** …
|
|
145
|
+
* **Established in:** …
|
|
146
|
+
*
|
|
147
|
+
* ---
|
|
148
|
+
*
|
|
149
|
+
* The parser is lenient — missing fields default to null. Entries without a
|
|
150
|
+
* usable Verify command are still indexed (so other features can reference
|
|
151
|
+
* them by ID) but won't fire pre-commit alarms.
|
|
152
|
+
*/
|
|
153
|
+
export function parseRegistryMarkdown(content, scope, sourceRel) {
|
|
154
|
+
const sections = content.split(/^### /m).slice(1);
|
|
155
|
+
const out = [];
|
|
156
|
+
for (const sec of sections) {
|
|
157
|
+
const newlineIdx = sec.indexOf('\n');
|
|
158
|
+
const header = newlineIdx === -1 ? sec : sec.slice(0, newlineIdx);
|
|
159
|
+
const body = newlineIdx === -1 ? '' : sec.slice(newlineIdx + 1);
|
|
160
|
+
// Header: "<RULE-ID> — <title>" or "<RULE-ID>: <title>" or "<RULE-ID> - <title>"
|
|
161
|
+
const idMatch = header.match(/^([A-Z][A-Z0-9]{1,7}-\d{3})\s*[—:–-]\s*(.+?)\s*$/);
|
|
162
|
+
if (!idMatch) continue;
|
|
163
|
+
const id = idMatch[1];
|
|
164
|
+
const title = idMatch[2];
|
|
165
|
+
|
|
166
|
+
const fields = extractFields(body);
|
|
167
|
+
if (!fields) continue;
|
|
168
|
+
|
|
169
|
+
const verifies = parseVerifyField(fields.verify);
|
|
170
|
+
|
|
171
|
+
out.push({
|
|
172
|
+
id,
|
|
173
|
+
title,
|
|
174
|
+
severity: classifySeverity(fields.severity),
|
|
175
|
+
severityRaw: fields.severity || '',
|
|
176
|
+
symptom: fields.symptom || null,
|
|
177
|
+
fix: fields.fix || null,
|
|
178
|
+
verifies,
|
|
179
|
+
seenIn: fields.seenIn || null,
|
|
180
|
+
establishedIn: fields.establishedIn || null,
|
|
181
|
+
knownEdgeCases: fields.knownEdgeCases || null,
|
|
182
|
+
scope,
|
|
183
|
+
source: sourceRel,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Extract the standard fields (Severity / Symptom / Fix / Verify / Seen in
|
|
191
|
+
* / Established in / Known edge cases) from a registry-entry body.
|
|
192
|
+
*
|
|
193
|
+
* Each field can be a single line OR span multiple lines including fenced
|
|
194
|
+
* code blocks. We slice on `**FieldName:**` boundaries.
|
|
195
|
+
*/
|
|
196
|
+
function extractFields(body) {
|
|
197
|
+
const FIELD_ORDER = [
|
|
198
|
+
['severity', /\*\*Severity:\*\*/i],
|
|
199
|
+
['symptom', /\*\*Symptom:\*\*/i],
|
|
200
|
+
['fix', /\*\*Fix:\*\*/i],
|
|
201
|
+
['verify', /\*\*Verify:\*\*/i],
|
|
202
|
+
['seenIn', /\*\*Seen in:\*\*/i],
|
|
203
|
+
['establishedIn', /\*\*Established in:\*\*/i],
|
|
204
|
+
['knownEdgeCases', /\*\*Known edge cases:\*\*/i],
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
// Find each field's start position; the field value runs to the next
|
|
208
|
+
// field start (or to a `---` divider / end of body).
|
|
209
|
+
const found = [];
|
|
210
|
+
for (const [name, re] of FIELD_ORDER) {
|
|
211
|
+
const m = body.match(re);
|
|
212
|
+
if (m) found.push({ name, start: m.index, headerEnd: m.index + m[0].length });
|
|
213
|
+
}
|
|
214
|
+
if (found.length === 0) return null;
|
|
215
|
+
found.sort((a, b) => a.start - b.start);
|
|
216
|
+
|
|
217
|
+
const dividerIdx = body.search(/^---\s*$/m);
|
|
218
|
+
const out = {};
|
|
219
|
+
for (let i = 0; i < found.length; i++) {
|
|
220
|
+
const cur = found[i];
|
|
221
|
+
const next = found[i + 1];
|
|
222
|
+
const stopAt = next
|
|
223
|
+
? next.start
|
|
224
|
+
: dividerIdx >= 0 && dividerIdx > cur.start
|
|
225
|
+
? dividerIdx
|
|
226
|
+
: body.length;
|
|
227
|
+
out[cur.name] = body.slice(cur.headerEnd, stopAt).trim();
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Extract one-or-more runnable grep/find commands from a Verify field value.
|
|
234
|
+
*
|
|
235
|
+
* Verify field shapes seen in the registry:
|
|
236
|
+
* - Inline: `**Verify:** grep -rn "printStackTrace()" src/main/java/`
|
|
237
|
+
* - Fenced bash:
|
|
238
|
+
* ```bash
|
|
239
|
+
* grep -l "ALTER TABLE" BBDD/*.sql | xargs grep -L "IF NOT EXISTS"
|
|
240
|
+
* ```
|
|
241
|
+
* - Prose ("manual review") — returns []
|
|
242
|
+
*
|
|
243
|
+
* Returns an array of { command, kind } where kind ∈ { 'grep', 'find', 'other' }.
|
|
244
|
+
* Multi-command (piped) verifies are kept as a single entry — the runner shells out.
|
|
245
|
+
*/
|
|
246
|
+
function parseVerifyField(raw) {
|
|
247
|
+
if (!raw) return [];
|
|
248
|
+
const out = [];
|
|
249
|
+
|
|
250
|
+
// Fenced code blocks first.
|
|
251
|
+
const fenceRe = /```(?:[a-z]*)?\n([\s\S]*?)\n```/g;
|
|
252
|
+
let m;
|
|
253
|
+
while ((m = fenceRe.exec(raw)) !== null) {
|
|
254
|
+
const block = m[1];
|
|
255
|
+
for (const line of block.split('\n')) {
|
|
256
|
+
const cmd = line.trim();
|
|
257
|
+
if (isRunnableCommand(cmd)) out.push(toRule(cmd));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Inline backtick command (single-line `cmd`).
|
|
262
|
+
const inlineRe = /`([^`\n]+)`/g;
|
|
263
|
+
while ((m = inlineRe.exec(raw)) !== null) {
|
|
264
|
+
const cmd = m[1].trim();
|
|
265
|
+
if (isRunnableCommand(cmd)) out.push(toRule(cmd));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Plain (no backticks) — a single line beginning with grep/find/rg.
|
|
269
|
+
if (out.length === 0) {
|
|
270
|
+
for (const line of raw.split('\n')) {
|
|
271
|
+
const t = line.trim().replace(/^[-*]\s*/, '');
|
|
272
|
+
if (isRunnableCommand(t)) out.push(toRule(t));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return dedupe(out);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function isRunnableCommand(s) {
|
|
280
|
+
if (!s) return false;
|
|
281
|
+
if (s.length < 5 || s.length > 800) return false;
|
|
282
|
+
return /^(grep|rg|find|git\s+grep)\b/.test(s);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function toRule(command) {
|
|
286
|
+
let kind = 'other';
|
|
287
|
+
if (/^grep\b/.test(command)) kind = 'grep';
|
|
288
|
+
else if (/^rg\b/.test(command)) kind = 'grep';
|
|
289
|
+
else if (/^find\b/.test(command)) kind = 'find';
|
|
290
|
+
else if (/^git\s+grep\b/.test(command)) kind = 'grep';
|
|
291
|
+
return { command, kind };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function dedupe(arr) {
|
|
295
|
+
const seen = new Set();
|
|
296
|
+
return arr.filter((x) => {
|
|
297
|
+
if (seen.has(x.command)) return false;
|
|
298
|
+
seen.add(x.command);
|
|
299
|
+
return true;
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function classifySeverity(raw) {
|
|
304
|
+
if (!raw) return 'unknown';
|
|
305
|
+
const lower = raw.toLowerCase();
|
|
306
|
+
if (lower.includes('critical') || lower.includes('🔴')) return 'critical';
|
|
307
|
+
if (lower.includes('high') || lower.includes('🟠')) return 'high';
|
|
308
|
+
if (lower.includes('medium') || lower.includes('🟡')) return 'medium';
|
|
309
|
+
if (lower.includes('low') || lower.includes('🟢')) return 'low';
|
|
310
|
+
return 'unknown';
|
|
311
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: skill-registry/resume.js
|
|
3
|
+
* Purpose: Lessons-learned handover — invokes `automation-skills resume`
|
|
4
|
+
* so the developer can record what they learned on the branch to
|
|
5
|
+
* the mscope skill repo's implementation-history.md.
|
|
6
|
+
*
|
|
7
|
+
* Invoked from create-pr (knowledge-capture step, alongside gotcha
|
|
8
|
+
* solicitation) at the natural "branch done, about to publish" moment.
|
|
9
|
+
* The mscope `automation-skills resume` command analyses
|
|
10
|
+
* `git log <trunk>..HEAD`, prompts interactively for lessons, and appends
|
|
11
|
+
* them to the skill repo. We hand over stdio so the prompt works.
|
|
12
|
+
*
|
|
13
|
+
* Branch preconditions (feature branch, commits ahead of trunk) are
|
|
14
|
+
* guaranteed by the create-pr context, so no git validation happens here.
|
|
15
|
+
*
|
|
16
|
+
* Never throws and never blocks the caller's flow — the integration is
|
|
17
|
+
* value-add, not a hard dependency. The CLI not being installed is a
|
|
18
|
+
* silent skip (callers can check isResumeCliAvailable() first to decide
|
|
19
|
+
* whether to announce the handover).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawnSync } from 'child_process';
|
|
23
|
+
import logger from '../logger.js';
|
|
24
|
+
|
|
25
|
+
/** Process-lifetime cache of the CLI availability probe. */
|
|
26
|
+
let _cliAvailable = null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Test hook — clears the memoized CLI availability. Not for production use.
|
|
30
|
+
*/
|
|
31
|
+
export function _resetCliAvailabilityCache() {
|
|
32
|
+
_cliAvailable = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Probe whether the `automation-skills` CLI is reachable on PATH (memoized
|
|
37
|
+
* for the process lifetime — one spawn per run). Lets callers announce the
|
|
38
|
+
* interactive handover only when it will actually happen.
|
|
39
|
+
*
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
export function isResumeCliAvailable() {
|
|
43
|
+
if (_cliAvailable !== null) return _cliAvailable;
|
|
44
|
+
const res = spawnSync('automation-skills', ['--version'], {
|
|
45
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
46
|
+
});
|
|
47
|
+
_cliAvailable = !res.error && res.status === 0;
|
|
48
|
+
return _cliAvailable;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Hand control to `automation-skills resume [skill] [--trunk <branch>]`
|
|
53
|
+
* with inherited stdio (interactive prompt).
|
|
54
|
+
*
|
|
55
|
+
* @param {object} options
|
|
56
|
+
* @param {string} options.repoRoot - Working repo root (cwd for the subcommand).
|
|
57
|
+
* @param {string|null} options.skill - Optional skill hint (frontend|backend|fe|be).
|
|
58
|
+
* @param {string|null} options.trunk - Optional trunk override passed through.
|
|
59
|
+
* @returns {{ ran: boolean, reason?: string, exitCode?: number }}
|
|
60
|
+
*/
|
|
61
|
+
export function runResumeFlow({ repoRoot, skill = null, trunk = null } = {}) {
|
|
62
|
+
if (!isResumeCliAvailable()) {
|
|
63
|
+
logger.debug('skill-registry - runResumeFlow', 'automation-skills CLI not on PATH — skipping lessons capture');
|
|
64
|
+
return { ran: false, reason: 'cli-missing' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const resumeArgs = ['resume'];
|
|
68
|
+
if (skill) resumeArgs.push(skill);
|
|
69
|
+
if (trunk) resumeArgs.push('--trunk', trunk);
|
|
70
|
+
|
|
71
|
+
const proc = spawnSync('automation-skills', resumeArgs, {
|
|
72
|
+
stdio: 'inherit',
|
|
73
|
+
cwd: repoRoot,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (proc.error) {
|
|
77
|
+
logger.debug('skill-registry - runResumeFlow', 'resume spawn failed', { error: proc.error.message });
|
|
78
|
+
return { ran: false, reason: 'spawn-error' };
|
|
79
|
+
}
|
|
80
|
+
return { ran: true, exitCode: proc.status ?? 0 };
|
|
81
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: skill-registry/runner.js
|
|
3
|
+
* Purpose: Run the parsed skill-registry's Verify patterns against a set of
|
|
4
|
+
* staged files. Returns structured findings the pre-commit hook
|
|
5
|
+
* can surface alongside (or instead of) AI analysis.
|
|
6
|
+
*
|
|
7
|
+
* Design:
|
|
8
|
+
* - Inputs: array of rules from parser.js + array of staged absolute file paths.
|
|
9
|
+
* - For each staged file:
|
|
10
|
+
* 1. Read its content.
|
|
11
|
+
* 2. Pick the rules whose scope matches the file's language (backend
|
|
12
|
+
* rules for .java, frontend for .jsx/.tsx/.js/.css, SQL rules for
|
|
13
|
+
* .sql — eventually; for now we map by scope tag).
|
|
14
|
+
* 3. For each rule with an extractable regex from its Verify command,
|
|
15
|
+
* search the file and emit findings with line numbers.
|
|
16
|
+
* - Returns: { totalFindings, byFile: { path → [findings] }, bySeverity: {…} }
|
|
17
|
+
*
|
|
18
|
+
* NOT done here:
|
|
19
|
+
* - File-level greps (e.g. "files that DON'T contain X" / inverted verifies):
|
|
20
|
+
* those need a different model (whole-repo audit, not pre-commit).
|
|
21
|
+
* - Cross-file dependencies (verifies that pipe through xargs).
|
|
22
|
+
* - The deny-list / allow-list around which rules block vs warn — caller
|
|
23
|
+
* decides via the severity field on each rule.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { readFileSync } from 'fs';
|
|
27
|
+
import { extname } from 'path';
|
|
28
|
+
import { SCOPE_BY_EXT } from './parser.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run all applicable rules against a set of staged files.
|
|
32
|
+
* @param {Array<object>} rules - From parser.loadRegistry().rules
|
|
33
|
+
* @param {Array<string>} stagedAbsPaths - Absolute file paths
|
|
34
|
+
* @param {object} [options]
|
|
35
|
+
* @param {string} [options.repoRoot] - For displaying relative paths in output
|
|
36
|
+
* @returns {{ totalFindings: number, findings: Array, byFile: object, bySeverity: object }}
|
|
37
|
+
*/
|
|
38
|
+
export function runRules(rules, stagedAbsPaths, options = {}) {
|
|
39
|
+
const repoRoot = options.repoRoot || '';
|
|
40
|
+
const findings = [];
|
|
41
|
+
|
|
42
|
+
// Precompile each rule's matchers once.
|
|
43
|
+
const compiled = compileRules(rules);
|
|
44
|
+
|
|
45
|
+
for (const absPath of stagedAbsPaths) {
|
|
46
|
+
const ext = extname(absPath).toLowerCase();
|
|
47
|
+
const scope = SCOPE_BY_EXT[ext];
|
|
48
|
+
if (!scope) continue;
|
|
49
|
+
|
|
50
|
+
// Skip binary / very large files defensively.
|
|
51
|
+
let content;
|
|
52
|
+
try {
|
|
53
|
+
content = readFileSync(absPath, 'utf8');
|
|
54
|
+
if (content.length > 1_000_000) continue; // 1MB cap
|
|
55
|
+
} catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const applicable = compiled.filter((r) => r.scope === scope);
|
|
60
|
+
if (applicable.length === 0) continue;
|
|
61
|
+
|
|
62
|
+
const lines = content.split('\n');
|
|
63
|
+
for (const rule of applicable) {
|
|
64
|
+
for (const matcher of rule.matchers) {
|
|
65
|
+
// Try matching each line; collect line numbers.
|
|
66
|
+
for (let i = 0; i < lines.length; i++) {
|
|
67
|
+
const line = lines[i];
|
|
68
|
+
if (matcher.exclude && matcher.exclude.test(line)) continue;
|
|
69
|
+
if (matcher.regex.test(line)) {
|
|
70
|
+
// Reset lastIndex for global regexes; we only need the first hit per line.
|
|
71
|
+
matcher.regex.lastIndex = 0;
|
|
72
|
+
findings.push({
|
|
73
|
+
ruleId: rule.id,
|
|
74
|
+
severity: rule.severity,
|
|
75
|
+
title: rule.title,
|
|
76
|
+
scope: rule.scope,
|
|
77
|
+
file: repoRoot ? relativizePath(absPath, repoRoot) : absPath,
|
|
78
|
+
absPath,
|
|
79
|
+
line: i + 1,
|
|
80
|
+
lineText: line.trim().slice(0, 200),
|
|
81
|
+
// Lightweight pointer back to the registry entry for the UI.
|
|
82
|
+
registryRef: `${rule.scope}/improvement-registry.md → ${rule.id}`,
|
|
83
|
+
});
|
|
84
|
+
} else {
|
|
85
|
+
matcher.regex.lastIndex = 0;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return summarize(findings);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Convert each rule's verify commands into JS RegExp matchers we can run
|
|
97
|
+
* line-by-line. Rules where the verify can't be extracted are skipped (they
|
|
98
|
+
* remain in the registry for other features like `lookup`).
|
|
99
|
+
*/
|
|
100
|
+
function compileRules(rules) {
|
|
101
|
+
const out = [];
|
|
102
|
+
for (const rule of rules) {
|
|
103
|
+
const matchers = [];
|
|
104
|
+
for (const v of rule.verifies) {
|
|
105
|
+
const m = extractMatcher(v.command);
|
|
106
|
+
if (m) matchers.push(m);
|
|
107
|
+
}
|
|
108
|
+
if (matchers.length > 0) {
|
|
109
|
+
out.push({
|
|
110
|
+
id: rule.id,
|
|
111
|
+
severity: rule.severity,
|
|
112
|
+
title: rule.title,
|
|
113
|
+
scope: rule.scope,
|
|
114
|
+
matchers,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Extract a regex + optional exclude regex from a Verify grep command.
|
|
123
|
+
*
|
|
124
|
+
* Supported shapes (covers ~50 of the 95 current registry entries):
|
|
125
|
+
* grep -rn "PATTERN" path
|
|
126
|
+
* grep -rEn "REGEX" path
|
|
127
|
+
* grep -rn "PATTERN" path --include=*.ext
|
|
128
|
+
* grep -rn "PATTERN" path | grep -v "EXCLUDE"
|
|
129
|
+
* grep -rEn "REGEX1" path | grep -E "REGEX2" (we treat the pipe as AND)
|
|
130
|
+
* rg "PATTERN" path
|
|
131
|
+
*
|
|
132
|
+
* Returns { regex, exclude } or null if the command doesn't fit the supported shapes.
|
|
133
|
+
*/
|
|
134
|
+
function extractMatcher(command) {
|
|
135
|
+
if (!command) return null;
|
|
136
|
+
|
|
137
|
+
// Strip quoted strings before checking for shell control characters —
|
|
138
|
+
// a literal `;` inside the grep regex (e.g. `^import .*\.\*;`) must
|
|
139
|
+
// not trip our reject heuristic.
|
|
140
|
+
const outsideQuotes = command
|
|
141
|
+
.replace(/"[^"]*"/g, '"_"')
|
|
142
|
+
.replace(/'[^']*'/g, "'_'");
|
|
143
|
+
|
|
144
|
+
if (/xargs/.test(outsideQuotes)) return null;
|
|
145
|
+
// Shell control outside quotes (||, &&, ;, $(), backticks for command sub) → file-level / audit-style.
|
|
146
|
+
if (/\|\||&&|;|\$\(|`/.test(outsideQuotes)) return null;
|
|
147
|
+
if (/\s\|\s/.test(outsideQuotes) && !/\|\s+grep/.test(outsideQuotes)) return null;
|
|
148
|
+
|
|
149
|
+
// Split on the (optional) `| grep` pipe. We support: first grep matches,
|
|
150
|
+
// pipe-through grep further filters (positive or `-v` excludes).
|
|
151
|
+
const segments = command.split(/\s\|\s/).map((s) => s.trim());
|
|
152
|
+
const primary = segments[0];
|
|
153
|
+
|
|
154
|
+
const tokens = tokenizeGrepInvocation(primary);
|
|
155
|
+
if (!tokens) return null;
|
|
156
|
+
|
|
157
|
+
// Reject FILE-LEVEL grep modes (list filenames only) — those are
|
|
158
|
+
// whole-repo audits, not line-level pre-commit checks.
|
|
159
|
+
// -l → list matching filenames only
|
|
160
|
+
// -L → list NON-matching filenames only (inverted file presence)
|
|
161
|
+
// We accept `-ln`/`-rln`/`-Ln` because `n` is line numbers (still file-level).
|
|
162
|
+
if (tokens.flags.includes('l') || tokens.flags.includes('L')) return null;
|
|
163
|
+
|
|
164
|
+
const isExtended = tokens.flags.includes('E');
|
|
165
|
+
let pattern;
|
|
166
|
+
try {
|
|
167
|
+
pattern = isExtended ? tokens.pattern : escapeForRegex(tokens.pattern);
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let regex;
|
|
173
|
+
try {
|
|
174
|
+
regex = new RegExp(pattern);
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Optional exclude from a `| grep -v "X"` follow-up.
|
|
180
|
+
let exclude = null;
|
|
181
|
+
for (const seg of segments.slice(1)) {
|
|
182
|
+
const t = tokenizeGrepInvocation(seg);
|
|
183
|
+
if (!t) continue;
|
|
184
|
+
if (t.flags.includes('v')) {
|
|
185
|
+
try {
|
|
186
|
+
const excPat = t.flags.includes('E') ? t.pattern : escapeForRegex(t.pattern);
|
|
187
|
+
exclude = new RegExp(excPat);
|
|
188
|
+
} catch {
|
|
189
|
+
/* leave exclude null */
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Non-inverted secondary grep == narrowing AND. We could AND it with
|
|
193
|
+
// primary but in practice the registry uses this for file-list filtering
|
|
194
|
+
// (e.g. JBE-004 piping into `grep -A 1`), which is whole-file context
|
|
195
|
+
// not single-line. Conservative: skip.
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { regex, exclude };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Tokenize a single `grep ...` invocation into { flags, pattern, paths }.
|
|
203
|
+
*
|
|
204
|
+
* The pattern is the FIRST positional argument after the flags.
|
|
205
|
+
* Patterns may be quoted with " or ' or backticks; flags can be combined or split.
|
|
206
|
+
*
|
|
207
|
+
* Returns null if we can't recognise the shape (in which case the rule's
|
|
208
|
+
* verify just doesn't fire — silently skipped).
|
|
209
|
+
*/
|
|
210
|
+
function tokenizeGrepInvocation(s) {
|
|
211
|
+
const trimmed = s.trim();
|
|
212
|
+
// Must start with grep / rg / git grep
|
|
213
|
+
let rest;
|
|
214
|
+
if (/^grep\s/.test(trimmed)) rest = trimmed.slice(5).trim();
|
|
215
|
+
else if (/^rg\s/.test(trimmed)) rest = trimmed.slice(3).trim();
|
|
216
|
+
else if (/^git\s+grep\s/.test(trimmed)) rest = trimmed.replace(/^git\s+grep\s/, '').trim();
|
|
217
|
+
else return null;
|
|
218
|
+
|
|
219
|
+
const flags = [];
|
|
220
|
+
while (rest.startsWith('-')) {
|
|
221
|
+
// Stop at '--' (separator)
|
|
222
|
+
if (rest.startsWith('-- ')) {
|
|
223
|
+
rest = rest.slice(3).trim();
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
const m = rest.match(/^-(-?)([A-Za-z]+)(=(\S+))?\s*/);
|
|
227
|
+
if (!m) break;
|
|
228
|
+
const isLong = m[1] === '-';
|
|
229
|
+
if (isLong) {
|
|
230
|
+
flags.push(m[2]);
|
|
231
|
+
} else {
|
|
232
|
+
for (const ch of m[2]) flags.push(ch);
|
|
233
|
+
}
|
|
234
|
+
rest = rest.slice(m[0].length);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// First positional: the pattern. Strip surrounding quotes if any.
|
|
238
|
+
const patMatch = rest.match(/^("([^"]*)"|'([^']*)'|(\S+))/);
|
|
239
|
+
if (!patMatch) return null;
|
|
240
|
+
const pattern = patMatch[2] ?? patMatch[3] ?? patMatch[4];
|
|
241
|
+
|
|
242
|
+
return { flags, pattern };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function escapeForRegex(s) {
|
|
246
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function relativizePath(abs, root) {
|
|
250
|
+
const r = root.replace(/[\\/]+$/, '');
|
|
251
|
+
if (abs.startsWith(r)) {
|
|
252
|
+
return abs.slice(r.length + 1).replace(/\\/g, '/');
|
|
253
|
+
}
|
|
254
|
+
return abs.replace(/\\/g, '/');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function summarize(findings) {
|
|
258
|
+
const byFile = {};
|
|
259
|
+
const bySeverity = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
|
|
260
|
+
for (const f of findings) {
|
|
261
|
+
(byFile[f.file] ||= []).push(f);
|
|
262
|
+
bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
|
|
263
|
+
}
|
|
264
|
+
return { totalFindings: findings.length, findings, byFile, bySeverity };
|
|
265
|
+
}
|