claude-git-hooks 2.66.1 → 2.68.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/CHANGELOG.md +117 -8
- package/README.md +34 -0
- package/bin/claude-hooks +19 -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 +20 -15
- package/lib/commands/close-release.js +25 -19
- package/lib/commands/create-pr.js +30 -1
- package/lib/commands/create-release.js +19 -13
- package/lib/commands/install.js +9 -19
- package/lib/commands/update.js +14 -28
- package/lib/defaults.json +9 -0
- package/lib/hooks/pre-commit.js +112 -32
- package/lib/utils/analysis-engine.js +7 -3
- package/lib/utils/auto-update.js +198 -0
- package/lib/utils/config-registry.js +1 -0
- 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 +9 -3
- package/package.json +84 -85
- package/templates/CLAUDE_ANALYSIS_PROMPT.md +2 -1
- package/templates/config.advanced.example.json +42 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: skill-registry/index.js
|
|
3
|
+
* Purpose: Public API (facade) for the mscope automation-skills integration.
|
|
4
|
+
*
|
|
5
|
+
* Integration entry points — consumers should only need these three:
|
|
6
|
+
* - runSkillChecks(...) — freshness + registry load + line-level checks
|
|
7
|
+
* + display + blockOn gating decision.
|
|
8
|
+
* - getCatalogueSection(...) — rendered rule catalogue for the analysis
|
|
9
|
+
* prompt (memoized registry load).
|
|
10
|
+
* - recordSkillGaps(...) — append unclassified AI findings to the
|
|
11
|
+
* skill repo's skill-feedback.md.
|
|
12
|
+
*
|
|
13
|
+
* Lower-level building blocks remain exported for tests and advanced use:
|
|
14
|
+
* - loadRegistry() — parse the JBE/UIK rule catalogue.
|
|
15
|
+
* - runRules(...) — line-level pre-commit checks against staged files.
|
|
16
|
+
* - displayFindings(...) — formatted console output.
|
|
17
|
+
* - checkFreshness() — non-blocking call to `automation-skills check`.
|
|
18
|
+
*
|
|
19
|
+
* Designed so the pre-commit hook can do:
|
|
20
|
+
*
|
|
21
|
+
* import * as skill from '../utils/skill-registry/index.js';
|
|
22
|
+
* const check = skill.runSkillChecks(stagedAbsPaths, { repoRoot, config });
|
|
23
|
+
* if (check.shouldBlock) process.exit(1); // exit stays in the hook
|
|
24
|
+
*
|
|
25
|
+
* Failure mode is always "silent skip" — if the skill repo isn't found, or
|
|
26
|
+
* the registry is malformed, the hook continues without these checks. The
|
|
27
|
+
* mscope skill is a value-add, not a hard dependency of this repo.
|
|
28
|
+
*
|
|
29
|
+
* No function in this module calls process.exit() — utils return decisions,
|
|
30
|
+
* the CLI/hook layer acts on them (repo convention).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export { resolveSkillRepoRoot, loadRegistry, SCOPE_BY_EXT } from './parser.js';
|
|
34
|
+
export { runRules } from './runner.js';
|
|
35
|
+
|
|
36
|
+
import { spawnSync } from 'child_process';
|
|
37
|
+
import { loadRegistry } from './parser.js';
|
|
38
|
+
import { runRules } from './runner.js';
|
|
39
|
+
import { renderCatalogue } from './catalogue.js';
|
|
40
|
+
import { writeSkillGapCandidates } from './feedback-writer.js';
|
|
41
|
+
import logger from '../logger.js';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Severity rank used by the blockOn gate. Lower = more severe.
|
|
45
|
+
* 'never' (or any unknown value) disables gating.
|
|
46
|
+
*/
|
|
47
|
+
const SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Process-lifetime cache of registry loads, keyed by the workingRepoRoot
|
|
51
|
+
* used for resolution. Lets runSkillChecks and getCatalogueSection share
|
|
52
|
+
* a single filesystem pass per commit.
|
|
53
|
+
*/
|
|
54
|
+
const registryCache = new Map();
|
|
55
|
+
|
|
56
|
+
function loadRegistryCached(workingRepoRoot) {
|
|
57
|
+
const key = workingRepoRoot || '';
|
|
58
|
+
if (!registryCache.has(key)) {
|
|
59
|
+
registryCache.set(key, loadRegistry(null, workingRepoRoot));
|
|
60
|
+
}
|
|
61
|
+
return registryCache.get(key);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Test hook — clears the memoized registry. Not for production use.
|
|
66
|
+
*/
|
|
67
|
+
export function _resetRegistryCache() {
|
|
68
|
+
registryCache.clear();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One-call integration point for the pre-commit hook: freshness warning,
|
|
73
|
+
* registry load (memoized), rule run, console display, and the blockOn
|
|
74
|
+
* gating decision.
|
|
75
|
+
*
|
|
76
|
+
* Never calls process.exit — returns `shouldBlock` and lets the hook decide.
|
|
77
|
+
* Throws only on unexpected internal errors (callers wrap in try/catch).
|
|
78
|
+
*
|
|
79
|
+
* @param {string[]} absPaths - Absolute paths of staged files to check.
|
|
80
|
+
* @param {object} options
|
|
81
|
+
* @param {string} options.repoRoot - Working repo root (for sibling resolution + relative display).
|
|
82
|
+
* @param {object} options.config - Merged config; reads config.skillRegistry.blockOn.
|
|
83
|
+
* @returns {{ skillRoot: string|null, rules: Array, findings: Array,
|
|
84
|
+
* totalFindings: number, shouldBlock: boolean, triggeringCount: number }}
|
|
85
|
+
*/
|
|
86
|
+
export function runSkillChecks(absPaths, { repoRoot, config } = {}) {
|
|
87
|
+
const freshness = checkFreshness();
|
|
88
|
+
if (freshness.status === 'stale') {
|
|
89
|
+
logger.warning(`📚 mscope skill is behind: installed v${freshness.installed}, latest v${freshness.latest}. Run: automation-skills update`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { rules, skillRoot } = loadRegistryCached(repoRoot);
|
|
93
|
+
if (!skillRoot || rules.length === 0) {
|
|
94
|
+
if (!skillRoot) {
|
|
95
|
+
logger.debug('skill-registry - runSkillChecks', 'Skill registry not found — skipping skill-driven checks');
|
|
96
|
+
}
|
|
97
|
+
return { skillRoot, rules: rules || [], findings: [], totalFindings: 0, shouldBlock: false, triggeringCount: 0 };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const result = runRules(rules, absPaths, { repoRoot });
|
|
101
|
+
displayFindings(result);
|
|
102
|
+
|
|
103
|
+
// Optional gating: blockOn = 'critical' | 'high' | 'medium' | 'low' | 'never'
|
|
104
|
+
// (default: 'never' — warn-only for incremental adoption).
|
|
105
|
+
const blockOn = config?.skillRegistry?.blockOn || 'never';
|
|
106
|
+
const threshold = SEVERITY_RANK[blockOn] ?? -1;
|
|
107
|
+
const triggering = threshold >= 0
|
|
108
|
+
? result.findings.filter((f) => (SEVERITY_RANK[f.severity] ?? 99) <= threshold)
|
|
109
|
+
: [];
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
skillRoot,
|
|
113
|
+
rules,
|
|
114
|
+
findings: result.findings,
|
|
115
|
+
totalFindings: result.totalFindings,
|
|
116
|
+
shouldBlock: triggering.length > 0,
|
|
117
|
+
triggeringCount: triggering.length,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Rendered `=== PLATFORM ANTIPATTERN CATALOGUE ===` body for injection into
|
|
123
|
+
* the Claude analysis prompt. Uses the memoized registry load, so calling
|
|
124
|
+
* this after runSkillChecks costs nothing.
|
|
125
|
+
*
|
|
126
|
+
* Never throws — the catalogue is value-add; on any failure returns null.
|
|
127
|
+
*
|
|
128
|
+
* @param {object} options
|
|
129
|
+
* @param {string} options.repoRoot - Working repo root for sibling resolution.
|
|
130
|
+
* @returns {string|null} Markdown catalogue, or null if unavailable/empty.
|
|
131
|
+
*/
|
|
132
|
+
export function getCatalogueSection({ repoRoot } = {}) {
|
|
133
|
+
try {
|
|
134
|
+
const { rules, skillRoot } = loadRegistryCached(repoRoot);
|
|
135
|
+
if (!skillRoot || rules.length === 0) return null;
|
|
136
|
+
return renderCatalogue(rules) || null;
|
|
137
|
+
} catch (err) {
|
|
138
|
+
logger.debug('skill-registry - getCatalogueSection', 'Catalogue unavailable', { error: err?.message });
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Append skill-gap candidates (AI findings with no catalogue rule ID) to the
|
|
145
|
+
* skill repo's skill-feedback.md. Thin wrapper over writeSkillGapCandidates;
|
|
146
|
+
* throws on write failure (caller decides how loud to be).
|
|
147
|
+
*
|
|
148
|
+
* @param {object} aiResult - Result from analysis-engine.runAnalysis.
|
|
149
|
+
* @param {object} context - { skillRoot, branch, repoName }
|
|
150
|
+
* @returns {{ written: number, skippedDuplicate: number, skippedClassified: number }}
|
|
151
|
+
*/
|
|
152
|
+
export function recordSkillGaps(aiResult, context) {
|
|
153
|
+
return writeSkillGapCandidates(aiResult, context);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Run `automation-skills check --json` to find out if the installed skill
|
|
158
|
+
* is behind the latest. Returns:
|
|
159
|
+
* { status: 'fresh' | 'stale' | 'unknown', installed, latest }
|
|
160
|
+
*
|
|
161
|
+
* Never throws; never blocks the hook. If the CLI isn't installed or the
|
|
162
|
+
* check fails, returns { status: 'unknown' }.
|
|
163
|
+
*/
|
|
164
|
+
export function checkFreshness({ timeoutMs = 3000 } = {}) {
|
|
165
|
+
try {
|
|
166
|
+
const res = spawnSync('automation-skills', ['check', '--json'], {
|
|
167
|
+
timeout: timeoutMs,
|
|
168
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
169
|
+
});
|
|
170
|
+
if (res.error || (res.status !== 0 && res.status !== 1)) {
|
|
171
|
+
return { status: 'unknown' };
|
|
172
|
+
}
|
|
173
|
+
const line = (res.stdout || '').toString().trim();
|
|
174
|
+
if (!line) return { status: 'unknown' };
|
|
175
|
+
const parsed = JSON.parse(line);
|
|
176
|
+
return {
|
|
177
|
+
status: parsed.status === 'stale' ? 'stale'
|
|
178
|
+
: parsed.status === 'fresh' ? 'fresh'
|
|
179
|
+
: parsed.status === 'ahead' ? 'ahead'
|
|
180
|
+
: 'unknown',
|
|
181
|
+
installed: parsed.installed,
|
|
182
|
+
latest: parsed.latest,
|
|
183
|
+
};
|
|
184
|
+
} catch {
|
|
185
|
+
return { status: 'unknown' };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Print a formatted summary of pre-commit findings (from runRules) to
|
|
191
|
+
* stdout. Returns nothing.
|
|
192
|
+
*
|
|
193
|
+
* Format intentionally mirrors the existing hook's `displayResults` style
|
|
194
|
+
* so it slots in naturally.
|
|
195
|
+
*
|
|
196
|
+
* 📚 mscope skill registry — 2 findings (1 high, 1 medium)
|
|
197
|
+
*
|
|
198
|
+
* [JBE-016] medium Wildcard import (`import x.*;`)
|
|
199
|
+
* file.java:12 import jakarta.persistence.*;
|
|
200
|
+
* → see automation-standards-backend/docs/improvement-registry.md#jbe-016
|
|
201
|
+
*/
|
|
202
|
+
export function displayFindings({ totalFindings, findings, bySeverity }) {
|
|
203
|
+
if (totalFindings === 0) {
|
|
204
|
+
logger.info('📚 mscope skill registry — no rule violations in staged files.');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const sev = bySeverity || {};
|
|
209
|
+
const sevParts = [];
|
|
210
|
+
if (sev.critical) sevParts.push(`${sev.critical} 🔴 critical`);
|
|
211
|
+
if (sev.high) sevParts.push(`${sev.high} 🟠 high`);
|
|
212
|
+
if (sev.medium) sevParts.push(`${sev.medium} 🟡 medium`);
|
|
213
|
+
if (sev.low) sevParts.push(`${sev.low} 🟢 low`);
|
|
214
|
+
|
|
215
|
+
logger.info(`📚 mscope skill registry — ${totalFindings} finding${totalFindings === 1 ? '' : 's'}${
|
|
216
|
+
sevParts.length ? ` (${sevParts.join(', ')})` : ''}`);
|
|
217
|
+
logger.info('');
|
|
218
|
+
|
|
219
|
+
// Group findings by rule for compact output.
|
|
220
|
+
const byRule = {};
|
|
221
|
+
for (const f of findings) {
|
|
222
|
+
(byRule[f.ruleId] ||= { rule: f, occurrences: [] }).occurrences.push(f);
|
|
223
|
+
}
|
|
224
|
+
const rulesSorted = Object.keys(byRule).sort((a, b) => {
|
|
225
|
+
// High severity first, then by rule id.
|
|
226
|
+
const order = { critical: 0, high: 1, medium: 2, low: 3, unknown: 4 };
|
|
227
|
+
const sa = order[byRule[a].rule.severity] ?? 99;
|
|
228
|
+
const sb = order[byRule[b].rule.severity] ?? 99;
|
|
229
|
+
if (sa !== sb) return sa - sb;
|
|
230
|
+
return a.localeCompare(b);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
for (const ruleId of rulesSorted) {
|
|
234
|
+
const { rule, occurrences } = byRule[ruleId];
|
|
235
|
+
const sevTag = sevTagFor(rule.severity);
|
|
236
|
+
logger.info(` [${ruleId}] ${sevTag} ${rule.title}`);
|
|
237
|
+
for (const occ of occurrences.slice(0, 5)) {
|
|
238
|
+
logger.info(` ${occ.file}:${occ.line} ${occ.lineText.slice(0, 100)}`);
|
|
239
|
+
}
|
|
240
|
+
if (occurrences.length > 5) {
|
|
241
|
+
logger.info(` … and ${occurrences.length - 5} more occurrence${occurrences.length - 5 === 1 ? '' : 's'}`);
|
|
242
|
+
}
|
|
243
|
+
logger.info(` → see ${rule.scope}/improvement-registry.md → ${ruleId}`);
|
|
244
|
+
logger.info('');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function sevTagFor(sev) {
|
|
249
|
+
if (sev === 'critical') return '🔴 critical';
|
|
250
|
+
if (sev === 'high') return '🟠 high';
|
|
251
|
+
if (sev === 'medium') return '🟡 medium';
|
|
252
|
+
if (sev === 'low') return '🟢 low';
|
|
253
|
+
return `⚪ ${ sev}`;
|
|
254
|
+
}
|
|
@@ -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
|
+
}
|