workq-mcp 0.1.5 → 0.1.7
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/README.md +7 -3
- package/lib/natural-git.mjs +741 -31
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,9 +67,13 @@ cd /Users/jin/code/lotto
|
|
|
67
67
|
workq natural-git --project-key LOTTO
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
This command reads the local repository
|
|
71
|
-
`natural-git/
|
|
72
|
-
|
|
70
|
+
This command reads the local repository and creates a screen/region-centered
|
|
71
|
+
natural-spec repo: `natural-git/README.md`, `natural-git/screens/.../README.md`,
|
|
72
|
+
`natural-git/screens/.../regions/*.md`, `natural-git/screens/.../rules.md`, and
|
|
73
|
+
`natural-git/screens/.../verification.md`. Source files, configuration, CLI, and
|
|
74
|
+
runtime details are kept under `natural-git/_evidence/` as traceability evidence,
|
|
75
|
+
not as the default user-facing feature spec. The generated docs are then imported
|
|
76
|
+
into the Work Q v2 natural-spec repo.
|
|
73
77
|
After that, open Work Q v2 and run `자연어 커밋 -> 명세 머지 -> 커밋 승인·Agent 큐`.
|
|
74
78
|
Work Q creates a queued MCP work packet from the accepted natural commit. Your
|
|
75
79
|
desktop Codex/Claude agent should claim that task, update the local repository,
|
package/lib/natural-git.mjs
CHANGED
|
@@ -136,10 +136,6 @@ function languageForExtension(extension) {
|
|
|
136
136
|
return map[extension] ?? extension.replace(".", "").toUpperCase();
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
function docPathForSource(sourcePath) {
|
|
140
|
-
return `natural-git/code/${sourcePath.replace(/\//g, "__")}.md`;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
139
|
function cleanLine(line) {
|
|
144
140
|
const trimmed = line.trim();
|
|
145
141
|
if (!trimmed || SECRET_LINE.test(trimmed)) return "";
|
|
@@ -158,6 +154,31 @@ function unique(values, limit = 40) {
|
|
|
158
154
|
|
|
159
155
|
const NATURAL_SPEC_FORMAT_COMMENT = "<!-- workq-natural-spec-format: feature-v2 -->";
|
|
160
156
|
|
|
157
|
+
function slugSegment(value, fallback = "spec") {
|
|
158
|
+
return (
|
|
159
|
+
String(value ?? "")
|
|
160
|
+
.toLowerCase()
|
|
161
|
+
.replace(/\.[a-z0-9]+$/i, "")
|
|
162
|
+
.replace(/[^a-z0-9가-힣]+/g, "-")
|
|
163
|
+
.replace(/^-+|-+$/g, "")
|
|
164
|
+
.slice(0, 72) || fallback
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function sourcePathSlug(sourcePath) {
|
|
169
|
+
return slugSegment(sourcePath.replace(/\.[^.]+$/i, ""), "source");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isTestFile(file) {
|
|
173
|
+
const normalized = file.path.toLowerCase();
|
|
174
|
+
const basename = path.basename(normalized);
|
|
175
|
+
return (
|
|
176
|
+
/(^|\/)(tests?|e2e|__tests__|spec)\//.test(normalized) ||
|
|
177
|
+
/\.(spec|test)\.[cm]?[jt]sx?$/.test(normalized) ||
|
|
178
|
+
/^test[_-]/.test(basename)
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
161
182
|
function stripTags(value) {
|
|
162
183
|
return value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
163
184
|
}
|
|
@@ -178,6 +199,11 @@ function extractVisibleTexts(file) {
|
|
|
178
199
|
return unique(texts.map((item) => compact(item, 100)).filter((item) => item && !/^[{}()[\];,.]+$/.test(item)), 18);
|
|
179
200
|
}
|
|
180
201
|
|
|
202
|
+
function primaryVisibleTitle(file) {
|
|
203
|
+
const texts = extractVisibleTexts(file);
|
|
204
|
+
return texts.find((item) => item.length >= 2 && item.length <= 40) ?? "";
|
|
205
|
+
}
|
|
206
|
+
|
|
181
207
|
function extractDesignFacts(file) {
|
|
182
208
|
if (![".css", ".html", ".tsx", ".jsx"].includes(file.extension)) return [];
|
|
183
209
|
const facts = [];
|
|
@@ -235,6 +261,63 @@ function isCliEntryFile(file) {
|
|
|
235
261
|
);
|
|
236
262
|
}
|
|
237
263
|
|
|
264
|
+
function isApiOrServerFile(file) {
|
|
265
|
+
const normalized = file.path.toLowerCase();
|
|
266
|
+
return (
|
|
267
|
+
/(^|\/)(api|routes?|server|controllers?)\//.test(normalized) ||
|
|
268
|
+
/(^|\/)(server|route|router|api)\.[cm]?[jt]sx?$/.test(normalized) ||
|
|
269
|
+
/\b(app|router)\.(get|post|put|patch|delete|use)\s*\(/i.test(file.content)
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isDataModelFile(file) {
|
|
274
|
+
const normalized = file.path.toLowerCase();
|
|
275
|
+
return (
|
|
276
|
+
/(^|\/)(models?|schemas?|entities|migrations?)\//.test(normalized) ||
|
|
277
|
+
/(model|schema|entity)\.[cm]?[jt]sx?$/.test(normalized) ||
|
|
278
|
+
/new\s+mongoose\.Schema\s*\(/.test(file.content)
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function isConfigurationFile(file) {
|
|
283
|
+
const normalized = file.path.toLowerCase();
|
|
284
|
+
const basename = path.basename(normalized);
|
|
285
|
+
return (
|
|
286
|
+
file.path === "package.json" ||
|
|
287
|
+
isEnvironmentConfigFile(file) ||
|
|
288
|
+
/(^|\/)(config|configs|scripts?|ci|\.github)\//.test(normalized) ||
|
|
289
|
+
/^(package-lock|pnpm-lock|yarn|tsconfig|next\.config|vite\.config|playwright\.config|dockerfile)/.test(basename)
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function isUserFacingFile(file) {
|
|
294
|
+
const normalized = file.path.toLowerCase();
|
|
295
|
+
return (
|
|
296
|
+
[".html", ".css", ".jsx", ".tsx"].includes(file.extension) ||
|
|
297
|
+
extractVisibleTexts(file).length > 0 ||
|
|
298
|
+
/(^|\/)(app|pages|components|ui|views|screens)\//.test(normalized)
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function hasImplementationLogic(file) {
|
|
303
|
+
return /(?:function\s+[A-Za-z_$][\w$]*|def\s+[A-Za-z_][\w]*|=>|\bfor\s*\(|\bif\s*\()/.test(file.content);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function specDirectoryForSource(file) {
|
|
307
|
+
if (isTestFile(file)) return `natural-git/verification/e2e/${sourcePathSlug(file.path)}`;
|
|
308
|
+
if (isCliEntryFile(file)) return `natural-git/interfaces/cli/${/\bworkq\s+/.test(file.content) ? "workq-cli" : sourcePathSlug(file.path)}`;
|
|
309
|
+
if (isApiOrServerFile(file)) return `natural-git/interfaces/api/${sourcePathSlug(file.path)}`;
|
|
310
|
+
if (isDataModelFile(file)) return `natural-git/data/models/${sourcePathSlug(file.path)}`;
|
|
311
|
+
if (isConfigurationFile(file)) {
|
|
312
|
+
if (file.path === "package.json") return "natural-git/operations/runtime/project-runtime";
|
|
313
|
+
if (isEnvironmentConfigFile(file)) return "natural-git/operations/runtime/runtime-environment";
|
|
314
|
+
return `natural-git/operations/configuration/${sourcePathSlug(file.path)}`;
|
|
315
|
+
}
|
|
316
|
+
if (isUserFacingFile(file)) return `natural-git/features/${slugSegment(primaryVisibleTitle(file) || file.path, sourcePathSlug(file.path))}`;
|
|
317
|
+
if (hasImplementationLogic(file)) return `natural-git/features/${sourcePathSlug(file.path)}`;
|
|
318
|
+
return `natural-git/supporting/${sourcePathSlug(file.path)}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
238
321
|
function extractCliFacts(file) {
|
|
239
322
|
if (!isCliEntryFile(file)) return [];
|
|
240
323
|
const facts = [];
|
|
@@ -360,7 +443,113 @@ function packageJsonDoc(content) {
|
|
|
360
443
|
}
|
|
361
444
|
}
|
|
362
445
|
|
|
446
|
+
function humanizeTestTitle(title) {
|
|
447
|
+
const normalized = title.replace(/\s+/g, " ").trim();
|
|
448
|
+
const known = [
|
|
449
|
+
[/regenerates legacy line-by-line natural specs on import/i, "예전 line-by-line 자연어 명세를 가져오면 기능 중심 명세로 재생성되는지 검증한다."],
|
|
450
|
+
[/registers a repo and turns its code into natural-language git specs/i, "새 프로젝트를 연결하고 로컬 코드 저장소를 자연어 명세 저장소로 변환하는 전체 흐름을 검증한다."],
|
|
451
|
+
[/bug report to desktop agent MCP completion flow/i, "버그 리포트가 MCP 작업 큐로 전달되고 데스크톱 에이전트 완료 처리까지 이어지는지 검증한다."],
|
|
452
|
+
[/project selector persists/i, "화면 새로고침 뒤에도 사용자가 선택한 프로젝트가 유지되는지 검증한다."],
|
|
453
|
+
[/clipboard/i, "사용자가 클립보드나 첨부 입력으로 리포트 근거를 남길 수 있는지 검증한다."],
|
|
454
|
+
[/natural specs/i, "자연어 명세 생성과 반영 흐름을 검증한다."],
|
|
455
|
+
[/local repository analyzer/i, "로컬 레포 분석기가 제품 기능과 유즈케이스를 추출하는지 검증한다."]
|
|
456
|
+
];
|
|
457
|
+
const match = known.find(([pattern]) => pattern.test(normalized));
|
|
458
|
+
if (match) return match[1];
|
|
459
|
+
return `"${normalized}" 시나리오가 사용자 관점의 기대 동작을 만족하는지 검증한다.`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function extractTestScenarios(file) {
|
|
463
|
+
const scenarios = [];
|
|
464
|
+
for (const match of file.content.matchAll(/\b(?:test|it)\s*\(\s*["'`]([^"'`]{3,180})["'`]/g)) {
|
|
465
|
+
scenarios.push(humanizeTestTitle(match[1]));
|
|
466
|
+
}
|
|
467
|
+
return unique(scenarios, 16);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function extractApiInteractions(file) {
|
|
471
|
+
const endpoints = [];
|
|
472
|
+
for (const match of file.content.matchAll(/\brequest\.(get|post|put|patch|delete)\s*\(\s*["'`]([^"'`]+)["'`]/g)) {
|
|
473
|
+
endpoints.push(`${match[1].toUpperCase()} ${match[2]}`);
|
|
474
|
+
}
|
|
475
|
+
for (const match of file.content.matchAll(/\bfetch\s*\(\s*["'`]([^"'`]+)["'`]/g)) {
|
|
476
|
+
endpoints.push(`FETCH ${match[1]}`);
|
|
477
|
+
}
|
|
478
|
+
return unique(endpoints, 20);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function extractMcpToolNames(file) {
|
|
482
|
+
const tools = [];
|
|
483
|
+
for (const match of file.content.matchAll(/\bname\s*:\s*["'`](workq_[^"'`]+)["'`]/g)) {
|
|
484
|
+
tools.push(match[1]);
|
|
485
|
+
}
|
|
486
|
+
return unique(tools, 20);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function testFileTitle(file) {
|
|
490
|
+
if (/v2/i.test(file.path)) return "Work Q v2 자연어 명세 저장소 검증 시나리오";
|
|
491
|
+
if (/smoke/i.test(file.path)) return "운영 스모크 검증 시나리오";
|
|
492
|
+
if (/analy/i.test(file.path)) return "레포 분석과 자연어 명세 생성 검증 시나리오";
|
|
493
|
+
return "제품 검증 시나리오 명세";
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function testFileToDoc(file) {
|
|
497
|
+
const scenarios = extractTestScenarios(file);
|
|
498
|
+
const endpoints = extractApiInteractions(file);
|
|
499
|
+
const mcpTools = extractMcpToolNames(file);
|
|
500
|
+
const inferred = [];
|
|
501
|
+
if (/\/api\/auth\/login/.test(file.content)) inferred.push("오너가 테스트 계정으로 로그인한다.");
|
|
502
|
+
if (/connect-project/.test(file.content)) inferred.push("사용자가 프로젝트 키, 레포, 로컬 경로를 등록한다.");
|
|
503
|
+
if (/workq_import_natural_git_specs/.test(file.content)) inferred.push("로컬 MCP가 생성한 자연어 명세 문서를 Work Q에 업로드한다.");
|
|
504
|
+
if (/\/api\/v2\/spec-git\/commit/.test(file.content)) inferred.push("사용자가 자연어 명세 변경을 커밋으로 승인한다.");
|
|
505
|
+
if (/\/api\/v2\/spec-git\/merge/.test(file.content)) inferred.push("Work Q가 자연어 커밋을 머지 가능한 상태인지 검사한다.");
|
|
506
|
+
if (/\/api\/v2\/spec-git\/deploy/.test(file.content)) inferred.push("승인된 자연어 커밋이 MCP Agent 작업 큐로 전달된다.");
|
|
507
|
+
if (/workq_get_work_packet/.test(file.content)) inferred.push("데스크톱 에이전트가 작업 패킷을 받아 실제 코드 작업 기준을 확인한다.");
|
|
508
|
+
if (/workq_finish_task/.test(file.content)) inferred.push("에이전트가 작업 완료 후 검증 결과를 Work Q에 남긴다.");
|
|
509
|
+
return [
|
|
510
|
+
`# ${testFileTitle(file)}`,
|
|
511
|
+
NATURAL_SPEC_FORMAT_COMMENT,
|
|
512
|
+
"",
|
|
513
|
+
"## 기능 중심 자연어 명세",
|
|
514
|
+
"이 명세는 제품 기능 자체가 아니라, 사용자가 기대하는 Work Q 흐름이 실제로 동작하는지 확인하는 검증 시나리오를 정의한다.",
|
|
515
|
+
"",
|
|
516
|
+
"## 검증하는 사용자 흐름",
|
|
517
|
+
...unique([...scenarios, ...inferred], 20).map((item) => `- ${item}`),
|
|
518
|
+
"",
|
|
519
|
+
endpoints.length ? "## 사용되는 웹 API" : "",
|
|
520
|
+
...endpoints.map((item) => `- ${item}`),
|
|
521
|
+
"",
|
|
522
|
+
mcpTools.length ? "## 사용되는 MCP 작업" : "",
|
|
523
|
+
...mcpTools.map((item) => `- ${item}`),
|
|
524
|
+
"",
|
|
525
|
+
"## 핵심 규칙",
|
|
526
|
+
"- 테스트 코드는 제품 명세가 아니라 명세와 구현이 맞는지 확인하는 검증 근거로만 사용한다.",
|
|
527
|
+
"- 자연어 명세, 커밋, 머지, Agent 큐, 완료 처리 중 하나라도 끊기면 제품 흐름은 통과한 것으로 보지 않는다.",
|
|
528
|
+
"- 검증 시나리오의 기대값은 사용자가 보는 상태와 MCP Agent가 받는 작업 기준을 함께 확인해야 한다.",
|
|
529
|
+
"",
|
|
530
|
+
"## 검증 관점",
|
|
531
|
+
"- 화면에 표시되는 명세 경로와 편집 내용이 실제 자연어 git 저장소 상태와 일치하는지 확인한다.",
|
|
532
|
+
"- API 응답, MCP 응답, 화면 표시가 같은 프로젝트 키와 같은 자연어 커밋을 가리키는지 확인한다.",
|
|
533
|
+
"- 이 문서는 리포트 분류 시 구현 완료 여부와 회귀 여부를 판단하는 검증 증거로 사용한다."
|
|
534
|
+
].filter((line, index, array) => line || array[index - 1]).join("\n");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function titleForSourceFile(file) {
|
|
538
|
+
if (isTestFile(file)) return testFileTitle(file);
|
|
539
|
+
if (isCliEntryFile(file)) return "Work Q CLI 기능명세";
|
|
540
|
+
if (file.path === "package.json") return "프로젝트 실행 환경 기능명세";
|
|
541
|
+
const titleText = primaryVisibleTitle(file);
|
|
542
|
+
if (titleText && file.extension === ".html") return `${titleText} 기능명세`;
|
|
543
|
+
if (extractEnvironmentFacts(file).length) return "런타임 환경 설정 기능명세";
|
|
544
|
+
if (extractDataModelFacts(file).length) return "데이터 모델 기능명세";
|
|
545
|
+
if (file.extension === ".css") return "화면 디자인 기능명세";
|
|
546
|
+
if (isApiOrServerFile(file)) return "서버 API 기능명세";
|
|
547
|
+
if (hasImplementationLogic(file)) return `${sourcePathSlug(file.path).replace(/-/g, " ")} 기능명세`;
|
|
548
|
+
return "보조 기능명세";
|
|
549
|
+
}
|
|
550
|
+
|
|
363
551
|
function sourceFileToDoc(file) {
|
|
552
|
+
if (isTestFile(file)) return testFileToDoc(file);
|
|
364
553
|
const structure = {
|
|
365
554
|
imports: [],
|
|
366
555
|
exports: [],
|
|
@@ -399,20 +588,7 @@ function sourceFileToDoc(file) {
|
|
|
399
588
|
],
|
|
400
589
|
12
|
|
401
590
|
);
|
|
402
|
-
const
|
|
403
|
-
const title = isCliEntryFile(file)
|
|
404
|
-
? "Work Q CLI 기능명세"
|
|
405
|
-
: file.path === "package.json"
|
|
406
|
-
? "프로젝트 실행 환경 기능명세"
|
|
407
|
-
: titleText && file.extension === ".html"
|
|
408
|
-
? `${titleText} 기능명세`
|
|
409
|
-
: extractEnvironmentFacts(file).length
|
|
410
|
-
? "런타임 환경 설정 기능명세"
|
|
411
|
-
: dataModelFacts.length
|
|
412
|
-
? "데이터 모델 기능명세"
|
|
413
|
-
: file.extension === ".css"
|
|
414
|
-
? "화면 디자인 기능명세"
|
|
415
|
-
: `${file.path} 기능명세`;
|
|
591
|
+
const title = titleForSourceFile(file);
|
|
416
592
|
return [
|
|
417
593
|
`# ${title}`,
|
|
418
594
|
NATURAL_SPEC_FORMAT_COMMENT,
|
|
@@ -439,6 +615,532 @@ function sourceFileToDoc(file) {
|
|
|
439
615
|
].filter((line, index, array) => line || array[index - 1]).join("\n");
|
|
440
616
|
}
|
|
441
617
|
|
|
618
|
+
function sourceFileRulesDoc(file) {
|
|
619
|
+
const structure = {
|
|
620
|
+
imports: [],
|
|
621
|
+
exports: [],
|
|
622
|
+
classes: [],
|
|
623
|
+
routes: [],
|
|
624
|
+
events: [],
|
|
625
|
+
selectors: [],
|
|
626
|
+
...extractStructure(file.lines)
|
|
627
|
+
};
|
|
628
|
+
const dataModelFacts = extractDataModelFacts(file);
|
|
629
|
+
const functionalFacts = extractFunctionalFacts(file, structure);
|
|
630
|
+
const cliRules = extractCliRules(file);
|
|
631
|
+
const environmentFacts = extractEnvironmentFacts(file);
|
|
632
|
+
const designFacts = extractDesignFacts(file);
|
|
633
|
+
const rules = unique([...dataModelFacts, ...cliRules, ...environmentFacts, ...functionalFacts, ...designFacts], 30);
|
|
634
|
+
return [
|
|
635
|
+
`# ${titleForSourceFile(file)} - 규칙`,
|
|
636
|
+
NATURAL_SPEC_FORMAT_COMMENT,
|
|
637
|
+
"",
|
|
638
|
+
"## 핵심 규칙",
|
|
639
|
+
...(rules.length ? rules.map((item) => `- ${item}`) : ["- 이 기능은 연결된 상위 명세의 기대 동작을 보조한다."]),
|
|
640
|
+
"",
|
|
641
|
+
"## 판단 기준",
|
|
642
|
+
"- 리포트가 이 규칙과 다르게 동작한다고 주장하고 실제 구현도 다르면 버그 후보로 본다.",
|
|
643
|
+
"- 기대 동작은 있지만 이 규칙에 없으면 기획부재 또는 명세추가 후보로 본다.",
|
|
644
|
+
"- 서로 다른 규칙이 동시에 만족될 수 없으면 기능충돌 후보로 본다."
|
|
645
|
+
].join("\n");
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function sourceFileVerificationDoc(file) {
|
|
649
|
+
const structure = {
|
|
650
|
+
imports: [],
|
|
651
|
+
exports: [],
|
|
652
|
+
classes: [],
|
|
653
|
+
routes: [],
|
|
654
|
+
events: [],
|
|
655
|
+
selectors: [],
|
|
656
|
+
...extractStructure(file.lines)
|
|
657
|
+
};
|
|
658
|
+
const verificationFacts = isTestFile(file) ? extractTestScenarios(file) : buildVerificationFacts(file, structure);
|
|
659
|
+
return [
|
|
660
|
+
`# ${titleForSourceFile(file)} - 검증`,
|
|
661
|
+
NATURAL_SPEC_FORMAT_COMMENT,
|
|
662
|
+
"",
|
|
663
|
+
"## 검증해야 할 것",
|
|
664
|
+
...verificationFacts.map((item) => `- ${item}`),
|
|
665
|
+
"",
|
|
666
|
+
"## 완료 기준",
|
|
667
|
+
"- 관련 화면, API, MCP 응답, 또는 데이터 상태가 이 명세의 기대 동작과 일치한다.",
|
|
668
|
+
"- 자동 테스트가 있으면 통과 결과를 Work Q 작업 완료 기록에 남긴다.",
|
|
669
|
+
"- 자동 테스트가 없으면 수동 검증 방법과 남은 위험을 Work Q에 남긴다."
|
|
670
|
+
].join("\n");
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function sourceFileToDocs(file) {
|
|
674
|
+
const directory = specDirectoryForSource(file);
|
|
675
|
+
const title = titleForSourceFile(file);
|
|
676
|
+
return [
|
|
677
|
+
{
|
|
678
|
+
path: `${directory}/README.md`,
|
|
679
|
+
title,
|
|
680
|
+
content: sourceFileToDoc(file)
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
path: `${directory}/rules.md`,
|
|
684
|
+
title: `${title} 규칙`,
|
|
685
|
+
content: sourceFileRulesDoc(file)
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
path: `${directory}/verification.md`,
|
|
689
|
+
title: `${title} 검증`,
|
|
690
|
+
content: sourceFileVerificationDoc(file)
|
|
691
|
+
}
|
|
692
|
+
];
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function isRoutePageFile(file) {
|
|
696
|
+
return (
|
|
697
|
+
/^src\/app\/(?:.*\/)?page\.(tsx|jsx|ts|js)$/.test(file.path) ||
|
|
698
|
+
/^pages\/(?:.*\/)?[^/]+\.(tsx|jsx|ts|js)$/.test(file.path) ||
|
|
699
|
+
file.path === "index.html"
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function routeInfoForFile(file) {
|
|
704
|
+
if (file.path === "index.html") {
|
|
705
|
+
const title = primaryVisibleTitle(file) || "Root";
|
|
706
|
+
return {
|
|
707
|
+
url_pattern: "/",
|
|
708
|
+
directory: "natural-git/screens/root",
|
|
709
|
+
title: `${title} 화면 명세`,
|
|
710
|
+
page_path: file.path
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
if (["src/app/page.tsx", "src/app/page.jsx", "src/app/page.ts", "src/app/page.js"].includes(file.path)) {
|
|
714
|
+
return {
|
|
715
|
+
url_pattern: "/",
|
|
716
|
+
directory: "natural-git/screens/root",
|
|
717
|
+
title: "/ 화면 명세",
|
|
718
|
+
page_path: file.path
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
const appMatch = file.path.match(/^src\/app\/(.+)\/page\.(tsx|jsx|ts|js)$/);
|
|
722
|
+
if (appMatch) {
|
|
723
|
+
const route = appMatch[1]
|
|
724
|
+
.split("/")
|
|
725
|
+
.filter((segment) => segment && !/^\(.+\)$/.test(segment))
|
|
726
|
+
.join("/");
|
|
727
|
+
const normalized = route || "root";
|
|
728
|
+
const url = normalized === "root" ? "/" : `/${normalized}`;
|
|
729
|
+
return {
|
|
730
|
+
url_pattern: url.replace(/\[(.+?)\]/g, "[$1]"),
|
|
731
|
+
directory: `natural-git/screens/${normalized.replace(/\[(.+?)\]/g, "[$1]")}`,
|
|
732
|
+
title: `${url} 화면 명세`,
|
|
733
|
+
page_path: file.path
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
const pagesMatch = file.path.match(/^pages\/(.+)\.(tsx|jsx|ts|js)$/);
|
|
737
|
+
if (pagesMatch) {
|
|
738
|
+
const route = pagesMatch[1].replace(/\/index$/, "root");
|
|
739
|
+
const url = route === "root" ? "/" : `/${route}`;
|
|
740
|
+
return {
|
|
741
|
+
url_pattern: url,
|
|
742
|
+
directory: `natural-git/screens/${route}`,
|
|
743
|
+
title: `${url} 화면 명세`,
|
|
744
|
+
page_path: file.path
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function sourceEvidencePath(file) {
|
|
751
|
+
return `natural-git/_evidence/source-files/${sourcePathSlug(file.path)}.md`;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function sourceEvidenceDoc(file) {
|
|
755
|
+
const structure = {
|
|
756
|
+
imports: [],
|
|
757
|
+
exports: [],
|
|
758
|
+
classes: [],
|
|
759
|
+
routes: [],
|
|
760
|
+
events: [],
|
|
761
|
+
selectors: [],
|
|
762
|
+
...extractStructure(file.lines)
|
|
763
|
+
};
|
|
764
|
+
const visibleTexts = extractVisibleTexts(file);
|
|
765
|
+
const relatedScreens = routeInfoForFile(file);
|
|
766
|
+
const evidenceLines = unique(
|
|
767
|
+
[
|
|
768
|
+
visibleTexts.length ? `화면에 보이는 문구: ${visibleTexts.map((item) => `"${item}"`).join(", ")}` : "",
|
|
769
|
+
structure.routes?.length ? `API/요청 단서: ${structure.routes.slice(0, 8).map((item) => item.kind).join(", ")}` : "",
|
|
770
|
+
structure.events?.length ? `사용자 이벤트 단서: ${structure.events.slice(0, 8).map((item) => item.event).join(", ")}` : "",
|
|
771
|
+
isCliEntryFile(file) ? "CLI 명령과 로컬 MCP 연결 흐름을 구현한다." : "",
|
|
772
|
+
isEnvironmentConfigFile(file) ? "런타임 설정과 외부 연결 조건을 구현한다." : "",
|
|
773
|
+
isTestFile(file) ? "자동 검증 또는 회귀 검증 시나리오를 구현한다." : ""
|
|
774
|
+
],
|
|
775
|
+
12
|
|
776
|
+
);
|
|
777
|
+
return {
|
|
778
|
+
path: sourceEvidencePath(file),
|
|
779
|
+
title: `구현 근거: ${file.path}`,
|
|
780
|
+
content: [
|
|
781
|
+
`# 구현 근거: ${file.path}`,
|
|
782
|
+
NATURAL_SPEC_FORMAT_COMMENT,
|
|
783
|
+
"",
|
|
784
|
+
"## 성격",
|
|
785
|
+
"이 문서는 사용자 기능명세가 아니라, 화면/영역/워크플로우 명세를 뒷받침하는 구현 근거다.",
|
|
786
|
+
"",
|
|
787
|
+
"## 연결 가능한 명세",
|
|
788
|
+
relatedScreens ? `- ${relatedScreens.directory}/` : "- 아직 특정 화면 명세에 직접 연결되지 않았다.",
|
|
789
|
+
"",
|
|
790
|
+
"## 관찰 근거",
|
|
791
|
+
...(evidenceLines.length ? evidenceLines.map((item) => `- ${item}`) : ["- 이 파일은 제품 동작을 구성하는 보조 구현 근거로 보존한다."]),
|
|
792
|
+
"",
|
|
793
|
+
"## 사용 원칙",
|
|
794
|
+
"- 이 문서를 사용자용 기능명세처럼 기본 화면에 노출하지 않는다.",
|
|
795
|
+
"- 버그/기획부재/충돌 판단 시에는 상위 screen/region/rules 명세를 먼저 읽고, 필요한 경우에만 구현 근거로 확인한다.",
|
|
796
|
+
"- 이 문서의 파일 경로와 내부 구현명은 제품 의도가 아니라 traceability 근거다."
|
|
797
|
+
].join("\n")
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function workQV2Regions() {
|
|
802
|
+
return [
|
|
803
|
+
{
|
|
804
|
+
slug: "project-repo-registration",
|
|
805
|
+
title: "프로젝트 · 레포 등록 영역",
|
|
806
|
+
purpose: "사용자는 Work Q에서 관리할 프로젝트와 연결된 코드 레포 정보를 등록하고 로컬 에이전트 연결 방법을 확인한다.",
|
|
807
|
+
initial: [
|
|
808
|
+
"Active project 선택값이 보인다.",
|
|
809
|
+
"프로젝트 키, 프로젝트 이름, GitHub owner, repo, default branch, branch prefix, local repo path, test command, smoke command 입력칸이 보인다.",
|
|
810
|
+
"검증된 로컬 바인딩이 없으면 아직 연결되지 않았다는 안내가 표시된다."
|
|
811
|
+
],
|
|
812
|
+
actions: [
|
|
813
|
+
"사용자는 프로젝트/레포 정보를 입력한다.",
|
|
814
|
+
"`프로젝트/레포 저장` 버튼을 누르면 입력한 프로젝트 컨텍스트가 저장된다.",
|
|
815
|
+
"`레포 코드 자연어 분석` 버튼을 누르면 서버가 접근 가능한 로컬 경로를 분석해 natural-spec repo 문서를 만든다.",
|
|
816
|
+
"운영 환경에서는 안내된 명령을 따라 데스크톱에서 `workq natural-git`으로 업로드한다."
|
|
817
|
+
],
|
|
818
|
+
rules: [
|
|
819
|
+
"project key는 여러 프로젝트를 구분하는 안정적인 식별자다.",
|
|
820
|
+
"local path는 서버가 읽을 수 있을 때만 직접 분석에 사용한다.",
|
|
821
|
+
"로컬 에이전트 바인딩은 repo owner, repo, local path, branch가 일치해야 검증된 연결로 취급한다."
|
|
822
|
+
],
|
|
823
|
+
verification: ["저장 후 Active project가 해당 프로젝트로 유지된다.", "repo owner/repo/default branch/local path가 프로젝트 상태와 일치한다.", "잘못된 경로나 미연결 상태에서는 사용자가 다음 행동을 알 수 있어야 한다."]
|
|
824
|
+
},
|
|
825
|
+
{
|
|
826
|
+
slug: "project-status-summary",
|
|
827
|
+
title: "프로젝트 상태 요약 영역",
|
|
828
|
+
purpose: "사용자는 현재 프로젝트, 명세 상태, Agent 큐 준비 상태를 빠르게 확인한다.",
|
|
829
|
+
initial: ["프로젝트 키와 프로젝트 이름이 보인다.", "레포와 기본 브랜치가 보인다.", "문서 수, 수정 문서 수, 입력/결정 필요 수, 충돌 수가 보인다.", "Agent 큐가 생성됐는지 또는 승인 대기인지 보인다."],
|
|
830
|
+
actions: ["이 영역은 직접 입력 영역이 아니라 현재 상태를 읽는 요약 영역이다."],
|
|
831
|
+
rules: ["상태 숫자는 선택된 Active project 기준으로만 계산한다.", "프로젝트를 바꾸면 상태 요약도 같은 프로젝트 기준으로 바뀐다."],
|
|
832
|
+
verification: ["프로젝트 전환 후 다른 프로젝트의 상태가 섞이지 않는다.", "문서 수정, 커밋, 큐 생성 후 상태 숫자와 배지가 갱신된다."]
|
|
833
|
+
},
|
|
834
|
+
{
|
|
835
|
+
slug: "natural-spec-repo",
|
|
836
|
+
title: "natural-spec repo 파일 트리 영역",
|
|
837
|
+
purpose: "사용자는 자연어 명세 git에 들어 있는 화면/영역/규칙/검증 문서를 선택한다.",
|
|
838
|
+
initial: ["natural-spec repo 제목과 저장소 위치 안내가 보인다.", "기본적으로 사용자용 명세 문서가 보인다.", "구현 근거 문서는 evidence로 분리되어 기본 판단 문서와 구분된다."],
|
|
839
|
+
actions: ["사용자는 문서 경로를 클릭해 편집할 문서를 선택한다.", "선택된 문서는 활성 상태로 표시된다."],
|
|
840
|
+
rules: ["URL 화면 명세와 region 명세를 우선 보여준다.", "`_evidence`와 source index는 사용자 기능명세가 아니라 구현 근거로 취급한다.", "동적 URL은 숫자 id나 페이지 번호별로 나누지 않고 하나의 패턴 문서로 묶는다."],
|
|
841
|
+
verification: ["`natural-git/screens/v2/README.md`가 기본 기능명세로 보인다.", "`natural-git/_evidence/...`가 기능명세처럼 오해되지 않는다."]
|
|
842
|
+
},
|
|
843
|
+
{
|
|
844
|
+
slug: "natural-spec-editor",
|
|
845
|
+
title: "자연어 명세 편집 영역",
|
|
846
|
+
purpose: "사용자는 선택한 Markdown 자연어 명세를 읽고 수정한다.",
|
|
847
|
+
initial: ["파일 트리에서 선택한 문서의 경로와 제목이 보인다.", "문서 본문이 Markdown 편집기에 표시된다."],
|
|
848
|
+
actions: ["사용자는 Markdown 내용을 수정한다.", "`Save draft` 버튼을 눌러 draft를 저장한다."],
|
|
849
|
+
rules: ["draft 저장은 git commit이 아니다.", "명세 변경은 자연어 커밋을 거쳐야 기준 HEAD가 된다."],
|
|
850
|
+
verification: ["저장 전후 문서 상태가 수정됨/동기화로 구분된다.", "다른 문서를 선택해도 선택한 문서의 draft가 올바르게 표시된다."]
|
|
851
|
+
},
|
|
852
|
+
{
|
|
853
|
+
slug: "commit-merge-agent-queue",
|
|
854
|
+
title: "commit · merge · queue 영역",
|
|
855
|
+
purpose: "사용자는 자연어 명세 변경을 커밋하고, 머지 가능성을 검사하고, 승인된 커밋을 MCP Agent 작업 큐로 보낸다.",
|
|
856
|
+
initial: ["commit message 입력칸과 자연어 커밋, 명세 머지, 커밋 승인·Agent 큐, 롤백 버튼이 보인다."],
|
|
857
|
+
actions: ["`자연어 커밋`은 draft를 natural spec git commit으로 확정한다.", "`명세 머지`는 입력 필요, 결정 필요, 충돌을 검사한다.", "`커밋 승인·Agent 큐`는 승인된 자연어 커밋을 데스크톱 Agent 작업으로 만든다.", "`롤백`은 이전 natural spec commit 상태로 되돌린다."],
|
|
858
|
+
rules: ["수정된 문서가 없으면 자연어 커밋을 만들 수 없다.", "입력 필요나 충돌이 남아 있으면 Agent 큐를 만들지 않는다.", "Agent 큐 작업은 승인된 자연어 커밋을 기준으로 만들어진다."],
|
|
859
|
+
verification: ["커밋 id는 실제 git commit SHA다.", "merge/run 상태와 Agent queue run 상태가 화면에 기록된다.", "큐 생성 결과에 task id가 남는다."]
|
|
860
|
+
},
|
|
861
|
+
{
|
|
862
|
+
slug: "report-intake",
|
|
863
|
+
title: "리포트 · 이미지 접수 영역",
|
|
864
|
+
purpose: "사용자는 버그, 개선, 개발문의, 기획문의를 입력하고 스크린샷을 첨부해 자연어 커밋 제안을 만든다.",
|
|
865
|
+
initial: ["유형, 제목, 현재 URL, 실제 동작, 기대 동작, 재현 방법 입력칸이 보인다.", "스크린샷 붙여넣기/파일 선택 영역이 보인다."],
|
|
866
|
+
actions: ["사용자는 리포트 내용을 입력한다.", "스크린샷을 붙여넣거나 파일을 선택한다.", "`AI 분석 후 자연어 커밋 제안 만들기`를 누르면 Work Q가 리포트를 명세 기준으로 분류하고 제안 문서를 만든다.", "기존 리포트는 다시 분석하거나 문의 답변 또는 큐 등록으로 보낼 수 있다."],
|
|
867
|
+
rules: ["버그는 승인된 명세와 실제 동작이 다를 때만 버그로 본다.", "명세가 없으면 버그로 단정하지 않고 기획부재나 결정 필요로 남긴다.", "첨부 파일은 허용된 이미지 형식과 크기 제한을 따른다."],
|
|
868
|
+
verification: ["리포트 제출 후 suggested 문서가 생성된다.", "첨부 스크린샷이 리포트와 연결된다.", "개발문의/기획문의는 단순 버그 큐가 아니라 문의 답변 흐름으로 처리된다."]
|
|
869
|
+
},
|
|
870
|
+
{
|
|
871
|
+
slug: "existing-workq-queue",
|
|
872
|
+
title: "기존 WorkQ 큐 영역",
|
|
873
|
+
purpose: "사용자는 기존 WorkQ MCP 작업 큐에 쌓인 작업과 상태를 확인한다.",
|
|
874
|
+
initial: ["큐가 비어 있으면 빈 상태 안내가 보인다.", "작업이 있으면 상태 배지, 제목, 요약이 보인다."],
|
|
875
|
+
actions: ["이 영역은 작업 상태를 읽는 영역이며, 리포트 영역이나 자연어 커밋 승인에서 큐가 생성된다."],
|
|
876
|
+
rules: ["선택된 프로젝트의 작업만 보여준다.", "완료/결정 필요/진행 상태를 구분해 표시한다."],
|
|
877
|
+
verification: ["다른 프로젝트의 작업이 섞이지 않는다.", "Agent가 완료하면 상태가 완료로 보인다."]
|
|
878
|
+
},
|
|
879
|
+
{
|
|
880
|
+
slug: "mini-ai-model-settings",
|
|
881
|
+
title: "Mini AI 모델 설정 영역",
|
|
882
|
+
purpose: "사용자는 리포트 분류와 문의 답변에 사용할 OpenAI 호환 모델 provider를 설정하고 테스트한다.",
|
|
883
|
+
initial: ["Base URL, Model, API key 입력칸이 보인다.", "저장, 테스트, 삭제 버튼이 보인다."],
|
|
884
|
+
actions: ["사용자는 모델 정보를 저장한다.", "저장된 provider를 테스트한다.", "필요하면 provider 설정을 삭제한다."],
|
|
885
|
+
rules: ["API key는 저장 후 입력칸에 다시 노출하지 않는다.", "provider가 없으면 테스트 버튼은 동작하지 않는다."],
|
|
886
|
+
verification: ["저장 후 provider 이름과 endpoint 상태가 보인다.", "테스트 결과가 JSON으로 표시된다.", "삭제 후 provider 없음 상태가 된다."]
|
|
887
|
+
},
|
|
888
|
+
{
|
|
889
|
+
slug: "roadmap",
|
|
890
|
+
title: "로드맵 영역",
|
|
891
|
+
purpose: "사용자는 natural spec 근거와 분리된 가벼운 아이디어/검토/보류 메모를 관리한다.",
|
|
892
|
+
initial: ["제목, 상태, 메모 입력칸과 로드맵 추가 버튼이 보인다.", "기존 로드맵 항목 수가 보인다."],
|
|
893
|
+
actions: ["로드맵 항목을 추가한다.", "항목을 위/아래로 이동한다.", "항목을 삭제한다."],
|
|
894
|
+
rules: ["로드맵은 승인된 기능명세가 아니다.", "로드맵 순서는 참고 우선순위로만 사용한다."],
|
|
895
|
+
verification: ["항목 추가, 순서 이동, 삭제가 선택된 프로젝트 안에서만 반영된다."]
|
|
896
|
+
},
|
|
897
|
+
{
|
|
898
|
+
slug: "decision-needed-mcp",
|
|
899
|
+
title: "결정 필요 작업 · MCP 영역",
|
|
900
|
+
purpose: "사용자는 결정이 필요한 작업을 승인 또는 거절하고 MCP 엔드포인트 상태를 확인한다.",
|
|
901
|
+
initial: ["MCP URL과 token 준비 상태가 보인다.", "decision_needed 작업 목록이 있으면 카드로 표시된다."],
|
|
902
|
+
actions: ["사용자는 결정 내용을 입력한다.", "`승인하고 큐`를 누르면 Agent 작업으로 보낸다.", "`거절`을 누르면 작업을 닫는다."],
|
|
903
|
+
rules: ["결정 내용이 없으면 승인/거절할 수 없다.", "승인된 결정은 이후 명세 또는 작업 기준으로 남아야 한다."],
|
|
904
|
+
verification: ["결정 처리 후 decision_needed 목록에서 사라진다.", "승인한 작업은 Agent 큐에서 확인된다."]
|
|
905
|
+
}
|
|
906
|
+
];
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function genericHtmlRegions(file) {
|
|
910
|
+
const title = primaryVisibleTitle(file) || "화면";
|
|
911
|
+
const hasOneToNineLoop = /\bfor\s*\(\s*let\s+\w+\s*=\s*1\s*;\s*\w+\s*<=\s*9\s*;/.test(file.content);
|
|
912
|
+
if (hasOneToNineLoop && /단/.test(file.content)) {
|
|
913
|
+
return [
|
|
914
|
+
{
|
|
915
|
+
slug: "dan-selection",
|
|
916
|
+
title: "단 선택 영역",
|
|
917
|
+
purpose: "사용자는 1단부터 9단까지 중 보고 싶은 단을 선택한다.",
|
|
918
|
+
initial: ["1단부터 9단까지 9개의 선택 버튼이 보인다.", "아직 선택하지 않았을 때는 선택을 유도하는 안내 문구가 보인다."],
|
|
919
|
+
actions: ["사용자가 특정 단 버튼을 누르면 그 단이 선택된다.", "다른 단을 누르면 이전 선택은 해제되고 새 단만 선택된다."],
|
|
920
|
+
rules: ["선택 상태는 항상 하나만 유지된다.", "버튼은 1단부터 9단까지 자동 생성된다."],
|
|
921
|
+
verification: ["1단부터 9단까지 모든 버튼이 표시된다.", "두 개 이상의 버튼이 동시에 선택 상태가 되지 않는다."]
|
|
922
|
+
},
|
|
923
|
+
{
|
|
924
|
+
slug: "multiplication-result",
|
|
925
|
+
title: "곱셈표 결과 영역",
|
|
926
|
+
purpose: "사용자는 선택한 단의 1부터 9까지 곱셈 결과를 확인한다.",
|
|
927
|
+
initial: ["처음에는 결과 대신 안내 문구가 보인다."],
|
|
928
|
+
actions: ["단을 선택하면 결과 영역이 선택한 단의 곱셈표로 교체된다.", "다른 단을 선택하면 결과도 새 단으로 갱신된다."],
|
|
929
|
+
rules: ["선택한 단에 대해 ×1부터 ×9까지 총 9줄을 보여준다.", "결과값은 즉석에서 계산하며 하드코딩하지 않는다.", "결과 상단에는 선택한 단 제목을 표시한다."],
|
|
930
|
+
verification: ["7단 선택 시 7 × 1 = 7부터 7 × 9 = 63까지 표시된다.", "선택한 단을 바꾸면 이전 결과가 남지 않는다."]
|
|
931
|
+
}
|
|
932
|
+
];
|
|
933
|
+
}
|
|
934
|
+
return [
|
|
935
|
+
{
|
|
936
|
+
slug: "main-content",
|
|
937
|
+
title: `${title} 주요 화면 영역`,
|
|
938
|
+
purpose: "사용자는 이 화면에서 주요 콘텐츠와 조작 가능한 요소를 확인한다.",
|
|
939
|
+
initial: extractVisibleTexts(file).slice(0, 8).map((item) => `"${item}" 문구가 보인다.`),
|
|
940
|
+
actions: ["사용자는 화면에 표시된 버튼, 링크, 입력 요소를 조작한다."],
|
|
941
|
+
rules: ["화면에 보이는 문구와 조작 요소는 같은 URL 화면 명세 안에서 관리한다."],
|
|
942
|
+
verification: ["초기 화면의 주요 문구와 조작 요소가 표시된다."]
|
|
943
|
+
}
|
|
944
|
+
];
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function genericRouteRegions(file) {
|
|
948
|
+
const texts = extractVisibleTexts(file);
|
|
949
|
+
return [
|
|
950
|
+
{
|
|
951
|
+
slug: "main-screen",
|
|
952
|
+
title: "주요 화면 영역",
|
|
953
|
+
purpose: "사용자는 이 URL에서 제공되는 핵심 화면을 본다.",
|
|
954
|
+
initial: texts.slice(0, 8).map((item) => `"${item}" 문구가 보인다.`),
|
|
955
|
+
actions: ["사용자는 화면에 표시된 버튼, 입력칸, 선택값을 조작한다."],
|
|
956
|
+
rules: ["같은 URL 패턴에 속한 상태 변화는 같은 화면 명세 안에서 관리한다."],
|
|
957
|
+
verification: ["URL 접속 시 주요 화면 문구와 조작 요소가 표시된다."]
|
|
958
|
+
}
|
|
959
|
+
];
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function regionsForRoute(file, route) {
|
|
963
|
+
if (route.url_pattern === "/v2" && file.path === "src/app/v2/page.tsx") return workQV2Regions();
|
|
964
|
+
if (file.path === "index.html") return genericHtmlRegions(file);
|
|
965
|
+
return genericRouteRegions(file);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function screenReadmeDoc(file, route, regions) {
|
|
969
|
+
const isWorkQV2 = route.url_pattern === "/v2" && file.path === "src/app/v2/page.tsx";
|
|
970
|
+
const title = isWorkQV2 ? "Work Q v2 대시보드 화면 명세" : route.title;
|
|
971
|
+
const purpose = isWorkQV2
|
|
972
|
+
? "사용자는 Work Q v2에서 프로젝트와 레포를 연결하고, natural-spec repo를 편집하며, 리포트와 자연어 커밋을 Agent 작업 큐로 연결한다."
|
|
973
|
+
: `${route.url_pattern} URL에서 사용자가 보는 화면의 목적, 영역, 조작, 결과를 정의한다.`;
|
|
974
|
+
return {
|
|
975
|
+
path: `${route.directory}/README.md`,
|
|
976
|
+
title,
|
|
977
|
+
content: [
|
|
978
|
+
`# ${title}`,
|
|
979
|
+
NATURAL_SPEC_FORMAT_COMMENT,
|
|
980
|
+
"",
|
|
981
|
+
"## URL 패턴",
|
|
982
|
+
`- ${route.url_pattern}`,
|
|
983
|
+
"",
|
|
984
|
+
"## 화면 목적",
|
|
985
|
+
purpose,
|
|
986
|
+
"",
|
|
987
|
+
"## 화면 영역",
|
|
988
|
+
...regions.map((region) => `- ${region.title}: ${region.purpose}`),
|
|
989
|
+
"",
|
|
990
|
+
"## 핵심 원칙",
|
|
991
|
+
"- URL 패턴을 최상위 화면 명세로 삼는다.",
|
|
992
|
+
"- 화면 안의 사용자 인식 영역을 region 명세로 나눈다.",
|
|
993
|
+
"- 코드 파일 경로는 사용자 기능명세 제목으로 쓰지 않고 evidence로 분리한다.",
|
|
994
|
+
"- 페이지네이션, 게시물 번호, 상세 id처럼 값만 바뀌는 URL은 하나의 URL 패턴으로 추상화한다.",
|
|
995
|
+
"",
|
|
996
|
+
"## 구현 근거",
|
|
997
|
+
`- ${sourceEvidencePath(file)}`
|
|
998
|
+
].join("\n")
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function screenRulesDoc(route, regions) {
|
|
1003
|
+
return {
|
|
1004
|
+
path: `${route.directory}/rules.md`,
|
|
1005
|
+
title: `${route.url_pattern} 화면 규칙`,
|
|
1006
|
+
content: [
|
|
1007
|
+
`# ${route.url_pattern} 화면 규칙`,
|
|
1008
|
+
NATURAL_SPEC_FORMAT_COMMENT,
|
|
1009
|
+
"",
|
|
1010
|
+
"## 공통 판단 기준",
|
|
1011
|
+
"- 리포트가 이 화면 또는 하위 영역의 규칙과 다른 실제 동작을 제보하면 bug 후보로 본다.",
|
|
1012
|
+
"- 기대 동작이 plausible하지만 이 화면/영역/rules에 없으면 기획부재 또는 decision needed 후보로 본다.",
|
|
1013
|
+
"- 같은 화면 안의 두 영역 규칙이 동시에 만족될 수 없으면 기능충돌 후보로 본다.",
|
|
1014
|
+
"- 코드에서 관찰된 구현만으로 제품 의도를 단정하지 않는다.",
|
|
1015
|
+
"",
|
|
1016
|
+
"## 영역별 핵심 규칙",
|
|
1017
|
+
...regions.flatMap((region) => [`### ${region.title}`, ...region.rules.map((rule) => `- ${rule}`), ""])
|
|
1018
|
+
].join("\n")
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function screenVerificationDoc(route, regions) {
|
|
1023
|
+
return {
|
|
1024
|
+
path: `${route.directory}/verification.md`,
|
|
1025
|
+
title: `${route.url_pattern} 화면 검증`,
|
|
1026
|
+
content: [
|
|
1027
|
+
`# ${route.url_pattern} 화면 검증`,
|
|
1028
|
+
NATURAL_SPEC_FORMAT_COMMENT,
|
|
1029
|
+
"",
|
|
1030
|
+
"## 화면 검증 원칙",
|
|
1031
|
+
"- URL 접속 후 사용자가 보는 주요 영역이 표시되어야 한다.",
|
|
1032
|
+
"- 각 영역의 입력, 버튼, 상태 변화, 오류 상태를 검증한다.",
|
|
1033
|
+
"- Agent 완료 전에는 관련 영역의 verification 항목을 확인하고 Work Q 완료 기록에 남긴다.",
|
|
1034
|
+
"",
|
|
1035
|
+
"## 영역별 검증",
|
|
1036
|
+
...regions.flatMap((region) => [`### ${region.title}`, ...region.verification.map((item) => `- ${item}`), ""])
|
|
1037
|
+
].join("\n")
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function regionDoc(route, region) {
|
|
1042
|
+
return {
|
|
1043
|
+
path: `${route.directory}/regions/${region.slug}.md`,
|
|
1044
|
+
title: region.title,
|
|
1045
|
+
content: [
|
|
1046
|
+
`# ${region.title}`,
|
|
1047
|
+
NATURAL_SPEC_FORMAT_COMMENT,
|
|
1048
|
+
"",
|
|
1049
|
+
"## 목적",
|
|
1050
|
+
region.purpose,
|
|
1051
|
+
"",
|
|
1052
|
+
"## 초기 상태",
|
|
1053
|
+
...region.initial.map((item) => `- ${item}`),
|
|
1054
|
+
"",
|
|
1055
|
+
"## 사용자 동작",
|
|
1056
|
+
...region.actions.map((item) => `- ${item}`),
|
|
1057
|
+
"",
|
|
1058
|
+
"## 핵심 규칙",
|
|
1059
|
+
...region.rules.map((item) => `- ${item}`),
|
|
1060
|
+
"",
|
|
1061
|
+
"## 검증 방법",
|
|
1062
|
+
...region.verification.map((item) => `- ${item}`)
|
|
1063
|
+
].join("\n")
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function fallbackRouteInfo() {
|
|
1068
|
+
return {
|
|
1069
|
+
url_pattern: "<product-overview>",
|
|
1070
|
+
directory: "natural-git/screens/product-overview",
|
|
1071
|
+
title: "제품 개요 화면 명세",
|
|
1072
|
+
page_path: "repository"
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function fallbackRegions(sourceFiles) {
|
|
1077
|
+
const files = sourceFiles.map((file) => file.path.toLowerCase());
|
|
1078
|
+
const hasLottoGenerator = files.some((filePath) => /generate[_-]?numbers/.test(filePath));
|
|
1079
|
+
const hasServer = sourceFiles.some((file) => isApiOrServerFile(file));
|
|
1080
|
+
const regions = [];
|
|
1081
|
+
|
|
1082
|
+
if (hasLottoGenerator) {
|
|
1083
|
+
regions.push({
|
|
1084
|
+
slug: "lotto-number-generation",
|
|
1085
|
+
title: "로또 번호 생성 기능 영역",
|
|
1086
|
+
purpose: "사용자는 로또 번호 후보를 생성하고, 생성 결과가 로또 번호 규칙을 지키는지 확인한다.",
|
|
1087
|
+
initial: ["로또 번호 생성 기능은 코드 레포에 존재하지만 아직 특정 URL 화면 파일과 직접 연결되지 않았다."],
|
|
1088
|
+
actions: ["사용자는 번호 생성을 요청한다.", "시스템은 1부터 45 사이의 번호 6개를 결과로 제공한다."],
|
|
1089
|
+
rules: ["생성 결과는 1부터 45 사이의 숫자여야 한다.", "한 조합 안에서 같은 번호가 두 번 나오면 안 된다.", "번호 개수는 6개여야 한다."],
|
|
1090
|
+
verification: ["생성 결과의 길이가 6인지 확인한다.", "모든 번호가 1부터 45 사이인지 확인한다.", "중복 번호가 없는지 확인한다."]
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
if (hasServer) {
|
|
1095
|
+
regions.push({
|
|
1096
|
+
slug: "service-api",
|
|
1097
|
+
title: "서비스 API 영역",
|
|
1098
|
+
purpose: "외부 요청은 서버 API를 통해 제품 기능의 결과를 받는다.",
|
|
1099
|
+
initial: ["서버 API 구현 근거가 존재한다."],
|
|
1100
|
+
actions: ["사용자 또는 클라이언트는 API 요청을 보낸다.", "서버는 연결된 제품 기능의 결과를 응답한다."],
|
|
1101
|
+
rules: ["API 응답은 연결된 사용자 기능 명세의 규칙을 따라야 한다.", "API가 실패하면 호출자가 실패를 구분할 수 있어야 한다."],
|
|
1102
|
+
verification: ["API 호출 결과가 연결된 기능 명세와 같은 형식과 규칙을 만족하는지 확인한다."]
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
if (!regions.length) {
|
|
1107
|
+
regions.push({
|
|
1108
|
+
slug: "observed-product-behavior",
|
|
1109
|
+
title: "관찰된 제품 동작 영역",
|
|
1110
|
+
purpose: "레포에서 관찰된 구현을 바탕으로 아직 화면에 연결되지 않은 제품 동작 후보를 관리한다.",
|
|
1111
|
+
initial: ["명확한 URL 화면 파일이 발견되지 않았다."],
|
|
1112
|
+
actions: ["오너는 이 영역의 초안을 읽고 실제 제품 의도인지 승인하거나 수정한다."],
|
|
1113
|
+
rules: ["코드에서 관찰된 동작은 승인 전까지 제품 의도로 단정하지 않는다.", "사용자 기능으로 확정하려면 자연어 커밋으로 승인해야 한다."],
|
|
1114
|
+
verification: ["오너가 기능 의도를 승인하거나, 더 구체적인 화면/유즈케이스 명세로 분리한다."]
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
return regions;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function routeScreenDocs(sourceFiles) {
|
|
1122
|
+
const docs = sourceFiles.flatMap((file) => {
|
|
1123
|
+
const route = routeInfoForFile(file);
|
|
1124
|
+
if (!route) return [];
|
|
1125
|
+
const regions = regionsForRoute(file, route);
|
|
1126
|
+
return [
|
|
1127
|
+
screenReadmeDoc(file, route, regions),
|
|
1128
|
+
screenRulesDoc(route, regions),
|
|
1129
|
+
screenVerificationDoc(route, regions),
|
|
1130
|
+
...regions.map((region) => regionDoc(route, region))
|
|
1131
|
+
];
|
|
1132
|
+
});
|
|
1133
|
+
if (docs.length || !sourceFiles.length) return docs;
|
|
1134
|
+
const route = fallbackRouteInfo();
|
|
1135
|
+
const regions = fallbackRegions(sourceFiles);
|
|
1136
|
+
return [
|
|
1137
|
+
screenReadmeDoc(sourceFiles[0], route, regions),
|
|
1138
|
+
screenRulesDoc(route, regions),
|
|
1139
|
+
screenVerificationDoc(route, regions),
|
|
1140
|
+
...regions.map((region) => regionDoc(route, region))
|
|
1141
|
+
];
|
|
1142
|
+
}
|
|
1143
|
+
|
|
442
1144
|
function overviewDoc({ projectKey, projectName, repository, root, sourceFiles }) {
|
|
443
1145
|
const byLanguage = new Map();
|
|
444
1146
|
for (const file of sourceFiles) byLanguage.set(file.language, (byLanguage.get(file.language) ?? 0) + 1);
|
|
@@ -447,7 +1149,7 @@ function overviewDoc({ projectKey, projectName, repository, root, sourceFiles })
|
|
|
447
1149
|
NATURAL_SPEC_FORMAT_COMMENT,
|
|
448
1150
|
"",
|
|
449
1151
|
"## 의미",
|
|
450
|
-
"이 명세는 레포에 존재하는
|
|
1152
|
+
"이 명세는 레포에 존재하는 URL/화면을 최상위 기준으로 삼고, 화면 안의 사용자 인식 영역을 자연어 Markdown 문서로 정의한 것이다. Work Q v2에서는 이 자연어 문서를 기준으로 리포트와 코드 변경을 판단한다.",
|
|
451
1153
|
"",
|
|
452
1154
|
"## 프로젝트",
|
|
453
1155
|
`- 프로젝트 키: ${projectKey}`,
|
|
@@ -459,9 +1161,17 @@ function overviewDoc({ projectKey, projectName, repository, root, sourceFiles })
|
|
|
459
1161
|
"## 언어별 파일 수",
|
|
460
1162
|
...Array.from(byLanguage.entries()).map(([language, count]) => `- ${language}: ${count}개`),
|
|
461
1163
|
"",
|
|
1164
|
+
"## 디렉토리 구조",
|
|
1165
|
+
"- `natural-git/screens/`: URL 패턴별 최상위 화면 명세",
|
|
1166
|
+
"- `natural-git/screens/<url>/regions/`: 화면 안의 사용자 인식 영역 명세",
|
|
1167
|
+
"- `natural-git/screens/<url>/components/`: 여러 화면에서 재사용되는 컴포넌트 명세",
|
|
1168
|
+
"- `natural-git/screens/<url>/dialogs/`: 모달, 팝업, 드로어 상태 명세",
|
|
1169
|
+
"- `natural-git/verification/`: 기능이 실제로 동작하는지 확인하는 검증 시나리오 명세",
|
|
1170
|
+
"- `natural-git/_evidence/`: 코드 파일, 설정, 테스트의 구현 근거. 기본 사용자 명세가 아니라 traceability 근거다.",
|
|
1171
|
+
"",
|
|
462
1172
|
"## 운영 규칙",
|
|
463
|
-
"- 자연어 문서는
|
|
464
|
-
"-
|
|
1173
|
+
"- 자연어 문서는 URL 화면과 사용자 인식 영역 중심으로 작성한다.",
|
|
1174
|
+
"- 코드 파일 문서는 사용자 기능명세가 아니라 `_evidence` 아래 구현 근거로 보존한다.",
|
|
465
1175
|
"- 코드의 현재 동작이 곧 제품 의도라는 뜻은 아니다.",
|
|
466
1176
|
"- 오너가 자연어 커밋으로 승인한 내용만 다음 코드 변경의 기준이 된다.",
|
|
467
1177
|
"- 코드가 바뀌면 이 자연어 git 명세도 다시 분석하거나 수정해야 한다."
|
|
@@ -470,11 +1180,11 @@ function overviewDoc({ projectKey, projectName, repository, root, sourceFiles })
|
|
|
470
1180
|
|
|
471
1181
|
function indexDoc(sourceFiles) {
|
|
472
1182
|
return [
|
|
473
|
-
"#
|
|
1183
|
+
"# 구현 근거 색인",
|
|
474
1184
|
"",
|
|
475
|
-
"이 문서는 분석된 파일과
|
|
1185
|
+
"이 문서는 분석된 파일과 evidence 문서를 연결한다. 사용자는 보통 이 색인보다 `natural-git/screens/` 아래의 화면/영역 명세를 먼저 읽는다.",
|
|
476
1186
|
"",
|
|
477
|
-
...sourceFiles.map((file) => `- \`${file.path}\` -> \`${
|
|
1187
|
+
...sourceFiles.map((file) => `- \`${file.path}\` -> \`${sourceEvidencePath(file)}\``)
|
|
478
1188
|
].join("\n");
|
|
479
1189
|
}
|
|
480
1190
|
|
|
@@ -499,6 +1209,8 @@ export function generateNaturalGitDocs({ localPath, projectKey, projectName = ""
|
|
|
499
1209
|
};
|
|
500
1210
|
})
|
|
501
1211
|
.filter(Boolean);
|
|
1212
|
+
const screenDocs = routeScreenDocs(sourceFiles);
|
|
1213
|
+
const verificationDocs = sourceFiles.filter(isTestFile).flatMap((file) => sourceFileToDocs(file));
|
|
502
1214
|
const docs = [
|
|
503
1215
|
{
|
|
504
1216
|
path: "natural-git/README.md",
|
|
@@ -506,15 +1218,13 @@ export function generateNaturalGitDocs({ localPath, projectKey, projectName = ""
|
|
|
506
1218
|
content: overviewDoc({ projectKey, projectName, repository, root, sourceFiles })
|
|
507
1219
|
},
|
|
508
1220
|
{
|
|
509
|
-
path: "natural-git/
|
|
510
|
-
title: "
|
|
1221
|
+
path: "natural-git/_evidence/source-index.md",
|
|
1222
|
+
title: "구현 근거 색인",
|
|
511
1223
|
content: indexDoc(sourceFiles)
|
|
512
1224
|
},
|
|
513
|
-
...
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
content: sourceFileToDoc(file)
|
|
517
|
-
}))
|
|
1225
|
+
...screenDocs,
|
|
1226
|
+
...verificationDocs,
|
|
1227
|
+
...sourceFiles.map((file) => sourceEvidenceDoc(file))
|
|
518
1228
|
];
|
|
519
1229
|
return {
|
|
520
1230
|
root,
|