@itcase/config 1.7.11 → 1.7.13

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.
@@ -1,5 +1,6 @@
1
1
  import { readdirSync, readFileSync } from 'node:fs'
2
- import { join, relative } from 'node:path'
2
+ import { join, relative, resolve } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
3
4
 
4
5
  const FORBIDDEN_SPEC = /^(file:|link:)/i
5
6
 
@@ -94,37 +95,54 @@ function collectHits(pkg) {
94
95
  return hits
95
96
  }
96
97
 
97
- const cliFiles = process.argv.slice(2)
98
- const files = cliFiles.length ? cliFiles : findPackageJsonFiles(process.cwd())
98
+ function findForbiddenDependencyHits(files) {
99
+ const targets = files?.length ? files : findPackageJsonFiles(process.cwd())
100
+ const hitsByFile = []
99
101
 
100
- const hitsByFile = []
102
+ for (const file of targets) {
103
+ const pkg = readPackageJson(file)
104
+ if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) continue
101
105
 
102
- for (const file of files) {
103
- const pkg = readPackageJson(file)
104
- if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) continue
105
-
106
- const hits = collectHits(pkg)
107
- if (hits.length) {
108
- hitsByFile.push({ file, hits })
106
+ const hits = collectHits(pkg)
107
+ if (hits.length) {
108
+ hitsByFile.push({ file, hits })
109
+ }
109
110
  }
111
+
112
+ return hitsByFile
110
113
  }
111
114
 
112
- if (!hitsByFile.length) {
113
- process.exit(0)
115
+ function formatFileDependencyReport(hitsByFile) {
116
+ const lines = [
117
+ '❌ Локальные зависимости file: / link: нельзя коммитить.',
118
+ ' Замените их на версию из npm registry.',
119
+ '',
120
+ ]
121
+
122
+ for (const { file, hits } of hitsByFile) {
123
+ lines.push(` ${relative(process.cwd(), file)}`)
124
+ for (const { locator, spec } of hits) {
125
+ lines.push(` ${locator}: ${spec}`)
126
+ }
127
+ }
128
+
129
+ return lines.join('\n')
114
130
  }
115
131
 
116
- const lines = [
117
- '❌ Локальные зависимости file: / link: нельзя коммитить.',
118
- ' Замените их на версию из npm registry.',
119
- '',
120
- ]
132
+ function isDirectCli() {
133
+ const entry = process.argv[1]
134
+ if (!entry) return false
135
+ return import.meta.url === pathToFileURL(resolve(entry)).href
136
+ }
121
137
 
122
- for (const { file, hits } of hitsByFile) {
123
- lines.push(` ${relative(process.cwd(), file)}`)
124
- for (const { locator, spec } of hits) {
125
- lines.push(` ${locator}: ${spec}`)
138
+ if (isDirectCli()) {
139
+ const hitsByFile = findForbiddenDependencyHits(process.argv.slice(2))
140
+ if (!hitsByFile.length) {
141
+ process.exit(0)
126
142
  }
143
+
144
+ process.stderr.write(`${formatFileDependencyReport(hitsByFile)}\n`)
145
+ process.exit(1)
127
146
  }
128
147
 
129
- process.stderr.write(`${lines.join('\n')}\n`)
130
- process.exit(1)
148
+ export { findForbiddenDependencyHits, formatFileDependencyReport }
@@ -1,22 +1,36 @@
1
+ /* eslint-disable perfectionist/sort-objects */
2
+
1
3
  import { existsSync } from 'node:fs'
2
- import { dirname, join, resolve } from 'node:path'
3
- import { fileURLToPath } from 'node:url'
4
+ import { resolve } from 'node:path'
4
5
 
5
- const checkNoFileDependencies = join(
6
- dirname(fileURLToPath(import.meta.url)),
7
- 'checkNoFileDependencies.js',
8
- )
6
+ import {
7
+ findForbiddenDependencyHits,
8
+ formatFileDependencyReport,
9
+ } from './checkNoFileDependencies.js'
9
10
 
10
11
  function existingFiles(files) {
11
12
  return files.filter((file) => existsSync(file))
12
13
  }
13
14
 
15
+ // Команды запускаются без shell (string-argv): каждый аргумент в кавычках,
16
+ // иначе путь с пробелом разрежется. npx не нужен — lint-staged берёт бинарь из node_modules/.bin.
17
+ function commandLine(parts) {
18
+ return parts.map((part) => JSON.stringify(part)).join(' ')
19
+ }
20
+
14
21
  function eslintCommand(files) {
15
- return `npx eslint --fix --color ${files.join(' ')}`
22
+ return commandLine(['eslint', '--fix', '--color', ...files])
16
23
  }
17
24
 
18
25
  function stylelintCommand(files) {
19
- return `npx stylelint --fix --color --formatter verbose ${files.join(' ')}`
26
+ return commandLine([
27
+ 'stylelint',
28
+ '--fix',
29
+ '--color',
30
+ '--formatter',
31
+ 'verbose',
32
+ ...files,
33
+ ])
20
34
  }
21
35
 
22
36
  function packageJsonCommand(files) {
@@ -24,10 +38,12 @@ function packageJsonCommand(files) {
24
38
  const rootPackageJson = resolve(process.cwd(), 'package.json')
25
39
  const isRoot = files.some((file) => resolve(file) === rootPackageJson)
26
40
  if (existsSync(rootPackageJson) && isRoot) {
27
- // lint-staged автоматически добавляет изменения задач в index
41
+ // С lint-staged 17.6 задача стейджит все tracked-файлы, которые она изменила,
42
+ // не только совпавшие с glob. package-lock.json попадёт в коммит без git add
43
+ // (git add в команде lint-staged просит убрать).
28
44
  return 'npm install --package-lock-only'
29
45
  }
30
- return 'true' // Пропускаем, если не найден
46
+ return []
31
47
  }
32
48
 
33
49
  /**
@@ -43,9 +59,25 @@ function createLintStagedConfig({
43
59
  typecheckCommand = 'tsc --noEmit --pretty',
44
60
  } = {}) {
45
61
  return {
46
- // На любой staged-файл: если в package.json есть file:/link: — exit 1, коммит не пройдёт
47
- '*': () => `node ${JSON.stringify(checkNoFileDependencies)}`,
48
- '*.(js|jsx|ts|tsx)': (files) => {
62
+ // На любой staged-файл: если в package.json есть file:/link: — задача падает, коммит не пройдёт.
63
+ // Function task (lint-staged 16+, log() с 17.6): без отдельного node-процесса.
64
+ // До 17.6 второй аргумент не передаётся — деструктуризация { log } роняла задачу
65
+ // и lint-staged убивал параллельный eslint (Task killed).
66
+ '*': {
67
+ title: 'check file: dependencies',
68
+ task: async (_filepaths, context) => {
69
+ const hitsByFile = findForbiddenDependencyHits()
70
+ if (!hitsByFile.length) return
71
+
72
+ const report = formatFileDependencyReport(hitsByFile)
73
+ if (typeof context?.log === 'function') context.log('%s', report)
74
+ else process.stderr.write(`${report}\n`)
75
+ throw new Error('Локальные зависимости file: / link: нельзя коммитить.')
76
+ },
77
+ },
78
+ // picomatch (lint-staged 16.4+) раскрывает brace-glob.
79
+ // tsc только для .ts/.tsx; .js/.jsx/.mjs/.cjs идут в eslint.
80
+ '*.{js,jsx,mjs,cjs,ts,tsx}': (files) => {
49
81
  const existing = existingFiles(files)
50
82
  const tsFiles = existing.filter((file) => /\.(ts|tsx)$/.test(file))
51
83
  const commands = []
@@ -58,16 +90,16 @@ function createLintStagedConfig({
58
90
  commands.push(eslintCommand(existing))
59
91
  }
60
92
 
61
- return commands.length ? commands : 'true'
93
+ return commands
62
94
  },
63
95
  '*.css': (files) => {
64
96
  const existing = existingFiles(files)
65
- if (!existing.length) return 'true'
97
+ if (!existing.length) return []
66
98
  return stylelintCommand(existing)
67
99
  },
68
100
  '*.mdx': (files) => {
69
101
  const existing = existingFiles(files)
70
- if (!existing.length) return 'true'
102
+ if (!existing.length) return []
71
103
  return eslintCommand(existing)
72
104
  },
73
105
  'package.json': packageJsonCommand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itcase/config",
3
- "version": "1.7.11",
3
+ "version": "1.7.13",
4
4
  "author": "ITCase",
5
5
  "description": "ITCase Config",
6
6
  "engines": {
@@ -43,11 +43,11 @@
43
43
  "@commitlint/config-conventional": "^21.2.3",
44
44
  "@commitlint/cz-commitlint": "^21.2.3",
45
45
  "@lehoczky/postcss-fluid": "^1.0.3",
46
- "@lerna-lite/cli": "^5.6.1",
47
- "@lerna-lite/exec": "^5.6.1",
48
- "@lerna-lite/list": "^5.6.1",
49
- "@lerna-lite/run": "^5.6.1",
50
- "@lerna-lite/watch": "^5.6.1",
46
+ "@lerna-lite/cli": "^5.6.2",
47
+ "@lerna-lite/exec": "^5.6.2",
48
+ "@lerna-lite/list": "^5.6.2",
49
+ "@lerna-lite/run": "^5.6.2",
50
+ "@lerna-lite/watch": "^5.6.2",
51
51
  "@rollup/plugin-alias": "^6.0.0",
52
52
  "@rollup/plugin-babel": "^7.1.0",
53
53
  "@rollup/plugin-commonjs": "^29.0.3",
@@ -68,8 +68,8 @@
68
68
  "commitizen": "^4.3.2",
69
69
  "conventional-changelog-conventionalcommits": "9.3.1",
70
70
  "conventional-changelog-writer": "9.2.1",
71
- "cssnano": "^9.0.5",
72
- "cssnano-preset-default": "^9.0.5",
71
+ "cssnano": "^9.1.0",
72
+ "cssnano-preset-default": "^9.1.0",
73
73
  "inquirer": "^14.2.2",
74
74
  "log-update": "^8.0.0",
75
75
  "postcss": "^8.5.28",
@@ -80,7 +80,7 @@
80
80
  "postcss-combine-duplicated-selectors": "^10.0.3",
81
81
  "postcss-convert-unit": "^0.1.3",
82
82
  "postcss-dark-theme-class": "^2.0.0",
83
- "postcss-discard-duplicates": "^9.0.3",
83
+ "postcss-discard-duplicates": "^9.0.4",
84
84
  "postcss-each": "^1.1.0",
85
85
  "postcss-easings": "^4.0.0",
86
86
  "postcss-extend-rule": "^4.0.0",
@@ -96,7 +96,7 @@
96
96
  "postcss-nested-ancestors": "^3.0.0",
97
97
  "postcss-normalize": "^13.0.1",
98
98
  "postcss-prepend-imports": "^1.0.1",
99
- "postcss-preset-env": "^11.5.3",
99
+ "postcss-preset-env": "^11.5.4",
100
100
  "postcss-pxtorem": "^6.1.0",
101
101
  "postcss-sort-media-queries": "^6.7.1",
102
102
  "postcss-unit": "^0.0.3",
@@ -118,8 +118,8 @@
118
118
  "eslint": "9.39.2",
119
119
  "husky": "^9.1.7",
120
120
  "inquirer": "^14.2.2",
121
- "lint-staged": "^17.5.1",
122
- "prettier": "^3.9.8",
121
+ "lint-staged": "^17.6.0",
122
+ "prettier": "^3.9.9",
123
123
  "storybook": "^10.6.0",
124
124
  "stylelint": "^17.15.0",
125
125
  "typescript": "^6.0.3"
@@ -25,6 +25,10 @@ const CLI_RELATIVE = 'smart-commit/cli.js'
25
25
  const DEFAULT_MODEL = 'composer-2.5'
26
26
 
27
27
  const MAX_DIFF = 80_000
28
+ const GIT_MAX_BUFFER = 32 * 1024 * 1024
29
+ const GIT_PATH_BATCH = 40
30
+ const PLAN_FILE_BATCH = 40
31
+ const ARG_SAFE = 100_000
28
32
  const LOCKFILE_NAMES = new Set([
29
33
  'package-lock.json',
30
34
  'npm-shrinkwrap.json',
@@ -37,6 +41,9 @@ const AGENT_STEP_MS = 2_500
37
41
  const AGENT_STILL_WORKING_S = 120
38
42
  const AGENT_LOG_ROWS = 8
39
43
  const AGENT_LOG_MAX = 160
44
+ const HOOK_LOG_ROWS = 6
45
+ const SPIN_MS = 80
46
+ const SPIN_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
40
47
  const NYAN_FRAME_MS = 90
41
48
  const BANANA_FRAME_MS = 100
42
49
  const UPYACHKA_FRAME_MS = 100
@@ -99,27 +106,18 @@ async function main(argv) {
99
106
 
100
107
  let plans = tryFastPlan(changedFiles, cwd, scopes, types)
101
108
  if (!plans) {
102
- const prompt = buildPrompt(scopes, types, changedFiles, cwd, option.type)
103
- const raw = await withAgentProgress(
104
- {
105
- fileCount: changedFiles.length,
106
- sprite: pickSprite(option.sprite),
107
- verbose: option.verbose,
108
- },
109
- (progress) =>
110
- askAgent(
111
- cwd,
112
- option.model || process.env.SMART_COMMIT_MODEL || DEFAULT_MODEL,
113
- prompt,
114
- option.verbose,
115
- progress.onAgentActivity,
116
- ),
117
- )
118
- plans = parsePlan(raw, changedFiles)
109
+ plans = await planWithAgent({
110
+ cwd: cwd,
111
+ files: changedFiles,
112
+ model: option.model || process.env.SMART_COMMIT_MODEL || DEFAULT_MODEL,
113
+ option: option,
114
+ scopes: scopes,
115
+ types: types,
116
+ })
119
117
  plans = assignOmittedFiles(plans, changedFiles)
120
118
  plans = attachLockfiles(plans, changedFiles)
121
119
  }
122
- await validateAndNormalizePlans(cwd, plans)
120
+ await validateAndNormalizePlans(cwd, plans, scopes, types)
123
121
 
124
122
  process.stdout.write('\nCommits:\n')
125
123
  for (const plan of plans) process.stdout.write(` • ${plan.message}\n`)
@@ -155,7 +153,7 @@ async function main(argv) {
155
153
  ensureGitIndexReady(cwd)
156
154
 
157
155
  const total = activePlans.length
158
- process.stdout.write(`Creating ${total} commit(s)…\n`)
156
+ process.stdout.write(`\n${paint('1', `Creating ${total} commit(s)…`)}\n`)
159
157
 
160
158
  let created = 0
161
159
  for (let index = 0; index < total; index++) {
@@ -163,15 +161,15 @@ async function main(argv) {
163
161
  const step = `[${index + 1}/${total}]`
164
162
  const planFiles = plan.files.filter((file) => fileHasChanges(cwd, file))
165
163
  if (!planFiles.length) {
166
- process.stdout.write(`${step} skip empty: ${plan.message}\n`)
164
+ process.stdout.write(`${dim(step)} skip empty: ${plan.message}\n`)
167
165
  continue
168
166
  }
169
167
 
170
168
  const startedAt = Date.now()
171
169
  const fileLabel =
172
170
  planFiles.length === 1 ? '1 file' : `${planFiles.length} files`
173
- process.stdout.write(`${step} ${plan.message}\n`)
174
- process.stdout.write(` staging ${fileLabel}…\n`)
171
+ process.stdout.write(`\n${paint('1;36', step)} ${plan.message}\n`)
172
+ process.stdout.write(`${dim(` staging ${fileLabel}…`)}\n`)
175
173
 
176
174
  // Drop leftover index entries so only this plan's files are committed.
177
175
  git(cwd, ['reset', '-q', 'HEAD'])
@@ -185,26 +183,36 @@ async function main(argv) {
185
183
  )
186
184
  }
187
185
 
188
- process.stdout.write(
189
- option.noVerify
190
- ? ' git commit --no-verify (hooks skipped)…\n'
191
- : ' git commit (husky)…\n',
186
+ const commitLabel = option.noVerify
187
+ ? 'git commit --no-verify'
188
+ : 'git commit'
189
+ const hookLocks = siblingLocksForFiles(planFiles)
190
+ const dirtyLocksBefore = new Set(
191
+ hookLocks.filter((file) => fileHasChanges(cwd, file)),
192
192
  )
193
193
  try {
194
194
  const commitArgs = ['commit', '-m', plan.message]
195
195
  // Skips pre-commit (lint-staged) and commit-msg. lint-staged has no
196
196
  // equivalent flag — the hook simply does not run.
197
197
  if (option.noVerify) commitArgs.push('--no-verify')
198
- git(cwd, commitArgs, { inherit: true })
198
+ await gitCommitWithProgress(cwd, commitArgs, commitLabel)
199
+ const included = includeHookLockfiles(cwd, planFiles, dirtyLocksBefore)
200
+ if (included.length) {
201
+ process.stdout.write(
202
+ `${dim(` hook updated ${included.join(', ')}, added to commit`)}\n`,
203
+ )
204
+ }
199
205
  } catch (error) {
200
206
  throw new Error(`git commit failed: ${plan.message}\n${error.message}`, {
201
207
  cause: error,
202
208
  })
203
209
  }
204
210
  created++
205
- process.stdout.write(` done (${formatElapsed(startedAt)})\n`)
211
+ process.stdout.write(
212
+ `${paint('1;32', ` done ${formatElapsed(startedAt)}`)}\n`,
213
+ )
206
214
  }
207
- process.stdout.write(`Created ${created} commit(s).\n`)
215
+ process.stdout.write(`\n${paint('1;32', `Created ${created} commit(s).`)}\n`)
208
216
 
209
217
  const remaining = changedFiles.filter((file) => fileHasChanges(cwd, file))
210
218
  if (remaining.length) {
@@ -391,9 +399,16 @@ function siblingManifest(file) {
391
399
  return directory === '.' ? 'package.json' : `${directory}/package.json`
392
400
  }
393
401
 
402
+ function siblingLockPaths(manifest) {
403
+ const directory = path.posix.dirname(manifest)
404
+ return [...LOCKFILE_NAMES].map((name) =>
405
+ directory === '.' ? name : `${directory}/${name}`,
406
+ )
407
+ }
408
+
394
409
  function buildPrompt(scopes, types, files, cwd, type) {
395
410
  const statusByFile = new Map(
396
- lines(cwd, ['status', '--short', '--porcelain', '--', ...files]).map(
411
+ linesFromPaths(cwd, ['status', '--short', '--porcelain'], files).map(
397
412
  (line) => [line.slice(3), line.slice(0, 2).trim()],
398
413
  ),
399
414
  )
@@ -405,6 +420,7 @@ function buildPrompt(scopes, types, files, cwd, type) {
405
420
 
406
421
  return `Plan git commits. Do not edit files or run git commit.
407
422
  Return ONLY JSON: {"commits":[{"message":"type(scope): subject","files":["a.ts"]}]}
423
+ Every message must be type(scope): subject. Put a scope from the list in parentheses, including when the type is "other".
408
424
  Types: ${types.join(', ')}
409
425
  Scopes (exact): ${buildScopeEnum(scopes).join(', ')}
410
426
  Subject rules:
@@ -443,13 +459,30 @@ function buildSourceDiff(cwd, files) {
443
459
  }
444
460
 
445
461
  const parts = []
446
- if (tracked.length) {
447
- const text = out(cwd, ['diff', 'HEAD', '--', ...tracked])
448
- if (text) parts.push(text)
462
+ let used = 0
463
+ const pushLimited = (text) => {
464
+ if (used >= MAX_DIFF) return false
465
+ // Empty batch (no changes / ENOBUFS skip) — keep scanning remaining files.
466
+ if (!text) return true
467
+ const room = MAX_DIFF - used
468
+ if (text.length > room) {
469
+ parts.push(`${text.slice(0, room)}\n…truncated`)
470
+ used = MAX_DIFF
471
+ return false
472
+ }
473
+ parts.push(text)
474
+ used += text.length
475
+ return true
449
476
  }
477
+
478
+ forEachBatch(tracked, (batch) => {
479
+ if (used >= MAX_DIFF) return false
480
+ return pushLimited(tryOut(cwd, ['diff', 'HEAD', '--', ...batch]))
481
+ })
450
482
  for (const file of untracked) {
483
+ if (used >= MAX_DIFF) break
451
484
  const piece = buildUntrackedDiff(cwd, file)
452
- if (piece) parts.push(piece)
485
+ if (!pushLimited(piece)) break
453
486
  }
454
487
  return parts.join('\n\n')
455
488
  }
@@ -634,7 +667,22 @@ function askAgent(cwd, model, prompt, verbose, onAgentActivity) {
634
667
  if (model) args.push('--model', model)
635
668
  if (process.env.CURSOR_API_KEY)
636
669
  args.push('--api-key', process.env.CURSOR_API_KEY)
637
- args.push(prompt)
670
+
671
+ let promptFile = ''
672
+ let agentPrompt = prompt
673
+ if (prompt.length > ARG_SAFE) {
674
+ const candidate = path.join(cwd, '.git', 'smart-commit-prompt.txt')
675
+ try {
676
+ fs.writeFileSync(candidate, prompt, 'utf8')
677
+ promptFile = candidate
678
+ agentPrompt =
679
+ `Read the file ${candidate} and follow it exactly. ` +
680
+ 'Return ONLY the JSON it requests. Do not edit files or run git commit.'
681
+ } catch {
682
+ // If .git cannot be written, keep the original prompt.
683
+ }
684
+ }
685
+ args.push(agentPrompt)
638
686
 
639
687
  if (verbose) process.stderr.write(`Using: ${cmd}\n`)
640
688
  if (onAgentActivity) {
@@ -667,6 +715,13 @@ function askAgent(cwd, model, prompt, verbose, onAgentActivity) {
667
715
  settled = true
668
716
  process.removeListener('SIGINT', onSignal)
669
717
  process.removeListener('SIGTERM', onSignal)
718
+ if (promptFile) {
719
+ try {
720
+ fs.unlinkSync(promptFile)
721
+ } catch {
722
+ // Ignore cleanup errors.
723
+ }
724
+ }
670
725
  if (error) reject(error)
671
726
  else resolve(value)
672
727
  }
@@ -754,20 +809,12 @@ function applyAgentLine(stream, line) {
754
809
  const text = extractThinkingText(event)
755
810
  if (event.subtype === 'completed') {
756
811
  return stream.thinking
757
- ? {
758
- log: `Thinking: ${stream.thinking}`,
759
- stream: true,
760
- streamKey: 'thinking',
761
- }
812
+ ? thinkingLog(stream)
762
813
  : { log: 'Finished thinking' }
763
814
  }
764
815
  if (text) {
765
816
  stream.thinking += text
766
- return {
767
- log: `Thinking: ${stream.thinking}`,
768
- stream: true,
769
- streamKey: 'thinking',
770
- }
817
+ return thinkingLog(stream)
771
818
  }
772
819
  return { log: 'Thinking' }
773
820
  }
@@ -782,13 +829,9 @@ function applyAgentLine(stream, line) {
782
829
  const thinking = extractContentText(event.message?.content, 'thinking')
783
830
  if (thinking) {
784
831
  stream.thinking += thinking
785
- return {
786
- log: `Thinking: ${stream.thinking}`,
787
- stream: true,
788
- streamKey: 'thinking',
789
- }
832
+ return thinkingLog(stream)
790
833
  }
791
- const text = extractAssistantText(event)
834
+ const text = extractContentText(event.message?.content, 'text')
792
835
  if (!text) return null
793
836
  if (typeof event.timestamp_ms === 'number') stream.lastAssistant += text
794
837
  else stream.lastAssistant = text
@@ -812,8 +855,12 @@ function applyAgentLine(stream, line) {
812
855
  return null
813
856
  }
814
857
 
815
- function extractAssistantText(event) {
816
- return extractContentText(event.message?.content, 'text')
858
+ function thinkingLog(stream) {
859
+ return {
860
+ log: `Thinking: ${stream.thinking}`,
861
+ stream: true,
862
+ streamKey: 'thinking',
863
+ }
817
864
  }
818
865
 
819
866
  function extractThinkingText(event) {
@@ -940,6 +987,131 @@ function parsePlan(raw, allowedFiles) {
940
987
  return plans
941
988
  }
942
989
 
990
+ async function planWithAgent(context) {
991
+ const batches = splitPlanBatches(context.files)
992
+ const plans = []
993
+ for (let index = 0; index < batches.length; index++) {
994
+ const batch = batches[index]
995
+ if (batches.length > 1) {
996
+ process.stdout.write(
997
+ `Planning ${index + 1}/${batches.length} (${batch.length} files)…\n`,
998
+ )
999
+ }
1000
+ const batchPlans = await planBatch({ ...context, files: batch })
1001
+ plans.push(...batchPlans)
1002
+ }
1003
+ return plans
1004
+ }
1005
+
1006
+ async function planBatch(context) {
1007
+ try {
1008
+ return await requestPlan(context)
1009
+ } catch (error) {
1010
+ if (context.files.length < 2 || !isRetryablePlanError(error)) throw error
1011
+ const mid = Math.ceil(context.files.length / 2)
1012
+ process.stderr.write(
1013
+ `Commit plan failed for ${context.files.length} files, splitting…\n`,
1014
+ )
1015
+ const left = await planBatch({
1016
+ ...context,
1017
+ files: context.files.slice(0, mid),
1018
+ })
1019
+ const right = await planBatch({
1020
+ ...context,
1021
+ files: context.files.slice(mid),
1022
+ })
1023
+ return [...left, ...right]
1024
+ }
1025
+ }
1026
+
1027
+ function isRetryablePlanError(error) {
1028
+ return /Bad JSON|Empty commit plan|No JSON/.test(String(error?.message || ''))
1029
+ }
1030
+
1031
+ async function requestPlan(context) {
1032
+ const prompt = buildPrompt(
1033
+ context.scopes,
1034
+ context.types,
1035
+ context.files,
1036
+ context.cwd,
1037
+ context.option.type,
1038
+ )
1039
+ const raw = await withAgentProgress(
1040
+ {
1041
+ fileCount: context.files.length,
1042
+ sprite: pickSprite(context.option.sprite),
1043
+ verbose: context.option.verbose,
1044
+ },
1045
+ (progress) =>
1046
+ askAgent(
1047
+ context.cwd,
1048
+ context.model,
1049
+ prompt,
1050
+ context.option.verbose,
1051
+ progress.onAgentActivity,
1052
+ ),
1053
+ )
1054
+ return parsePlan(raw, context.files)
1055
+ }
1056
+
1057
+ function splitPlanBatches(files) {
1058
+ if (files.length <= PLAN_FILE_BATCH) return [files]
1059
+
1060
+ const groups = new Map()
1061
+ for (const file of files) {
1062
+ if (isLockfile(file)) continue
1063
+ const key = planGroupKey(file)
1064
+ const group = groups.get(key) || []
1065
+ group.push(file)
1066
+ groups.set(key, group)
1067
+ }
1068
+
1069
+ for (const file of files) {
1070
+ if (!isLockfile(file)) continue
1071
+ const manifest = siblingManifest(file)
1072
+ let placed = false
1073
+ for (const group of groups.values()) {
1074
+ if (!group.includes(manifest)) continue
1075
+ group.push(file)
1076
+ placed = true
1077
+ break
1078
+ }
1079
+ if (placed) continue
1080
+ const key = planGroupKey(file)
1081
+ const group = groups.get(key) || []
1082
+ group.push(file)
1083
+ groups.set(key, group)
1084
+ }
1085
+
1086
+ const batches = []
1087
+ let current = []
1088
+ for (const group of groups.values()) {
1089
+ if (group.length > PLAN_FILE_BATCH) {
1090
+ if (current.length) {
1091
+ batches.push(current)
1092
+ current = []
1093
+ }
1094
+ for (let index = 0; index < group.length; index += PLAN_FILE_BATCH) {
1095
+ batches.push(group.slice(index, index + PLAN_FILE_BATCH))
1096
+ }
1097
+ continue
1098
+ }
1099
+ if (current.length && current.length + group.length > PLAN_FILE_BATCH) {
1100
+ batches.push(current)
1101
+ current = []
1102
+ }
1103
+ current.push(...group)
1104
+ }
1105
+ if (current.length) batches.push(current)
1106
+ return batches.length ? batches : [files]
1107
+ }
1108
+
1109
+ function planGroupKey(file) {
1110
+ const parts = file.split('/')
1111
+ if (parts.length <= 2) return parts[0] || '.'
1112
+ return `${parts[0]}/${parts[1]}`
1113
+ }
1114
+
943
1115
  function tryFastPlan(files, cwd, scopes, types) {
944
1116
  const sourceFiles = files.filter((file) => !isLockfile(file))
945
1117
  if (sourceFiles.length !== 1) return null
@@ -1118,7 +1290,7 @@ function normalizeCommitMessage(message) {
1118
1290
  return normalizeCommitSubject(subject)
1119
1291
  }
1120
1292
 
1121
- async function validateAndNormalizePlans(cwd, plans) {
1293
+ async function validateAndNormalizePlans(cwd, plans, scopes, types) {
1122
1294
  const loaded = await load({}, cwd)
1123
1295
  const lintOptions = {
1124
1296
  defaultIgnores: loaded.defaultIgnores,
@@ -1128,38 +1300,71 @@ async function validateAndNormalizePlans(cwd, plans) {
1128
1300
 
1129
1301
  for (const plan of plans) {
1130
1302
  plan.message = normalizeCommitSubject(plan.message)
1131
- const report = await lint(plan.message, loaded.rules, lintOptions)
1303
+ let report = await lint(plan.message, loaded.rules, lintOptions)
1132
1304
  if (report.valid) continue
1133
1305
 
1306
+ const oldMessage = plan.message
1307
+ const repaired = repairCommitHeader(plan.message, scopes, types)
1308
+ if (repaired !== oldMessage) {
1309
+ report = await lint(repaired, loaded.rules, lintOptions)
1310
+ if (report.valid) {
1311
+ process.stderr.write(
1312
+ `Adjusted commit message for commitlint:\n ${oldMessage}\n ${repaired}\n`,
1313
+ )
1314
+ plan.message = repaired
1315
+ continue
1316
+ }
1317
+ }
1318
+
1134
1319
  const errors =
1135
1320
  report.errors?.map((error) => error.message).join('; ') || 'invalid'
1136
1321
  throw new Error(
1137
1322
  `Commit message failed commitlint:\n ${plan.message}\n ${errors}\n` +
1138
- 'Use lower-case subject without camelCase (e.g. "public dir" not "publicDir").',
1323
+ 'Use type(scope): subject. Scope in parentheses is required. Subject is lower-case without camelCase (e.g. "public dir" not "publicDir").',
1139
1324
  )
1140
1325
  }
1141
1326
  }
1142
1327
 
1328
+ function repairCommitHeader(message, scopes, types) {
1329
+ const match = String(message || '')
1330
+ .trim()
1331
+ .match(/^(\w+)(?:\(([^)]*)\))?:\s*(.+)$/)
1332
+ if (!match) return message
1333
+
1334
+ let [, type, rawScope, subject] = match
1335
+ const scopeEnum = buildScopeEnum(scopes)
1336
+ if (types.length && !types.includes(type)) {
1337
+ if (types.includes('chore')) type = 'chore'
1338
+ else if (types.includes('other')) type = 'other'
1339
+ else type = types[0]
1340
+ }
1341
+
1342
+ const matchedScope = matchScope((rawScope || '').trim(), scopeEnum)
1343
+ const scope = matchedScope || pickScope(scopes, ['other'])
1344
+
1345
+ return normalizeCommitSubject(`${type}(${scope}): ${subject}`)
1346
+ }
1347
+
1348
+ function matchScope(scope, scopeEnum) {
1349
+ if (scopeEnum.includes(scope)) return scope
1350
+ const normalized = scope
1351
+ .trim()
1352
+ .replace(/\s*\/\s*/g, ' / ')
1353
+ .replace(/\s+/g, ' ')
1354
+ if (scopeEnum.includes(normalized)) return normalized
1355
+ return ''
1356
+ }
1357
+
1143
1358
  function isOnDisk(cwd, file) {
1144
1359
  return fs.existsSync(path.join(cwd, file))
1145
1360
  }
1146
1361
 
1147
1362
  function isTracked(cwd, file) {
1148
- return (
1149
- spawnSync('git', ['ls-files', '--error-unmatch', '--', file], {
1150
- cwd: cwd,
1151
- encoding: 'utf8',
1152
- }).status === 0
1153
- )
1363
+ return gitOk(cwd, ['ls-files', '--error-unmatch', '--', file])
1154
1364
  }
1155
1365
 
1156
1366
  function isInHead(cwd, file) {
1157
- return (
1158
- spawnSync('git', ['cat-file', '-e', `HEAD:${file}`], {
1159
- cwd: cwd,
1160
- encoding: 'utf8',
1161
- }).status === 0
1162
- )
1367
+ return gitOk(cwd, ['cat-file', '-e', `HEAD:${file}`])
1163
1368
  }
1164
1369
 
1165
1370
  function isUntracked(cwd, file) {
@@ -1181,6 +1386,44 @@ function fileHasChanges(cwd, file) {
1181
1386
  )
1182
1387
  }
1183
1388
 
1389
+ function siblingLocksForFiles(files) {
1390
+ const locks = []
1391
+ for (const file of files) {
1392
+ if (path.basename(file) !== 'package.json') continue
1393
+ locks.push(...siblingLockPaths(file))
1394
+ }
1395
+ return [...new Set(locks)]
1396
+ }
1397
+
1398
+ // lint-staged runs `npm install --package-lock-only` for package.json, but
1399
+ // older lint-staged only re-stages the matched file. The lockfile stays dirty
1400
+ // after the commit. Fold that hook output into the commit we just created.
1401
+ function includeHookLockfiles(cwd, planFiles, dirtyBefore) {
1402
+ const locks = []
1403
+ for (const file of planFiles) {
1404
+ if (path.basename(file) !== 'package.json') continue
1405
+ for (const lock of siblingLockPaths(file)) {
1406
+ if (!fileHasChanges(cwd, lock)) continue
1407
+ const wasDirty = dirtyBefore.has(lock)
1408
+ const wasInCommit = planFiles.includes(lock)
1409
+ if (!wasDirty || wasInCommit) locks.push(lock)
1410
+ }
1411
+ }
1412
+
1413
+ const unique = [...new Set(locks)]
1414
+ if (!unique.length) return []
1415
+
1416
+ stageFiles(cwd, unique)
1417
+ const staged = new Set(lines(cwd, ['diff', '--cached', '--name-only']))
1418
+ const stagedLocks = unique.filter((file) => staged.has(file))
1419
+ if (!stagedLocks.length) return []
1420
+
1421
+ // --no-verify: the hook already ran and produced this lockfile.
1422
+ // Running it again would call npm install and dirty the lockfile once more.
1423
+ git(cwd, ['commit', '--amend', '--no-edit', '--no-verify'])
1424
+ return stagedLocks
1425
+ }
1426
+
1184
1427
  function stageFiles(cwd, files) {
1185
1428
  if (!files.length) return
1186
1429
 
@@ -1193,10 +1436,12 @@ function stageFiles(cwd, files) {
1193
1436
 
1194
1437
  // `git add -A -- deleted/path` warns or misses the deletion when the
1195
1438
  // parent directory is already gone. `git rm` stages the index removal.
1196
- if (existing.length) git(cwd, ['add', '-A', '--', ...existing])
1197
- if (removed.length) {
1198
- git(cwd, ['rm', '--ignore-unmatch', '-q', '--', ...removed])
1199
- }
1439
+ forEachBatch(existing, (batch) => {
1440
+ git(cwd, ['add', '-A', '--', ...batch])
1441
+ })
1442
+ forEachBatch(removed, (batch) => {
1443
+ git(cwd, ['rm', '--ignore-unmatch', '-q', '--', ...batch])
1444
+ })
1200
1445
  }
1201
1446
 
1202
1447
  async function confirm(count) {
@@ -1277,31 +1522,191 @@ function ensureGitIndexReady(cwd) {
1277
1522
  )
1278
1523
  }
1279
1524
 
1525
+ async function gitCommitWithProgress(cwd, args, label) {
1526
+ for (let attempt = 1; attempt <= INDEX_LOCK_RETRIES; attempt++) {
1527
+ const result = await spawnGitWithSpinner(cwd, args, label)
1528
+ if (result.error) throw result.error
1529
+ if (!result.status) {
1530
+ if (process.stderr.isTTY) writeGitSummary(result.stdout)
1531
+ else writeHookOutput(result.stdout, result.stderr)
1532
+ return
1533
+ }
1534
+
1535
+ const stderr = result.stderr || ''
1536
+ if (!retryIndexLock(cwd, attempt, stderr)) {
1537
+ throw new Error(
1538
+ (stderr || result.stdout || '').trim() || `git ${args.join(' ')}`,
1539
+ )
1540
+ }
1541
+ await sleep(INDEX_LOCK_WAIT_MS * attempt)
1542
+ }
1543
+ }
1544
+
1545
+ function spawnGitWithSpinner(cwd, args, label) {
1546
+ return new Promise((resolve) => {
1547
+ const child = spawn('git', args, {
1548
+ cwd: cwd,
1549
+ stdio: ['ignore', 'pipe', 'pipe'],
1550
+ })
1551
+ let stdout = ''
1552
+ let stderr = ''
1553
+ const logLines = []
1554
+ let frame = 0
1555
+ const startedAt = Date.now()
1556
+ const tty = Boolean(process.stderr.isTTY)
1557
+
1558
+ const pushLog = (chunk) => {
1559
+ for (const line of String(chunk).split('\n')) {
1560
+ const trimmed = line.trim()
1561
+ if (!trimmed) continue
1562
+ logLines.push(trimmed)
1563
+ }
1564
+ if (logLines.length > 40) logLines.splice(0, logLines.length - 40)
1565
+ }
1566
+
1567
+ child.stdout.on('data', (chunk) => {
1568
+ stdout += chunk
1569
+ pushLog(chunk)
1570
+ })
1571
+ child.stderr.on('data', (chunk) => {
1572
+ stderr += chunk
1573
+ pushLog(chunk)
1574
+ })
1575
+
1576
+ let timer
1577
+ if (tty) {
1578
+ const render = () => {
1579
+ const columns = process.stderr.columns || 80
1580
+ const spin = SPIN_FRAMES[frame % SPIN_FRAMES.length]
1581
+ frame += 1
1582
+ const hookLines = logLines
1583
+ .slice(-HOOK_LOG_ROWS)
1584
+ .map((line) =>
1585
+ dim(` │ ${clipLog(line, Math.max(20, columns - 10))}`),
1586
+ )
1587
+ const status = paint(
1588
+ '1;36',
1589
+ ` ${spin} ${label} ${formatElapsed(startedAt)}`,
1590
+ )
1591
+ const block = [paint('1;33', ' husky'), ...hookLines, status]
1592
+ logUpdateStderr(block.join('\n'))
1593
+ }
1594
+ timer = setInterval(render, SPIN_MS)
1595
+ render()
1596
+ } else {
1597
+ timer = setInterval(() => {
1598
+ process.stderr.write(` ${label}… ${formatElapsed(startedAt)}\n`)
1599
+ }, AGENT_STEP_MS)
1600
+ }
1601
+
1602
+ const stop = () => {
1603
+ clearInterval(timer)
1604
+ if (tty) logUpdateStderr.clear()
1605
+ }
1606
+
1607
+ const onSignal = () => {
1608
+ if (child.exitCode === null && !child.killed) child.kill('SIGTERM')
1609
+ }
1610
+ process.once('SIGINT', onSignal)
1611
+ process.once('SIGTERM', onSignal)
1612
+
1613
+ const finish = (result) => {
1614
+ stop()
1615
+ process.removeListener('SIGINT', onSignal)
1616
+ process.removeListener('SIGTERM', onSignal)
1617
+ resolve(result)
1618
+ }
1619
+
1620
+ child.on('error', (error) => {
1621
+ finish({ status: 1, error: error, stderr: stderr, stdout: stdout })
1622
+ })
1623
+ child.on('close', (code) => {
1624
+ finish({
1625
+ status: code ?? 1,
1626
+ stderr: stderr,
1627
+ stdout: stdout,
1628
+ })
1629
+ })
1630
+ })
1631
+ }
1632
+
1633
+ function writeGitSummary(stdout) {
1634
+ const lines = String(stdout || '')
1635
+ .split('\n')
1636
+ .map((line) => line.trimEnd())
1637
+ .filter(
1638
+ (line) =>
1639
+ /^\s*\[[^\]]+\]/.test(line) || /^\s*\d+ files? changed/.test(line),
1640
+ )
1641
+ if (!lines.length) return
1642
+ process.stdout.write(`${dim(lines.join('\n'))}\n`)
1643
+ }
1644
+
1645
+ function paint(code, text) {
1646
+ if (!process.stdout.isTTY || process.env.NO_COLOR) return text
1647
+ return `\x1b[${code}m${text}\x1b[0m`
1648
+ }
1649
+
1650
+ function dim(text) {
1651
+ return paint('2', text)
1652
+ }
1653
+
1654
+ function writeHookOutput(stdout, stderr) {
1655
+ if (stdout) {
1656
+ process.stdout.write(stdout.endsWith('\n') ? stdout : `${stdout}\n`)
1657
+ }
1658
+ if (stderr) {
1659
+ process.stderr.write(stderr.endsWith('\n') ? stderr : `${stderr}\n`)
1660
+ }
1661
+ }
1662
+
1663
+ function sleep(ms) {
1664
+ return new Promise((resolve) => {
1665
+ setTimeout(resolve, ms)
1666
+ })
1667
+ }
1668
+
1280
1669
  function git(cwd, args, option = {}) {
1281
1670
  for (let attempt = 1; attempt <= INDEX_LOCK_RETRIES; attempt++) {
1282
1671
  const result = spawnSync('git', args, {
1672
+ maxBuffer: GIT_MAX_BUFFER,
1283
1673
  cwd: cwd,
1284
1674
  encoding: 'utf8',
1285
1675
  stdio: option.inherit ? ['ignore', 'inherit', 'pipe'] : undefined,
1286
1676
  })
1677
+ if (result.error) throw result.error
1287
1678
  if (!result.status) return result
1288
1679
  // git diff exits 1 when there are differences
1289
1680
  if (result.status === 1 && args[0] === 'diff') return result
1290
1681
 
1291
1682
  const stderr = result.stderr || ''
1292
- if (!isIndexLockError(stderr) || attempt === INDEX_LOCK_RETRIES) {
1683
+ if (!retryIndexLock(cwd, attempt, stderr)) {
1293
1684
  if (option.inherit && stderr) process.stderr.write(stderr)
1294
1685
  throw new Error(stderr.trim() || `git ${args.join(' ')}`)
1295
1686
  }
1296
-
1297
- if (attempt === 1) {
1298
- process.stderr.write('Waiting for git index.lock to clear…\n')
1299
- }
1300
- clearStaleIndexLock(cwd)
1301
1687
  sleepSync(INDEX_LOCK_WAIT_MS * attempt)
1302
1688
  }
1303
1689
  }
1304
1690
 
1691
+ function retryIndexLock(cwd, attempt, stderr) {
1692
+ if (!isIndexLockError(stderr) || attempt === INDEX_LOCK_RETRIES) return false
1693
+ if (attempt === 1) {
1694
+ process.stderr.write('Waiting for git index.lock to clear…\n')
1695
+ }
1696
+ clearStaleIndexLock(cwd)
1697
+ return true
1698
+ }
1699
+
1700
+ function gitOk(cwd, args) {
1701
+ return spawnSync('git', args, { cwd: cwd, encoding: 'utf8' }).status === 0
1702
+ }
1703
+
1704
+ function forEachBatch(items, visit) {
1705
+ for (let index = 0; index < items.length; index += GIT_PATH_BATCH) {
1706
+ if (visit(items.slice(index, index + GIT_PATH_BATCH)) === false) return
1707
+ }
1708
+ }
1709
+
1305
1710
  function out(cwd, args) {
1306
1711
  return (git(cwd, args).stdout || '').trim()
1307
1712
  }
@@ -1309,3 +1714,22 @@ function out(cwd, args) {
1309
1714
  function lines(cwd, args) {
1310
1715
  return out(cwd, args).split('\n').filter(Boolean)
1311
1716
  }
1717
+
1718
+ function linesFromPaths(cwd, prefix, files) {
1719
+ if (!files.length) return []
1720
+ const chunks = []
1721
+ forEachBatch(files, (batch) => {
1722
+ const text = out(cwd, [...prefix, '--', ...batch])
1723
+ if (text) chunks.push(text)
1724
+ })
1725
+ return chunks.join('\n').split('\n').filter(Boolean)
1726
+ }
1727
+
1728
+ function tryOut(cwd, args) {
1729
+ try {
1730
+ return out(cwd, args)
1731
+ } catch (error) {
1732
+ if (/ENOBUFS|maxBuffer/i.test(String(error?.message || ''))) return ''
1733
+ throw error
1734
+ }
1735
+ }