sentix 2.0.22 → 2.2.0
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/package.json +1 -1
- package/src/commands/init.js +27 -3
- package/src/commands/update.js +126 -88
- package/src/commands/version.js +36 -6
- package/src/lib/changelog.js +131 -36
package/package.json
CHANGED
package/src/commands/init.js
CHANGED
|
@@ -504,8 +504,16 @@ function generateGovernorDirective() {
|
|
|
504
504
|
# Sentix Governor — 필수 준수 사항
|
|
505
505
|
|
|
506
506
|
> **이 프로젝트는 Sentix 프레임워크로 관리된다.**
|
|
507
|
-
> **아래 규칙은 어떤 역할(/frontend, /backend 등)에서든 반드시 따라야 한다.**
|
|
508
|
-
|
|
507
|
+
> **아래 규칙은 어떤 역할(/frontend, /backend 등)에서든, 어떤 worktree에서든 반드시 따라야 한다.**
|
|
508
|
+
|
|
509
|
+
## 세션 시작 시 필수 읽기 (순서대로)
|
|
510
|
+
|
|
511
|
+
\`\`\`
|
|
512
|
+
1. CLAUDE.md (이 파일)
|
|
513
|
+
2. FRAMEWORK.md — 5-Layer 아키텍처, 에이전트 정의
|
|
514
|
+
3. docs/agent-methods.md — 에이전트별 메서드 순서 (필수 준수)
|
|
515
|
+
4. .sentix/rules/hard-rules.md — 파괴 방지 6개 규칙
|
|
516
|
+
\`\`\`
|
|
509
517
|
|
|
510
518
|
## 코드 수정 전 필수 절차
|
|
511
519
|
|
|
@@ -517,6 +525,22 @@ function generateGovernorDirective() {
|
|
|
517
525
|
4. 티켓 없이 코드 수정 금지 — sentix ticket create 또는 sentix feature add 사용
|
|
518
526
|
\`\`\`
|
|
519
527
|
|
|
528
|
+
## 에이전트 메서드 순서 (docs/agent-methods.md 필수 참조)
|
|
529
|
+
|
|
530
|
+
\`\`\`
|
|
531
|
+
planner: analyze() → research() → scope() → estimate() → emit()
|
|
532
|
+
→ WHAT/WHERE만 정의. HOW(구현 방법) 금지.
|
|
533
|
+
|
|
534
|
+
dev: snapshot() → implement() → test() → verify() → report()
|
|
535
|
+
→ 구현 방법은 dev가 결정. 품질 판단은 pr-review에 위임.
|
|
536
|
+
|
|
537
|
+
pr-review: diff() → validate() → grade() → calibrate() → verdict()
|
|
538
|
+
→ 회의적 판정. 의심스러우면 REJECTED.
|
|
539
|
+
|
|
540
|
+
dev-fix: diagnose() → fix() → test() → learn() → report()
|
|
541
|
+
→ LESSON_LEARNED 필수.
|
|
542
|
+
\`\`\`
|
|
543
|
+
|
|
520
544
|
## 파괴 방지 하드 룰 6개
|
|
521
545
|
|
|
522
546
|
1. 작업 전 테스트 스냅샷 필수
|
|
@@ -549,7 +573,7 @@ sentix ticket create "설명" # 버그 티켓 생성
|
|
|
549
573
|
sentix feature add "설명" # 기능 티켓 생성
|
|
550
574
|
sentix status # 상태 확인
|
|
551
575
|
sentix doctor # 설치 진단
|
|
552
|
-
sentix update # 프레임워크 최신화
|
|
576
|
+
sentix update # 프레임워크 최신화 (worktree도 root 포함)
|
|
553
577
|
\`\`\`
|
|
554
578
|
`;
|
|
555
579
|
}
|
package/src/commands/update.js
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
|
|
12
12
|
import { registerCommand } from '../registry.js';
|
|
13
13
|
import { VERSION } from '../version.js';
|
|
14
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
14
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
15
15
|
import { resolve, dirname } from 'node:path';
|
|
16
|
+
import { execSync } from 'node:child_process';
|
|
16
17
|
import { fileURLToPath } from 'node:url';
|
|
17
18
|
|
|
18
19
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -20,16 +21,129 @@ const sentixRoot = resolve(__dirname, '..', '..');
|
|
|
20
21
|
|
|
21
22
|
// 동기화 대상: 프레임워크 공통 파일 (모든 프로젝트가 동일해야 하는 것)
|
|
22
23
|
const SYNC_FILES = [
|
|
24
|
+
// CI/CD
|
|
23
25
|
{ src: '.github/workflows/deploy.yml', dst: '.github/workflows/deploy.yml' },
|
|
24
26
|
{ src: '.github/workflows/security-scan.yml', dst: '.github/workflows/security-scan.yml' },
|
|
27
|
+
|
|
28
|
+
// 하드 룰 + 검증
|
|
25
29
|
{ src: '.sentix/rules/hard-rules.md', dst: '.sentix/rules/hard-rules.md' },
|
|
30
|
+
{ src: 'scripts/pre-commit.js', dst: 'scripts/pre-commit.js' },
|
|
31
|
+
|
|
32
|
+
// 프레임워크 문서
|
|
26
33
|
{ src: 'FRAMEWORK.md', dst: 'FRAMEWORK.md' },
|
|
27
34
|
{ src: 'docs/governor-sop.md', dst: 'docs/governor-sop.md' },
|
|
28
35
|
{ src: 'docs/agent-scopes.md', dst: 'docs/agent-scopes.md' },
|
|
36
|
+
{ src: 'docs/agent-methods.md', dst: 'docs/agent-methods.md' },
|
|
29
37
|
{ src: 'docs/severity.md', dst: 'docs/severity.md' },
|
|
30
38
|
{ src: 'docs/architecture.md', dst: 'docs/architecture.md' },
|
|
39
|
+
|
|
40
|
+
// Claude Code 네이티브 에이전트
|
|
41
|
+
{ src: '.claude/settings.json', dst: '.claude/settings.json' },
|
|
42
|
+
{ src: '.claude/agents/planner.md', dst: '.claude/agents/planner.md' },
|
|
43
|
+
{ src: '.claude/agents/dev.md', dst: '.claude/agents/dev.md' },
|
|
44
|
+
{ src: '.claude/agents/pr-review.md', dst: '.claude/agents/pr-review.md' },
|
|
45
|
+
{ src: '.claude/agents/dev-fix.md', dst: '.claude/agents/dev-fix.md' },
|
|
46
|
+
{ src: '.claude/agents/security.md', dst: '.claude/agents/security.md' },
|
|
31
47
|
];
|
|
32
48
|
|
|
49
|
+
// ── Worktree 감지: 현재 위치 + main working tree ────────
|
|
50
|
+
|
|
51
|
+
function getUpdateTargets(ctx) {
|
|
52
|
+
const cwd = resolve(ctx.cwd);
|
|
53
|
+
const targets = [cwd];
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
// git worktree인지 확인
|
|
57
|
+
const gitCommonDir = execSync('git rev-parse --git-common-dir', {
|
|
58
|
+
cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
59
|
+
}).trim();
|
|
60
|
+
|
|
61
|
+
const mainGitDir = resolve(cwd, gitCommonDir);
|
|
62
|
+
|
|
63
|
+
// main working tree 경로 찾기
|
|
64
|
+
const worktreeList = execSync('git worktree list --porcelain', {
|
|
65
|
+
cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const mainMatch = worktreeList.match(/^worktree (.+)$/m);
|
|
69
|
+
if (mainMatch) {
|
|
70
|
+
const mainRoot = mainMatch[1].trim();
|
|
71
|
+
const resolvedMain = resolve(mainRoot);
|
|
72
|
+
if (resolvedMain !== cwd && existsSync(resolvedMain)) {
|
|
73
|
+
targets.push(resolvedMain);
|
|
74
|
+
ctx.log(`Worktree detected → also updating main: ${resolvedMain}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// git 없거나 worktree 아님 — 현재 디렉토리만
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return targets;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── 파일 동기화 실행 ─────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
async function syncFiles(targetDir, dryRun, ctx) {
|
|
87
|
+
const results = { updated: [], created: [], skipped: [], unchanged: [] };
|
|
88
|
+
|
|
89
|
+
for (const { src, dst } of SYNC_FILES) {
|
|
90
|
+
const srcPath = resolve(sentixRoot, src);
|
|
91
|
+
const dstPath = resolve(targetDir, dst);
|
|
92
|
+
|
|
93
|
+
if (!existsSync(srcPath)) {
|
|
94
|
+
results.skipped.push({ file: dst, reason: 'source not found' });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const srcContent = readFileSync(srcPath, 'utf-8');
|
|
99
|
+
|
|
100
|
+
if (existsSync(dstPath)) {
|
|
101
|
+
const dstContent = readFileSync(dstPath, 'utf-8');
|
|
102
|
+
|
|
103
|
+
if (srcContent === dstContent) {
|
|
104
|
+
results.unchanged.push(dst);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const srcLines = srcContent.split('\n');
|
|
109
|
+
const dstLines = dstContent.split('\n');
|
|
110
|
+
const added = srcLines.length - dstLines.length;
|
|
111
|
+
|
|
112
|
+
ctx.log(`${dryRun ? '[DRY] ' : ''}Updating: ${dst}`);
|
|
113
|
+
ctx.log(` ${dstLines.length} lines → ${srcLines.length} lines (${added >= 0 ? '+' : ''}${added})`);
|
|
114
|
+
|
|
115
|
+
if (!dryRun) {
|
|
116
|
+
mkdirSync(dirname(dstPath), { recursive: true });
|
|
117
|
+
writeFileSync(dstPath, srcContent);
|
|
118
|
+
ctx.success(`Updated: ${dst}`);
|
|
119
|
+
}
|
|
120
|
+
results.updated.push(dst);
|
|
121
|
+
} else {
|
|
122
|
+
ctx.log(`${dryRun ? '[DRY] ' : ''}Creating: ${dst}`);
|
|
123
|
+
if (!dryRun) {
|
|
124
|
+
mkdirSync(dirname(dstPath), { recursive: true });
|
|
125
|
+
writeFileSync(dstPath, srcContent);
|
|
126
|
+
ctx.success(`Created: ${dst}`);
|
|
127
|
+
}
|
|
128
|
+
results.created.push(dst);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 요약
|
|
133
|
+
const totalChanges = results.updated.length + results.created.length;
|
|
134
|
+
if (results.updated.length > 0) {
|
|
135
|
+
ctx.log(`Updated: ${results.updated.length} — ${results.updated.join(', ')}`);
|
|
136
|
+
}
|
|
137
|
+
if (results.created.length > 0) {
|
|
138
|
+
ctx.log(`Created: ${results.created.length} — ${results.created.join(', ')}`);
|
|
139
|
+
}
|
|
140
|
+
if (totalChanges === 0) {
|
|
141
|
+
ctx.success('Already up to date.');
|
|
142
|
+
} else if (!dryRun) {
|
|
143
|
+
ctx.success(`${totalChanges} file(s) updated to sentix v${VERSION}.`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
33
147
|
registerCommand('update', {
|
|
34
148
|
description: 'Update framework files to the latest sentix version',
|
|
35
149
|
usage: 'sentix update [--dry]',
|
|
@@ -39,9 +153,6 @@ registerCommand('update', {
|
|
|
39
153
|
|
|
40
154
|
ctx.log(`sentix update v${VERSION}`);
|
|
41
155
|
ctx.log(`source: ${sentixRoot}`);
|
|
42
|
-
ctx.log(`target: ${ctx.cwd}`);
|
|
43
|
-
if (dryRun) ctx.warn('DRY RUN — no files will be changed\n');
|
|
44
|
-
else ctx.log('');
|
|
45
156
|
|
|
46
157
|
// sentix 원본에서 실행 중인지 확인
|
|
47
158
|
if (resolve(ctx.cwd) === resolve(sentixRoot)) {
|
|
@@ -49,95 +160,22 @@ registerCommand('update', {
|
|
|
49
160
|
return;
|
|
50
161
|
}
|
|
51
162
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
ctx.error('This project has not been initialized with sentix.');
|
|
55
|
-
ctx.log('Run: sentix init');
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const results = { updated: [], created: [], skipped: [], unchanged: [] };
|
|
163
|
+
// 업데이트 대상 디렉토리 수집 (현재 + worktree면 root도)
|
|
164
|
+
const targets = getUpdateTargets(ctx);
|
|
60
165
|
|
|
61
|
-
for (const
|
|
62
|
-
|
|
166
|
+
for (const target of targets) {
|
|
167
|
+
ctx.log(`\n--- target: ${target} ---`);
|
|
168
|
+
if (dryRun) ctx.warn('DRY RUN\n');
|
|
63
169
|
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
170
|
+
// sentix가 초기화된 프로젝트인지 확인
|
|
171
|
+
const hasConfig = existsSync(resolve(target, '.sentix/config.toml'));
|
|
172
|
+
const hasClaude = existsSync(resolve(target, 'CLAUDE.md'));
|
|
173
|
+
if (!hasConfig && !hasClaude) {
|
|
174
|
+
ctx.warn(`Not a sentix project: ${target} — skipping`);
|
|
67
175
|
continue;
|
|
68
176
|
}
|
|
69
177
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (ctx.exists(dst)) {
|
|
73
|
-
const dstContent = await ctx.readFile(dst);
|
|
74
|
-
|
|
75
|
-
if (srcContent === dstContent) {
|
|
76
|
-
results.unchanged.push(dst);
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// diff 요약 생성
|
|
81
|
-
const srcLines = srcContent.split('\n');
|
|
82
|
-
const dstLines = dstContent.split('\n');
|
|
83
|
-
const added = srcLines.length - dstLines.length;
|
|
84
|
-
|
|
85
|
-
ctx.log(`${dryRun ? '[DRY] ' : ''}Updating: ${dst}`);
|
|
86
|
-
ctx.log(` ${dstLines.length} lines → ${srcLines.length} lines (${added >= 0 ? '+' : ''}${added})`);
|
|
87
|
-
|
|
88
|
-
// 주요 변경 내용 표시 (새로 추가된 라인 중 의미 있는 것)
|
|
89
|
-
const dstSet = new Set(dstLines.map(l => l.trim()));
|
|
90
|
-
const newLines = srcLines
|
|
91
|
-
.filter(l => l.trim() && !l.trim().startsWith('#') && !dstSet.has(l.trim()))
|
|
92
|
-
.slice(0, 5);
|
|
93
|
-
if (newLines.length > 0) {
|
|
94
|
-
ctx.log(' New:');
|
|
95
|
-
for (const line of newLines) {
|
|
96
|
-
ctx.log(` + ${line.trim().substring(0, 80)}`);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (!dryRun) {
|
|
101
|
-
await ctx.writeFile(dst, srcContent);
|
|
102
|
-
ctx.success(`Updated: ${dst}`);
|
|
103
|
-
}
|
|
104
|
-
results.updated.push(dst);
|
|
105
|
-
} else {
|
|
106
|
-
ctx.log(`${dryRun ? '[DRY] ' : ''}Creating: ${dst}`);
|
|
107
|
-
if (!dryRun) {
|
|
108
|
-
await ctx.writeFile(dst, srcContent);
|
|
109
|
-
ctx.success(`Created: ${dst}`);
|
|
110
|
-
}
|
|
111
|
-
results.created.push(dst);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// 요약
|
|
116
|
-
ctx.log('\n=== Update Summary ===');
|
|
117
|
-
if (results.updated.length > 0) {
|
|
118
|
-
ctx.log(`Updated: ${results.updated.length} file(s)`);
|
|
119
|
-
for (const f of results.updated) ctx.log(` ${f}`);
|
|
120
|
-
}
|
|
121
|
-
if (results.created.length > 0) {
|
|
122
|
-
ctx.log(`Created: ${results.created.length} file(s)`);
|
|
123
|
-
for (const f of results.created) ctx.log(` ${f}`);
|
|
124
|
-
}
|
|
125
|
-
if (results.unchanged.length > 0) {
|
|
126
|
-
ctx.log(`Unchanged: ${results.unchanged.length} file(s)`);
|
|
127
|
-
}
|
|
128
|
-
if (results.skipped.length > 0) {
|
|
129
|
-
ctx.warn(`Skipped: ${results.skipped.length} file(s)`);
|
|
130
|
-
for (const s of results.skipped) ctx.log(` ${s.file} (${s.reason})`);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const totalChanges = results.updated.length + results.created.length;
|
|
134
|
-
if (totalChanges === 0) {
|
|
135
|
-
ctx.success('\nAlready up to date.');
|
|
136
|
-
} else if (dryRun) {
|
|
137
|
-
ctx.warn(`\n${totalChanges} file(s) would be changed. Run without --dry to apply.`);
|
|
138
|
-
} else {
|
|
139
|
-
ctx.success(`\n${totalChanges} file(s) updated to sentix v${VERSION}.`);
|
|
140
|
-
ctx.log('Run: sentix doctor — to verify project health');
|
|
178
|
+
await syncFiles(target, dryRun, ctx);
|
|
141
179
|
}
|
|
142
180
|
},
|
|
143
181
|
});
|
package/src/commands/version.js
CHANGED
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
* sentix version changelog — CHANGELOG 미리보기 생성
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { spawnSync, execSync } from 'node:child_process';
|
|
10
10
|
import { registerCommand } from '../registry.js';
|
|
11
11
|
import { parseSemver, bumpSemver } from '../lib/semver.js';
|
|
12
|
-
import { generateForVersion, prependToChangelog } from '../lib/changelog.js';
|
|
12
|
+
import { generateForVersion, prependToChangelog, detectBumpType } from '../lib/changelog.js';
|
|
13
13
|
|
|
14
14
|
registerCommand('version', {
|
|
15
15
|
description: 'Manage project version (bump | current | changelog)',
|
|
16
|
-
usage: 'sentix version <bump|current|changelog> [major|minor|patch]',
|
|
16
|
+
usage: 'sentix version <bump|current|changelog> [auto|major|minor|patch]',
|
|
17
17
|
|
|
18
18
|
async run(args, ctx) {
|
|
19
19
|
const subcommand = args[0];
|
|
@@ -21,9 +21,13 @@ registerCommand('version', {
|
|
|
21
21
|
if (!subcommand || subcommand === 'current') {
|
|
22
22
|
await showCurrent(ctx);
|
|
23
23
|
} else if (subcommand === 'bump') {
|
|
24
|
-
|
|
24
|
+
let type = args[1] || 'auto';
|
|
25
|
+
if (type === 'auto') {
|
|
26
|
+
type = autoDetectBumpType(ctx);
|
|
27
|
+
ctx.log(`Auto-detected bump type: ${type}\n`);
|
|
28
|
+
}
|
|
25
29
|
if (!['major', 'minor', 'patch'].includes(type)) {
|
|
26
|
-
ctx.error(`Invalid bump type: ${type} (use major|minor|patch)`);
|
|
30
|
+
ctx.error(`Invalid bump type: ${type} (use auto|major|minor|patch)`);
|
|
27
31
|
return;
|
|
28
32
|
}
|
|
29
33
|
await bumpVersion(type, ctx);
|
|
@@ -31,11 +35,37 @@ registerCommand('version', {
|
|
|
31
35
|
await showChangelog(ctx);
|
|
32
36
|
} else {
|
|
33
37
|
ctx.error(`Unknown subcommand: ${subcommand}`);
|
|
34
|
-
ctx.log('Usage: sentix version <bump|current|changelog> [major|minor|patch]');
|
|
38
|
+
ctx.log('Usage: sentix version <bump|current|changelog> [auto|major|minor|patch]');
|
|
35
39
|
}
|
|
36
40
|
},
|
|
37
41
|
});
|
|
38
42
|
|
|
43
|
+
// ── Auto-detect bump type from commits ───────────────
|
|
44
|
+
|
|
45
|
+
function autoDetectBumpType(ctx) {
|
|
46
|
+
try {
|
|
47
|
+
// Get commits since last tag
|
|
48
|
+
let range = '';
|
|
49
|
+
try {
|
|
50
|
+
const lastTag = execSync('git describe --tags --abbrev=0 HEAD~1 2>/dev/null', {
|
|
51
|
+
cwd: ctx.cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
52
|
+
}).trim();
|
|
53
|
+
if (lastTag) range = `${lastTag}..HEAD`;
|
|
54
|
+
} catch {
|
|
55
|
+
range = 'HEAD~20..HEAD';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const log = execSync(`git log ${range} --pretty=format:"%s" 2>/dev/null`, {
|
|
59
|
+
cwd: ctx.cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
60
|
+
}).trim();
|
|
61
|
+
|
|
62
|
+
const messages = log ? log.split('\n') : [];
|
|
63
|
+
return detectBumpType(messages);
|
|
64
|
+
} catch {
|
|
65
|
+
return 'patch';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
39
69
|
// ── sentix version current ────────────────────────────
|
|
40
70
|
|
|
41
71
|
async function showCurrent(ctx) {
|
package/src/lib/changelog.js
CHANGED
|
@@ -1,62 +1,173 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CHANGELOG.md auto-generation from
|
|
2
|
+
* CHANGELOG.md auto-generation from git commits + ticket index.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Format:
|
|
5
5
|
* ## [x.y.z] — YYYY-MM-DD
|
|
6
6
|
* ### Category
|
|
7
7
|
* - entry
|
|
8
|
+
*
|
|
9
|
+
* Commit convention:
|
|
10
|
+
* feat: → New Features
|
|
11
|
+
* fix: → Bug Fixes
|
|
12
|
+
* ci: → CI/CD
|
|
13
|
+
* docs: → Documentation
|
|
14
|
+
* chore: → Improvements
|
|
15
|
+
* refactor: → Improvements
|
|
8
16
|
*/
|
|
9
17
|
|
|
18
|
+
import { execSync } from 'node:child_process';
|
|
10
19
|
import { loadIndex } from './ticket-index.js';
|
|
11
20
|
|
|
12
21
|
/**
|
|
13
|
-
*
|
|
22
|
+
* Parse conventional commit messages from git log since last tag.
|
|
14
23
|
*/
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
function getCommitsSinceLastTag(cwd) {
|
|
25
|
+
// Find last tag
|
|
26
|
+
let range = '';
|
|
27
|
+
try {
|
|
28
|
+
const lastTag = execSync('git describe --tags --abbrev=0 HEAD~1 2>/dev/null', {
|
|
29
|
+
cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
30
|
+
}).trim();
|
|
31
|
+
if (lastTag) range = `${lastTag}..HEAD`;
|
|
32
|
+
} catch {
|
|
33
|
+
// No previous tag — use all commits (limit 50)
|
|
34
|
+
range = 'HEAD~50..HEAD';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const log = execSync(`git log ${range} --pretty=format:"%s" 2>/dev/null`, {
|
|
39
|
+
cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
40
|
+
}).trim();
|
|
41
|
+
return log ? log.split('\n') : [];
|
|
42
|
+
} catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
20
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Categorize commit messages by conventional commit prefix.
|
|
49
|
+
*/
|
|
50
|
+
function categorizeCommits(messages) {
|
|
21
51
|
const categories = {
|
|
22
52
|
'New Features': [],
|
|
23
53
|
'Bug Fixes': [],
|
|
24
54
|
'Security Fixes': [],
|
|
55
|
+
'CI/CD': [],
|
|
56
|
+
'Documentation': [],
|
|
25
57
|
'Improvements': [],
|
|
26
58
|
};
|
|
27
59
|
|
|
28
|
-
for (const
|
|
29
|
-
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
60
|
+
for (const msg of messages) {
|
|
61
|
+
// Skip version bump commits
|
|
62
|
+
if (msg.startsWith('chore: bump version')) continue;
|
|
63
|
+
if (msg.startsWith('Merge pull request')) continue;
|
|
64
|
+
|
|
65
|
+
const cleaned = msg.replace(/^"(.*)"$/, '$1');
|
|
66
|
+
|
|
67
|
+
if (/^feat(\(.+?\))?:\s/.test(cleaned)) {
|
|
68
|
+
categories['New Features'].push(`- ${cleaned.replace(/^feat(\(.+?\))?:\s*/, '')}`);
|
|
69
|
+
} else if (/^fix(\(.+?\))?:\s/.test(cleaned)) {
|
|
70
|
+
categories['Bug Fixes'].push(`- ${cleaned.replace(/^fix(\(.+?\))?:\s*/, '')}`);
|
|
71
|
+
} else if (/^security(\(.+?\))?:\s/.test(cleaned)) {
|
|
72
|
+
categories['Security Fixes'].push(`- ${cleaned.replace(/^security(\(.+?\))?:\s*/, '')}`);
|
|
73
|
+
} else if (/^ci(\(.+?\))?:\s/.test(cleaned)) {
|
|
74
|
+
categories['CI/CD'].push(`- ${cleaned.replace(/^ci(\(.+?\))?:\s*/, '')}`);
|
|
75
|
+
} else if (/^docs(\(.+?\))?:\s/.test(cleaned)) {
|
|
76
|
+
categories['Documentation'].push(`- ${cleaned.replace(/^docs(\(.+?\))?:\s*/, '')}`);
|
|
77
|
+
} else if (/^(chore|refactor|perf|style)(\(.+?\))?:\s/.test(cleaned)) {
|
|
78
|
+
categories['Improvements'].push(`- ${cleaned.replace(/^(chore|refactor|perf|style)(\(.+?\))?:\s*/, '')}`);
|
|
79
|
+
} else if (cleaned.trim()) {
|
|
80
|
+
categories['Improvements'].push(`- ${cleaned}`);
|
|
38
81
|
}
|
|
39
82
|
}
|
|
40
83
|
|
|
41
84
|
return categories;
|
|
42
85
|
}
|
|
43
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Auto-detect bump type from commit messages.
|
|
89
|
+
*
|
|
90
|
+
* Rules:
|
|
91
|
+
* BREAKING CHANGE or feat!: → major
|
|
92
|
+
* feat: → minor
|
|
93
|
+
* fix:/ci:/docs:/chore: → patch
|
|
94
|
+
*/
|
|
95
|
+
export function detectBumpType(messages) {
|
|
96
|
+
let hasBreaking = false;
|
|
97
|
+
let hasFeat = false;
|
|
98
|
+
|
|
99
|
+
for (const msg of messages) {
|
|
100
|
+
if (msg.includes('BREAKING CHANGE') || /^[a-z]+!:/.test(msg)) {
|
|
101
|
+
hasBreaking = true;
|
|
102
|
+
}
|
|
103
|
+
if (/^feat(\(.+?\))?:\s/.test(msg)) {
|
|
104
|
+
hasFeat = true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (hasBreaking) return 'major';
|
|
109
|
+
if (hasFeat) return 'minor';
|
|
110
|
+
return 'patch';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build changelog entries from git commits + resolved tickets.
|
|
115
|
+
*/
|
|
116
|
+
export async function buildFromTickets(ctx) {
|
|
117
|
+
// Start with commit-based categories
|
|
118
|
+
const commits = getCommitsSinceLastTag(ctx.cwd);
|
|
119
|
+
const categories = categorizeCommits(commits);
|
|
120
|
+
|
|
121
|
+
// Merge ticket-based entries
|
|
122
|
+
try {
|
|
123
|
+
const entries = await loadIndex(ctx);
|
|
124
|
+
const resolved = entries.filter(e =>
|
|
125
|
+
e.status === 'resolved' || e.status === 'closed'
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
for (const ticket of resolved) {
|
|
129
|
+
const line = `- \`${ticket.id}\`: ${ticket.title}`;
|
|
130
|
+
if (ticket.type === 'feature') {
|
|
131
|
+
categories['New Features'].push(line);
|
|
132
|
+
} else if (ticket.severity === 'critical' && ticket.title.toLowerCase().includes('security')) {
|
|
133
|
+
categories['Security Fixes'].push(line);
|
|
134
|
+
} else if (ticket.type === 'bug') {
|
|
135
|
+
categories['Bug Fixes'].push(line);
|
|
136
|
+
} else {
|
|
137
|
+
categories['Improvements'].push(line);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// ticket index not available — commits only
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return categories;
|
|
145
|
+
}
|
|
146
|
+
|
|
44
147
|
/**
|
|
45
148
|
* Generate a formatted changelog entry string.
|
|
46
149
|
*/
|
|
47
150
|
export function generateChangelogEntry(version, date, categories) {
|
|
48
151
|
const lines = [`## [${version}] — ${date}`, ''];
|
|
49
152
|
|
|
153
|
+
let hasContent = false;
|
|
50
154
|
for (const [category, items] of Object.entries(categories)) {
|
|
51
155
|
if (items.length > 0) {
|
|
156
|
+
hasContent = true;
|
|
52
157
|
lines.push(`### ${category}`, '');
|
|
53
|
-
|
|
158
|
+
// Deduplicate
|
|
159
|
+
const unique = [...new Set(items)];
|
|
160
|
+
for (const item of unique) {
|
|
54
161
|
lines.push(item);
|
|
55
162
|
}
|
|
56
163
|
lines.push('');
|
|
57
164
|
}
|
|
58
165
|
}
|
|
59
166
|
|
|
167
|
+
if (!hasContent) {
|
|
168
|
+
lines.push('- Maintenance release', '');
|
|
169
|
+
}
|
|
170
|
+
|
|
60
171
|
return lines.join('\n');
|
|
61
172
|
}
|
|
62
173
|
|
|
@@ -85,26 +196,10 @@ export async function prependToChangelog(ctx, entry) {
|
|
|
85
196
|
}
|
|
86
197
|
|
|
87
198
|
/**
|
|
88
|
-
* Generate a changelog entry from
|
|
199
|
+
* Generate a changelog entry from git commits + tickets for a given version.
|
|
89
200
|
*/
|
|
90
201
|
export async function generateForVersion(ctx, version) {
|
|
91
202
|
const date = new Date().toISOString().slice(0, 10);
|
|
92
203
|
const categories = await buildFromTickets(ctx);
|
|
93
|
-
|
|
94
|
-
// Also check governor-state for the latest request
|
|
95
|
-
if (ctx.exists('tasks/governor-state.json')) {
|
|
96
|
-
try {
|
|
97
|
-
const state = await ctx.readJSON('tasks/governor-state.json');
|
|
98
|
-
if (state.status === 'completed' && state.request) {
|
|
99
|
-
const line = `- ${state.request} (cycle: ${state.cycle_id})`;
|
|
100
|
-
if (!Object.values(categories).flat().length) {
|
|
101
|
-
categories['Improvements'].push(line);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
} catch {
|
|
105
|
-
// Non-critical
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
204
|
return generateChangelogEntry(version, date, categories);
|
|
110
205
|
}
|