@icode-js/icode 3.0.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.
Files changed (46) hide show
  1. package/README.md +346 -0
  2. package/bin/icode.js +6 -0
  3. package/package.json +34 -0
  4. package/src/cli.js +131 -0
  5. package/src/commands/ai.js +287 -0
  6. package/src/commands/checkout.js +59 -0
  7. package/src/commands/clean.js +65 -0
  8. package/src/commands/codereview.js +52 -0
  9. package/src/commands/config.js +513 -0
  10. package/src/commands/explain.js +80 -0
  11. package/src/commands/help.js +49 -0
  12. package/src/commands/info.js +57 -0
  13. package/src/commands/migrate.js +86 -0
  14. package/src/commands/push.js +125 -0
  15. package/src/commands/sync.js +74 -0
  16. package/src/commands/tag.js +53 -0
  17. package/src/commands/undo.js +66 -0
  18. package/src/core/ai-client.js +1125 -0
  19. package/src/core/ai-commit-summary.js +18 -0
  20. package/src/core/ai-config.js +342 -0
  21. package/src/core/ai-diff-range.js +117 -0
  22. package/src/core/args.js +47 -0
  23. package/src/core/commit-conventions.js +169 -0
  24. package/src/core/config-store.js +194 -0
  25. package/src/core/errors.js +25 -0
  26. package/src/core/git-context.js +105 -0
  27. package/src/core/git-service.js +428 -0
  28. package/src/core/hook-diagnostics.js +23 -0
  29. package/src/core/loading.js +36 -0
  30. package/src/core/logger.js +55 -0
  31. package/src/core/prompts.js +152 -0
  32. package/src/core/shell.js +77 -0
  33. package/src/workflows/ai-codereview-workflow.js +126 -0
  34. package/src/workflows/ai-commit-workflow.js +128 -0
  35. package/src/workflows/ai-conflict-workflow.js +102 -0
  36. package/src/workflows/ai-explain-workflow.js +116 -0
  37. package/src/workflows/ai-risk-review-workflow.js +49 -0
  38. package/src/workflows/checkout-workflow.js +85 -0
  39. package/src/workflows/clean-workflow.js +131 -0
  40. package/src/workflows/info-workflow.js +30 -0
  41. package/src/workflows/migrate-workflow.js +449 -0
  42. package/src/workflows/push-workflow.js +276 -0
  43. package/src/workflows/rollback-workflow.js +84 -0
  44. package/src/workflows/sync-workflow.js +141 -0
  45. package/src/workflows/tag-workflow.js +64 -0
  46. package/src/workflows/undo-workflow.js +328 -0
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Format an AI commit result so the commit id is kept on the first line and
3
+ * multiline body content is preserved for later lines.
4
+ */
5
+ export function formatAiCommitSummary(commitId, commitMessage) {
6
+ const normalizedMessage = String(commitMessage || '').trim()
7
+
8
+ if (!normalizedMessage) {
9
+ return commitId ? String(commitId).trim() : '无标题'
10
+ }
11
+
12
+ if (!commitId) {
13
+ return normalizedMessage
14
+ }
15
+
16
+ const [header, ...restLines] = normalizedMessage.split('\n')
17
+ return [`${String(commitId).trim()} ${header}`, ...restLines].join('\n').trim()
18
+ }
@@ -0,0 +1,342 @@
1
+ import { IcodeError } from './errors.js'
2
+ import { readConfig, writeConfig } from './config-store.js'
3
+
4
+ const DEFAULT_PROFILE = {
5
+ provider: 'custom',
6
+ format: 'openai',
7
+ baseUrl: '',
8
+ apiKey: '',
9
+ model: '',
10
+ temperature: 0.2,
11
+ maxTokens: 1200,
12
+ headers: {},
13
+ requestBody: {}
14
+ }
15
+
16
+ const ALLOWED_OPTION_SCOPES = new Set(['commit', 'conflict', 'explain', 'push'])
17
+
18
+ function clone(value) {
19
+ return JSON.parse(JSON.stringify(value))
20
+ }
21
+
22
+ function normalizeFormat(format) {
23
+ const normalized = (format || 'openai').trim().toLowerCase()
24
+ if (!['openai', 'anthropic', 'ollama'].includes(normalized)) {
25
+ throw new IcodeError(`不支持的 AI 接口格式: ${format}`, {
26
+ code: 'AI_FORMAT_INVALID',
27
+ exitCode: 2
28
+ })
29
+ }
30
+ return normalized
31
+ }
32
+
33
+ function ensureAiSection(config) {
34
+ if (!config.ai || typeof config.ai !== 'object') {
35
+ config.ai = {
36
+ activeProfile: '',
37
+ profiles: {}
38
+ }
39
+ }
40
+
41
+ if (!config.ai.profiles || typeof config.ai.profiles !== 'object') {
42
+ config.ai.profiles = {}
43
+ }
44
+
45
+ if (!config.ai.options || typeof config.ai.options !== 'object' || Array.isArray(config.ai.options)) {
46
+ config.ai.options = {}
47
+ }
48
+
49
+ if (typeof config.ai.activeProfile !== 'string') {
50
+ config.ai.activeProfile = ''
51
+ }
52
+
53
+ return config
54
+ }
55
+
56
+ function normalizeProfile(profile = {}) {
57
+ const next = {
58
+ ...clone(DEFAULT_PROFILE),
59
+ ...profile,
60
+ provider: (profile.provider || DEFAULT_PROFILE.provider).trim(),
61
+ format: normalizeFormat(profile.format || DEFAULT_PROFILE.format),
62
+ baseUrl: (profile.baseUrl || '').trim(),
63
+ apiKey: (profile.apiKey || '').trim(),
64
+ model: (profile.model || '').trim(),
65
+ temperature: Number.isFinite(Number(profile.temperature)) ? Number(profile.temperature) : DEFAULT_PROFILE.temperature,
66
+ maxTokens: Number.isFinite(Number(profile.maxTokens)) ? Number(profile.maxTokens) : DEFAULT_PROFILE.maxTokens,
67
+ headers: profile.headers && typeof profile.headers === 'object' ? profile.headers : {},
68
+ requestBody: profile.requestBody && typeof profile.requestBody === 'object' && !Array.isArray(profile.requestBody) ? profile.requestBody : {}
69
+ }
70
+
71
+ return next
72
+ }
73
+
74
+ function resolveActiveProfileName(aiConfig) {
75
+ const active = (aiConfig.activeProfile || '').trim()
76
+ if (active && aiConfig.profiles[active]) {
77
+ return active
78
+ }
79
+
80
+ const names = Object.keys(aiConfig.profiles)
81
+ return names[0] || ''
82
+ }
83
+
84
+ function maskKey(value) {
85
+ const raw = (value || '').trim()
86
+ if (!raw) {
87
+ return ''
88
+ }
89
+
90
+ if (raw.length <= 8) {
91
+ return `${raw.slice(0, 2)}****${raw.slice(-1)}`
92
+ }
93
+
94
+ return `${raw.slice(0, 4)}****${raw.slice(-4)}`
95
+ }
96
+
97
+ export function getAiConfig() {
98
+ const config = ensureAiSection(readConfig())
99
+ const activeProfile = resolveActiveProfileName(config.ai)
100
+ const sanitizedOptions = sanitizeAiCommandOptions(config.ai.options)
101
+ const activeProfileChanged = activeProfile && config.ai.activeProfile !== activeProfile
102
+ const optionsChanged = JSON.stringify(config.ai.options || {}) !== JSON.stringify(sanitizedOptions)
103
+
104
+ if (activeProfileChanged) {
105
+ config.ai.activeProfile = activeProfile
106
+ }
107
+
108
+ if (optionsChanged) {
109
+ config.ai.options = sanitizedOptions
110
+ }
111
+
112
+ if (activeProfileChanged || optionsChanged) {
113
+ writeConfig(config)
114
+ }
115
+
116
+ return config.ai
117
+ }
118
+
119
+ function sanitizeAiCommandOptions(optionsMap) {
120
+ const source = optionsMap && typeof optionsMap === 'object' && !Array.isArray(optionsMap) ? optionsMap : {}
121
+ const next = {}
122
+
123
+ Object.entries(source).forEach(([scope, value]) => {
124
+ if (!ALLOWED_OPTION_SCOPES.has(scope)) {
125
+ return
126
+ }
127
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
128
+ return
129
+ }
130
+ next[scope] = clone(value)
131
+ })
132
+
133
+ return next
134
+ }
135
+
136
+ function normalizeOptionScope(scopeName) {
137
+ const scope = (scopeName || '').trim().toLowerCase()
138
+ if (!scope) {
139
+ throw new IcodeError('缺少 options 作用域。可选: commit|conflict|explain|push', {
140
+ code: 'AI_OPTIONS_SCOPE_EMPTY',
141
+ exitCode: 2
142
+ })
143
+ }
144
+
145
+ if (!ALLOWED_OPTION_SCOPES.has(scope)) {
146
+ throw new IcodeError(`不支持的 options 作用域: ${scopeName}`, {
147
+ code: 'AI_OPTIONS_SCOPE_INVALID',
148
+ exitCode: 2
149
+ })
150
+ }
151
+
152
+ return scope
153
+ }
154
+
155
+ function normalizeOptionsPayload(payload) {
156
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
157
+ throw new IcodeError('options 内容必须是 JSON 对象', {
158
+ code: 'AI_OPTIONS_PAYLOAD_INVALID',
159
+ exitCode: 2
160
+ })
161
+ }
162
+
163
+ return payload
164
+ }
165
+
166
+ export function listAiProfiles() {
167
+ const aiConfig = getAiConfig()
168
+
169
+ return Object.entries(aiConfig.profiles).map(([name, profile]) => {
170
+ const normalized = normalizeProfile(profile)
171
+ const { apiKey, ...rest } = normalized
172
+ return {
173
+ name,
174
+ ...rest,
175
+ hasApiKey: Boolean(apiKey),
176
+ apiKeyMasked: maskKey(apiKey),
177
+ isActive: aiConfig.activeProfile === name
178
+ }
179
+ })
180
+ }
181
+
182
+ export function listAiCommandOptions() {
183
+ const aiConfig = getAiConfig()
184
+ return sanitizeAiCommandOptions(aiConfig.options)
185
+ }
186
+
187
+ export function getAiCommandOptions(scopeName = '') {
188
+ const aiConfig = getAiConfig()
189
+ if (!scopeName) {
190
+ return sanitizeAiCommandOptions(aiConfig.options)
191
+ }
192
+
193
+ const scope = normalizeOptionScope(scopeName)
194
+ const scoped = aiConfig.options?.[scope]
195
+ if (!scoped || typeof scoped !== 'object' || Array.isArray(scoped)) {
196
+ return {}
197
+ }
198
+
199
+ return clone(scoped)
200
+ }
201
+
202
+ export function upsertAiCommandOptions(scopeName, payload, options = {}) {
203
+ const scope = normalizeOptionScope(scopeName)
204
+ const normalizedPayload = normalizeOptionsPayload(payload)
205
+ const replace = options.replace === true
206
+
207
+ const config = ensureAiSection(readConfig())
208
+ const current = config.ai.options?.[scope]
209
+ const currentObject = current && typeof current === 'object' && !Array.isArray(current) ? current : {}
210
+
211
+ config.ai.options[scope] = replace
212
+ ? clone(normalizedPayload)
213
+ : {
214
+ ...currentObject,
215
+ ...clone(normalizedPayload)
216
+ }
217
+
218
+ writeConfig(config)
219
+ return clone(config.ai.options[scope])
220
+ }
221
+
222
+ export function removeAiCommandOptions(scopeName) {
223
+ const scope = normalizeOptionScope(scopeName)
224
+ const config = ensureAiSection(readConfig())
225
+ delete config.ai.options[scope]
226
+ writeConfig(config)
227
+ }
228
+
229
+ export function getAiProfile(profileName = '') {
230
+ const aiConfig = getAiConfig()
231
+ const name = (profileName || aiConfig.activeProfile || '').trim()
232
+
233
+ if (!name) {
234
+ throw new IcodeError('未配置 AI profile。请先执行: icode config ai set <name> ...', {
235
+ code: 'AI_PROFILE_EMPTY',
236
+ exitCode: 2
237
+ })
238
+ }
239
+
240
+ const rawProfile = aiConfig.profiles[name]
241
+ if (!rawProfile) {
242
+ throw new IcodeError(`AI profile 不存在: ${name}`, {
243
+ code: 'AI_PROFILE_MISSING',
244
+ exitCode: 2
245
+ })
246
+ }
247
+
248
+ return {
249
+ name,
250
+ ...normalizeProfile(rawProfile)
251
+ }
252
+ }
253
+
254
+ export function upsertAiProfile(profileName, partialProfile) {
255
+ const name = (profileName || '').trim()
256
+ if (!name) {
257
+ throw new IcodeError('profile 名称不能为空', {
258
+ code: 'AI_PROFILE_NAME_EMPTY',
259
+ exitCode: 2
260
+ })
261
+ }
262
+
263
+ const config = ensureAiSection(readConfig())
264
+ const current = config.ai.profiles[name] || {}
265
+ const nextProfile = normalizeProfile({
266
+ ...current,
267
+ ...partialProfile
268
+ })
269
+
270
+ config.ai.profiles[name] = nextProfile
271
+ if (!config.ai.activeProfile) {
272
+ config.ai.activeProfile = name
273
+ }
274
+
275
+ writeConfig(config)
276
+ return {
277
+ name,
278
+ ...nextProfile
279
+ }
280
+ }
281
+
282
+ export function removeAiProfile(profileName) {
283
+ const name = (profileName || '').trim()
284
+ if (!name) {
285
+ throw new IcodeError('profile 名称不能为空', {
286
+ code: 'AI_PROFILE_NAME_EMPTY',
287
+ exitCode: 2
288
+ })
289
+ }
290
+
291
+ const config = ensureAiSection(readConfig())
292
+ if (!config.ai.profiles[name]) {
293
+ throw new IcodeError(`AI profile 不存在: ${name}`, {
294
+ code: 'AI_PROFILE_MISSING',
295
+ exitCode: 2
296
+ })
297
+ }
298
+
299
+ delete config.ai.profiles[name]
300
+
301
+ if (config.ai.activeProfile === name) {
302
+ config.ai.activeProfile = Object.keys(config.ai.profiles)[0] || ''
303
+ }
304
+
305
+ writeConfig(config)
306
+ }
307
+
308
+ export function useAiProfile(profileName) {
309
+ const name = (profileName || '').trim()
310
+ if (!name) {
311
+ throw new IcodeError('profile 名称不能为空', {
312
+ code: 'AI_PROFILE_NAME_EMPTY',
313
+ exitCode: 2
314
+ })
315
+ }
316
+
317
+ const config = ensureAiSection(readConfig())
318
+ if (!config.ai.profiles[name]) {
319
+ throw new IcodeError(`AI profile 不存在: ${name}`, {
320
+ code: 'AI_PROFILE_MISSING',
321
+ exitCode: 2
322
+ })
323
+ }
324
+
325
+ config.ai.activeProfile = name
326
+ writeConfig(config)
327
+ return getAiProfile(name)
328
+ }
329
+
330
+ export function getAiProfileForDisplay(profileName = '') {
331
+ const profile = getAiProfile(profileName)
332
+ const { apiKey, ...rest } = profile
333
+ return {
334
+ ...rest,
335
+ hasApiKey: Boolean(apiKey),
336
+ apiKeyMasked: maskKey(apiKey)
337
+ }
338
+ }
339
+
340
+ export function maskApiKey(value) {
341
+ return maskKey(value)
342
+ }
@@ -0,0 +1,117 @@
1
+ import { IcodeError } from './errors.js'
2
+ import { logger } from './logger.js'
3
+
4
+ function unique(values) {
5
+ return [...new Set(values.filter(Boolean))]
6
+ }
7
+
8
+ function readCommandErrorOutput(error) {
9
+ return `${error?.meta?.stderr || ''}\n${error?.meta?.stdout || ''}\n${error?.message || ''}`.trim()
10
+ }
11
+
12
+ function summarizeGitError(error) {
13
+ const text = readCommandErrorOutput(error)
14
+ if (!text) {
15
+ return ''
16
+ }
17
+
18
+ const lines = text
19
+ .split('\n')
20
+ .map((item) => item.trim())
21
+ .filter(Boolean)
22
+
23
+ return lines[0] || ''
24
+ }
25
+
26
+ export async function resolveAiDiffRange({ git, context, baseRef = '', headRef = 'HEAD', label = 'AI', explicitHead = false }) {
27
+ const explicitBase = Boolean((baseRef || '').trim())
28
+ const remoteDefaultBase = `origin/${context.defaultBranch}`
29
+ const localDefaultBase = context.defaultBranch || ''
30
+ const candidates = explicitBase
31
+ ? [(baseRef || '').trim()]
32
+ : unique([remoteDefaultBase, localDefaultBase])
33
+
34
+ if (explicitHead) {
35
+ const headResult = await git.exec(['rev-parse', '--verify', `${headRef}^{commit}`], {
36
+ allowFailure: true
37
+ })
38
+
39
+ if (headResult.exitCode !== 0) {
40
+ const reason = summarizeGitError({
41
+ meta: {
42
+ stderr: headResult.stderr || '',
43
+ stdout: headResult.stdout || ''
44
+ }
45
+ })
46
+
47
+ throw new IcodeError(`指定终点不可用: ${headRef}${reason ? ` (${reason})` : ''}`, {
48
+ code: 'AI_DIFF_HEAD_INVALID',
49
+ exitCode: 2,
50
+ meta: {
51
+ headRef,
52
+ reason,
53
+ stderr: headResult.stderr || '',
54
+ stdout: headResult.stdout || ''
55
+ }
56
+ })
57
+ }
58
+ }
59
+
60
+ let lastError = null
61
+
62
+ for (let index = 0; index < candidates.length; index += 1) {
63
+ const candidateBase = candidates[index]
64
+ try {
65
+ const rangeResult = await git.diffBetween(candidateBase, headRef, { style: 'three-dot' })
66
+
67
+ if (!explicitBase && index > 0) {
68
+ logger.warn(`${label} 默认基线 ${remoteDefaultBase} 不可用,已回退到本地分支 ${candidateBase}。`)
69
+ }
70
+
71
+ return {
72
+ ...rangeResult,
73
+ baseRef: candidateBase,
74
+ explicitBase
75
+ }
76
+ } catch (error) {
77
+ lastError = error
78
+ if (explicitBase) {
79
+ break
80
+ }
81
+ }
82
+ }
83
+
84
+ const reason = summarizeGitError(lastError)
85
+
86
+ if (explicitBase) {
87
+ throw new IcodeError(`指定基线不可用: ${baseRef}${reason ? ` (${reason})` : ''}`, {
88
+ code: 'AI_DIFF_BASE_INVALID',
89
+ exitCode: 2,
90
+ cause: lastError,
91
+ meta: {
92
+ baseRef,
93
+ headRef,
94
+ reason,
95
+ stderr: lastError?.meta?.stderr || '',
96
+ stdout: lastError?.meta?.stdout || ''
97
+ }
98
+ })
99
+ }
100
+
101
+ throw new IcodeError(
102
+ `默认基线 ${remoteDefaultBase} 不可用,且无法回退到本地分支 ${localDefaultBase || '(empty)'}${reason ? ` (${reason})` : ''}`,
103
+ {
104
+ code: 'AI_DIFF_BASE_UNAVAILABLE',
105
+ exitCode: 2,
106
+ cause: lastError,
107
+ meta: {
108
+ baseRef: remoteDefaultBase,
109
+ fallbackBase: localDefaultBase,
110
+ headRef,
111
+ reason,
112
+ stderr: lastError?.meta?.stderr || '',
113
+ stdout: lastError?.meta?.stdout || ''
114
+ }
115
+ }
116
+ )
117
+ }
@@ -0,0 +1,47 @@
1
+ const LEGACY_ALIAS_MAP = new Map([
2
+ ['-pm', '--pull-main'],
3
+ ['--pullMainBranch', '--pull-main'],
4
+ ['--pushOrigin', '--push-origin'],
5
+ ['--notPushCurrent', '--not-push-current'],
6
+ ['--repoMode', '--repo-mode'],
7
+ ['--noVerify', '--no-verify'],
8
+ ['--allLocal', '--all-local'],
9
+ ['--mergeMain', '--merge-main'],
10
+ ['--aiReview', '--ai-review'],
11
+ ['--aiProfile', '--ai-profile'],
12
+ ['--aiCommit', '--ai-commit']
13
+ ])
14
+
15
+ export function normalizeLegacyArgs(argv = []) {
16
+ return argv.map((arg) => LEGACY_ALIAS_MAP.get(arg) || arg)
17
+ }
18
+
19
+ export function parseConfigValue(raw) {
20
+ if (raw === 'true') {
21
+ return true
22
+ }
23
+ if (raw === 'false') {
24
+ return false
25
+ }
26
+ if (raw === 'null') {
27
+ return null
28
+ }
29
+ if (raw === 'undefined') {
30
+ return undefined
31
+ }
32
+
33
+ const numberValue = Number(raw)
34
+ if (Number.isFinite(numberValue) && raw.trim() !== '') {
35
+ return numberValue
36
+ }
37
+
38
+ if ((raw.startsWith('{') && raw.endsWith('}')) || (raw.startsWith('[') && raw.endsWith(']'))) {
39
+ try {
40
+ return JSON.parse(raw)
41
+ } catch {
42
+ return raw
43
+ }
44
+ }
45
+
46
+ return raw
47
+ }
@@ -0,0 +1,169 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ const RELEVANT_HOOK_NAMES = ['commit-msg', 'prepare-commit-msg', 'pre-commit']
5
+ const RELEVANT_CONFIG_FILES = [
6
+ 'commitlint.config.js',
7
+ 'commitlint.config.cjs',
8
+ 'commitlint.config.mjs',
9
+ 'commitlint.config.ts',
10
+ '.commitlintrc',
11
+ '.commitlintrc.json',
12
+ '.commitlintrc.js',
13
+ '.commitlintrc.cjs',
14
+ '.commitlintrc.mjs',
15
+ '.commitlintrc.yaml',
16
+ '.commitlintrc.yml'
17
+ ]
18
+ const MAX_FILE_SNIPPET = 1200
19
+ const MAX_PACKAGE_SNIPPET = 1600
20
+ const MAX_SUMMARY_LENGTH = 4000
21
+
22
+ function unique(values) {
23
+ return [...new Set(values)]
24
+ }
25
+
26
+ function fileExists(filePath) {
27
+ try {
28
+ return fs.statSync(filePath).isFile()
29
+ } catch {
30
+ return false
31
+ }
32
+ }
33
+
34
+ function readTextSnippet(filePath, limit) {
35
+ const text = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n').trim()
36
+ if (!text) {
37
+ return ''
38
+ }
39
+
40
+ if (text.length <= limit) {
41
+ return text
42
+ }
43
+
44
+ return `${text.slice(0, limit)}\n...<truncated>`
45
+ }
46
+
47
+ function collectHookFiles(context) {
48
+ const candidateDirs = []
49
+
50
+ if (context.hasHuskyFolder) {
51
+ candidateDirs.push(path.resolve(context.topLevelPath, '.husky'))
52
+ }
53
+
54
+ if (context.hasHookPath) {
55
+ candidateDirs.push(context.hookPath)
56
+ }
57
+
58
+ return unique(candidateDirs)
59
+ .flatMap((dirPath) => RELEVANT_HOOK_NAMES.map((fileName) => path.resolve(dirPath, fileName)))
60
+ .filter(fileExists)
61
+ }
62
+
63
+ function collectConfigFiles(topLevelPath) {
64
+ return RELEVANT_CONFIG_FILES
65
+ .map((fileName) => path.resolve(topLevelPath, fileName))
66
+ .filter(fileExists)
67
+ }
68
+
69
+ function extractPackageJsonSnippet(topLevelPath) {
70
+ const packageJsonPath = path.resolve(topLevelPath, 'package.json')
71
+ if (!fileExists(packageJsonPath)) {
72
+ return ''
73
+ }
74
+
75
+ try {
76
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
77
+ const relevant = {}
78
+ const scripts = Object.entries(pkg.scripts || {})
79
+ .filter(([name]) => /commit|lint|husky/i.test(name))
80
+ .slice(0, 8)
81
+
82
+ if (scripts.length) {
83
+ relevant.scripts = Object.fromEntries(scripts)
84
+ }
85
+
86
+ if (pkg.commitlint && typeof pkg.commitlint === 'object') {
87
+ relevant.commitlint = pkg.commitlint
88
+ }
89
+
90
+ if (pkg.husky && typeof pkg.husky === 'object') {
91
+ relevant.husky = pkg.husky
92
+ }
93
+
94
+ if (pkg['lint-staged'] && typeof pkg['lint-staged'] === 'object') {
95
+ relevant['lint-staged'] = pkg['lint-staged']
96
+ }
97
+
98
+ if (pkg.config?.commitizen && typeof pkg.config.commitizen === 'object') {
99
+ relevant.commitizen = pkg.config.commitizen
100
+ }
101
+
102
+ if (!Object.keys(relevant).length) {
103
+ return ''
104
+ }
105
+
106
+ const text = JSON.stringify(relevant, null, 2)
107
+ if (text.length <= MAX_PACKAGE_SNIPPET) {
108
+ return text
109
+ }
110
+
111
+ return `${text.slice(0, MAX_PACKAGE_SNIPPET)}\n...<truncated>`
112
+ } catch {
113
+ return ''
114
+ }
115
+ }
116
+
117
+ function buildFileSection(topLevelPath, filePath, limit = MAX_FILE_SNIPPET) {
118
+ const snippet = readTextSnippet(filePath, limit)
119
+ if (!snippet) {
120
+ return ''
121
+ }
122
+
123
+ return `[${path.relative(topLevelPath, filePath) || path.basename(filePath)}]\n${snippet}`
124
+ }
125
+
126
+ function truncateSummary(text) {
127
+ if (text.length <= MAX_SUMMARY_LENGTH) {
128
+ return text
129
+ }
130
+
131
+ return `${text.slice(0, MAX_SUMMARY_LENGTH)}\n\n...<truncated>`
132
+ }
133
+
134
+ export function scanCommitConventions(context) {
135
+ const hookFiles = collectHookFiles(context)
136
+ const hookSections = hookFiles
137
+ .map((filePath) => buildFileSection(context.topLevelPath, filePath))
138
+ .filter(Boolean)
139
+
140
+ const configFiles = collectConfigFiles(context.topLevelPath)
141
+ const configSections = configFiles
142
+ .map((filePath) => buildFileSection(context.topLevelPath, filePath))
143
+ .filter(Boolean)
144
+
145
+ const packageJsonSnippet = extractPackageJsonSnippet(context.topLevelPath)
146
+ const sections = []
147
+
148
+ if (hookSections.length) {
149
+ sections.push(`Relevant Git hooks:\n${hookSections.join('\n\n')}`)
150
+ }
151
+
152
+ if (configSections.length) {
153
+ sections.push(`Relevant commit config files:\n${configSections.join('\n\n')}`)
154
+ }
155
+
156
+ if (packageJsonSnippet) {
157
+ sections.push(`Relevant package.json fields:\n[package.json]\n${packageJsonSnippet}`)
158
+ }
159
+
160
+ return {
161
+ hasConventions: sections.length > 0,
162
+ summary: truncateSummary(sections.join('\n\n')),
163
+ sources: [
164
+ ...hookFiles.map((filePath) => path.relative(context.topLevelPath, filePath) || path.basename(filePath)),
165
+ ...configFiles.map((filePath) => path.relative(context.topLevelPath, filePath) || path.basename(filePath)),
166
+ ...(packageJsonSnippet ? ['package.json'] : [])
167
+ ]
168
+ }
169
+ }