sonamu 0.8.24 → 0.8.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 (88) hide show
  1. package/dist/api/__tests__/config.test.js +189 -0
  2. package/dist/api/config.d.ts.map +1 -1
  3. package/dist/api/config.js +7 -2
  4. package/dist/api/sonamu.d.ts.map +1 -1
  5. package/dist/api/sonamu.js +14 -10
  6. package/dist/auth/index.d.ts +1 -0
  7. package/dist/auth/index.d.ts.map +1 -1
  8. package/dist/auth/index.js +2 -1
  9. package/dist/auth/knex-adapter.d.ts +23 -0
  10. package/dist/auth/knex-adapter.d.ts.map +1 -0
  11. package/dist/auth/knex-adapter.js +163 -0
  12. package/dist/auth/plugins/wrappers/admin.d.ts +2 -2
  13. package/dist/bin/__tests__/ts-loader-register.test.js +45 -0
  14. package/dist/bin/cli.js +47 -9
  15. package/dist/bin/ts-loader-register.js +3 -29
  16. package/dist/bin/ts-loader-registration.d.ts +2 -0
  17. package/dist/bin/ts-loader-registration.d.ts.map +1 -0
  18. package/dist/bin/ts-loader-registration.js +42 -0
  19. package/dist/cone/cone-generator.js +3 -3
  20. package/dist/database/puri-subset.test-d.js +9 -1
  21. package/dist/database/puri-subset.types.d.ts +1 -1
  22. package/dist/database/puri-subset.types.d.ts.map +1 -1
  23. package/dist/database/puri-subset.types.js +1 -1
  24. package/dist/testing/fixture-generator.js +5 -5
  25. package/dist/ui/ai-client.js +2 -2
  26. package/dist/ui/api.d.ts.map +1 -1
  27. package/dist/ui/api.js +14 -14
  28. package/dist/ui/cdd-service.d.ts +15 -18
  29. package/dist/ui/cdd-service.d.ts.map +1 -1
  30. package/dist/ui/cdd-service.js +246 -222
  31. package/dist/ui/cdd-types.d.ts +41 -68
  32. package/dist/ui/cdd-types.d.ts.map +1 -1
  33. package/dist/ui/cdd-types.js +2 -2
  34. package/dist/ui-web/assets/index-CKo0Z2Iu.css +1 -0
  35. package/dist/ui-web/assets/{index-CxiydzeC.js → index-DK-2aacv.js} +83 -83
  36. package/dist/ui-web/index.html +2 -2
  37. package/package.json +6 -2
  38. package/src/api/__tests__/config.test.ts +225 -0
  39. package/src/api/config.ts +10 -4
  40. package/src/api/sonamu.ts +16 -13
  41. package/src/auth/index.ts +1 -0
  42. package/src/auth/knex-adapter.ts +208 -0
  43. package/src/bin/__tests__/ts-loader-register.test.ts +62 -0
  44. package/src/bin/cli.ts +52 -9
  45. package/src/bin/ts-loader-register.ts +2 -32
  46. package/src/bin/ts-loader-registration.ts +55 -0
  47. package/src/cone/cone-generator.ts +2 -2
  48. package/src/database/puri-subset.test-d.ts +102 -0
  49. package/src/database/puri-subset.types.ts +1 -1
  50. package/src/skills/commands/sonamu-skills.md +20 -0
  51. package/src/skills/sonamu/SKILL.md +179 -137
  52. package/src/skills/sonamu/ai-agents.md +69 -69
  53. package/src/skills/sonamu/api.md +147 -147
  54. package/src/skills/sonamu/auth-migration.md +220 -220
  55. package/src/skills/sonamu/auth-plugins.md +83 -83
  56. package/src/skills/sonamu/auth.md +106 -106
  57. package/src/skills/sonamu/cdd.md +65 -200
  58. package/src/skills/sonamu/cone.md +138 -138
  59. package/src/skills/sonamu/config.md +191 -191
  60. package/src/skills/sonamu/create-sonamu.md +66 -66
  61. package/src/skills/sonamu/database.md +158 -158
  62. package/src/skills/sonamu/entity-basic.md +292 -293
  63. package/src/skills/sonamu/entity-relations.md +246 -246
  64. package/src/skills/sonamu/entity-validation-checklist.md +124 -124
  65. package/src/skills/sonamu/fixture-cli.md +231 -231
  66. package/src/skills/sonamu/framework-change.md +37 -37
  67. package/src/skills/sonamu/frontend.md +223 -223
  68. package/src/skills/sonamu/i18n.md +82 -82
  69. package/src/skills/sonamu/migration.md +77 -77
  70. package/src/skills/sonamu/model.md +222 -222
  71. package/src/skills/sonamu/naite.md +86 -86
  72. package/src/skills/sonamu/project-init.md +228 -228
  73. package/src/skills/sonamu/puri.md +122 -122
  74. package/src/skills/sonamu/scaffolding.md +154 -154
  75. package/src/skills/sonamu/skill-contribution.md +124 -124
  76. package/src/skills/sonamu/subset.md +46 -46
  77. package/src/skills/sonamu/tasks.md +82 -82
  78. package/src/skills/sonamu/testing-devrunner.md +147 -147
  79. package/src/skills/sonamu/testing.md +673 -673
  80. package/src/skills/sonamu/upsert.md +79 -79
  81. package/src/skills/sonamu/vector.md +67 -67
  82. package/src/testing/fixture-generator.ts +4 -4
  83. package/src/ui/ai-client.ts +1 -1
  84. package/src/ui/api.ts +18 -17
  85. package/src/ui/cdd-service.ts +264 -254
  86. package/src/ui/cdd-types.ts +40 -75
  87. package/dist/ui-web/assets/index-BrQKU3j9.css +0 -1
  88. package/src/skills/sonamu/workflow.md +0 -317
@@ -4,30 +4,28 @@ import os from "os";
4
4
  import path from "path";
5
5
  import { Sonamu } from "../api/sonamu";
6
6
  import type {
7
- CddContentEnvelope,
8
- CddDashboardData,
9
- CddDocumentSummary,
7
+ CddAcEntry,
8
+ CddAcFile,
9
+ CddAcListResult,
10
+ CddAddRuleRequest,
11
+ CddContentResult,
10
12
  CddFileType,
11
- CddSchema,
12
- CddSchemaDetailEnvelope,
13
- CddSchemaReference,
14
- CddSchemaSummary,
15
- CddSpecStatus,
13
+ CddRuleDetail,
14
+ CddRuleEntry,
15
+ CddRuleSummary,
16
16
  CddTreeNode,
17
17
  } from "./cdd-types";
18
18
 
19
19
  export type {
20
- CddContentEnvelope,
21
- CddDashboardData,
22
- CddDocumentSummary,
20
+ CddAcEntry,
21
+ CddAcFile,
22
+ CddAcListResult,
23
+ CddAddRuleRequest,
24
+ CddContentResult,
23
25
  CddFileType,
24
- CddSchema,
25
- CddSchemaDetailEnvelope,
26
- CddSchemaField,
27
- CddSchemaFieldType,
28
- CddSchemaReference,
29
- CddSchemaSummary,
30
- CddSpecStatus,
26
+ CddRuleDetail,
27
+ CddRuleEntry,
28
+ CddRuleSummary,
31
29
  CddTreeNode,
32
30
  } from "./cdd-types";
33
31
 
@@ -36,6 +34,11 @@ function getContractDir(): string {
36
34
  return path.join(Sonamu.apiRootPath, "..", "..", "contract");
37
35
  }
38
36
 
37
+ /** 프로젝트 루트 경로 반환 */
38
+ function getProjectRoot(): string {
39
+ return path.join(Sonamu.apiRootPath, "..", "..");
40
+ }
41
+
39
42
  /** 경로가 contract/ 디렉터리 내부인지 검증 */
40
43
  function assertInsideContractDir(filePath: string): void {
41
44
  const contractDir = getContractDir();
@@ -47,8 +50,8 @@ function assertInsideContractDir(filePath: string): void {
47
50
 
48
51
  /** 파일명에서 CddFileType 판별 */
49
52
  function detectFileType(fileName: string): CddFileType | undefined {
50
- if (fileName.endsWith(".contract.json")) return "contract";
51
- if (fileName.endsWith(".spec.json")) return "spec";
53
+ if (fileName.endsWith(".contract.md")) return "contract";
54
+ if (fileName.endsWith(".rules.json")) return "rules";
52
55
  return undefined;
53
56
  }
54
57
 
@@ -62,16 +65,19 @@ function scanDirectory(dirPath: string, relativeTo: string): CddTreeNode[] {
62
65
  const relPath = path.relative(relativeTo, fullPath);
63
66
 
64
67
  if (entry.isDirectory()) {
68
+ if (entry.name === "rules") continue;
65
69
  const children = scanDirectory(fullPath, relativeTo);
66
- nodes.push({
67
- name: entry.name,
68
- path: relPath,
69
- type: "directory",
70
- children,
71
- });
70
+ if (children.length > 0) {
71
+ nodes.push({
72
+ name: entry.name,
73
+ path: relPath,
74
+ type: "directory",
75
+ children,
76
+ });
77
+ }
72
78
  } else if (entry.isFile()) {
73
79
  const fileType = detectFileType(entry.name);
74
- if (fileType) {
80
+ if (fileType && fileType !== "rules") {
75
81
  nodes.push({
76
82
  name: entry.name,
77
83
  path: relPath,
@@ -95,17 +101,8 @@ export function getCddTree(): { exists: boolean; tree: CddTreeNode[] } {
95
101
  return { exists: true, tree };
96
102
  }
97
103
 
98
- /** schema ID로 schema 파일을 찾아 반환 */
99
- function resolveSchema(schemaId: string): CddSchema | null {
100
- const contractDir = getContractDir();
101
- const schemaPath = path.join(contractDir, "schemas", `${schemaId}.schema.json`);
102
- if (!fs.existsSync(schemaPath)) return null;
103
- const raw = fs.readFileSync(schemaPath, "utf-8");
104
- return JSON.parse(raw) as CddSchema;
105
- }
106
-
107
- /** JSON 파일의 전체 내용을 읽어 schema와 함께 envelope로 반환 */
108
- export function readContent(filePath: string): CddContentEnvelope {
104
+ /** 파일 내용을 읽어 반환 (contract.md markdown 원문, rules.json → JSON 문자열) */
105
+ export function readContent(filePath: string): CddContentResult {
109
106
  assertInsideContractDir(filePath);
110
107
 
111
108
  const contractDir = getContractDir();
@@ -115,20 +112,16 @@ export function readContent(filePath: string): CddContentEnvelope {
115
112
  throw new Error(`파일을 찾을 수 없습니다: ${filePath}`);
116
113
  }
117
114
 
118
- const raw = fs.readFileSync(absPath, "utf-8");
119
- const document = JSON.parse(raw) as Record<string, unknown>;
115
+ const content = fs.readFileSync(absPath, "utf-8");
120
116
  const fileType = detectFileType(path.basename(filePath));
121
- const schemaId = typeof document.schema === "string" ? document.schema : null;
122
- const schema = schemaId ? resolveSchema(schemaId) : null;
123
117
 
124
118
  return {
125
- document,
126
- schema,
119
+ content,
127
120
  fileType: fileType ?? "contract",
128
121
  };
129
122
  }
130
123
 
131
- /** JSON 파일을 외부 에디터로 직접 편집 */
124
+ /** 파일을 외부 에디터로 직접 편집 */
132
125
  export async function editContent(
133
126
  filePath: string,
134
127
  ): Promise<{ success: boolean; filePath: string }> {
@@ -203,269 +196,286 @@ function runEditor(editor: { bin: string; args: string[] }, filePath: string): P
203
196
  });
204
197
  }
205
198
 
206
- /* ========================================================================
207
- * Schema 관리 API
208
- * ======================================================================== */
209
-
210
- /** contract/schemas/ 디렉터리 내 .schema.json 파일 경로 목록 반환 */
211
- function scanSchemaFiles(): { absPath: string; relPath: string; fileName: string }[] {
212
- const schemasDir = path.join(getContractDir(), "schemas");
213
- if (!fs.existsSync(schemasDir)) return [];
214
-
215
- return fs
216
- .readdirSync(schemasDir, { withFileTypes: true })
217
- .filter((e) => e.isFile() && e.name.endsWith(".schema.json"))
218
- .map((e) => ({
219
- absPath: path.join(schemasDir, e.name),
220
- relPath: `schemas/${e.name}`,
221
- fileName: e.name,
222
- }));
223
- }
224
-
225
- /** 특정 schemaId를 참조하는 contract/spec 문서들을 재귀 수집 */
226
- function collectSchemaReferences(
227
- schemaId: string,
228
- dirPath: string,
229
- relativeTo: string,
230
- ): CddSchemaReference[] {
231
- if (!fs.existsSync(dirPath)) return [];
232
- const refs: CddSchemaReference[] = [];
233
- const entries = fs.readdirSync(dirPath, { withFileTypes: true });
199
+ /** 소스 파일을 외부 에디터로 열기 (대기하지 않음) */
200
+ export function openSourceFile(filePath: string): void {
201
+ const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(Sonamu.apiRootPath, filePath);
234
202
 
235
- for (const entry of entries) {
236
- const fullPath = path.join(dirPath, entry.name);
237
- if (entry.isDirectory()) {
238
- if (entry.name === "schemas") continue;
239
- refs.push(...collectSchemaReferences(schemaId, fullPath, relativeTo));
240
- } else if (entry.isFile()) {
241
- const fileType = detectFileType(entry.name);
242
- if (!fileType) continue;
243
- try {
244
- const raw = fs.readFileSync(fullPath, "utf-8");
245
- const doc = JSON.parse(raw) as Record<string, unknown>;
246
- if (doc.schema === schemaId) {
247
- refs.push({
248
- path: path.relative(relativeTo, fullPath),
249
- fileType,
250
- name: entry.name,
251
- });
252
- }
253
- } catch {
254
- // JSON 파싱 실패 시 무시
255
- }
256
- }
203
+ if (!fs.existsSync(absPath)) {
204
+ throw new Error(`파일을 찾을 수 없습니다: ${filePath}`);
257
205
  }
258
- return refs;
259
- }
260
206
 
261
- /** schema 파일명에서 기대되는 id 추출 */
262
- function expectedSchemaId(fileName: string): string {
263
- return fileName.replace(/\.schema\.json$/, "");
207
+ const editor = resolveEditorCli({ wait: false });
208
+ const child = spawn(editor.bin, [...editor.args, absPath], {
209
+ stdio: "ignore",
210
+ detached: true,
211
+ });
212
+ child.unref();
264
213
  }
265
214
 
266
- /** schemas/ 하위 경로가 contract/schemas/ 내부인지 검증 */
267
- function assertInsideSchemaDir(schemaKey: string): void {
268
- const schemasDir = path.join(getContractDir(), "schemas");
269
- const resolved = path.resolve(schemasDir, `${schemaKey}.schema.json`);
270
- if (!resolved.startsWith(schemasDir + path.sep) && resolved !== schemasDir) {
271
- throw new Error(`유효하지 않은 스키마 키입니다: ${schemaKey}`);
272
- }
273
- }
215
+ /* ========================================================================
216
+ * Rules API
217
+ * ======================================================================== */
274
218
 
275
- /** schema 목록 반환 */
276
- export function listSchemas(): { schemas: CddSchemaSummary[] } {
219
+ /** contract/rules/ 디렉터리 내 .rules.json 파일 목록 반환 */
220
+ export function listRules(): { rules: CddRuleSummary[] } {
277
221
  const contractDir = getContractDir();
278
- const files = scanSchemaFiles();
279
- const schemas: CddSchemaSummary[] = [];
222
+ const rulesDir = path.join(contractDir, "rules");
223
+ if (!fs.existsSync(rulesDir)) return { rules: [] };
224
+
225
+ const entries = fs.readdirSync(rulesDir, { withFileTypes: true });
226
+ const rules: CddRuleSummary[] = [];
227
+
228
+ for (const entry of entries) {
229
+ if (!entry.isFile() || !entry.name.endsWith(".rules.json")) continue;
230
+
231
+ const key = entry.name.replace(/\.rules\.json$/, "");
232
+ const relPath = `rules/${entry.name}`;
233
+ const absPath = path.join(rulesDir, entry.name);
280
234
 
281
- for (const file of files) {
282
- const key = expectedSchemaId(file.fileName);
283
235
  try {
284
- const raw = fs.readFileSync(file.absPath, "utf-8");
285
- const schema = JSON.parse(raw) as CddSchema;
286
- const refs = collectSchemaReferences(schema.id, contractDir, contractDir);
287
- schemas.push({
236
+ const raw = fs.readFileSync(absPath, "utf-8");
237
+ const doc = JSON.parse(raw) as { description?: string; rules?: unknown[] };
238
+ rules.push({
288
239
  key,
289
- id: schema.id,
290
- path: file.relPath,
291
- type: schema.type,
292
- fieldCount: schema.fields.length,
293
- referenceCount: refs.length,
294
- hasIdMismatch: schema.id !== key,
240
+ path: relPath,
241
+ description: typeof doc.description === "string" ? doc.description : "",
242
+ ruleCount: Array.isArray(doc.rules) ? doc.rules.length : 0,
295
243
  });
296
244
  } catch (err) {
297
- schemas.push({
245
+ rules.push({
298
246
  key,
299
- id: key,
300
- path: file.relPath,
301
- type: "contract",
302
- fieldCount: 0,
303
- referenceCount: 0,
304
- hasIdMismatch: false,
247
+ path: relPath,
248
+ description: "",
249
+ ruleCount: 0,
305
250
  parseError: err instanceof Error ? err.message : String(err),
306
251
  });
307
252
  }
308
253
  }
309
254
 
310
- return { schemas };
255
+ return { rules };
311
256
  }
312
257
 
313
- /** schema 상세 반환 (파일명 기반 key로 조회) */
314
- export function readSchema(schemaKey: string): CddSchemaDetailEnvelope {
315
- assertInsideSchemaDir(schemaKey);
316
-
258
+ /** rules 파일 상세 반환 */
259
+ export function readRule(ruleKey: string): CddRuleDetail {
317
260
  const contractDir = getContractDir();
318
- const absPath = path.join(contractDir, "schemas", `${schemaKey}.schema.json`);
261
+ const absPath = path.join(contractDir, "rules", `${ruleKey}.rules.json`);
319
262
 
320
263
  if (!fs.existsSync(absPath)) {
321
- throw new Error(`스키마를 찾을 수 없습니다: ${schemaKey}`);
264
+ throw new Error(`Rules 파일을 찾을 수 없습니다: ${ruleKey}`);
322
265
  }
323
266
 
324
267
  const raw = fs.readFileSync(absPath, "utf-8");
325
- const schema = JSON.parse(raw) as CddSchema;
326
- const relPath = path.relative(contractDir, absPath);
327
- const references = collectSchemaReferences(schema.id, contractDir, contractDir);
268
+ const doc = JSON.parse(raw) as { description?: string; rules?: CddRuleEntry[] };
328
269
 
329
270
  return {
330
- key: schemaKey,
331
- path: relPath,
332
- schema,
333
- references,
334
- hasIdMismatch: schema.id !== schemaKey,
271
+ key: ruleKey,
272
+ path: `rules/${ruleKey}.rules.json`,
273
+ description: typeof doc.description === "string" ? doc.description : "",
274
+ rules: Array.isArray(doc.rules) ? doc.rules : [],
335
275
  };
336
276
  }
337
277
 
338
- /** schema 파일을 외부 에디터로 편집 (파일명 기반 key로 조회) */
339
- export async function editSchema(
340
- schemaKey: string,
341
- ): Promise<{ success: boolean; schemaKey: string }> {
342
- assertInsideSchemaDir(schemaKey);
343
-
278
+ /** rules 파일에 규칙 추가 */
279
+ export function addRule(req: CddAddRuleRequest): CddRuleDetail {
344
280
  const contractDir = getContractDir();
345
- const absPath = path.join(contractDir, "schemas", `${schemaKey}.schema.json`);
281
+ const rulesDir = path.join(contractDir, "rules");
282
+ const absPath = path.join(rulesDir, `${req.ruleKey}.rules.json`);
346
283
 
347
284
  if (!fs.existsSync(absPath)) {
348
- throw new Error(`스키마를 찾을 수 없습니다: ${schemaKey}`);
285
+ throw new Error(`Rules 파일을 찾을 수 없습니다: ${req.ruleKey}`);
349
286
  }
350
287
 
351
- const editor = resolveEditorCli();
352
- await runEditor(editor, absPath);
288
+ const raw = fs.readFileSync(absPath, "utf-8");
289
+ const doc = JSON.parse(raw) as { description?: string; rules?: CddRuleEntry[] };
290
+ const rules: CddRuleEntry[] = Array.isArray(doc.rules) ? doc.rules : [];
291
+
292
+ const nextId = generateNextRuleId(rules);
293
+ const newEntry: CddRuleEntry = {
294
+ id: nextId,
295
+ when: req.when,
296
+ instruction: req.instruction,
297
+ };
298
+ if (req.examples && req.examples.length > 0) {
299
+ newEntry.examples = req.examples;
300
+ }
353
301
 
354
- return { success: true, schemaKey };
355
- }
302
+ rules.push(newEntry);
303
+ doc.rules = rules;
356
304
 
357
- const VALID_SPEC_STATUSES = new Set<CddSpecStatus>([
358
- "draft",
359
- "specifying",
360
- "implementing",
361
- "validating",
362
- "done",
363
- ]);
305
+ fs.writeFileSync(absPath, `${JSON.stringify(doc, null, 2)}\n`, "utf-8");
364
306
 
365
- /** 모든 contract/spec 문서를 스캔하여 대시보드 통계를 반환 */
366
- export function getDashboard(): CddDashboardData {
367
- const contractDir = getContractDir();
368
- if (!fs.existsSync(contractDir)) {
369
- return {
370
- exists: false,
371
- stats: {
372
- totalContracts: 0,
373
- totalSpecs: 0,
374
- statusDistribution: { draft: 0, specifying: 0, implementing: 0, validating: 0, done: 0 },
375
- },
376
- documents: [],
377
- };
307
+ return {
308
+ key: req.ruleKey,
309
+ path: `rules/${req.ruleKey}.rules.json`,
310
+ description: typeof doc.description === "string" ? doc.description : "",
311
+ rules,
312
+ };
313
+ }
314
+
315
+ /** 기존 id 패턴을 분석하여 다음 순번 id 생성 */
316
+ function generateNextRuleId(rules: CddRuleEntry[]): string {
317
+ if (rules.length === 0) return "R-001";
318
+
319
+ const numericPattern = /^(.+?)(\d+)$/;
320
+ let bestPrefix = "R-";
321
+ let maxNum = 0;
322
+
323
+ for (const rule of rules) {
324
+ const match = numericPattern.exec(rule.id);
325
+ if (match) {
326
+ const prefix = match[1];
327
+ const num = Number.parseInt(match[2], 10);
328
+ if (num > maxNum) {
329
+ bestPrefix = prefix;
330
+ maxNum = num;
331
+ }
332
+ }
378
333
  }
379
334
 
380
- const documents: CddDocumentSummary[] = [];
381
- const statusDistribution: Record<CddSpecStatus, number> = {
382
- draft: 0,
383
- specifying: 0,
384
- implementing: 0,
385
- validating: 0,
386
- done: 0,
387
- };
335
+ const nextNum = maxNum + 1;
336
+ const padLen = Math.max(3, String(maxNum).length);
337
+ return `${bestPrefix}${String(nextNum).padStart(padLen, "0")}`;
338
+ }
388
339
 
389
- function scanForDashboard(dirPath: string): void {
390
- const entries = fs.readdirSync(dirPath, { withFileTypes: true });
391
- for (const entry of entries) {
392
- const fullPath = path.join(dirPath, entry.name);
393
- if (entry.isDirectory()) {
394
- if (entry.name === "schemas") continue;
395
- scanForDashboard(fullPath);
396
- } else if (entry.isFile()) {
397
- const fileType = detectFileType(entry.name);
398
- if (!fileType) continue;
399
- const relPath = path.relative(contractDir, fullPath);
400
- try {
401
- const raw = fs.readFileSync(fullPath, "utf-8");
402
- const doc = JSON.parse(raw) as Record<string, unknown>;
403
-
404
- const summary: CddDocumentSummary = {
405
- path: relPath,
406
- name: entry.name,
407
- fileType,
408
- schemaId: typeof doc.schema === "string" ? doc.schema : undefined,
409
- lastModified: typeof doc.lastModified === "string" ? doc.lastModified : undefined,
410
- };
411
-
412
- if (fileType === "contract") {
413
- const features = doc.features;
414
- if (features && typeof features === "object" && !Array.isArray(features)) {
415
- summary.featureCount = Object.keys(features).length;
416
- }
417
- } else {
418
- const status = typeof doc.status === "string" ? doc.status : "draft";
419
- summary.status = VALID_SPEC_STATUSES.has(status as CddSpecStatus)
420
- ? (status as CddSpecStatus)
421
- : "draft";
422
- statusDistribution[summary.status]++;
423
-
424
- if (Array.isArray(doc.acceptanceCriteria)) {
425
- summary.acceptanceCriteriaCount = doc.acceptanceCriteria.length;
426
- }
427
- if (Array.isArray(doc.sources)) {
428
- summary.sourceCount = doc.sources.length;
429
- }
340
+ /* ========================================================================
341
+ * AC API (modules/cdd ac-list 파싱 로직 복사)
342
+ * ======================================================================== */
343
+
344
+ /** describe/test 패턴 파싱 */
345
+ function parseAcEntries(content: string): CddAcEntry[] {
346
+ const entries: CddAcEntry[] = [];
347
+ const lines = content.split("\n");
348
+
349
+ let currentDescribe: string | null = null;
350
+ let describeDepth = 0;
351
+ let braceDepth = 0;
352
+ let pendingTestAs = false;
353
+ let testAsBraceDepth = 0;
354
+ let testAsNeedName = false;
355
+
356
+ for (const line of lines) {
357
+ const trimmed = line.trim();
358
+
359
+ const describeMatch = trimmed.match(/^describe\(["'`](.+?)["'`]/);
360
+ if (describeMatch) {
361
+ currentDescribe = describeMatch[1];
362
+ describeDepth = braceDepth;
363
+ }
364
+
365
+ const testMatch = trimmed.match(/^(?:test|it)\(["'`](.+?)["'`]/);
366
+ if (testMatch) {
367
+ entries.push({
368
+ describe: currentDescribe,
369
+ test: testMatch[1],
370
+ });
371
+ }
372
+
373
+ if (!pendingTestAs && trimmed.match(/^testAs\s*\(/)) {
374
+ const inlineMatch = trimmed.match(/^testAs\s*\(\s*\{[^}]*\}\s*,\s*["'`](.+?)["'`]/);
375
+ if (inlineMatch) {
376
+ entries.push({ describe: currentDescribe, test: inlineMatch[1] });
377
+ } else {
378
+ pendingTestAs = true;
379
+ testAsBraceDepth = 0;
380
+ testAsNeedName = false;
381
+ for (const ch of trimmed) {
382
+ if (ch === "{") testAsBraceDepth++;
383
+ if (ch === "}") testAsBraceDepth--;
384
+ }
385
+ if (testAsBraceDepth <= 0) {
386
+ testAsNeedName = true;
387
+ const nameMatch = trimmed.match(/}\s*,\s*["'`](.+?)["'`]/);
388
+ if (nameMatch) {
389
+ entries.push({ describe: currentDescribe, test: nameMatch[1] });
390
+ pendingTestAs = false;
391
+ testAsNeedName = false;
430
392
  }
393
+ }
394
+ }
395
+ } else if (pendingTestAs) {
396
+ if (!testAsNeedName) {
397
+ for (const ch of trimmed) {
398
+ if (ch === "{") testAsBraceDepth++;
399
+ if (ch === "}") testAsBraceDepth--;
400
+ }
401
+ if (testAsBraceDepth <= 0) {
402
+ testAsNeedName = true;
403
+ const nameMatch = trimmed.match(/}\s*,\s*["'`](.+?)["'`]/);
404
+ if (nameMatch) {
405
+ entries.push({ describe: currentDescribe, test: nameMatch[1] });
406
+ pendingTestAs = false;
407
+ testAsNeedName = false;
408
+ }
409
+ }
410
+ } else {
411
+ const nameMatch = trimmed.match(/^["'`](.+?)["'`]/);
412
+ if (nameMatch) {
413
+ entries.push({ describe: currentDescribe, test: nameMatch[1] });
414
+ pendingTestAs = false;
415
+ testAsNeedName = false;
416
+ }
417
+ }
418
+ }
431
419
 
432
- documents.push(summary);
433
- } catch (err) {
434
- documents.push({
435
- path: relPath,
436
- name: entry.name,
437
- fileType,
438
- parseError: err instanceof Error ? err.message : String(err),
439
- });
420
+ for (const ch of trimmed) {
421
+ if (ch === "{") braceDepth++;
422
+ if (ch === "}") {
423
+ braceDepth--;
424
+ if (currentDescribe && braceDepth <= describeDepth) {
425
+ currentDescribe = null;
440
426
  }
441
427
  }
442
428
  }
443
429
  }
444
430
 
445
- scanForDashboard(contractDir);
431
+ return entries;
432
+ }
446
433
 
447
- const totalContracts = documents.filter((d) => d.fileType === "contract" && !d.parseError).length;
448
- const totalSpecs = documents.filter((d) => d.fileType === "spec" && !d.parseError).length;
434
+ /** 프로젝트 *.test.ts 파일을 스캔하여 AC 목록 반환 */
435
+ export function getAcList(): CddAcListResult {
436
+ const projectRoot = getProjectRoot();
437
+ const files = findTestFiles(projectRoot);
438
+
439
+ const acFiles: CddAcFile[] = [];
440
+ for (const absPath of files) {
441
+ const content = fs.readFileSync(absPath, "utf-8");
442
+ const entries = parseAcEntries(content);
443
+ if (entries.length > 0) {
444
+ acFiles.push({
445
+ path: path.relative(projectRoot, absPath),
446
+ entries,
447
+ });
448
+ }
449
+ }
449
450
 
450
- return {
451
- exists: true,
452
- stats: { totalContracts, totalSpecs, statusDistribution },
453
- documents,
454
- };
451
+ const total = acFiles.reduce((sum, f) => sum + f.entries.length, 0);
452
+ return { files: acFiles, total };
455
453
  }
456
454
 
457
- /** 소스 파일을 외부 에디터로 열기 (대기하지 않음) */
458
- export function openSourceFile(filePath: string): void {
459
- const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(Sonamu.apiRootPath, filePath);
455
+ /** *.test.ts 파일을 재귀 탐색 (node_modules, dist 제외) */
456
+ function findTestFiles(dir: string): string[] {
457
+ const results: string[] = [];
458
+ const IGNORE = new Set(["node_modules", "dist", ".git", "contract"]);
460
459
 
461
- if (!fs.existsSync(absPath)) {
462
- throw new Error(`파일을 찾을 수 없습니다: ${filePath}`);
460
+ function walk(currentDir: string): void {
461
+ let entries: fs.Dirent[];
462
+ try {
463
+ entries = fs.readdirSync(currentDir, { withFileTypes: true });
464
+ } catch {
465
+ return;
466
+ }
467
+ for (const entry of entries) {
468
+ if (IGNORE.has(entry.name)) continue;
469
+ const fullPath = path.join(currentDir, entry.name);
470
+ if (entry.isDirectory()) {
471
+ walk(fullPath);
472
+ } else if (entry.isFile() && entry.name.endsWith(".test.ts")) {
473
+ results.push(fullPath);
474
+ }
475
+ }
463
476
  }
464
477
 
465
- const editor = resolveEditorCli({ wait: false });
466
- const child = spawn(editor.bin, [...editor.args, absPath], {
467
- stdio: "ignore",
468
- detached: true,
469
- });
470
- child.unref();
478
+ walk(dir);
479
+ results.sort();
480
+ return results;
471
481
  }