claude-git-hooks 2.66.1 → 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.
@@ -24,6 +24,7 @@ import {
24
24
  } from '../utils/interactive-ui.js';
25
25
  import { getBranchPushStatus, pushBranch, stageFiles, createCommit, getRepoRoot } from '../utils/git-operations.js';
26
26
  import logger from '../utils/logger.js';
27
+ import { libraryModuleUrl } from '../utils/library-resolver.js';
27
28
  import { resolveLabels } from '../utils/label-resolver.js';
28
29
  import { CostTracker } from '../utils/cost-tracker.js';
29
30
  import { error, fatal, checkGitRepo } from './helpers.js';
@@ -465,7 +466,7 @@ export async function runCreatePr(args) {
465
466
  logger.debug('create-pr', 'Step 5.7: Running Library maintenance pipeline');
466
467
  try {
467
468
  showInfo('Running Library maintenance pipeline...');
468
- const { createPrPipeline } = await import('../../.library/librarian/index.js');
469
+ const { createPrPipeline } = await import(libraryModuleUrl('librarian/index.js'));
469
470
 
470
471
  const pipelineSummary = await createPrPipeline({ repoRoot: root });
471
472
  const {
@@ -534,6 +535,34 @@ export async function runCreatePr(args) {
534
535
  }
535
536
  }
536
537
 
538
+ // Step 5.75: mscope lessons-learned capture (automation-skills resume).
539
+ // Interactive knowledge-capture alongside the gotcha solicitation above:
540
+ // hands stdio to `automation-skills resume`, which records the branch's
541
+ // lessons to the skill repo's implementation-history.md. Skipped in
542
+ // headless mode (interactive by nature), config-gated via
543
+ // skillRegistry.resumeOnCreatePr, and never blocks PR creation.
544
+ if (!headless &&
545
+ config.skillRegistry?.enabled !== false &&
546
+ config.skillRegistry?.resumeOnCreatePr !== false) {
547
+ try {
548
+ const { isResumeCliAvailable, runResumeFlow } = await import('../utils/skill-registry/resume.js');
549
+ if (isResumeCliAvailable()) {
550
+ showInfo('Handing control to `automation-skills resume` to capture lessons-learned...');
551
+ const resumeOut = runResumeFlow({ repoRoot: root });
552
+ if (resumeOut.ran && resumeOut.exitCode === 0) {
553
+ showSuccess('Lessons captured to the mscope skill repo');
554
+ } else if (resumeOut.ran) {
555
+ showWarning('Lessons capture exited without recording (see output above)');
556
+ }
557
+ } else {
558
+ logger.debug('create-pr', 'automation-skills CLI not found — skipping lessons capture');
559
+ }
560
+ } catch (resumeErr) {
561
+ // Knowledge capture failure is non-blocking — log and continue.
562
+ showWarning(`Lessons capture unavailable: ${resumeErr.message}`);
563
+ }
564
+ }
565
+
537
566
  // Step 5.8: Smart tag pushing (Issue #44)
538
567
  // Runs AFTER library maintenance so tags include the library commit.
539
568
  logger.debug('create-pr', 'Step 5.8: Checking and pushing unpushed tags');
@@ -59,6 +59,7 @@ import {
59
59
  promptConfirmation
60
60
  } from '../utils/interactive-ui.js';
61
61
  import logger from '../utils/logger.js';
62
+ import { libraryModuleUrl, hasLibrary } from '../utils/library-resolver.js';
62
63
  import { colors, error, checkGitRepo } from './helpers.js';
63
64
  import {
64
65
  CONSOLE_WARNING_TEMPLATE,
@@ -372,21 +373,26 @@ export async function runCreateRelease(args) {
372
373
  showSuccess('Preconditions validated');
373
374
  console.log('');
374
375
 
375
- // Step 2: Library verification gate — silent on clean, loud-warn on stale, never blocks
376
+ // Step 2: Library verification gate — silent on clean, loud-warn on stale, never blocks.
377
+ // Only runs when the working repo has its own .library/ (the tool ships without one).
376
378
  let verifyResult = null;
377
- logger.debug('create-release', 'Step 2: Running Library verification gate');
378
- try {
379
- const { verify } = await import('../../.library/librarian/index.js');
380
- verifyResult = await verify();
379
+ if (!hasLibrary()) {
380
+ logger.debug('create-release', 'No .library/ in current repo — skipping Library verification gate');
381
+ } else {
382
+ logger.debug('create-release', 'Step 2: Running Library verification gate');
383
+ try {
384
+ const { verify } = await import(libraryModuleUrl('librarian/index.js'));
385
+ verifyResult = await verify();
381
386
 
382
- if (verifyResult.clean) {
383
- logger.debug('create-release', 'Library is clean — no warning needed');
384
- } else {
385
- _emitLibraryWarning(verifyResult);
387
+ if (verifyResult.clean) {
388
+ logger.debug('create-release', 'Library is clean — no warning needed');
389
+ } else {
390
+ _emitLibraryWarning(verifyResult);
391
+ }
392
+ } catch (verifyErr) {
393
+ const msg = `\n${colors.yellow} ${LIBRARY_VERIFY_SKIPPED_WARNING_RELEASE} ${verifyErr.message}${colors.reset}\n\n`;
394
+ process.stderr.write(msg);
386
395
  }
387
- } catch (verifyErr) {
388
- const msg = `\n${colors.yellow} ${LIBRARY_VERIFY_SKIPPED_WARNING_RELEASE} ${verifyErr.message}${colors.reset}\n\n`;
389
- process.stderr.write(msg);
390
396
  }
391
397
 
392
398
  // Step 3: Discover version files
@@ -511,7 +517,7 @@ export async function runCreateRelease(args) {
511
517
  logger.debug('create-release', 'Step 9: Running Library regeneration');
512
518
  showInfo('📚 Regenerating stale Library books...');
513
519
  try {
514
- const { createPrPipeline } = await import('../../.library/librarian/index.js');
520
+ const { createPrPipeline } = await import(libraryModuleUrl('librarian/index.js'));
515
521
  const root = getRepoRoot();
516
522
  const pipelineSummary = await createPrPipeline({ repoRoot: root });
517
523
 
package/lib/defaults.json CHANGED
@@ -112,6 +112,11 @@
112
112
  "model": "sonnet",
113
113
  "timeout": 360000
114
114
  },
115
+ "skillRegistry": {
116
+ "enabled": true,
117
+ "blockOn": "never",
118
+ "resumeOnCreatePr": true
119
+ },
115
120
  "orchestrator": {
116
121
  "model": "opus",
117
122
  "timeout": 60000,
@@ -19,8 +19,8 @@
19
19
  * - resolution-prompt: Issue resolution generation
20
20
  */
21
21
 
22
- import { join } from 'path';
23
- import { getStagedFiles, getRepoRoot, getStagedTreeSha } from '../utils/git-operations.js';
22
+ import { join, basename } from 'path';
23
+ import { getStagedFiles, getRepoRoot, getStagedTreeSha, getCurrentBranch } from '../utils/git-operations.js';
24
24
  import { writeMarker } from '../utils/hooks-verified-marker.js';
25
25
  import { filterFiles } from '../utils/file-operations.js';
26
26
  import {
@@ -33,9 +33,11 @@ import { generateResolutionPrompt, shouldGeneratePrompt } from '../utils/resolut
33
33
  import { loadPreset } from '../utils/preset-loader.js';
34
34
  import { getVersion } from '../utils/package-info.js';
35
35
  import logger from '../utils/logger.js';
36
+ import { libraryModuleUrl, hasLibrary } from '../utils/library-resolver.js';
36
37
  import { getConfig } from '../config.js';
37
38
  import { recordMetric } from '../utils/metrics.js';
38
39
  import { runLinters, displayLintResults, lintIssuesToAnalysisDetails } from '../utils/linter-runner.js';
40
+ import * as skillRegistry from '../utils/skill-registry/index.js';
39
41
 
40
42
  /**
41
43
  * Configuration loaded from lib/config.js
@@ -151,39 +153,44 @@ const main = async () => {
151
153
  process.exit(0);
152
154
  }
153
155
 
154
- // Library staleness check — non-blocking warning
155
- try {
156
- const { checkBook } = await import('../../.library/tools/staleness.js');
157
- const { getBooksDir } = await import('../../.library/paths.js');
158
- const booksDir = getBooksDir();
159
- const sourceFiles = validFiles
160
- .map(f => (typeof f === 'string' ? f : f.path))
161
- .filter(p => p.startsWith('lib/'));
162
-
163
- if (sourceFiles.length > 0) {
164
- const staleBooks = [];
165
- for (const srcPath of sourceFiles) {
166
- const bookName = `${srcPath.replace(/^lib\/(?:.*\/)?/, '').replace(/\.js$/, '')}.md`;
167
- const bookPath = join(booksDir, bookName);
168
- try {
169
- const result = await checkBook(bookPath, getRepoRoot());
170
- if (result.status === 'stale') {
171
- staleBooks.push(result.book);
156
+ // Library staleness check — non-blocking warning.
157
+ // Only runs when the working repo has its own .library/ (the tool ships without one).
158
+ if (!hasLibrary()) {
159
+ logger.debug('pre-commit', 'No .library/ in current repo — skipping Library staleness check');
160
+ } else {
161
+ try {
162
+ const { checkBook } = await import(libraryModuleUrl('tools/staleness.js'));
163
+ const { getBooksDir } = await import(libraryModuleUrl('paths.js'));
164
+ const booksDir = getBooksDir();
165
+ const sourceFiles = validFiles
166
+ .map(f => (typeof f === 'string' ? f : f.path))
167
+ .filter(p => p.startsWith('lib/'));
168
+
169
+ if (sourceFiles.length > 0) {
170
+ const staleBooks = [];
171
+ for (const srcPath of sourceFiles) {
172
+ const bookName = `${srcPath.replace(/^lib\/(?:.*\/)?/, '').replace(/\.js$/, '')}.md`;
173
+ const bookPath = join(booksDir, bookName);
174
+ try {
175
+ const result = await checkBook(bookPath, getRepoRoot());
176
+ if (result.status === 'stale') {
177
+ staleBooks.push(result.book);
178
+ }
179
+ } catch {
180
+ // Book doesn't exist or check failed — skip silently
172
181
  }
173
- } catch {
174
- // Book doesn't exist or check failed — skip silently
175
182
  }
176
- }
177
- if (staleBooks.length > 0) {
178
- logger.warning(`📚 ${staleBooks.length} library book(s) will become stale after this commit`);
179
- for (const book of staleBooks) {
180
- logger.warning(` └─ ${book}`);
183
+ if (staleBooks.length > 0) {
184
+ logger.warning(`📚 ${staleBooks.length} library book(s) will become stale after this commit`);
185
+ for (const book of staleBooks) {
186
+ logger.warning(` └─ ${book}`);
187
+ }
188
+ logger.warning(' Run: npm run library:regenerate');
181
189
  }
182
- logger.warning(' Run: npm run library:regenerate');
183
190
  }
191
+ } catch {
192
+ logger.warning('📚 Library staleness check unavailable — .library/ tools not found');
184
193
  }
185
- } catch {
186
- logger.warning('📚 Library staleness check unavailable — .library/ tools not found');
187
194
  }
188
195
 
189
196
  // Step 3: Run linters (fast, deterministic — before Claude analysis)
@@ -220,6 +227,43 @@ const main = async () => {
220
227
  }
221
228
  }
222
229
 
230
+ // Step 3.5: mscope skill-registry check (deterministic; fast; warn-only by default).
231
+ // The facade runs the JBE-NNN / UIK-NNN catalogue's `Verify:` greps
232
+ // against the staged file contents, displays findings alongside lint +
233
+ // AI output, and returns the blockOn gating decision. Exiting on a
234
+ // blocked commit stays here in the hook — utils never exit.
235
+ //
236
+ // Silent skip if the skill repo isn't locatable — the integration is
237
+ // value-add, never a hard dep.
238
+ //
239
+ // skillRoot is hoisted to function-scope so Step 9.5 (skill-gap writer)
240
+ // can reuse the resolution (registry load is memoized in the facade).
241
+ let skillRoot = null;
242
+ let skillCheck = null;
243
+ if (config.skillRegistry?.enabled !== false) {
244
+ try {
245
+ const absPaths = validFiles
246
+ .map((f) => (typeof f === 'string' ? f : f.path))
247
+ .map((p) => (p.startsWith('/') || /^[A-Za-z]:/.test(p) ? p : join(repoRoot, p)));
248
+
249
+ skillCheck = skillRegistry.runSkillChecks(absPaths, { repoRoot, config });
250
+ skillRoot = skillCheck.skillRoot;
251
+ } catch (err) {
252
+ // Never break the hook for skill-registry failures.
253
+ logger.debug('pre-commit - main', 'Skill registry check failed', { error: err?.message });
254
+ }
255
+ }
256
+
257
+ // The escape hatch is the config knob — `--no-verify` is
258
+ // intentionally not advertised (project convention discourages
259
+ // skipping hooks; the config is the supported override).
260
+ // process.exit stays outside the try-catch per repo convention.
261
+ if (skillCheck?.shouldBlock) {
262
+ logger.error(`Skill registry: ${skillCheck.triggeringCount} finding(s) at or above '${config.skillRegistry?.blockOn}' threshold — commit blocked.`);
263
+ logger.error('To downgrade the threshold, set config.skillRegistry.blockOn = "never" (or a less strict severity).');
264
+ process.exit(1);
265
+ }
266
+
223
267
  // Step 4: Build file data using shared engine
224
268
  logger.debug('pre-commit - main', 'Building file data for analysis');
225
269
  const filesData = buildFilesData(validFiles, { staged: true });
@@ -232,11 +276,17 @@ const main = async () => {
232
276
  logger.info('⚡ Intelligent orchestration: grouping files and assigning models');
233
277
  }
234
278
 
235
- // Step 6: Run analysis using shared engine
279
+ // Step 6: Run analysis using shared engine. The skill-registry
280
+ // catalogue (memoized load — free after Step 3.5) lets Claude classify
281
+ // findings against the mscope rule IDs; unmatched findings become
282
+ // skill-gap candidates in Step 9.5.
236
283
  const result = await runAnalysis(filesData, config, {
237
284
  hook: 'pre-commit',
238
285
  saveDebug: config.system.debug,
239
- headless: isHeadless
286
+ headless: isHeadless,
287
+ catalogueSection: config.skillRegistry?.enabled !== false
288
+ ? skillRegistry.getCatalogueSection({ repoRoot })
289
+ : null
240
290
  });
241
291
 
242
292
  // Step 7: Display results using shared function
@@ -308,6 +358,36 @@ const main = async () => {
308
358
  }
309
359
  }
310
360
 
361
+ // Step 9.5: Skill-gap writer (Option E2). For each detail Claude left
362
+ // un-classified (no detail.rule from the catalogue), append a
363
+ // `[skill-gap]` candidate to the appropriate skill-feedback.md.
364
+ // Per the maintainer's `feedback_skill_improvements_user_approval`
365
+ // rule, we never auto-merge candidates into the registry — they're
366
+ // surfaced for review during `automation-skills retro`.
367
+ //
368
+ // Reuses `skillRoot` resolved in Step 3.5 — no second loadRegistry()
369
+ // round-trip. Silent skip if the skill repo wasn't found or AI
370
+ // returned no details.
371
+ if (config.skillRegistry?.enabled !== false &&
372
+ skillRoot &&
373
+ Array.isArray(result.details) && result.details.length > 0) {
374
+ try {
375
+ const out = skillRegistry.recordSkillGaps(result, {
376
+ skillRoot,
377
+ branch: getCurrentBranch(),
378
+ repoName: basename(repoRoot),
379
+ });
380
+ if (out.written > 0) {
381
+ logger.info(`📝 ${out.written} skill-gap candidate(s) recorded to skill-feedback.md (${out.skippedDuplicate} duplicate(s) skipped, ${out.skippedClassified} classified to existing rules).`);
382
+ logger.info(' Review with: automation-skills retro');
383
+ }
384
+ } catch (err) {
385
+ // Warning (not debug): a failed write means feedback candidates
386
+ // were silently lost — the user should know. Commit continues.
387
+ logger.warning(`Skill-gap writer failed — candidates not recorded: ${err?.message}`);
388
+ }
389
+ }
390
+
311
391
  // Step 10: Check quality gate
312
392
  const qualityGatePassed = result.QUALITY_GATE === 'PASSED';
313
393
  const approved = result.approved !== false;
@@ -376,10 +376,12 @@ const ORCHESTRATOR_THRESHOLD = getDefaultSection('orchestrator').threshold;
376
376
  * @param {Object} options - Analysis options
377
377
  * @param {boolean} options.saveDebug - Save debug output (default: from config)
378
378
  * @param {string} options.hook - Hook name for telemetry (default: 'analysis')
379
+ * @param {string|null} options.catalogueSection - Rendered skill-registry catalogue forwarded
380
+ * to the prompt builder (opaque string; default: null)
379
381
  * @returns {Promise<Object>} Analysis result
380
382
  */
381
383
  export const runAnalysis = async (filesData, config, options = {}) => {
382
- const { saveDebug = config.system?.debug, hook = 'analysis', headless = false, costTracker = null } = options;
384
+ const { saveDebug = config.system?.debug, hook = 'analysis', headless = false, costTracker = null, catalogueSection = null } = options;
383
385
 
384
386
  if (filesData.length === 0) {
385
387
  logger.debug('analysis-engine - runAnalysis', 'No files to analyze');
@@ -425,7 +427,8 @@ export const runAnalysis = async (filesData, config, options = {}) => {
425
427
  BRANCH_NAME: getCurrentBranch()
426
428
  },
427
429
  commonContext,
428
- batchRationale: batch.rationale
430
+ batchRationale: batch.rationale,
431
+ catalogueSection
429
432
  })
430
433
  )
431
434
  );
@@ -484,7 +487,8 @@ export const runAnalysis = async (filesData, config, options = {}) => {
484
487
  metadata: {
485
488
  REPO_NAME: getRepoName(),
486
489
  BRANCH_NAME: getCurrentBranch()
487
- }
490
+ },
491
+ catalogueSection
488
492
  });
489
493
 
490
494
  const telemetryContext = {
@@ -70,6 +70,7 @@ const SECTION_REMOTE_MAP = {
70
70
  analysis: _settingsEntry('analysis'),
71
71
  commitMessage: _settingsEntry('commitMessage'),
72
72
  judge: _settingsEntry('judge'),
73
+ skillRegistry: _settingsEntry('skillRegistry'),
73
74
  orchestrator: _settingsEntry('orchestrator'),
74
75
  prMetadata: _settingsEntry('prMetadata'),
75
76
  linting: _settingsEntry('linting')
@@ -0,0 +1,50 @@
1
+ /**
2
+ * File: library-resolver.js
3
+ * Purpose: Resolve the Library (`.library/`) of the repository that claude-hooks
4
+ * is currently running in — never the bundled tool's own copy.
5
+ *
6
+ * Why: claude-git-hooks is a tool. When installed (globally or as a dependency)
7
+ * it intentionally ships WITHOUT a `.library/` (see package.json `files`). The
8
+ * Library that the pipeline maintains always belongs to the repo under work —
9
+ * e.g. running `create-pr` inside `mscope-transactions` maintains that repo's
10
+ * docs. The only time it touches git-hooks' own Library is when dog-fooding
11
+ * (editing git-hooks itself), in which case the working repo IS git-hooks.
12
+ *
13
+ * Consumers must therefore resolve `.library/` from the working repo root, not
14
+ * relative to their own (package) location. This module centralizes that.
15
+ *
16
+ * Cross-platform: getRepoRoot() returns a git path (`/mnt/c/...` under WSL/Linux,
17
+ * `C:/...` under native Windows). `path.join` + `pathToFileURL` produce a valid
18
+ * `file://` URL for the current runtime, so `import()` of an absolute path works
19
+ * everywhere (a bare `import('C:\\...')` is invalid on Windows).
20
+ *
21
+ * Dependencies:
22
+ * - git-operations: getRepoRoot() — the single source of truth for the repo root
23
+ */
24
+
25
+ import { existsSync } from 'fs';
26
+ import path from 'path';
27
+ import { pathToFileURL } from 'url';
28
+ import { getRepoRoot } from './git-operations.js';
29
+
30
+ /**
31
+ * Build a file:// URL for a module inside the current repo's `.library/`,
32
+ * suitable for dynamic `import()`.
33
+ *
34
+ * @param {string} subpath - Path inside `.library/` (e.g. 'librarian/index.js')
35
+ * @returns {string} A `file://` URL string
36
+ */
37
+ export function libraryModuleUrl(subpath) {
38
+ return pathToFileURL(path.join(getRepoRoot(), '.library', subpath)).href;
39
+ }
40
+
41
+ /**
42
+ * Whether the current repo has its own Library to maintain.
43
+ * Cheap pre-check so consumers can skip the pipeline cleanly (no scary warning)
44
+ * in repos that simply have no `.library/`.
45
+ *
46
+ * @returns {boolean} True when `.library/resolver.yaml` exists in the repo root
47
+ */
48
+ export function hasLibrary() {
49
+ return existsSync(path.join(getRepoRoot(), '.library', 'resolver.yaml'));
50
+ }
@@ -194,6 +194,8 @@ const formatFileSection = ({ path, diff, content, isNew }) => {
194
194
  * @param {Object} options.metadata - Additional metadata (repo name, branch, etc.)
195
195
  * @param {string|null} options.commonContext - Shared commit overview injected before file diffs (optional)
196
196
  * @param {string|null} options.batchRationale - Orchestrator's rationale for this batch (optional)
197
+ * @param {string|null} options.catalogueSection - Rendered platform antipattern catalogue (optional;
198
+ * callers obtain it via skill-registry getCatalogueSection())
197
199
  * @returns {Promise<string>} Complete analysis prompt
198
200
  *
199
201
  * File data object structure:
@@ -211,6 +213,7 @@ const buildAnalysisPrompt = async ({
211
213
  metadata = {},
212
214
  commonContext = null,
213
215
  batchRationale = null,
216
+ catalogueSection = null,
214
217
  baseDir = '.claude/prompts'
215
218
  } = {}) => {
216
219
  logger.debug('prompt-builder - buildAnalysisPrompt', 'Building analysis prompt', {
@@ -219,6 +222,7 @@ const buildAnalysisPrompt = async ({
219
222
  fileCount: files.length,
220
223
  hasCommonContext: !!commonContext,
221
224
  hasBatchRationale: !!batchRationale,
225
+ hasCatalogueSection: !!catalogueSection,
222
226
  baseDir
223
227
  });
224
228
 
@@ -247,6 +251,17 @@ const buildAnalysisPrompt = async ({
247
251
  prompt += '\n\n=== EVALUATION GUIDELINES ===\n';
248
252
  prompt += guidelines;
249
253
 
254
+ // Add the mscope platform antipattern catalogue if the caller supplied
255
+ // one (rendered via skill-registry getCatalogueSection()). The catalogue
256
+ // lists every JBE-NNN / UIK-NNN / etc. rule with severity; Claude is
257
+ // instructed to populate details[].rule with the catalogue ID when a
258
+ // finding matches. Unmatched findings (rule unset) become skill-gap
259
+ // candidates downstream.
260
+ if (catalogueSection) {
261
+ prompt += '\n\n=== PLATFORM ANTIPATTERN CATALOGUE ===\n';
262
+ prompt += catalogueSection;
263
+ }
264
+
250
265
  // Add changes section
251
266
  prompt += '\n\n=== CHANGES TO REVIEW ===\n';
252
267
 
@@ -0,0 +1,74 @@
1
+ /**
2
+ * File: skill-registry/catalogue.js
3
+ * Purpose: Render the parsed JBE/UIK rule index as a compact catalogue
4
+ * string suitable for injection into Claude's analysis prompt.
5
+ *
6
+ * Why this exists (Option E2): when Claude analyses code, we want it to
7
+ * label findings with the canonical rule ID (e.g. `JBE-001`) directly,
8
+ * rather than relying on fuzzy post-processing to guess. We do this by
9
+ * injecting a compact catalogue into the analysis prompt — one line per
10
+ * rule, grouped by scope, ~80 chars per line. The prompt's existing
11
+ * `details[].rule` field (already declared "optional" in the schema)
12
+ * becomes load-bearing: populated = covered; empty = skill-gap candidate.
13
+ *
14
+ * Token budget: ~95 rules × ~80 chars = ~7.6 KB → ~1,900 tokens added to
15
+ * each analysis prompt. Negligible against the 200k context window.
16
+ *
17
+ * Why a separate module: keeps catalogue formatting decisions (one-line
18
+ * vs full, severity tags, scope grouping) in one place where they can be
19
+ * tuned without touching prompt-builder or pre-commit.
20
+ */
21
+
22
+ /**
23
+ * Render the rule catalogue as a compact string for prompt injection.
24
+ * @param {Array<object>} rules - From parser.loadRegistry().rules
25
+ * @returns {string} catalogue text (empty string if no rules)
26
+ */
27
+ export function renderCatalogue(rules) {
28
+ if (!rules || rules.length === 0) return '';
29
+
30
+ const byScope = {};
31
+ for (const r of rules) {
32
+ (byScope[r.scope] ||= []).push(r);
33
+ }
34
+
35
+ const lines = [];
36
+ lines.push('Each finding MUST be classified against the platform antipattern catalogue below.');
37
+ lines.push('When a finding matches a catalogue entry, populate `details[].rule` with the rule ID');
38
+ lines.push('(e.g. "JBE-001"). Findings that don\'t match any entry leave `rule` empty — those');
39
+ lines.push('are candidate skill-gaps the team will review separately.');
40
+ lines.push('');
41
+
42
+ const SCOPE_LABELS = {
43
+ backend: 'BACKEND (Java / Spring Boot / JPA / SQL Server) — applies to .java and .sql files',
44
+ frontend: 'FRONTEND (React / @mscope/uikit / RHF / TanStack Query) — applies to .jsx/.tsx/.js/.css',
45
+ };
46
+
47
+ for (const scope of ['backend', 'frontend']) {
48
+ const list = byScope[scope];
49
+ if (!list || list.length === 0) continue;
50
+ lines.push(SCOPE_LABELS[scope] || scope.toUpperCase());
51
+
52
+ // Sort: id ascending (group prefix together: JBE-001..JBE-NNN, UIK-001..)
53
+ list.sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true }));
54
+
55
+ for (const r of list) {
56
+ const sevTag = (`[${ r.severity || 'unknown' }]`).padEnd(11);
57
+ const title = (r.title || '').replace(/`/g, '').slice(0, 110);
58
+ lines.push(` ${r.id.padEnd(9)} ${sevTag} ${title}`);
59
+ }
60
+ lines.push('');
61
+ }
62
+
63
+ lines.push('SEVERITY → JSON mapping:');
64
+ lines.push(' critical → BLOCKER (commit blocked)');
65
+ lines.push(' high → CRITICAL (commit blocked)');
66
+ lines.push(' medium → MAJOR (warn)');
67
+ lines.push(' low → MINOR (warn)');
68
+ lines.push('');
69
+ lines.push('When you flag a finding, the catalogue entry\'s severity tells you the MIN severity');
70
+ lines.push('to assign — don\'t downgrade. You may upgrade if context warrants (e.g. a JBE-001');
71
+ lines.push('printStackTrace inside a financial calculation path is BLOCKER, not CRITICAL).');
72
+
73
+ return lines.join('\n');
74
+ }