sentix 2.0.1

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.
@@ -0,0 +1,254 @@
1
+ /**
2
+ * verify-gates.js — 하드 룰 검증 게이트
3
+ *
4
+ * 에이전트 작업 완료 후 git diff를 분석하여 하드 룰 위반 여부를 코드로 검증한다.
5
+ * "AI에게 부탁하는 규칙"이 아닌 "코드가 강제하는 게이트".
6
+ *
7
+ * 검증 항목:
8
+ * #2 SCOPE 준수 — 변경 파일이 허용 범위 안에 있는가
9
+ * #3 export 삭제 금지 — export 키워드가 삭제되었는가
10
+ * #4 테스트 삭제 금지 — 테스트 파일에서 테스트가 삭제되었는가
11
+ * #5 순삭제 50줄 — net deletions이 50줄을 넘는가
12
+ */
13
+
14
+ import { execSync } from 'node:child_process';
15
+
16
+ /**
17
+ * Run all verification gates against the current git diff.
18
+ * @param {string} cwd - Working directory
19
+ * @param {object} [options]
20
+ * @param {string[]} [options.scope] - Allowed file patterns (glob-like). If empty, scope check is skipped.
21
+ * @returns {object} Gate results
22
+ */
23
+ export function runGates(cwd, options = {}) {
24
+ const results = {
25
+ passed: true,
26
+ checks: [],
27
+ violations: [],
28
+ summary: '',
29
+ };
30
+
31
+ let diff;
32
+ try {
33
+ diff = getDiff(cwd);
34
+ } catch {
35
+ // No git repo or no changes — all gates pass trivially
36
+ results.summary = 'No git changes detected — gates skipped';
37
+ return results;
38
+ }
39
+
40
+ if (!diff.files.length) {
41
+ results.summary = 'No file changes — gates skipped';
42
+ return results;
43
+ }
44
+
45
+ // Gate #2: SCOPE compliance
46
+ const scopeResult = checkScope(diff, options.scope);
47
+ results.checks.push(scopeResult);
48
+ if (!scopeResult.passed) {
49
+ results.passed = false;
50
+ results.violations.push(...scopeResult.violations);
51
+ }
52
+
53
+ // Gate #3: No export deletion
54
+ const exportResult = checkExportDeletion(diff);
55
+ results.checks.push(exportResult);
56
+ if (!exportResult.passed) {
57
+ results.passed = false;
58
+ results.violations.push(...exportResult.violations);
59
+ }
60
+
61
+ // Gate #4: No test deletion
62
+ const testResult = checkTestDeletion(diff);
63
+ results.checks.push(testResult);
64
+ if (!testResult.passed) {
65
+ results.passed = false;
66
+ results.violations.push(...testResult.violations);
67
+ }
68
+
69
+ // Gate #5: Net deletion limit (50 lines)
70
+ const deletionResult = checkNetDeletion(diff);
71
+ results.checks.push(deletionResult);
72
+ if (!deletionResult.passed) {
73
+ results.passed = false;
74
+ results.violations.push(...deletionResult.violations);
75
+ }
76
+
77
+ const passCount = results.checks.filter(c => c.passed).length;
78
+ results.summary = `${passCount}/${results.checks.length} gates passed`;
79
+
80
+ return results;
81
+ }
82
+
83
+ // ── Git diff parsing ──────────────────────────────────
84
+
85
+ function getDiff(cwd) {
86
+ const numstat = execSync('git diff --numstat HEAD 2>/dev/null || git diff --numstat', {
87
+ cwd,
88
+ encoding: 'utf-8',
89
+ stdio: ['pipe', 'pipe', 'pipe'],
90
+ timeout: 10000,
91
+ }).trim();
92
+
93
+ const diffContent = execSync('git diff HEAD 2>/dev/null || git diff', {
94
+ cwd,
95
+ encoding: 'utf-8',
96
+ stdio: ['pipe', 'pipe', 'pipe'],
97
+ timeout: 10000,
98
+ });
99
+
100
+ const files = [];
101
+ let totalAdded = 0;
102
+ let totalDeleted = 0;
103
+
104
+ for (const line of numstat.split('\n')) {
105
+ if (!line.trim()) continue;
106
+ const [added, deleted, file] = line.split('\t');
107
+ const a = parseInt(added) || 0;
108
+ const d = parseInt(deleted) || 0;
109
+ files.push({ file, added: a, deleted: d });
110
+ totalAdded += a;
111
+ totalDeleted += d;
112
+ }
113
+
114
+ // Extract deleted lines from full diff
115
+ const deletedLines = [];
116
+ let currentFile = null;
117
+ for (const line of diffContent.split('\n')) {
118
+ if (line.startsWith('diff --git')) {
119
+ const match = line.match(/b\/(.+)$/);
120
+ currentFile = match ? match[1] : null;
121
+ } else if (line.startsWith('-') && !line.startsWith('---') && currentFile) {
122
+ deletedLines.push({ file: currentFile, line: line.slice(1) });
123
+ }
124
+ }
125
+
126
+ return { files, totalAdded, totalDeleted, deletedLines };
127
+ }
128
+
129
+ // ── Gate #2: SCOPE compliance ─────────────────────────
130
+
131
+ function checkScope(diff, scope) {
132
+ const result = { rule: 'scope', passed: true, violations: [], detail: '' };
133
+
134
+ if (!scope || scope.length === 0) {
135
+ result.detail = 'No scope defined — skipped';
136
+ return result;
137
+ }
138
+
139
+ for (const { file } of diff.files) {
140
+ if (!matchesScope(file, scope)) {
141
+ result.passed = false;
142
+ result.violations.push({
143
+ rule: 'scope',
144
+ message: `File outside SCOPE: ${file}`,
145
+ file,
146
+ });
147
+ }
148
+ }
149
+
150
+ result.detail = result.passed
151
+ ? `${diff.files.length} files within scope`
152
+ : `${result.violations.length} file(s) outside scope`;
153
+
154
+ return result;
155
+ }
156
+
157
+ function matchesScope(file, patterns) {
158
+ for (const pattern of patterns) {
159
+ if (pattern.endsWith('/**')) {
160
+ const dir = pattern.slice(0, -3);
161
+ if (file.startsWith(dir + '/') || file === dir) return true;
162
+ } else if (pattern.endsWith('/*')) {
163
+ const dir = pattern.slice(0, -2);
164
+ if (file.startsWith(dir + '/') && !file.slice(dir.length + 1).includes('/')) return true;
165
+ } else if (file === pattern) {
166
+ return true;
167
+ }
168
+ }
169
+ return false;
170
+ }
171
+
172
+ // ── Gate #3: No export deletion ───────────────────────
173
+
174
+ function checkExportDeletion(diff) {
175
+ const result = { rule: 'no-export-deletion', passed: true, violations: [], detail: '' };
176
+
177
+ const exportPattern = /^export\s+(function|const|let|var|class|default|async)/;
178
+ const deletedExports = diff.deletedLines.filter(
179
+ d => exportPattern.test(d.line.trim()) && !isTestFile(d.file)
180
+ );
181
+
182
+ if (deletedExports.length > 0) {
183
+ result.passed = false;
184
+ for (const d of deletedExports) {
185
+ result.violations.push({
186
+ rule: 'no-export-deletion',
187
+ message: `Export deleted in ${d.file}: ${d.line.trim().substring(0, 60)}`,
188
+ file: d.file,
189
+ });
190
+ }
191
+ }
192
+
193
+ result.detail = result.passed
194
+ ? 'No exports deleted'
195
+ : `${deletedExports.length} export(s) deleted`;
196
+
197
+ return result;
198
+ }
199
+
200
+ // ── Gate #4: No test deletion ─────────────────────────
201
+
202
+ function checkTestDeletion(diff) {
203
+ const result = { rule: 'no-test-deletion', passed: true, violations: [], detail: '' };
204
+
205
+ const testPattern = /\b(describe|it|test)\s*\(/;
206
+ const deletedTests = diff.deletedLines.filter(
207
+ d => isTestFile(d.file) && testPattern.test(d.line)
208
+ );
209
+
210
+ if (deletedTests.length > 0) {
211
+ result.passed = false;
212
+ for (const d of deletedTests) {
213
+ result.violations.push({
214
+ rule: 'no-test-deletion',
215
+ message: `Test deleted in ${d.file}: ${d.line.trim().substring(0, 60)}`,
216
+ file: d.file,
217
+ });
218
+ }
219
+ }
220
+
221
+ result.detail = result.passed
222
+ ? 'No tests deleted'
223
+ : `${deletedTests.length} test(s) deleted`;
224
+
225
+ return result;
226
+ }
227
+
228
+ function isTestFile(file) {
229
+ return file.includes('__tests__/') ||
230
+ file.includes('.test.') ||
231
+ file.includes('.spec.') ||
232
+ file.includes('test/');
233
+ }
234
+
235
+ // ── Gate #5: Net deletion limit ───────────────────────
236
+
237
+ function checkNetDeletion(diff, limit = 50) {
238
+ const result = { rule: 'net-deletion-limit', passed: true, violations: [], detail: '' };
239
+
240
+ const netDeletions = diff.totalDeleted - diff.totalAdded;
241
+
242
+ if (netDeletions > limit) {
243
+ result.passed = false;
244
+ result.violations.push({
245
+ rule: 'net-deletion-limit',
246
+ message: `Net deletions: ${netDeletions} (limit: ${limit})`,
247
+ net_deletions: netDeletions,
248
+ });
249
+ }
250
+
251
+ result.detail = `Net: +${diff.totalAdded} -${diff.totalDeleted} (net ${netDeletions > 0 ? '+' : ''}${-netDeletions})`;
252
+
253
+ return result;
254
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Sentix Built-in Plugin: Auto Version Bump
3
+ *
4
+ * After a successful `sentix run` pipeline, automatically bumps the project version.
5
+ * - feature ticket → minor bump
6
+ * - bug ticket → patch bump
7
+ *
8
+ * Controlled by .sentix/config.toml:
9
+ * [version]
10
+ * auto_bump = true
11
+ */
12
+
13
+ import { registerHook } from '../registry.js';
14
+ import { bumpSemver } from '../lib/semver.js';
15
+ import { generateForVersion, prependToChangelog } from '../lib/changelog.js';
16
+
17
+ registerHook('after:command', async ({ command, args, ctx }) => {
18
+ if (command !== 'run') return;
19
+
20
+ try {
21
+ // Check config
22
+ if (!await isAutoBumpEnabled(ctx)) return;
23
+
24
+ // Check governor state
25
+ if (!ctx.exists('tasks/governor-state.json')) return;
26
+ const state = await ctx.readJSON('tasks/governor-state.json');
27
+ if (state.status !== 'completed') return;
28
+
29
+ // Determine bump type from ticket_type
30
+ const ticketType = state.ticket_type;
31
+ let bumpType = 'patch'; // safe default
32
+
33
+ if (ticketType === 'feature') {
34
+ bumpType = 'minor';
35
+ } else if (ticketType === 'bug') {
36
+ bumpType = 'patch';
37
+ }
38
+
39
+ // Read current version
40
+ if (!ctx.exists('package.json')) return;
41
+ const pkg = await ctx.readJSON('package.json');
42
+ const current = pkg.version;
43
+ if (!current) return;
44
+
45
+ const newVersion = bumpSemver(current, bumpType);
46
+
47
+ // Update package.json
48
+ pkg.version = newVersion;
49
+ await ctx.writeJSON('package.json', pkg);
50
+
51
+ // Update CHANGELOG
52
+ try {
53
+ const entry = await generateForVersion(ctx, newVersion);
54
+ if (entry.trim()) {
55
+ await prependToChangelog(ctx, entry);
56
+ }
57
+ } catch { /* non-critical */ }
58
+
59
+ // Log
60
+ await ctx.appendJSONL('tasks/pattern-log.jsonl', {
61
+ ts: new Date().toISOString(),
62
+ event: 'version:auto-bump',
63
+ from: current,
64
+ to: newVersion,
65
+ type: bumpType,
66
+ trigger: `pipeline:${state.cycle_id}`,
67
+ });
68
+
69
+ ctx.success(`Auto version bump: ${current} → ${newVersion} (${bumpType})`);
70
+ } catch {
71
+ // Silent — auto-version should never break pipeline
72
+ }
73
+ });
74
+
75
+ async function isAutoBumpEnabled(ctx) {
76
+ if (!ctx.exists('.sentix/config.toml')) return false;
77
+ try {
78
+ const config = await ctx.readFile('.sentix/config.toml');
79
+ const sectionHeader = '[version]';
80
+ const idx = config.indexOf(sectionHeader);
81
+ if (idx === -1) return false;
82
+ const afterSection = config.slice(idx + sectionHeader.length);
83
+ const nextSection = afterSection.indexOf('\n[');
84
+ const sectionContent = nextSection === -1 ? afterSection : afterSection.slice(0, nextSection);
85
+ return /auto_bump\s*=\s*true/.test(sectionContent);
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Sentix Built-in Plugin: Logger
3
+ *
4
+ * Logs all command executions to tasks/pattern-log.jsonl.
5
+ * This is the primary data source for the Pattern Engine (Layer 3).
6
+ * Includes basic log rotation: truncates to last 10,000 entries when exceeding 20,000.
7
+ */
8
+
9
+ import { registerHook } from '../registry.js';
10
+
11
+ const MAX_ENTRIES = 20_000;
12
+ const KEEP_ENTRIES = 10_000;
13
+
14
+ registerHook('before:command', async ({ command, args, ctx }) => {
15
+ try {
16
+ await ctx.appendJSONL('tasks/pattern-log.jsonl', {
17
+ ts: new Date().toISOString(),
18
+ event: 'command:start',
19
+ command,
20
+ args,
21
+ });
22
+ } catch {
23
+ // Silent — logging should never break command execution
24
+ }
25
+ });
26
+
27
+ registerHook('after:command', async ({ command, args, ctx }) => {
28
+ try {
29
+ await ctx.appendJSONL('tasks/pattern-log.jsonl', {
30
+ ts: new Date().toISOString(),
31
+ event: 'command:end',
32
+ command,
33
+ args,
34
+ });
35
+
36
+ // ── Log rotation ────────────────────────────────
37
+ await rotateIfNeeded(ctx);
38
+ } catch {
39
+ // Silent
40
+ }
41
+ });
42
+
43
+ async function rotateIfNeeded(ctx) {
44
+ try {
45
+ if (!ctx.exists('tasks/pattern-log.jsonl')) return;
46
+ const content = await ctx.readFile('tasks/pattern-log.jsonl');
47
+ const lines = content.split('\n').filter(Boolean);
48
+ if (lines.length > MAX_ENTRIES) {
49
+ const trimmed = lines.slice(-KEEP_ENTRIES).join('\n') + '\n';
50
+ await ctx.writeFile('tasks/pattern-log.jsonl', trimmed);
51
+ }
52
+ } catch {
53
+ // Silent — rotation failure is non-critical
54
+ }
55
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Sentix Plugin Registry
3
+ *
4
+ * Manages command registration, hook execution, and plugin loading.
5
+ * Loading order: src/commands/ → src/plugins/ → .sentix/plugins/ (project-local)
6
+ */
7
+
8
+ const commands = new Map();
9
+ const hooks = new Map();
10
+
11
+ /**
12
+ * Register a CLI command.
13
+ * @param {string} name - Command name (e.g., "init", "run")
14
+ * @param {{ description: string, usage: string, run: (args: string[], ctx: object) => Promise<void> }} opts
15
+ */
16
+ export function registerCommand(name, opts) {
17
+ commands.set(name, opts);
18
+ }
19
+
20
+ /**
21
+ * Register a lifecycle hook.
22
+ * @param {string} name - Hook name (e.g., "before:command", "after:command")
23
+ * @param {(info: object) => Promise<void>} fn - Hook handler
24
+ */
25
+ export function registerHook(name, fn) {
26
+ if (!hooks.has(name)) hooks.set(name, []);
27
+ hooks.get(name).push(fn);
28
+ }
29
+
30
+ /**
31
+ * Get a registered command by name.
32
+ * @param {string} name
33
+ * @returns {object|undefined}
34
+ */
35
+ export function getCommand(name) {
36
+ return commands.get(name);
37
+ }
38
+
39
+ /**
40
+ * Get all registered commands.
41
+ * @returns {Map}
42
+ */
43
+ export function getAllCommands() {
44
+ return commands;
45
+ }
46
+
47
+ /**
48
+ * Run all hooks for a given event.
49
+ * Errors in individual hooks are caught and logged — they never break command execution.
50
+ * @param {string} name - Hook event name
51
+ * @param {object} info - Context passed to hooks
52
+ */
53
+ export async function runHooks(name, info) {
54
+ const fns = hooks.get(name) || [];
55
+ for (const fn of fns) {
56
+ try {
57
+ await fn(info);
58
+ } catch (err) {
59
+ const hookName = fn.name || '(anonymous)';
60
+ console.error(`[sentix] Hook "${name}" (${hookName}) failed: ${err.message}`);
61
+ }
62
+ }
63
+ }
package/src/version.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Sentix version — single source of truth.
3
+ * Reads from package.json at module load time.
4
+ */
5
+
6
+ import { readFileSync } from 'node:fs';
7
+ import { resolve, dirname } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const pkgPath = resolve(__dirname, '..', 'package.json');
12
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
13
+
14
+ export const VERSION = pkg.version;
15
+ export const NAME = pkg.name;