@itcase/config 1.6.78 → 1.6.79

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/README.md CHANGED
@@ -87,7 +87,7 @@ npm install --save-dev commitizen @commitlint/cz-commitlint
87
87
 
88
88
  ## Smart commit
89
89
 
90
- `smart-commit` — Cursor Agent (`ask`) планирует коммиты по `SCOPES`, печатает сообщения, спрашивает `[y/N]`. Перед коммитом ждёт и снимает устаревший `.git/index.lock`, если его никто не держит.
90
+ `smart-commit` — Cursor Agent (`ask`) планирует коммиты по `SCOPES`, печатает сообщения, спрашивает `[y/N]`. Перед коммитом ждёт и снимает устаревший `.git/index.lock`, если его никто не держит. Lockfile (`package-lock.json` и аналоги) всегда кладётся в тот же коммит, что и соседний `package.json`; при одном только bump deps агент не вызывается.
91
91
 
92
92
  ```text
93
93
  [yellow pixel banana animation]
@@ -9,8 +9,11 @@ export default {
9
9
  'package.json': (files) => {
10
10
  // Проверяем, есть ли package.json в корне репозитория
11
11
  const rootPackageJson = resolve(process.cwd(), 'package.json')
12
- if (existsSync(rootPackageJson) && files.includes(rootPackageJson)) {
13
- return 'npm install --package-lock-only'
12
+ const isRoot = files.some((file) => resolve(file) === rootPackageJson)
13
+ if (existsSync(rootPackageJson) && isRoot) {
14
+ // lock пишется командой, но glob только package.json — без git add
15
+ // он останется вне коммита (типичный leftover после bump deps)
16
+ return ['npm install --package-lock-only', 'git add -- package-lock.json']
14
17
  }
15
18
  return 'true' // Пропускаем, если не найден
16
19
  },
@@ -17,8 +17,11 @@ export default {
17
17
  'package.json': (files) => {
18
18
  // Проверяем, есть ли package.json в корне репозитория
19
19
  const rootPackageJson = resolve(process.cwd(), 'package.json')
20
- if (existsSync(rootPackageJson) && files.includes(rootPackageJson)) {
21
- return 'npm install --package-lock-only'
20
+ const isRoot = files.some((file) => resolve(file) === rootPackageJson)
21
+ if (existsSync(rootPackageJson) && isRoot) {
22
+ // lock пишется командой, но glob только package.json — без git add
23
+ // он останется вне коммита (типичный leftover после bump deps)
24
+ return ['npm install --package-lock-only', 'git add -- package-lock.json']
22
25
  }
23
26
  return 'true' // Пропускаем, если не найден
24
27
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itcase/config",
3
- "version": "1.6.78",
3
+ "version": "1.6.79",
4
4
  "author": "ITCase",
5
5
  "description": "ITCase Config",
6
6
  "engines": {
@@ -22,6 +22,14 @@ const PACKAGE_NAME = '@itcase/config'
22
22
  const CLI_RELATIVE = 'smart-commit/cli.js'
23
23
 
24
24
  const MAX_DIFF = 80_000
25
+ const LOCKFILE_NAMES = new Set([
26
+ 'package-lock.json',
27
+ 'npm-shrinkwrap.json',
28
+ 'yarn.lock',
29
+ 'pnpm-lock.yaml',
30
+ 'bun.lock',
31
+ 'bun.lockb',
32
+ ])
25
33
  const AGENT_TIMEOUT_MS = 120_000
26
34
  const AGENT_STEP_MS = 2_500
27
35
  const NYAN_FRAME_MS = 90
@@ -77,24 +85,28 @@ async function main(argv) {
77
85
  return
78
86
  }
79
87
 
80
- const prompt = buildPrompt(scopes, types, changedFiles, cwd, option.type)
81
- const raw = await withAgentProgress(
82
- {
83
- fileCount: changedFiles.length,
84
- sprite: pickSprite(option.sprite),
85
- verbose: option.verbose,
86
- },
87
- (progress) =>
88
- askAgent(
89
- cwd,
90
- option.model,
91
- prompt,
92
- option.verbose,
93
- progress.onAgentActivity,
94
- ),
95
- )
96
- let plans = parsePlan(raw, changedFiles)
97
- plans = assignOmittedFiles(plans, changedFiles)
88
+ let plans = tryFastPlan(changedFiles, cwd, scopes, types)
89
+ if (!plans) {
90
+ const prompt = buildPrompt(scopes, types, changedFiles, cwd, option.type)
91
+ const raw = await withAgentProgress(
92
+ {
93
+ fileCount: changedFiles.length,
94
+ sprite: pickSprite(option.sprite),
95
+ verbose: option.verbose,
96
+ },
97
+ (progress) =>
98
+ askAgent(
99
+ cwd,
100
+ option.model || process.env.SMART_COMMIT_MODEL || '',
101
+ prompt,
102
+ option.verbose,
103
+ progress.onAgentActivity,
104
+ ),
105
+ )
106
+ plans = parsePlan(raw, changedFiles)
107
+ plans = assignOmittedFiles(plans, changedFiles)
108
+ plans = attachLockfiles(plans, changedFiles)
109
+ }
98
110
  await validateAndNormalizePlans(cwd, plans)
99
111
 
100
112
  process.stdout.write('\nCommits:\n')
@@ -332,6 +344,15 @@ function listFiles(cwd, option) {
332
344
  ]
333
345
  }
334
346
 
347
+ function isLockfile(file) {
348
+ return LOCKFILE_NAMES.has(path.basename(file))
349
+ }
350
+
351
+ function siblingManifest(file) {
352
+ const directory = path.posix.dirname(file)
353
+ return directory === '.' ? 'package.json' : `${directory}/package.json`
354
+ }
355
+
335
356
  function buildPrompt(scopes, types, files, cwd, type) {
336
357
  const statusByFile = new Map(
337
358
  lines(cwd, ['status', '--short', '--porcelain', '--', ...files]).map(
@@ -339,10 +360,9 @@ function buildPrompt(scopes, types, files, cwd, type) {
339
360
  ),
340
361
  )
341
362
 
342
- let diff = files
343
- .map((file) => buildFileDiff(cwd, file))
344
- .filter(Boolean)
345
- .join('\n\n')
363
+ const sourceFiles = files.filter((file) => !isLockfile(file))
364
+ const lockFiles = files.filter((file) => isLockfile(file))
365
+ let diff = buildSourceDiff(cwd, sourceFiles)
346
366
  if (diff.length > MAX_DIFF) diff = `${diff.slice(0, MAX_DIFF)}\n…truncated`
347
367
 
348
368
  return `Plan git commits. Do not edit files or run git commit.
@@ -353,46 +373,57 @@ Subject rules:
353
373
  ${promptSubjectRules.map((rule) => `- ${rule}`).join('\n')}
354
374
  Prefer one commit when changes belong to the same feature.
355
375
  Split by scope. Each listed file in exactly one commit — including new/untracked files (??).
376
+ Put each lockfile in the same commit as its sibling package.json. Do not make a lockfile-only commit.
356
377
  Do not add Co-authored-by or Made-with trailers.
357
- ${type ? `Prefer type "${type}".\n` : ''}SCOPES: ${JSON.stringify(scopes)}
358
- Files:
378
+ ${type ? `Prefer type "${type}".\n` : ''}Files:
359
379
  ${files
360
380
  .map((file) => {
361
381
  const status =
362
382
  statusByFile.get(file) || (isUntracked(cwd, file) ? '??' : ' M')
363
- return `- ${file} (${status})`
383
+ const lockNote = isLockfile(file) ? ', lockfile' : ''
384
+ return `- ${file} (${status}${lockNote})`
364
385
  })
365
386
  .join('\n')}
366
- status:
367
- ${out(cwd, ['status', '--short', '--', ...files]) || '(empty)'}
368
- diff:
387
+ ${
388
+ lockFiles.length
389
+ ? `Lockfiles (no diff, keep with package.json):\n${lockFiles
390
+ .map((file) => `- ${file}`)
391
+ .join('\n')}\n`
392
+ : ''
393
+ }diff:
369
394
  ${diff || '(none)'}
370
395
  `
371
396
  }
372
397
 
373
- function buildFileDiff(cwd, file) {
374
- const filePath = path.join(cwd, file)
375
- if (!fs.existsSync(filePath)) {
376
- return (
377
- out(cwd, ['diff', '--cached', '--', file]) ||
378
- out(cwd, ['diff', '--', file])
379
- )
398
+ function buildSourceDiff(cwd, files) {
399
+ const tracked = []
400
+ const untracked = []
401
+ for (const file of files) {
402
+ if (isUntracked(cwd, file)) untracked.push(file)
403
+ else tracked.push(file)
380
404
  }
381
405
 
382
- if (isUntracked(cwd, file)) {
383
- const nullPath = process.platform === 'win32' ? 'NUL' : '/dev/null'
384
- const untrackedDiff = out(cwd, ['diff', '--no-index', nullPath, file])
385
- return untrackedDiff
386
- ? `new file ${file}:\n${untrackedDiff}`
387
- : `new file ${file}: (empty)`
406
+ const parts = []
407
+ if (tracked.length) {
408
+ const text = out(cwd, ['diff', 'HEAD', '--', ...tracked])
409
+ if (text) parts.push(text)
410
+ }
411
+ for (const file of untracked) {
412
+ const piece = buildUntrackedDiff(cwd, file)
413
+ if (piece) parts.push(piece)
388
414
  }
415
+ return parts.join('\n\n')
416
+ }
389
417
 
390
- return [
391
- out(cwd, ['diff', '--cached', '--', file]),
392
- out(cwd, ['diff', '--', file]),
393
- ]
394
- .filter(Boolean)
395
- .join('\n')
418
+ function buildUntrackedDiff(cwd, file) {
419
+ const filePath = path.join(cwd, file)
420
+ if (!fs.existsSync(filePath)) return ''
421
+
422
+ const nullPath = process.platform === 'win32' ? 'NUL' : '/dev/null'
423
+ const untrackedDiff = out(cwd, ['diff', '--no-index', nullPath, file])
424
+ return untrackedDiff
425
+ ? `new file ${file}:\n${untrackedDiff}`
426
+ : `new file ${file}: (empty)`
396
427
  }
397
428
 
398
429
  function buildAgentProgressSteps(fileCount) {
@@ -604,6 +635,104 @@ function parsePlan(raw, allowedFiles) {
604
635
  return plans
605
636
  }
606
637
 
638
+ function tryFastPlan(files, cwd, scopes, types) {
639
+ const sourceFiles = files.filter((file) => !isLockfile(file))
640
+ if (sourceFiles.length !== 1) return null
641
+ if (path.basename(sourceFiles[0]) !== 'package.json') return null
642
+
643
+ const message = inferDepsMessage(cwd, sourceFiles[0], scopes, types)
644
+ if (!message) return null
645
+
646
+ return [
647
+ {
648
+ message: message,
649
+ files: [...sourceFiles, ...files.filter(isLockfile)],
650
+ },
651
+ ]
652
+ }
653
+
654
+ function inferDepsMessage(cwd, file, scopes, types) {
655
+ const type = types.includes('deps')
656
+ ? 'deps'
657
+ : types.includes('chore')
658
+ ? 'chore'
659
+ : ''
660
+ if (!type) return ''
661
+
662
+ const names = changedDependencyNames(cwd, file)
663
+ if (!names?.length) return ''
664
+
665
+ const scope = pickScope(scopes, ['packages', 'deps', 'other'])
666
+ const subject =
667
+ names.length === 1
668
+ ? `bump ${names[0]}`
669
+ : names.length <= 3
670
+ ? `bump ${names.join(', ')}`
671
+ : 'bump dependencies'
672
+ return `${type}(${scope}): ${subject}`
673
+ }
674
+
675
+ function changedDependencyNames(cwd, file) {
676
+ const current = readJsonFile(path.join(cwd, file))
677
+ if (!current) return null
678
+
679
+ let previous = {}
680
+ const shown = spawnSync('git', ['show', `HEAD:${file}`], {
681
+ cwd: cwd,
682
+ encoding: 'utf8',
683
+ })
684
+ if (!shown.status && shown.stdout) {
685
+ try {
686
+ previous = JSON.parse(shown.stdout)
687
+ } catch {
688
+ return null
689
+ }
690
+ } else if (isUntracked(cwd, file)) {
691
+ return null
692
+ }
693
+
694
+ const sections = [
695
+ 'dependencies',
696
+ 'devDependencies',
697
+ 'optionalDependencies',
698
+ 'peerDependencies',
699
+ ]
700
+ const names = []
701
+ for (const key of sections) {
702
+ const before = previous[key] || {}
703
+ const after = current[key] || {}
704
+ for (const name of new Set([
705
+ ...Object.keys(before),
706
+ ...Object.keys(after),
707
+ ])) {
708
+ if (before[name] !== after[name]) names.push(name)
709
+ }
710
+ }
711
+
712
+ const keys = new Set([...Object.keys(previous), ...Object.keys(current)])
713
+ for (const key of keys) {
714
+ if (sections.includes(key)) continue
715
+ if (JSON.stringify(previous[key]) !== JSON.stringify(current[key])) {
716
+ return null
717
+ }
718
+ }
719
+
720
+ return names.length ? names : null
721
+ }
722
+
723
+ function readJsonFile(filePath) {
724
+ try {
725
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'))
726
+ } catch {
727
+ return null
728
+ }
729
+ }
730
+
731
+ function pickScope(scopes, preferred) {
732
+ const list = buildScopeEnum(scopes)
733
+ return preferred.find((scope) => list.includes(scope)) || list[0] || 'other'
734
+ }
735
+
607
736
  function assignOmittedFiles(plans, allowedFiles) {
608
737
  const assigned = new Set(plans.flatMap((plan) => plan.files))
609
738
  const missing = allowedFiles.filter((file) => !assigned.has(file))
@@ -619,6 +748,23 @@ function assignOmittedFiles(plans, allowedFiles) {
619
748
  return plans
620
749
  }
621
750
 
751
+ function attachLockfiles(plans, allowedFiles) {
752
+ const locks = allowedFiles.filter((file) => isLockfile(file))
753
+ if (!locks.length) return plans
754
+
755
+ for (const lock of locks) {
756
+ for (const plan of plans) {
757
+ plan.files = plan.files.filter((file) => file !== lock)
758
+ }
759
+ const manifest = siblingManifest(lock)
760
+ const withManifest = plans.find((plan) => plan.files.includes(manifest))
761
+ const target = withManifest || findBestCommitForFile(plans, lock)
762
+ target.files.push(lock)
763
+ }
764
+
765
+ return plans.filter((plan) => plan.files.length)
766
+ }
767
+
622
768
  function findBestCommitForFile(plans, file) {
623
769
  const fileDir = path.dirname(file)
624
770
 
@@ -803,6 +949,8 @@ function git(cwd, args, option = {}) {
803
949
  stdio: option.inherit ? ['ignore', 'inherit', 'pipe'] : undefined,
804
950
  })
805
951
  if (!result.status) return result
952
+ // git diff exits 1 when there are differences
953
+ if (result.status === 1 && args[0] === 'diff') return result
806
954
 
807
955
  const stderr = result.stderr || ''
808
956
  if (!isIndexLockError(stderr) || attempt === INDEX_LOCK_RETRIES) {