@itcase/config 1.7.24 → 1.7.26

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 (2) hide show
  1. package/package.json +16 -16
  2. package/smart-commit/cli.js +248 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itcase/config",
3
- "version": "1.7.24",
3
+ "version": "1.7.26",
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.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",
46
+ "@lerna-lite/cli": "^5.6.3",
47
+ "@lerna-lite/exec": "^5.6.3",
48
+ "@lerna-lite/list": "^5.6.3",
49
+ "@lerna-lite/run": "^5.6.3",
50
+ "@lerna-lite/watch": "^5.6.3",
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,11 +68,11 @@
68
68
  "commitizen": "^4.3.2",
69
69
  "conventional-changelog-conventionalcommits": "9.3.1",
70
70
  "conventional-changelog-writer": "9.3.0",
71
- "cssnano": "^9.3.2",
72
- "cssnano-preset-default": "^9.3.2",
71
+ "cssnano": "^9.8.0",
72
+ "cssnano-preset-default": "^9.8.0",
73
73
  "inquirer": "^14.2.3",
74
74
  "log-update": "^8.0.0",
75
- "postcss": "^8.5.29",
75
+ "postcss": "^8.5.30",
76
76
  "postcss-aspect-ratio-polyfill": "^2.0.0",
77
77
  "postcss-clamp": "^4.1.0",
78
78
  "postcss-cli": "^12.0.0",
@@ -80,19 +80,19 @@
80
80
  "postcss-combine-duplicated-selectors": "^11.0.0",
81
81
  "postcss-convert-unit": "^0.1.3",
82
82
  "postcss-dark-theme-class": "^2.0.0",
83
- "postcss-discard-duplicates": "^9.0.5",
83
+ "postcss-discard-duplicates": "^9.0.7",
84
84
  "postcss-each": "^1.1.0",
85
85
  "postcss-easings": "^4.0.0",
86
86
  "postcss-extend-rule": "^4.0.0",
87
87
  "postcss-flexbugs-fixes": "^5.0.2",
88
88
  "postcss-hexrgba": "^2.1.0",
89
- "postcss-import": "^17.0.0",
89
+ "postcss-import": "^17.0.1",
90
90
  "postcss-import-ext-glob": "^2.1.1",
91
91
  "postcss-loader": "^8.2.1",
92
92
  "postcss-merge-at-rules": "^2.0.1",
93
93
  "postcss-mixins": "^12.1.2",
94
94
  "postcss-mq-extract": "^1.0.0",
95
- "postcss-nested": "^8.0.1",
95
+ "postcss-nested": "^9.0.0",
96
96
  "postcss-nested-ancestors": "^3.0.0",
97
97
  "postcss-normalize": "^13.0.1",
98
98
  "postcss-prepend-imports": "^1.0.1",
@@ -110,17 +110,17 @@
110
110
  "rollup-plugin-peer-deps-external": "^2.2.4",
111
111
  "rollup-preserve-directives": "^1.1.3",
112
112
  "semantic-release": "^25.0.9",
113
- "semantic-release-lerna": "^3.6.5"
113
+ "semantic-release-lerna": "^3.6.7"
114
114
  },
115
115
  "devDependencies": {
116
116
  "@itcase/agent-skills": "^1.0.13",
117
- "@itcase/lint": "^1.1.126",
117
+ "@itcase/lint": "^1.1.130",
118
118
  "eslint": "9.39.2",
119
119
  "husky": "^9.1.7",
120
120
  "inquirer": "^14.2.3",
121
121
  "lint-staged": "^17.6.0",
122
- "prettier": "^3.9.9",
123
- "storybook": "^10.6.1",
122
+ "prettier": "^3.9.10",
123
+ "storybook": "^10.6.2",
124
124
  "stylelint": "^17.16.0",
125
125
  "typescript": "^6.0.3"
126
126
  }
@@ -24,10 +24,18 @@ const CLI_RELATIVE = 'smart-commit/cli.js'
24
24
  // Cheap Cursor Models pool — commit messages do not need Auto / frontier models.
25
25
  const DEFAULT_MODEL = 'composer-2.5'
26
26
 
27
- const MAX_DIFF = 80_000
27
+ // A small change set still sends the patch. A large tree used to be many
28
+ // agent calls with up to 80k chars of diff each. Compact numstat keeps that
29
+ // to one call.
30
+ const MAX_DIFF = 20_000
31
+ const COMPACT_SUMMARY_MAX = 12_000
32
+ const SMALL_PLAN_FILES = 12
33
+ const PER_FILE_EXCERPT = 500
34
+ const EXCERPT_SKIP_LINES = 400
35
+ const EXCERPT_MAX_BYTES = 256 * 1024
28
36
  const GIT_MAX_BUFFER = 32 * 1024 * 1024
29
37
  const GIT_PATH_BATCH = 40
30
- const PLAN_FILE_BATCH = 40
38
+ const PLAN_FILE_BATCH = 250
31
39
  const ARG_SAFE = 100_000
32
40
  const LOCKFILE_NAMES = new Set([
33
41
  'package-lock.json',
@@ -221,10 +229,17 @@ async function main(argv) {
221
229
 
222
230
  // Drop leftover index entries so only this plan's files are committed.
223
231
  git(cwd, ['reset', '-q', 'HEAD'])
224
- stageFiles(cwd, filesToStage)
232
+ const skippedOnStage = stageFiles(cwd, filesToStage)
225
233
  const staged = lines(cwd, ['diff', '--cached', '--name-only'])
226
234
  const stagedForPlan = filesToStage.filter((file) => staged.includes(file))
227
235
  if (!stagedForPlan.length) {
236
+ const skipped = new Set(skippedOnStage)
237
+ // Ignored paths were not staged on purpose. Stop this commit and keep
238
+ // going — one ignored directory must not drop the rest of the run.
239
+ if (filesToStage.every((file) => skipped.has(file))) {
240
+ process.stdout.write(`${dim(` skip ignored: ${plan.message}`)}\n`)
241
+ continue
242
+ }
228
243
  throw new Error(
229
244
  `Nothing staged for commit: ${plan.message}\n` +
230
245
  `Files: ${filesToStage.join(', ')}`,
@@ -601,8 +616,10 @@ function buildPrompt(scopes, types, files, cwd, type) {
601
616
 
602
617
  const sourceFiles = files.filter((file) => !isLockfile(file))
603
618
  const lockFiles = files.filter((file) => isLockfile(file))
619
+ const diffLimit =
620
+ sourceFiles.length > SMALL_PLAN_FILES ? COMPACT_SUMMARY_MAX : MAX_DIFF
604
621
  let diff = buildSourceDiff(cwd, sourceFiles)
605
- if (diff.length > MAX_DIFF) diff = `${diff.slice(0, MAX_DIFF)}\n…truncated`
622
+ if (diff.length > diffLimit) diff = `${diff.slice(0, diffLimit)}\n…truncated`
606
623
 
607
624
  return `Plan git commits. Do not edit files or run git commit.
608
625
  Return ONLY JSON: {"commits":[{"message":"type(scope): subject","files":["a.ts"]}]}
@@ -615,6 +632,7 @@ Prefer one commit when changes belong to the same feature.
615
632
  Split by scope. Each listed file in exactly one commit — including deleted files (D) and new/untracked files (??).
616
633
  Put each lockfile in the same commit as its sibling package.json. Do not make a lockfile-only commit.
617
634
  Do not add Co-authored-by or Made-with trailers.
635
+ Do not use tools or read files. The file list and diff below are the only source.
618
636
  ${type ? `Prefer type "${type}".\n` : ''}Files:
619
637
  ${files
620
638
  .map((file) => {
@@ -637,6 +655,11 @@ ${diff || '(none)'}
637
655
  }
638
656
 
639
657
  function buildSourceDiff(cwd, files) {
658
+ if (files.length > SMALL_PLAN_FILES) return buildCompactSourceDiff(cwd, files)
659
+ return buildFullSourceDiff(cwd, files, MAX_DIFF)
660
+ }
661
+
662
+ function buildFullSourceDiff(cwd, files, limit) {
640
663
  const tracked = []
641
664
  const untracked = []
642
665
  for (const file of files) {
@@ -647,13 +670,13 @@ function buildSourceDiff(cwd, files) {
647
670
  const parts = []
648
671
  let used = 0
649
672
  const pushLimited = (text) => {
650
- if (used >= MAX_DIFF) return false
673
+ if (used >= limit) return false
651
674
  // Empty batch (no changes / ENOBUFS skip) — keep scanning remaining files.
652
675
  if (!text) return true
653
- const room = MAX_DIFF - used
676
+ const room = limit - used
654
677
  if (text.length > room) {
655
678
  parts.push(`${text.slice(0, room)}\n…truncated`)
656
- used = MAX_DIFF
679
+ used = limit
657
680
  return false
658
681
  }
659
682
  parts.push(text)
@@ -662,17 +685,120 @@ function buildSourceDiff(cwd, files) {
662
685
  }
663
686
 
664
687
  forEachBatch(tracked, (batch) => {
665
- if (used >= MAX_DIFF) return false
688
+ if (used >= limit) return false
666
689
  return pushLimited(tryOut(cwd, ['diff', 'HEAD', '--', ...batch]))
667
690
  })
668
691
  for (const file of untracked) {
669
- if (used >= MAX_DIFF) break
692
+ if (used >= limit) break
670
693
  const piece = buildUntrackedDiff(cwd, file)
671
694
  if (!pushLimited(piece)) break
672
695
  }
673
696
  return parts.join('\n\n')
674
697
  }
675
698
 
699
+ function buildCompactSourceDiff(cwd, files) {
700
+ const tracked = []
701
+ const untracked = new Set()
702
+ for (const file of files) {
703
+ if (isUntracked(cwd, file)) untracked.add(file)
704
+ else tracked.push(file)
705
+ }
706
+
707
+ const statLines = []
708
+ forEachBatch(tracked, (batch) => {
709
+ const text = tryOut(cwd, ['diff', '--numstat', 'HEAD', '--', ...batch])
710
+ if (text) statLines.push(...text.split('\n').filter(Boolean))
711
+ })
712
+ for (const file of untracked) statLines.push(untrackedNumstatLine(cwd, file))
713
+
714
+ const weightByFile = new Map()
715
+ for (const line of statLines) {
716
+ const file = line.split('\t').slice(2).join('\t')
717
+ weightByFile.set(file, parseNumstatWeight(line))
718
+ }
719
+
720
+ const header = `numstat (added, removed, path):\n${statLines.join('\n')}`
721
+ if (header.length >= COMPACT_SUMMARY_MAX) {
722
+ return `${header.slice(0, COMPACT_SUMMARY_MAX)}\n…truncated`
723
+ }
724
+
725
+ const ranked = [...files].sort((left, right) => {
726
+ const leftWeight = weightByFile.get(left)
727
+ const rightWeight = weightByFile.get(right)
728
+ return (rightWeight ?? -1) - (leftWeight ?? -1)
729
+ })
730
+
731
+ const excerptHeader = '\n\nchanged lines:\n'
732
+ let room = COMPACT_SUMMARY_MAX - header.length - excerptHeader.length
733
+ const excerpts = []
734
+ for (const file of ranked) {
735
+ if (room < 40) break
736
+ const weight = weightByFile.get(file)
737
+ if (weight == null || weight > EXCERPT_SKIP_LINES) continue
738
+ if (weight === 0 && !untracked.has(file)) continue
739
+ const excerpt = changedLineExcerpt(
740
+ cwd,
741
+ file,
742
+ untracked.has(file),
743
+ Math.min(PER_FILE_EXCERPT, room),
744
+ )
745
+ if (!excerpt) continue
746
+ excerpts.push(excerpt)
747
+ room -= excerpt.length + 1
748
+ }
749
+
750
+ if (!excerpts.length) return header
751
+ return `${header}${excerptHeader}${excerpts.join('\n')}`
752
+ }
753
+
754
+ function parseNumstatWeight(line) {
755
+ const [added, removed] = line.split('\t')
756
+ if (added === '-' || removed === '-') return null
757
+ return (Number(added) || 0) + (Number(removed) || 0)
758
+ }
759
+
760
+ function untrackedNumstatLine(cwd, file) {
761
+ const text = readTextFileHead(cwd, file)
762
+ if (text == null) return `-\t-\t${file}`
763
+ const lines = text ? text.split('\n').length : 0
764
+ return `${lines}\t0\t${file}`
765
+ }
766
+
767
+ function readTextFileHead(cwd, file) {
768
+ const filePath = path.join(cwd, file)
769
+ let stat
770
+ try {
771
+ stat = fs.statSync(filePath)
772
+ } catch {
773
+ return null
774
+ }
775
+ if (!stat.isFile() || stat.size > EXCERPT_MAX_BYTES) return null
776
+ const buffer = fs.readFileSync(filePath)
777
+ if (buffer.includes(0)) return null
778
+ return buffer.toString('utf8')
779
+ }
780
+
781
+ function changedLineExcerpt(cwd, file, untracked, cap) {
782
+ if (untracked) {
783
+ const text = readTextFileHead(cwd, file)
784
+ if (!text) return ''
785
+ const body = text.length > cap ? `${text.slice(0, cap)}…` : text
786
+ return `${file}:\n${body}`
787
+ }
788
+
789
+ const raw = tryOut(cwd, ['diff', '-U0', '--no-color', 'HEAD', '--', file])
790
+ if (!raw) return ''
791
+ const lines = []
792
+ for (const line of raw.split('\n')) {
793
+ if (line.startsWith('+++') || line.startsWith('---')) continue
794
+ if (line.startsWith('+') || line.startsWith('-')) lines.push(line)
795
+ }
796
+ if (!lines.length) return ''
797
+ let body = lines.join('\n')
798
+ if (body.length > cap) body = `${body.slice(0, cap)}…`
799
+ return `${file}:\n${body}`
800
+ }
801
+
676
802
  function buildUntrackedDiff(cwd, file) {
677
803
  const filePath = path.join(cwd, file)
678
804
  if (!fs.existsSync(filePath)) return ''
@@ -1548,13 +1674,28 @@ function isOnDisk(cwd, file) {
1548
1674
  function gitIgnoredFiles(cwd, files) {
1549
1675
  if (!files.length) return []
1550
1676
 
1551
- const result = spawnSync('git', ['check-ignore', '-z', '--stdin'], {
1677
+ // Without --no-index, check-ignore hides tracked files that match
1678
+ // gitignore. Those still stage, unless a parent directory is ignored:
1679
+ // `git add` then exits 1 and names the directory, not the file.
1680
+ const direct = pathsIgnoredByGit(cwd, files)
1681
+ const underIgnoredDirectory = filesUnderIgnoredDirectory(cwd, files)
1682
+ return [...new Set([...direct, ...underIgnoredDirectory])]
1683
+ }
1684
+
1685
+ function pathsIgnoredByGit(cwd, paths, option = {}) {
1686
+ if (!paths.length) return []
1687
+
1688
+ const args = ['check-ignore', '-z', '--stdin']
1689
+ if (option.noIndex) args.push('--no-index')
1690
+ const result = spawnSync('git', args, {
1691
+ maxBuffer: GIT_MAX_BUFFER,
1552
1692
  cwd: cwd,
1553
1693
  encoding: 'utf8',
1554
- input: `${files.join('\0')}\0`,
1694
+ input: `${paths.join('\0')}\0`,
1555
1695
  stdio: ['pipe', 'pipe', 'pipe'],
1556
1696
  })
1557
- // 0 — at least one path is ignored, 1 — none.
1697
+ // 0 — at least one path is ignored, 1 — none. Any other status is a
1698
+ // fatal; treat it as "nothing ignored" and let git add surface it.
1558
1699
  if (result.status !== 0) return []
1559
1700
 
1560
1701
  const ignored = new Set(
@@ -1562,7 +1703,67 @@ function gitIgnoredFiles(cwd, files) {
1562
1703
  .split('\0')
1563
1704
  .filter(Boolean),
1564
1705
  )
1565
- return files.filter((file) => ignored.has(file))
1706
+ return paths.filter((file) => ignored.has(file))
1707
+ }
1708
+
1709
+ function ancestorDirectories(file) {
1710
+ const ancestors = []
1711
+ let directory = path.posix.dirname(file)
1712
+ while (directory && directory !== '.') {
1713
+ ancestors.push(directory)
1714
+ directory = path.posix.dirname(directory)
1715
+ }
1716
+ return ancestors
1717
+ }
1718
+
1719
+ function filesUnderIgnoredDirectory(cwd, files) {
1720
+ const ancestorsByFile = new Map()
1721
+ const uniqueAncestors = []
1722
+ const seen = new Set()
1723
+ for (const file of files) {
1724
+ const ancestors = ancestorDirectories(file)
1725
+ ancestorsByFile.set(file, ancestors)
1726
+ for (const ancestor of ancestors) {
1727
+ if (seen.has(ancestor)) continue
1728
+ seen.add(ancestor)
1729
+ uniqueAncestors.push(ancestor)
1730
+ }
1731
+ }
1732
+
1733
+ const ignoredAncestors = new Set(
1734
+ pathsIgnoredByGit(cwd, uniqueAncestors, { noIndex: true }),
1735
+ )
1736
+ if (!ignoredAncestors.size) return []
1737
+
1738
+ return files.filter((file) =>
1739
+ ancestorsByFile
1740
+ .get(file)
1741
+ .some((ancestor) => ignoredAncestors.has(ancestor)),
1742
+ )
1743
+ }
1744
+
1745
+ function pathMatchesIgnored(file, ignoredPaths) {
1746
+ return ignoredPaths.some(
1747
+ (ignored) => file === ignored || file.startsWith(`${ignored}/`),
1748
+ )
1749
+ }
1750
+
1751
+ function ignoredPathsFromGitAdd(stderr) {
1752
+ const header =
1753
+ 'The following paths are ignored by one of your .gitignore files:'
1754
+ const start = String(stderr || '').indexOf(header)
1755
+ if (start < 0) return []
1756
+
1757
+ const paths = []
1758
+ for (const line of String(stderr)
1759
+ .slice(start + header.length)
1760
+ .split('\n')) {
1761
+ const trimmed = line.trim().replace(/\/+$/, '')
1762
+ if (!trimmed) continue
1763
+ if (trimmed.startsWith('hint:')) break
1764
+ paths.push(trimmed)
1765
+ }
1766
+ return paths
1566
1767
  }
1567
1768
 
1568
1769
  function isTracked(cwd, file) {
@@ -1631,14 +1832,17 @@ function includeHookLockfiles(cwd, planFiles, dirtyBefore) {
1631
1832
  }
1632
1833
 
1633
1834
  function stageFiles(cwd, files) {
1634
- if (!files.length) return
1835
+ if (!files.length) return []
1635
1836
 
1636
- // `git add -- ignored/path` exits 1 and abandons the rest of the batch.
1637
- // Tracked files that also match gitignore still stage; check-ignore
1638
- // omits them unless --no-index is passed.
1837
+ // `git add -- path/inside/ignored-dir` exits 1 and names the directory,
1838
+ // after it has already updated the index. Drop those paths first. If git
1839
+ // add still reports an ignored directory, take those paths back out and
1840
+ // keep the rest of the batch. Tracked files that match gitignore but do
1841
+ // not sit inside an ignored directory still stage.
1639
1842
  const ignored = new Set(gitIgnoredFiles(cwd, files))
1640
1843
  const stageableFiles = files.filter((file) => !ignored.has(file))
1641
- if (!stageableFiles.length) return
1844
+ const skipped = [...ignored]
1845
+ if (!stageableFiles.length) return skipped
1642
1846
 
1643
1847
  const existing = []
1644
1848
  const removed = []
@@ -1650,11 +1854,33 @@ function stageFiles(cwd, files) {
1650
1854
  // `git add -A -- deleted/path` warns or misses the deletion when the
1651
1855
  // parent directory is already gone. `git rm` stages the index removal.
1652
1856
  forEachBatch(existing, (batch) => {
1653
- git(cwd, ['add', '-A', '--', ...batch])
1857
+ const result = git(cwd, ['add', '-A', '--', ...batch], {
1858
+ allowIgnored: true,
1859
+ })
1860
+ if (!result.status) return
1861
+
1862
+ const ignoredPaths = ignoredPathsFromGitAdd(result.stderr || '')
1863
+ const blocked = batch.filter((file) =>
1864
+ pathMatchesIgnored(file, ignoredPaths),
1865
+ )
1866
+ if (!blocked.length) {
1867
+ throw new Error(
1868
+ (result.stderr || '').trim() || `git add -A -- ${batch.join(' ')}`,
1869
+ )
1870
+ }
1871
+
1872
+ // git add writes the index before returning 1, including tracked files
1873
+ // under the ignored directory. Take those back out of this commit.
1874
+ git(cwd, ['reset', '-q', 'HEAD', '--', ...blocked])
1875
+ skipped.push(...blocked)
1876
+ process.stdout.write(
1877
+ `${dim(` skipped (gitignore): ${blocked.join(', ')}`)}\n`,
1878
+ )
1654
1879
  })
1655
1880
  forEachBatch(removed, (batch) => {
1656
1881
  git(cwd, ['rm', '--ignore-unmatch', '-q', '--', ...batch])
1657
1882
  })
1883
+ return skipped
1658
1884
  }
1659
1885
 
1660
1886
  async function confirm(count) {
@@ -1899,6 +2125,9 @@ function git(cwd, args, option = {}) {
1899
2125
 
1900
2126
  const stderr = result.stderr || ''
1901
2127
  if (!retryIndexLock(cwd, attempt, stderr)) {
2128
+ if (option.allowIgnored && ignoredPathsFromGitAdd(stderr).length) {
2129
+ return result
2130
+ }
1902
2131
  if (option.inherit && stderr) process.stderr.write(stderr)
1903
2132
  throw new Error(stderr.trim() || `git ${args.join(' ')}`)
1904
2133
  }