claude-git-hooks 2.44.0 → 2.51.2
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 +147 -0
- package/CLAUDE.md +11 -12
- package/README.md +7 -0
- package/lib/commands/analyze-diff.js +27 -0
- package/lib/commands/analyze-pr.js +9 -17
- package/lib/commands/back-merge.js +14 -0
- package/lib/commands/close-release.js +22 -0
- package/lib/commands/create-pr.js +32 -0
- package/lib/commands/create-release.js +22 -0
- package/lib/commands/help.js +29 -58
- package/lib/config.js +19 -91
- package/lib/defaults.json +130 -0
- package/lib/hooks/pre-commit.js +36 -0
- package/lib/utils/analysis-engine.js +7 -3
- package/lib/utils/claude-client.js +9 -5
- package/lib/utils/config-registry.js +257 -0
- package/lib/utils/diff-analysis-orchestrator.js +13 -8
- package/lib/utils/judge.js +7 -4
- package/lib/utils/linear-connector.js +6 -4
- package/lib/utils/linter-runner.js +16 -1
- package/lib/utils/pr-metadata-engine.js +11 -8
- package/lib/utils/version-manager.js +6 -8
- package/package.json +12 -4
package/lib/config.js
CHANGED
|
@@ -24,104 +24,23 @@
|
|
|
24
24
|
import fs from 'fs';
|
|
25
25
|
import path from 'path';
|
|
26
26
|
import logger from './utils/logger.js';
|
|
27
|
+
import { getDefaults, resolveSection } from './utils/config-registry.js';
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
|
-
*
|
|
30
|
-
* These are NOT user-configurable
|
|
30
|
+
* Package defaults loaded from lib/defaults.json via config-registry.
|
|
31
|
+
* These are NOT user-configurable — sensible defaults that work for everyone.
|
|
32
|
+
* See lib/defaults.json for the full structure and values.
|
|
31
33
|
*/
|
|
32
|
-
const HARDCODED =
|
|
33
|
-
analysis: {
|
|
34
|
-
maxFileSize: 1000000, // 1MB - sufficient for most files
|
|
35
|
-
maxFiles: 30, // Reasonable limit per commit
|
|
36
|
-
timeout: 360000, // 6 minutes - adequate for Claude API
|
|
37
|
-
contextLines: 3, // Git default
|
|
38
|
-
ignoreExtensions: [] // Can be set in advanced config only
|
|
39
|
-
},
|
|
40
|
-
commitMessage: {
|
|
41
|
-
autoKeyword: 'auto', // Standard keyword
|
|
42
|
-
timeout: 300000, // Use same timeout as analysis
|
|
43
|
-
taskIdPattern: '([A-Z]{1,3}[-\\s]\\d{3,5})' // Jira/GitHub/Linear pattern
|
|
44
|
-
},
|
|
45
|
-
subagents: {
|
|
46
|
-
enabled: true // Enable by default (faster analysis via orchestration)
|
|
47
|
-
},
|
|
48
|
-
templates: {
|
|
49
|
-
baseDir: '.claude/prompts',
|
|
50
|
-
analysis: 'CLAUDE_ANALYSIS_PROMPT.md',
|
|
51
|
-
guidelines: 'CLAUDE_PRE_COMMIT.md',
|
|
52
|
-
commitMessage: 'COMMIT_MESSAGE.md',
|
|
53
|
-
analyzeDiff: 'ANALYZE_DIFF.md',
|
|
54
|
-
resolution: 'CLAUDE_RESOLUTION_PROMPT.md',
|
|
55
|
-
createGithubPR: 'CREATE_GITHUB_PR.md'
|
|
56
|
-
},
|
|
57
|
-
output: {
|
|
58
|
-
outputDir: '.claude/out',
|
|
59
|
-
debugFile: '.claude/out/debug-claude-response.json',
|
|
60
|
-
resolutionFile: '.claude/out/claude_resolution_prompt.md',
|
|
61
|
-
prAnalysisFile: '.claude/out/pr-analysis.json'
|
|
62
|
-
},
|
|
63
|
-
system: {
|
|
64
|
-
debug: false, // Controlled by --debug flag
|
|
65
|
-
wslCheckTimeout: 15000 // System behavior
|
|
66
|
-
},
|
|
67
|
-
git: {
|
|
68
|
-
diffFilter: 'ACM' // Standard: Added, Copied, Modified
|
|
69
|
-
},
|
|
70
|
-
github: {
|
|
71
|
-
enabled: true // Always enabled
|
|
72
|
-
},
|
|
73
|
-
prAnalysis: {
|
|
74
|
-
model: 'sonnet',
|
|
75
|
-
timeout: 300000, // 5 minutes
|
|
76
|
-
inlineCategories: ['bug', 'security', 'performance', 'hotspot'],
|
|
77
|
-
generalCategories: [
|
|
78
|
-
'ticket-alignment',
|
|
79
|
-
'scope',
|
|
80
|
-
'style',
|
|
81
|
-
'good-practice',
|
|
82
|
-
'extensibility',
|
|
83
|
-
'observability',
|
|
84
|
-
'documentation',
|
|
85
|
-
'testing'
|
|
86
|
-
]
|
|
87
|
-
},
|
|
88
|
-
linting: {
|
|
89
|
-
enabled: true, // Run linters before Claude analysis
|
|
90
|
-
autoFix: true, // Auto-fix and re-stage
|
|
91
|
-
failOnError: true, // Block commit on linting errors
|
|
92
|
-
failOnWarning: false, // Do not block on warnings
|
|
93
|
-
timeout: 30000 // 30s per linter
|
|
94
|
-
},
|
|
95
|
-
claude: {
|
|
96
|
-
defaultModel: 'sonnet' // Fallback model for SDK headless mode
|
|
97
|
-
}
|
|
98
|
-
};
|
|
34
|
+
const HARDCODED = getDefaults();
|
|
99
35
|
|
|
100
36
|
/**
|
|
101
37
|
* Default user-configurable values (v2.8.0)
|
|
38
|
+
* Derived from the github.pr section of HARDCODED.
|
|
102
39
|
* Only these can be overridden in .claude/config.json
|
|
103
40
|
*/
|
|
104
41
|
const defaults = {
|
|
105
|
-
// GitHub PR configuration (user-specific)
|
|
106
42
|
github: {
|
|
107
|
-
pr:
|
|
108
|
-
defaultBase: 'develop', // Project default branch
|
|
109
|
-
reviewers: [], // Project reviewers
|
|
110
|
-
labelRules: {
|
|
111
|
-
// Labels by preset
|
|
112
|
-
backend: ['backend', 'java'],
|
|
113
|
-
frontend: ['frontend', 'react'],
|
|
114
|
-
fullstack: ['fullstack'],
|
|
115
|
-
database: ['database', 'sql'],
|
|
116
|
-
ai: ['ai', 'tooling'],
|
|
117
|
-
default: []
|
|
118
|
-
},
|
|
119
|
-
// Auto-push configuration (v2.11.0)
|
|
120
|
-
autoPush: true, // Auto-push unpublished branches
|
|
121
|
-
pushConfirm: true, // Prompt for confirmation before push
|
|
122
|
-
verifyRemote: true, // Verify remote exists before push
|
|
123
|
-
showCommits: true // Show commit preview before push
|
|
124
|
-
}
|
|
43
|
+
pr: structuredClone(HARDCODED.github.pr)
|
|
125
44
|
}
|
|
126
45
|
};
|
|
127
46
|
|
|
@@ -132,7 +51,7 @@ const defaults = {
|
|
|
132
51
|
* - v2.8.0: { version: "2.8.0", preset: "...", overrides: {...} }
|
|
133
52
|
* - Legacy: { preset: "...", analysis: {...}, ... } (auto-migrates with warning)
|
|
134
53
|
*
|
|
135
|
-
* Merge priority: HARDCODED < defaults < preset config < user overrides
|
|
54
|
+
* Merge priority: HARDCODED < remote settings.json < defaults < preset config < user overrides
|
|
136
55
|
*
|
|
137
56
|
* @param {string} baseDir - Base directory to search for config (default: cwd)
|
|
138
57
|
* @returns {Promise<Object>} Merged configuration
|
|
@@ -194,8 +113,17 @@ const loadUserConfig = async (baseDir = process.cwd()) => {
|
|
|
194
113
|
}
|
|
195
114
|
}
|
|
196
115
|
|
|
197
|
-
//
|
|
198
|
-
const
|
|
116
|
+
// Fetch remote settings.json overrides for team-policy sections
|
|
117
|
+
const remoteSettings = {};
|
|
118
|
+
const settingsSections = ['analysis', 'commitMessage', 'linting'];
|
|
119
|
+
const sectionResults = await Promise.all(settingsSections.map((s) => resolveSection(s)));
|
|
120
|
+
settingsSections.forEach((section, i) => {
|
|
121
|
+
if (sectionResults[i]) remoteSettings[section] = sectionResults[i];
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Merge priority: HARDCODED < remote settings.json < defaults < preset < user overrides
|
|
125
|
+
const withRemote = deepMerge(HARDCODED, remoteSettings);
|
|
126
|
+
const baseConfig = deepMerge(withRemote, defaults);
|
|
199
127
|
const withPreset = deepMerge(baseConfig, presetConfig);
|
|
200
128
|
const final = deepMerge(withPreset, userOverrides);
|
|
201
129
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
{
|
|
2
|
+
"analysis": {
|
|
3
|
+
"maxFileSize": 1000000,
|
|
4
|
+
"maxFiles": 30,
|
|
5
|
+
"timeout": 360000,
|
|
6
|
+
"contextLines": 3,
|
|
7
|
+
"ignoreExtensions": []
|
|
8
|
+
},
|
|
9
|
+
"commitMessage": {
|
|
10
|
+
"autoKeyword": "auto",
|
|
11
|
+
"timeout": 300000,
|
|
12
|
+
"taskIdPattern": "([A-Z]{1,3}[-\\s]\\d{3,5})"
|
|
13
|
+
},
|
|
14
|
+
"subagents": {
|
|
15
|
+
"enabled": true
|
|
16
|
+
},
|
|
17
|
+
"templates": {
|
|
18
|
+
"baseDir": ".claude/prompts",
|
|
19
|
+
"analysis": "CLAUDE_ANALYSIS_PROMPT.md",
|
|
20
|
+
"guidelines": "CLAUDE_PRE_COMMIT.md",
|
|
21
|
+
"commitMessage": "COMMIT_MESSAGE.md",
|
|
22
|
+
"analyzeDiff": "ANALYZE_DIFF.md",
|
|
23
|
+
"resolution": "CLAUDE_RESOLUTION_PROMPT.md",
|
|
24
|
+
"createGithubPR": "CREATE_GITHUB_PR.md"
|
|
25
|
+
},
|
|
26
|
+
"output": {
|
|
27
|
+
"outputDir": ".claude/out",
|
|
28
|
+
"debugFile": ".claude/out/debug-claude-response.json",
|
|
29
|
+
"resolutionFile": ".claude/out/claude_resolution_prompt.md",
|
|
30
|
+
"prAnalysisFile": ".claude/out/pr-analysis.json"
|
|
31
|
+
},
|
|
32
|
+
"system": {
|
|
33
|
+
"debug": false,
|
|
34
|
+
"wslCheckTimeout": 15000
|
|
35
|
+
},
|
|
36
|
+
"git": {
|
|
37
|
+
"diffFilter": "ACM"
|
|
38
|
+
},
|
|
39
|
+
"github": {
|
|
40
|
+
"enabled": true,
|
|
41
|
+
"pr": {
|
|
42
|
+
"defaultBase": "develop",
|
|
43
|
+
"reviewers": [],
|
|
44
|
+
"labelRules": {
|
|
45
|
+
"backend": ["backend", "java"],
|
|
46
|
+
"frontend": ["frontend", "react"],
|
|
47
|
+
"fullstack": ["fullstack"],
|
|
48
|
+
"database": ["database", "sql"],
|
|
49
|
+
"ai": ["ai", "tooling"],
|
|
50
|
+
"default": []
|
|
51
|
+
},
|
|
52
|
+
"autoPush": true,
|
|
53
|
+
"pushConfirm": true,
|
|
54
|
+
"verifyRemote": true,
|
|
55
|
+
"showCommits": true
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"prAnalysis": {
|
|
59
|
+
"model": "sonnet",
|
|
60
|
+
"timeout": 300000,
|
|
61
|
+
"inlineCategories": ["bug", "security", "performance", "hotspot"],
|
|
62
|
+
"generalCategories": [
|
|
63
|
+
"ticket-alignment",
|
|
64
|
+
"scope",
|
|
65
|
+
"style",
|
|
66
|
+
"good-practice",
|
|
67
|
+
"extensibility",
|
|
68
|
+
"observability",
|
|
69
|
+
"documentation",
|
|
70
|
+
"testing"
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
"linting": {
|
|
74
|
+
"enabled": true,
|
|
75
|
+
"autoFix": true,
|
|
76
|
+
"failOnError": true,
|
|
77
|
+
"failOnWarning": false,
|
|
78
|
+
"timeout": 30000
|
|
79
|
+
},
|
|
80
|
+
"claude": {
|
|
81
|
+
"defaultModel": "sonnet"
|
|
82
|
+
},
|
|
83
|
+
"models": {
|
|
84
|
+
"modelMap": {
|
|
85
|
+
"haiku": "claude-haiku-4-5-20251001",
|
|
86
|
+
"sonnet": "claude-sonnet-4-6",
|
|
87
|
+
"opus": "claude-opus-4-6"
|
|
88
|
+
},
|
|
89
|
+
"defaults": {
|
|
90
|
+
"analysis": "sonnet",
|
|
91
|
+
"orchestrator": "opus",
|
|
92
|
+
"judge": "sonnet",
|
|
93
|
+
"prAnalysis": "sonnet",
|
|
94
|
+
"commitMessage": "sonnet"
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"tools": {
|
|
98
|
+
"prettier": { "enabled": true, "timeout": 30000 },
|
|
99
|
+
"eslint": { "enabled": true, "timeout": 30000 },
|
|
100
|
+
"spotless": { "enabled": true, "timeout": 120000, "perFile": true },
|
|
101
|
+
"sqlfluff": { "enabled": true, "timeout": 30000 }
|
|
102
|
+
},
|
|
103
|
+
"presetLinters": {
|
|
104
|
+
"frontend": ["prettier", "eslint"],
|
|
105
|
+
"backend": ["spotless"],
|
|
106
|
+
"fullstack": ["prettier", "eslint", "spotless"],
|
|
107
|
+
"database": ["sqlfluff"],
|
|
108
|
+
"ai": ["prettier", "eslint"],
|
|
109
|
+
"default": ["prettier", "eslint"]
|
|
110
|
+
},
|
|
111
|
+
"judge": {
|
|
112
|
+
"model": "sonnet",
|
|
113
|
+
"timeout": 360000
|
|
114
|
+
},
|
|
115
|
+
"orchestrator": {
|
|
116
|
+
"model": "opus",
|
|
117
|
+
"timeout": 60000,
|
|
118
|
+
"threshold": 3
|
|
119
|
+
},
|
|
120
|
+
"prMetadata": {
|
|
121
|
+
"timeout": 180000,
|
|
122
|
+
"maxDiffSize": 50000
|
|
123
|
+
},
|
|
124
|
+
"linear": {
|
|
125
|
+
"hostname": "api.linear.app",
|
|
126
|
+
"path": "/graphql",
|
|
127
|
+
"timeout": 10000,
|
|
128
|
+
"maxRetries": 2
|
|
129
|
+
}
|
|
130
|
+
}
|
package/lib/hooks/pre-commit.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* - resolution-prompt: Issue resolution generation
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
import { join } from 'path';
|
|
22
23
|
import { getStagedFiles, getRepoRoot, getStagedTreeSha } from '../utils/git-operations.js';
|
|
23
24
|
import { writeMarker } from '../utils/hooks-verified-marker.js';
|
|
24
25
|
import { filterFiles } from '../utils/file-operations.js';
|
|
@@ -150,6 +151,41 @@ const main = async () => {
|
|
|
150
151
|
process.exit(0);
|
|
151
152
|
}
|
|
152
153
|
|
|
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);
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
// Book doesn't exist or check failed — skip silently
|
|
175
|
+
}
|
|
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}`);
|
|
181
|
+
}
|
|
182
|
+
logger.warning(' Run: npm run library:regenerate');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
logger.warning('📚 Library staleness check unavailable — .library/ tools not found');
|
|
187
|
+
}
|
|
188
|
+
|
|
153
189
|
// Step 3: Run linters (fast, deterministic — before Claude analysis)
|
|
154
190
|
// Unfixable lint issues are forwarded to the judge for semantic resolution
|
|
155
191
|
let unfixableLintDetails = [];
|
|
@@ -35,6 +35,7 @@ import { orchestrateBatches } from './diff-analysis-orchestrator.js';
|
|
|
35
35
|
import { buildAnalysisPrompt } from './prompt-builder.js';
|
|
36
36
|
import logger from './logger.js';
|
|
37
37
|
import { recordMetric } from './metrics.js';
|
|
38
|
+
import { getDefaultSection, resolveSection } from './config-registry.js';
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* Standard file data schema used throughout the analysis pipeline
|
|
@@ -360,7 +361,7 @@ export const displayResults = (result, { silent = false } = {}) => {
|
|
|
360
361
|
};
|
|
361
362
|
|
|
362
363
|
// Minimum file count to trigger Opus orchestration instead of sequential analysis
|
|
363
|
-
const ORCHESTRATOR_THRESHOLD =
|
|
364
|
+
const ORCHESTRATOR_THRESHOLD = getDefaultSection('orchestrator').threshold;
|
|
364
365
|
|
|
365
366
|
/**
|
|
366
367
|
* Runs code analysis on files
|
|
@@ -385,12 +386,15 @@ export const runAnalysis = async (filesData, config, options = {}) => {
|
|
|
385
386
|
return createEmptyResult();
|
|
386
387
|
}
|
|
387
388
|
|
|
388
|
-
|
|
389
|
+
// Resolve orchestrator threshold: remote settings.json > local defaults.json
|
|
390
|
+
const orchConfig = await resolveSection('orchestrator');
|
|
391
|
+
const threshold = orchConfig?.threshold || ORCHESTRATOR_THRESHOLD;
|
|
392
|
+
const useOrchestrator = filesData.length >= threshold;
|
|
389
393
|
|
|
390
394
|
logger.debug('analysis-engine - runAnalysis', 'Starting analysis', {
|
|
391
395
|
fileCount: filesData.length,
|
|
392
396
|
useOrchestrator,
|
|
393
|
-
threshold
|
|
397
|
+
threshold
|
|
394
398
|
});
|
|
395
399
|
|
|
396
400
|
let result;
|
|
@@ -21,6 +21,7 @@ import path from 'path';
|
|
|
21
21
|
import os from 'os';
|
|
22
22
|
import logger from './logger.js';
|
|
23
23
|
import config from '../config.js';
|
|
24
|
+
import { getDefaultSection } from './config-registry.js';
|
|
24
25
|
import {
|
|
25
26
|
detectClaudeError,
|
|
26
27
|
formatClaudeError,
|
|
@@ -47,14 +48,17 @@ class ClaudeClientError extends Error {
|
|
|
47
48
|
|
|
48
49
|
/**
|
|
49
50
|
* Model alias map — resolves shorthand names to full SDK model IDs.
|
|
51
|
+
* Loaded from lib/defaults.json via config-registry, with env var overrides.
|
|
50
52
|
* Env var overrides match Lumiere contract (CLAUDE_MODEL_HAIKU, CLAUDE_MODEL_SONNET, CLAUDE_MODEL_OPUS).
|
|
51
53
|
* Full model IDs (e.g., 'claude-sonnet-4-6') pass through unchanged.
|
|
52
54
|
*/
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
55
|
+
const _localModelMap = getDefaultSection('models').modelMap;
|
|
56
|
+
const MODEL_ALIASES = Object.fromEntries(
|
|
57
|
+
Object.entries(_localModelMap).map(([alias, fullId]) => [
|
|
58
|
+
alias,
|
|
59
|
+
process.env[`CLAUDE_MODEL_${alias.toUpperCase()}`] || fullId
|
|
60
|
+
])
|
|
61
|
+
);
|
|
58
62
|
|
|
59
63
|
/**
|
|
60
64
|
* Resolves a model alias to a full model ID.
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: config-registry.js
|
|
3
|
+
* Purpose: Unified configuration reader with local defaults + remote overrides
|
|
4
|
+
*
|
|
5
|
+
* Design:
|
|
6
|
+
* - Reads lib/defaults.json synchronously at import time (ships with the package)
|
|
7
|
+
* - Fetches remote overrides from git-hooks-config via remote-config.js (async, cached)
|
|
8
|
+
* - Merge priority: local defaults < remote overrides
|
|
9
|
+
* - Consumers higher in the chain (presets, user .claude/config.json) merge on top separately
|
|
10
|
+
*
|
|
11
|
+
* Section-to-remote-file mapping:
|
|
12
|
+
* models → models.json (root)
|
|
13
|
+
* tools → tools.json (.tools key)
|
|
14
|
+
* prAnalysis → categories.json (.inlineCategories, .generalCategories → merged into prAnalysis)
|
|
15
|
+
* presetLinters → formatters.json (.presetTools)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'fs';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import { fileURLToPath } from 'url';
|
|
21
|
+
import logger from './logger.js';
|
|
22
|
+
import { fetchRemoteConfig } from './remote-config.js';
|
|
23
|
+
|
|
24
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
25
|
+
const __dirname = path.dirname(__filename);
|
|
26
|
+
|
|
27
|
+
/** Path to the package defaults file */
|
|
28
|
+
const DEFAULTS_PATH = path.resolve(__dirname, '..', 'defaults.json');
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Helper: create a mapping entry that reads a section key from settings.json.
|
|
32
|
+
* @param {string} sectionKey - Key within settings.json (e.g., 'analysis', 'judge')
|
|
33
|
+
* @returns {{ file: string, extract: (remote: Object) => Object }}
|
|
34
|
+
*/
|
|
35
|
+
const _settingsEntry = (sectionKey) => ({
|
|
36
|
+
file: 'settings.json',
|
|
37
|
+
extract: (remote) => remote?.[sectionKey] || {}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Maps defaults.json section names to remote config files and their extraction logic.
|
|
42
|
+
* Each entry defines:
|
|
43
|
+
* - file: the remote JSON filename to fetch
|
|
44
|
+
* - extract: function that transforms the remote JSON into the shape expected for deep-merge
|
|
45
|
+
* @type {Object<string, { file: string, extract: (remote: Object) => Object }>}
|
|
46
|
+
*/
|
|
47
|
+
const SECTION_REMOTE_MAP = {
|
|
48
|
+
models: {
|
|
49
|
+
file: 'models.json',
|
|
50
|
+
extract: (remote) => remote
|
|
51
|
+
},
|
|
52
|
+
tools: {
|
|
53
|
+
file: 'tools.json',
|
|
54
|
+
extract: (remote) => remote?.tools || {}
|
|
55
|
+
},
|
|
56
|
+
prAnalysis: {
|
|
57
|
+
file: 'categories.json',
|
|
58
|
+
extract: (remote) => {
|
|
59
|
+
const result = {};
|
|
60
|
+
if (remote?.inlineCategories) result.inlineCategories = remote.inlineCategories;
|
|
61
|
+
if (remote?.generalCategories) result.generalCategories = remote.generalCategories;
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
presetLinters: {
|
|
66
|
+
file: 'formatters.json',
|
|
67
|
+
extract: (remote) => remote?.presetTools || {}
|
|
68
|
+
},
|
|
69
|
+
// Team-policy sections — all read from settings.json
|
|
70
|
+
analysis: _settingsEntry('analysis'),
|
|
71
|
+
commitMessage: _settingsEntry('commitMessage'),
|
|
72
|
+
judge: _settingsEntry('judge'),
|
|
73
|
+
orchestrator: _settingsEntry('orchestrator'),
|
|
74
|
+
prMetadata: _settingsEntry('prMetadata'),
|
|
75
|
+
linting: _settingsEntry('linting')
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// ── Sync: local defaults (read once at import time) ────────────────────────
|
|
79
|
+
|
|
80
|
+
let _defaults = null;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Load defaults.json synchronously. Called once at module init.
|
|
84
|
+
* @returns {Object} Parsed defaults
|
|
85
|
+
* @throws {Error} If defaults.json is missing or invalid (fatal — package is broken)
|
|
86
|
+
*/
|
|
87
|
+
function _loadDefaults() {
|
|
88
|
+
if (_defaults !== null) return _defaults;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const raw = fs.readFileSync(DEFAULTS_PATH, 'utf8');
|
|
92
|
+
_defaults = JSON.parse(raw);
|
|
93
|
+
logger.debug('config-registry', 'Loaded defaults.json', {
|
|
94
|
+
sections: Object.keys(_defaults)
|
|
95
|
+
});
|
|
96
|
+
return _defaults;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Failed to load package defaults from ${DEFAULTS_PATH}: ${err.message}. ` +
|
|
100
|
+
'This indicates a broken installation — reinstall claude-git-hooks.'
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Load immediately on import
|
|
106
|
+
_loadDefaults();
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Returns the full defaults object (sync, no I/O after init).
|
|
110
|
+
* @returns {Object} Complete defaults from lib/defaults.json
|
|
111
|
+
*/
|
|
112
|
+
export function getDefaults() {
|
|
113
|
+
return _defaults;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Returns a single section from defaults (sync).
|
|
118
|
+
* @param {string} sectionName - Top-level key in defaults.json (e.g., 'models', 'tools', 'judge')
|
|
119
|
+
* @returns {Object|undefined} The section value, or undefined if not found
|
|
120
|
+
*/
|
|
121
|
+
export function getDefaultSection(sectionName) {
|
|
122
|
+
return _defaults?.[sectionName];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Async: remote overrides (fetched on demand, cached) ────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* In-memory cache for resolved sections (local + remote merged).
|
|
129
|
+
* @type {Map<string, Object>}
|
|
130
|
+
*/
|
|
131
|
+
const _resolvedCache = new Map();
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Deep-merge two objects (source overrides target).
|
|
135
|
+
* Arrays are replaced wholesale (not concatenated).
|
|
136
|
+
* @param {Object} target - Base object
|
|
137
|
+
* @param {Object} source - Override object
|
|
138
|
+
* @returns {Object} Merged result
|
|
139
|
+
*/
|
|
140
|
+
function _deepMerge(target, source) {
|
|
141
|
+
const result = { ...target };
|
|
142
|
+
for (const key in source) {
|
|
143
|
+
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
144
|
+
result[key] = _deepMerge(target[key] || {}, source[key]);
|
|
145
|
+
} else {
|
|
146
|
+
result[key] = source[key];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Resolve a config section by merging local defaults with remote overrides.
|
|
154
|
+
*
|
|
155
|
+
* If the section has a mapping in SECTION_REMOTE_MAP, the corresponding remote
|
|
156
|
+
* file is fetched and merged (remote > local). If fetch fails, local defaults
|
|
157
|
+
* are returned. Results are cached per process.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} sectionName - Top-level key in defaults.json
|
|
160
|
+
* @returns {Promise<Object>} Merged section (local defaults + remote overrides)
|
|
161
|
+
*/
|
|
162
|
+
export async function resolveSection(sectionName) {
|
|
163
|
+
if (_resolvedCache.has(sectionName)) {
|
|
164
|
+
return _resolvedCache.get(sectionName);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const localSection = getDefaultSection(sectionName);
|
|
168
|
+
if (localSection === undefined) {
|
|
169
|
+
logger.debug('config-registry - resolveSection', 'Section not found in defaults', {
|
|
170
|
+
sectionName
|
|
171
|
+
});
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const mapping = SECTION_REMOTE_MAP[sectionName];
|
|
176
|
+
if (!mapping) {
|
|
177
|
+
// No remote file for this section — return local as-is
|
|
178
|
+
_resolvedCache.set(sectionName, localSection);
|
|
179
|
+
return localSection;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const remoteData = await fetchRemoteConfig(mapping.file);
|
|
183
|
+
if (!remoteData) {
|
|
184
|
+
logger.debug('config-registry - resolveSection', 'Remote unavailable, using local defaults', {
|
|
185
|
+
sectionName,
|
|
186
|
+
remoteFile: mapping.file
|
|
187
|
+
});
|
|
188
|
+
_resolvedCache.set(sectionName, localSection);
|
|
189
|
+
return localSection;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const remoteOverrides = mapping.extract(remoteData);
|
|
193
|
+
const merged = _deepMerge(localSection, remoteOverrides);
|
|
194
|
+
|
|
195
|
+
logger.debug('config-registry - resolveSection', 'Merged local + remote', {
|
|
196
|
+
sectionName,
|
|
197
|
+
remoteFile: mapping.file,
|
|
198
|
+
localKeys: Object.keys(localSection),
|
|
199
|
+
remoteKeys: Object.keys(remoteOverrides)
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
_resolvedCache.set(sectionName, merged);
|
|
203
|
+
return merged;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Convenience resolvers ──────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Resolve a model alias to a full model ID, with remote override support.
|
|
210
|
+
* Priority: env var > remote models.json > local defaults.json
|
|
211
|
+
*
|
|
212
|
+
* @param {string} alias - Model alias ('sonnet', 'opus', 'haiku') or full ID
|
|
213
|
+
* @returns {Promise<string>} Full model ID
|
|
214
|
+
*/
|
|
215
|
+
export async function resolveModelAlias(alias) {
|
|
216
|
+
const modelsConfig = await resolveSection('models');
|
|
217
|
+
const modelMap = modelsConfig?.modelMap || {};
|
|
218
|
+
|
|
219
|
+
// Env var override (highest priority)
|
|
220
|
+
const envKey = `CLAUDE_MODEL_${alias.toUpperCase()}`;
|
|
221
|
+
if (process.env[envKey]) {
|
|
222
|
+
return process.env[envKey];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return modelMap[alias] || alias;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Resolve configuration for a specific linter tool, with remote override support.
|
|
230
|
+
* Merges local defaults.tools[toolName] with remote tools.json tools[toolName].
|
|
231
|
+
*
|
|
232
|
+
* @param {string} toolName - Tool name ('prettier', 'eslint', 'spotless', 'sqlfluff')
|
|
233
|
+
* @returns {Promise<Object|undefined>} Tool config (enabled, timeout, perFile) or undefined
|
|
234
|
+
*/
|
|
235
|
+
export async function resolveToolConfig(toolName) {
|
|
236
|
+
const toolsConfig = await resolveSection('tools');
|
|
237
|
+
return toolsConfig?.[toolName];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Test helpers ───────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Reset all caches. Test helper — not intended for production use.
|
|
244
|
+
*/
|
|
245
|
+
export function _resetRegistry() {
|
|
246
|
+
_resolvedCache.clear();
|
|
247
|
+
_defaults = null;
|
|
248
|
+
_loadDefaults();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Reset resolved cache only (keeps defaults loaded).
|
|
253
|
+
* Useful for testing remote merge behavior without re-reading defaults.json.
|
|
254
|
+
*/
|
|
255
|
+
export function _resetResolvedCache() {
|
|
256
|
+
_resolvedCache.clear();
|
|
257
|
+
}
|