@itcase/config 1.6.72 → 1.6.73

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.
@@ -16,7 +16,44 @@ export const commonRules = {
16
16
  }
17
17
 
18
18
  export const promptSubjectDescription =
19
- 'Write a short, imperative tense description of the change'
19
+ 'Short imperative description in lower-case (no camelCase: use "public dir" not "publicDir")'
20
+
21
+ export const promptSubjectRules = [
22
+ 'lower-case only (commitlint subject-case)',
23
+ 'no camelCase: write "public dir", not "publicDir"',
24
+ 'imperative mood, no trailing period',
25
+ 'single line — no body or footer',
26
+ ]
27
+
28
+ /**
29
+ * Splits camelCase/PascalCase words so subject-case lower-case can pass.
30
+ * @param {string} subject
31
+ * @returns {string}
32
+ */
33
+ export function normalizeSubjectCase(subject) {
34
+ return String(subject || '')
35
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
36
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
37
+ .toLowerCase()
38
+ .replace(/\s+/g, ' ')
39
+ .trim()
40
+ }
41
+
42
+ /**
43
+ * Normalizes full conventional commit header for commitlint subject-case.
44
+ * @param {string} message
45
+ * @returns {string}
46
+ */
47
+ export function normalizeCommitSubject(message) {
48
+ const header = String(message || '')
49
+ .split('\n')[0]
50
+ .trim()
51
+ const match = header.match(/^(\w+)\(([^)]+)\):\s*(.+)$/)
52
+ if (!match) return header
53
+
54
+ const [, type, scope, subject] = match
55
+ return `${type}(${scope}): ${normalizeSubjectCase(subject)}`
56
+ }
20
57
 
21
58
  /**
22
59
  * Builds flat scope-enum list from SCOPES.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itcase/config",
3
- "version": "1.6.72",
3
+ "version": "1.6.73",
4
4
  "author": "ITCase",
5
5
  "description": "ITCase Config",
6
6
  "engines": {
@@ -6,9 +6,15 @@ import { stdin as input, stdout as output } from 'node:process'
6
6
  import readline from 'node:readline/promises'
7
7
  import { fileURLToPath, pathToFileURL } from 'node:url'
8
8
 
9
+ import lint from '@commitlint/lint'
10
+ import load from '@commitlint/load'
9
11
  import { logUpdateStderr } from 'log-update'
10
12
 
11
- import { buildScopeEnum } from '../commitlint/commitlintCommon.js'
13
+ import {
14
+ buildScopeEnum,
15
+ normalizeCommitSubject,
16
+ promptSubjectRules,
17
+ } from '../commitlint/commitlintCommon.js'
12
18
  import { getBananaFrame } from './dancing-banana.js'
13
19
 
14
20
  const PACKAGE_NAME = '@itcase/config'
@@ -76,6 +82,7 @@ async function main(argv) {
76
82
  ),
77
83
  )
78
84
  const plans = parsePlan(raw, files)
85
+ await validateAndNormalizePlans(cwd, plans)
79
86
 
80
87
  process.stdout.write('\nCommits:\n')
81
88
  for (const plan of plans) process.stdout.write(` • ${plan.message}\n`)
@@ -90,16 +97,45 @@ async function main(argv) {
90
97
  return
91
98
  }
92
99
 
93
- for (const plan of plans) {
100
+ const activePlans = plans
101
+ .map((plan) => ({
102
+ ...plan,
103
+ files: plan.files.filter((file) => fileHasChanges(cwd, file)),
104
+ }))
105
+ .filter((plan) => plan.files.length)
106
+
107
+ if (!activePlans.length) {
108
+ process.stdout.write('No changes left to commit (already committed?).\n')
109
+ return
110
+ }
111
+
112
+ if (activePlans.length !== plans.length) {
113
+ process.stdout.write(
114
+ `Skipping ${plans.length - activePlans.length} empty commit(s).\n`,
115
+ )
116
+ }
117
+
118
+ let created = 0
119
+ for (const plan of activePlans) {
94
120
  git(cwd, ['add', '--', ...plan.files])
121
+ const staged = lines(cwd, ['diff', '--cached', '--name-only'])
122
+ const stagedForPlan = plan.files.filter((file) => staged.includes(file))
123
+ if (!stagedForPlan.length) {
124
+ throw new Error(
125
+ `Nothing staged for commit: ${plan.message}\n` +
126
+ `Files: ${plan.files.join(', ')}`,
127
+ )
128
+ }
129
+
95
130
  const commit = spawnSync('git', ['commit', '-m', plan.message], {
96
131
  cwd: cwd,
97
132
  encoding: 'utf8',
98
133
  stdio: ['ignore', 'inherit', 'inherit'],
99
134
  })
100
135
  if (commit.status) throw new Error(`git commit failed: ${plan.message}`)
136
+ created++
101
137
  }
102
- process.stdout.write(`Created ${plans.length} commit(s).\n`)
138
+ process.stdout.write(`Created ${created} commit(s).\n`)
103
139
  }
104
140
 
105
141
  function parseArgs(argv) {
@@ -267,7 +303,9 @@ function buildPrompt(scopes, types, files, cwd, type) {
267
303
  Return ONLY JSON: {"commits":[{"message":"type(scope): subject","files":["a.ts"]}]}
268
304
  Types: ${types.join(', ')}
269
305
  Scopes (exact): ${buildScopeEnum(scopes).join(', ')}
270
- Subject: short, imperative, lower-case. Single line only — no body, footer, or Co-authored-by.
306
+ Subject rules:
307
+ ${promptSubjectRules.map((rule) => `- ${rule}`).join('\n')}
308
+ Prefer one commit when changes belong to the same feature.
271
309
  Split by scope. Each listed file in exactly one commit.
272
310
  Do not add Co-authored-by or Made-with trailers.
273
311
  ${type ? `Prefer type "${type}".\n` : ''}SCOPES: ${JSON.stringify(scopes)}
@@ -491,7 +529,49 @@ function normalizeCommitMessage(message) {
491
529
  .filter((line) => !/^Made-with:/i.test(line))
492
530
 
493
531
  const subject = (lines[0] || '').replace(/\s+Co-authored-by:.*$/i, '').trim()
494
- return subject
532
+ return normalizeCommitSubject(subject)
533
+ }
534
+
535
+ async function validateAndNormalizePlans(cwd, plans) {
536
+ const loaded = await load({}, cwd)
537
+ const lintOptions = {
538
+ defaultIgnores: loaded.defaultIgnores,
539
+ ignores: loaded.ignores,
540
+ parserPreset: loaded.parserPreset,
541
+ }
542
+
543
+ for (const plan of plans) {
544
+ plan.message = normalizeCommitSubject(plan.message)
545
+ const report = await lint(plan.message, loaded.rules, lintOptions)
546
+ if (report.valid) continue
547
+
548
+ const errors =
549
+ report.errors?.map((error) => error.message).join('; ') || 'invalid'
550
+ throw new Error(
551
+ `Commit message failed commitlint:\n ${plan.message}\n ${errors}\n` +
552
+ 'Use lower-case subject without camelCase (e.g. "public dir" not "publicDir").',
553
+ )
554
+ }
555
+ }
556
+
557
+ function fileHasChanges(cwd, file) {
558
+ const filePath = path.join(cwd, file)
559
+ if (!fs.existsSync(filePath)) return false
560
+
561
+ const tracked =
562
+ spawnSync('git', ['ls-files', '--error-unmatch', '--', file], {
563
+ cwd: cwd,
564
+ encoding: 'utf8',
565
+ }).status === 0
566
+
567
+ if (!tracked) return true
568
+
569
+ return (
570
+ spawnSync('git', ['diff', '--quiet', 'HEAD', '--', file], {
571
+ cwd: cwd,
572
+ encoding: 'utf8',
573
+ }).status === 1
574
+ )
495
575
  }
496
576
 
497
577
  async function confirm(count) {