workq-mcp 0.1.7 → 0.1.9
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/lib/natural-git.mjs +410 -30
- package/package.json +1 -1
package/lib/natural-git.mjs
CHANGED
|
@@ -183,20 +183,58 @@ function stripTags(value) {
|
|
|
183
183
|
return value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
function isProbablyCodeText(value) {
|
|
187
|
+
const text = value.trim();
|
|
188
|
+
if (!text) return true;
|
|
189
|
+
if (/[{}<>]|=>|\$\{/.test(text)) return true;
|
|
190
|
+
if (/[;]\s*$/.test(text)) return true;
|
|
191
|
+
if (/\b(?:on[A-Z]\w*|className|aria-label|text\.\w+|event\.|set[A-Z]\w*|function|const|let|return|await|async|JSON\.|api\(|onAction\(|document\.|querySelector)/.test(text)) return true;
|
|
192
|
+
if (/\w+\([^)]*\)/.test(text) && !/[가-힣]/.test(text)) return true;
|
|
193
|
+
if (/^[\w.$-]+\s*[=:]\s*[\w.$-]+$/.test(text)) return true;
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function normalizeVisibleTextCandidate(value) {
|
|
198
|
+
const text = compact(stripTags(value).replace(/ /g, " "), 100);
|
|
199
|
+
if (!text || /^[{}()[\];,.]+$/.test(text)) return "";
|
|
200
|
+
if (isProbablyCodeText(text)) return "";
|
|
201
|
+
return text;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function pushVisibleText(texts, value) {
|
|
205
|
+
const text = normalizeVisibleTextCandidate(value);
|
|
206
|
+
if (text) texts.push(text);
|
|
207
|
+
}
|
|
208
|
+
|
|
186
209
|
function extractVisibleTexts(file) {
|
|
187
210
|
const texts = [];
|
|
188
211
|
const markupContent = file.content.replace(/<script\b[\s\S]*?<\/script>/gi, "").replace(/<style\b[\s\S]*?<\/style>/gi, "");
|
|
189
212
|
if (file.extension === ".html") {
|
|
190
|
-
for (const match of file.content.matchAll(/<title[^>]*>([\s\S]*?)<\/title>/gi)) texts
|
|
191
|
-
|
|
213
|
+
for (const match of file.content.matchAll(/<title[^>]*>([\s\S]*?)<\/title>/gi)) pushVisibleText(texts, match[1]);
|
|
214
|
+
}
|
|
215
|
+
if ([".html", ".tsx", ".jsx"].includes(file.extension)) {
|
|
216
|
+
for (const match of markupContent.matchAll(/>([^<>{}][^<>{}]{1,100})</g)) {
|
|
217
|
+
pushVisibleText(texts, match[1]);
|
|
218
|
+
}
|
|
192
219
|
}
|
|
193
220
|
for (const match of file.content.matchAll(/\b(?:textContent|innerText|placeholder|aria-label|title)\s*=\s*["'`]([^"'`]{1,100})["'`]/g)) {
|
|
194
|
-
texts
|
|
221
|
+
pushVisibleText(texts, match[1]);
|
|
195
222
|
}
|
|
196
|
-
|
|
197
|
-
|
|
223
|
+
if (file.extension === ".html") {
|
|
224
|
+
for (const match of file.content.matchAll(/<button[^>]*>([\s\S]{1,160}?)<\/button>/gi)) {
|
|
225
|
+
pushVisibleText(texts, match[1]);
|
|
226
|
+
}
|
|
198
227
|
}
|
|
199
|
-
return unique(texts
|
|
228
|
+
return unique(texts, 18);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function visibleTextInitialStatements(file) {
|
|
232
|
+
const texts = extractVisibleTexts(file).slice(0, 8);
|
|
233
|
+
if (texts.length) return texts.map((item) => `"${item}" 문구가 보인다.`);
|
|
234
|
+
return [
|
|
235
|
+
"초기 화면에는 이 URL의 기본 콘텐츠와 조작 영역이 표시된다.",
|
|
236
|
+
"동적 문구는 원본 코드 조각이 아니라 실제 렌더링 결과를 기준으로 확인한다."
|
|
237
|
+
];
|
|
200
238
|
}
|
|
201
239
|
|
|
202
240
|
function primaryVisibleTitle(file) {
|
|
@@ -906,6 +944,76 @@ function workQV2Regions() {
|
|
|
906
944
|
];
|
|
907
945
|
}
|
|
908
946
|
|
|
947
|
+
function isWorkQDashboardPage(file) {
|
|
948
|
+
return (
|
|
949
|
+
file.path === "src/app/page.tsx" &&
|
|
950
|
+
/ProjectContextPanel/.test(file.content) &&
|
|
951
|
+
/ReportPanel/.test(file.content) &&
|
|
952
|
+
/TriagePanel/.test(file.content) &&
|
|
953
|
+
/AgentQueuePanel/.test(file.content) &&
|
|
954
|
+
/McpPanel/.test(file.content)
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function workQDashboardRegions() {
|
|
959
|
+
return [
|
|
960
|
+
{
|
|
961
|
+
slug: "topbar-and-login",
|
|
962
|
+
title: "상단 상태 · 로그인 영역",
|
|
963
|
+
purpose: "사용자는 Work Q에 접속해 언어를 바꾸고, 소유자 세션으로 로그인한 뒤 현재 작업 상태를 확인한다.",
|
|
964
|
+
initial: ["상단에 Work Q 서비스 이름과 언어 전환, 새로고침 조작이 보인다.", "로그인 전에는 비밀번호 입력과 로그인 버튼이 보인다."],
|
|
965
|
+
actions: ["사용자는 표시 언어를 전환한다.", "사용자는 새로고침으로 최신 프로젝트와 작업 상태를 다시 불러온다.", "소유자는 비밀번호를 입력해 관리 화면에 들어간다."],
|
|
966
|
+
rules: ["로그인 전에는 프로젝트, 리포트, 큐 관리 영역을 보여주지 않는다.", "새로고침은 현재 선택된 프로젝트 컨텍스트를 유지해야 한다."],
|
|
967
|
+
verification: ["로그인 전에는 로그인 폼만 주요 조작으로 표시된다.", "언어 전환 후 화면 문구가 같은 의미로 바뀐다.", "새로고침 후 선택 프로젝트가 임의로 바뀌지 않는다."]
|
|
968
|
+
},
|
|
969
|
+
{
|
|
970
|
+
slug: "project-context",
|
|
971
|
+
title: "프로젝트 컨텍스트 영역",
|
|
972
|
+
purpose: "사용자는 여러 프로젝트와 레포 중 현재 Work Q가 판단하고 작업할 대상을 선택한다.",
|
|
973
|
+
initial: ["프로젝트 선택 목록과 새 프로젝트 입력 흐름이 보인다.", "프로젝트 키, 이름, 레포, 로컬 연결 상태를 확인할 수 있다."],
|
|
974
|
+
actions: ["사용자는 기존 프로젝트를 선택한다.", "사용자는 새 프로젝트 정보를 저장한다.", "사용자는 로컬 MCP가 연결한 레포 정보와 웹 프로젝트 정보가 맞는지 확인한다."],
|
|
975
|
+
rules: ["모든 리포트, 명세, 큐, 결정 필요 항목은 선택된 프로젝트 기준으로만 표시한다.", "프로젝트 키는 서로 다른 레포나 서비스를 섞지 않기 위한 안정적인 식별자다."],
|
|
976
|
+
verification: ["프로젝트를 바꾸면 리포트, 명세 지도, 큐 목록도 같은 프로젝트 기준으로 바뀐다.", "저장된 프로젝트 키와 MCP 연결 정보가 일치하지 않으면 사용자가 확인할 수 있어야 한다."]
|
|
977
|
+
},
|
|
978
|
+
{
|
|
979
|
+
slug: "report-intake",
|
|
980
|
+
title: "리포트 접수 영역",
|
|
981
|
+
purpose: "사용자는 버그, 개선, 기획문의, 개발문의를 올리고 필요한 맥락과 이미지를 첨부한다.",
|
|
982
|
+
initial: ["리포트 제목, 유형, URL, 실제 동작, 기대 동작, 재현 방법 입력 영역이 보인다.", "스크린샷 파일 선택 또는 붙여넣기 영역이 보인다."],
|
|
983
|
+
actions: ["사용자는 문제나 문의 내용을 작성한다.", "사용자는 스크린샷을 붙여넣거나 첨부한다.", "사용자는 리포트를 제출하고 분석 결과를 기다린다."],
|
|
984
|
+
rules: ["재현 방법은 비어 있으면 사용자가 어떤 행동을 했는지 판단하기 어렵기 때문에 입력 필요 상태로 남긴다.", "첨부 이미지는 리포트 판단 근거로 연결되지만 그 자체가 승인된 명세는 아니다."],
|
|
985
|
+
verification: ["리포트 제출 후 선택 프로젝트의 리포트 목록에 추가된다.", "스크린샷이 있는 리포트는 첨부 정보가 함께 남는다.", "필수 맥락이 부족하면 작업 큐로 바로 보내지 않고 입력 필요로 구분한다."]
|
|
986
|
+
},
|
|
987
|
+
{
|
|
988
|
+
slug: "triage-and-spec-map",
|
|
989
|
+
title: "판단 근거 · 기능명세 지도 영역",
|
|
990
|
+
purpose: "사용자는 리포트가 버그, 기획부재, 충돌, 개선 중 무엇인지와 그 판단 근거를 확인한다.",
|
|
991
|
+
initial: ["선택된 리포트의 분석 결과와 관련 기능 노드가 보인다.", "입력 필요, 결정 필요, 충돌 같은 상태가 카드나 칩으로 표시된다."],
|
|
992
|
+
actions: ["사용자는 판단 근거를 읽고 필요한 결정을 입력한다.", "사용자는 승인 가능한 항목을 Agent 큐로 보낸다.", "사용자는 부족한 정보를 보충해 다시 판단하게 한다."],
|
|
993
|
+
rules: ["승인된 기능명세와 다른 실제 동작이면 버그 후보로 본다.", "기대 동작은 있지만 승인된 명세가 없으면 기획부재 또는 결정 필요로 본다.", "서로 다른 승인 명세가 같은 조건에서 다른 결과를 요구하면 충돌로 본다."],
|
|
994
|
+
verification: ["각 판단 결과에는 어떤 명세 노드와 리포트 내용이 근거였는지 표시된다.", "결정 필요 항목은 사용자의 승인 또는 거절 전까지 자동 개발 큐로 넘어가지 않는다."]
|
|
995
|
+
},
|
|
996
|
+
{
|
|
997
|
+
slug: "project-memory-and-roadmap",
|
|
998
|
+
title: "프로젝트 기억 · 로드맵 영역",
|
|
999
|
+
purpose: "사용자는 현재 프로젝트의 기능명세, 알려진 충돌, 검증 상태, 가벼운 로드맵 메모를 확인한다.",
|
|
1000
|
+
initial: ["기능명세 지도와 프로젝트 메모, 로드맵 항목이 선택 프로젝트 기준으로 보인다."],
|
|
1001
|
+
actions: ["사용자는 로드맵 메모를 추가하거나 순서를 조정한다.", "사용자는 기능명세 카드의 상태를 확인한다."],
|
|
1002
|
+
rules: ["로드맵은 참고 메모이며 승인된 기능명세가 아니다.", "기능명세 지도는 리포트 분류와 Agent 작업의 기준으로 사용한다."],
|
|
1003
|
+
verification: ["로드맵 순서 변경이 같은 프로젝트 안에서 유지된다.", "명세 업데이트 후 기능명세 지도와 판단 근거가 같은 기준을 참조한다."]
|
|
1004
|
+
},
|
|
1005
|
+
{
|
|
1006
|
+
slug: "agent-queue-and-mcp",
|
|
1007
|
+
title: "Agent 큐 · MCP 연결 영역",
|
|
1008
|
+
purpose: "사용자는 데스크톱 Codex 또는 Claude가 가져갈 작업 큐와 MCP 연결 준비 상태를 확인한다.",
|
|
1009
|
+
initial: ["선택 프로젝트의 Agent 작업 목록과 상태가 보인다.", "MCP URL과 token 준비 상태가 보인다."],
|
|
1010
|
+
actions: ["로컬 Agent는 MCP로 다음 작업을 가져간다.", "작업이 끝난 Agent는 완료 결과와 검증 내용을 Work Q에 남긴다.", "사용자는 완료, 진행, 결정 필요 상태를 확인한다."],
|
|
1011
|
+
rules: ["큐 작업은 선택 프로젝트와 연결된 레포를 기준으로 처리해야 한다.", "Agent가 작업을 완료하면 완료 표시와 함께 명세 변경 또는 검증 근거를 남겨야 한다."],
|
|
1012
|
+
verification: ["다른 프로젝트의 큐가 섞여 보이지 않는다.", "MCP token이 없으면 로컬 Agent 연결이 준비되지 않은 상태로 보인다.", "완료된 작업은 큐 상태가 완료로 바뀐다."]
|
|
1013
|
+
}
|
|
1014
|
+
];
|
|
1015
|
+
}
|
|
1016
|
+
|
|
909
1017
|
function genericHtmlRegions(file) {
|
|
910
1018
|
const title = primaryVisibleTitle(file) || "화면";
|
|
911
1019
|
const hasOneToNineLoop = /\bfor\s*\(\s*let\s+\w+\s*=\s*1\s*;\s*\w+\s*<=\s*9\s*;/.test(file.content);
|
|
@@ -936,7 +1044,7 @@ function genericHtmlRegions(file) {
|
|
|
936
1044
|
slug: "main-content",
|
|
937
1045
|
title: `${title} 주요 화면 영역`,
|
|
938
1046
|
purpose: "사용자는 이 화면에서 주요 콘텐츠와 조작 가능한 요소를 확인한다.",
|
|
939
|
-
initial:
|
|
1047
|
+
initial: visibleTextInitialStatements(file),
|
|
940
1048
|
actions: ["사용자는 화면에 표시된 버튼, 링크, 입력 요소를 조작한다."],
|
|
941
1049
|
rules: ["화면에 보이는 문구와 조작 요소는 같은 URL 화면 명세 안에서 관리한다."],
|
|
942
1050
|
verification: ["초기 화면의 주요 문구와 조작 요소가 표시된다."]
|
|
@@ -945,13 +1053,12 @@ function genericHtmlRegions(file) {
|
|
|
945
1053
|
}
|
|
946
1054
|
|
|
947
1055
|
function genericRouteRegions(file) {
|
|
948
|
-
const texts = extractVisibleTexts(file);
|
|
949
1056
|
return [
|
|
950
1057
|
{
|
|
951
1058
|
slug: "main-screen",
|
|
952
1059
|
title: "주요 화면 영역",
|
|
953
1060
|
purpose: "사용자는 이 URL에서 제공되는 핵심 화면을 본다.",
|
|
954
|
-
initial:
|
|
1061
|
+
initial: visibleTextInitialStatements(file),
|
|
955
1062
|
actions: ["사용자는 화면에 표시된 버튼, 입력칸, 선택값을 조작한다."],
|
|
956
1063
|
rules: ["같은 URL 패턴에 속한 상태 변화는 같은 화면 명세 안에서 관리한다."],
|
|
957
1064
|
verification: ["URL 접속 시 주요 화면 문구와 조작 요소가 표시된다."]
|
|
@@ -960,15 +1067,245 @@ function genericRouteRegions(file) {
|
|
|
960
1067
|
}
|
|
961
1068
|
|
|
962
1069
|
function regionsForRoute(file, route) {
|
|
1070
|
+
if (isWorkQDashboardPage(file)) return workQDashboardRegions();
|
|
963
1071
|
if (route.url_pattern === "/v2" && file.path === "src/app/v2/page.tsx") return workQV2Regions();
|
|
964
1072
|
if (file.path === "index.html") return genericHtmlRegions(file);
|
|
965
1073
|
return genericRouteRegions(file);
|
|
966
1074
|
}
|
|
967
1075
|
|
|
968
|
-
|
|
1076
|
+
const INTERNAL_FEATURE_DEFINITIONS = [
|
|
1077
|
+
{
|
|
1078
|
+
slug: "repository-natural-analysis",
|
|
1079
|
+
title: "레포 코드 자연어 분석 내부 기능",
|
|
1080
|
+
purpose: "사용자가 연결한 코드 레포를 읽고 URL 화면, 화면 영역, 검증 근거 중심의 natural-spec repo 초안을 만든다.",
|
|
1081
|
+
match: [/repo-natural-spec/i, /generateNaturalSpecFromRepository/i, /generateNaturalGitDocs/i, /workq_import_natural_git_specs/i],
|
|
1082
|
+
used_by_routes: ["/v2"],
|
|
1083
|
+
related_regions: ["project-repo-registration", "natural-spec-repo"],
|
|
1084
|
+
triggers: ["사용자가 레포 코드 자연어 분석을 요청한다.", "로컬 MCP CLI가 `workq natural-git` 업로드를 실행한다."],
|
|
1085
|
+
outcomes: ["화면/영역 중심 Markdown 명세가 생성된다.", "코드 파일별 상세 구현은 `_evidence`로 분리된다."],
|
|
1086
|
+
failure_impact: ["새 프로젝트의 초기 기능명세가 비어 있거나 코드 중심 문서로 잘못 보일 수 있다."],
|
|
1087
|
+
verification: ["생성 결과에 `natural-git/screens/` 문서와 `_evidence` 문서가 함께 존재하는지 확인한다."]
|
|
1088
|
+
},
|
|
1089
|
+
{
|
|
1090
|
+
slug: "natural-spec-repo-sync",
|
|
1091
|
+
title: "자연어 git 저장소 동기화 내부 기능",
|
|
1092
|
+
purpose: "웹에서 편집한 자연어 명세 draft, commit, merge 상태를 natural-spec repo 기준 상태와 맞춘다.",
|
|
1093
|
+
match: [/spec-git/i, /natural-spec-repo/i, /stageNaturalSpecDocs/i, /commitNaturalSpec/i, /readNaturalSpecDocs/i],
|
|
1094
|
+
used_by_routes: ["/v2"],
|
|
1095
|
+
related_regions: ["natural-spec-repo", "natural-spec-editor", "commit-merge-agent-queue"],
|
|
1096
|
+
triggers: ["사용자가 자연어 명세를 저장한다.", "사용자가 자연어 커밋, 머지, 롤백을 실행한다."],
|
|
1097
|
+
outcomes: ["draft와 HEAD commit 상태가 구분된다.", "승인된 자연어 커밋만 Agent 큐 기준이 된다."],
|
|
1098
|
+
failure_impact: ["화면에 보이는 명세와 Agent가 기준으로 읽는 명세가 달라질 수 있다."],
|
|
1099
|
+
verification: ["draft 저장, commit, merge 이후 같은 프로젝트의 문서 상태와 commit id가 유지되는지 확인한다."]
|
|
1100
|
+
},
|
|
1101
|
+
{
|
|
1102
|
+
slug: "report-triage-and-classification",
|
|
1103
|
+
title: "리포트 판단 · 분류 내부 기능",
|
|
1104
|
+
purpose: "사용자가 올린 버그, 개선, 기획문의, 개발문의를 승인된 자연어 명세와 비교해 bug, 기획부재, 충돌, 결정 필요로 구분한다.",
|
|
1105
|
+
match: [/triage/i, /classification/i, /classify/i, /analyze-report/i, /mini.ai/i, /report/i],
|
|
1106
|
+
used_by_routes: ["/", "/v2"],
|
|
1107
|
+
related_regions: ["report-intake", "triage-and-spec-map", "decision-needed-mcp"],
|
|
1108
|
+
triggers: ["사용자가 리포트를 제출한다.", "사용자가 기존 리포트를 다시 분석한다."],
|
|
1109
|
+
outcomes: ["판단 결과와 근거 명세가 사용자에게 표시된다.", "승인이 필요한 항목은 자동 개발 큐로 넘어가지 않는다."],
|
|
1110
|
+
failure_impact: ["버그가 아닌 기획부재를 버그로 고치거나, 실제 버그를 문의로 놓칠 수 있다."],
|
|
1111
|
+
verification: ["명세가 있는 동작 차이는 bug 후보로, 명세가 없는 기대 동작은 기획부재/결정 필요로 표시되는지 확인한다."]
|
|
1112
|
+
},
|
|
1113
|
+
{
|
|
1114
|
+
slug: "desktop-agent-mcp-queue",
|
|
1115
|
+
title: "데스크톱 Agent MCP 큐 내부 기능",
|
|
1116
|
+
purpose: "승인된 자연어 커밋이나 결정 사항을 로컬 Codex/Claude가 가져갈 작업 패킷으로 만들고 완료 결과를 다시 받는다.",
|
|
1117
|
+
match: [/workq_list_tasks/i, /workq_get_work_packet/i, /workq_finish_task/i, /agent_task/i, /queued_for_agent/i, /MCP/i],
|
|
1118
|
+
used_by_routes: ["/", "/v2"],
|
|
1119
|
+
related_regions: ["agent-queue-and-mcp", "decision-needed-mcp", "existing-workq-queue", "commit-merge-agent-queue"],
|
|
1120
|
+
triggers: ["사용자가 승인된 작업을 Agent 큐로 보낸다.", "로컬 Agent가 MCP로 다음 작업을 조회한다.", "로컬 Agent가 완료 결과를 제출한다."],
|
|
1121
|
+
outcomes: ["작업 패킷에 프로젝트, 레포, 명세 근거, 완료 절차가 포함된다.", "완료 시 검증 결과와 명세 변경 근거가 Work Q에 남는다."],
|
|
1122
|
+
failure_impact: ["로컬 Agent가 잘못된 프로젝트를 고치거나, 완료 작업이 큐에 남을 수 있다."],
|
|
1123
|
+
verification: ["MCP 작업 조회, work packet 수신, finish task 호출 후 큐 상태가 완료로 바뀌는지 확인한다."]
|
|
1124
|
+
},
|
|
1125
|
+
{
|
|
1126
|
+
slug: "project-context-binding",
|
|
1127
|
+
title: "프로젝트 · 로컬 레포 바인딩 내부 기능",
|
|
1128
|
+
purpose: "웹의 프로젝트 키와 로컬 데스크톱의 실제 git 레포 정보를 대조해 엉뚱한 레포를 고치지 않도록 한다.",
|
|
1129
|
+
match: [/connect_local_project/i, /local.*project/i, /repository_owner/i, /local_path/i, /git remote/i, /project_key/i],
|
|
1130
|
+
used_by_routes: ["/", "/v2"],
|
|
1131
|
+
related_regions: ["project-context", "project-repo-registration", "project-status-summary"],
|
|
1132
|
+
triggers: ["사용자가 프로젝트를 등록한다.", "로컬 MCP가 현재 git root, remote, branch를 Work Q에 연결한다."],
|
|
1133
|
+
outcomes: ["프로젝트 키, repo owner/name, local path, branch가 같은 대상인지 확인된다."],
|
|
1134
|
+
failure_impact: ["여러 프로젝트를 사용할 때 다른 레포의 리포트나 작업을 처리할 수 있다."],
|
|
1135
|
+
verification: ["웹 프로젝트 정보와 MCP가 제출한 git root, remote, branch가 일치하지 않으면 연결 미검증 상태가 되는지 확인한다."]
|
|
1136
|
+
}
|
|
1137
|
+
];
|
|
1138
|
+
|
|
1139
|
+
function isUserFacingNavigationFile(file) {
|
|
1140
|
+
if (isTestFile(file) || isApiOrServerFile(file) || isCliEntryFile(file)) return false;
|
|
1141
|
+
return [".tsx", ".jsx", ".html", ".ts", ".js"].includes(file.extension);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function routeLiteralForSearch(route) {
|
|
1145
|
+
return route.url_pattern.replace(/\[(.+?)\]/g, "");
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function hasVisibleNavigationToRoute(route, routeFile, sourceFiles) {
|
|
1149
|
+
if (route.url_pattern === "/" || route.url_pattern === "<product-overview>") return true;
|
|
1150
|
+
const literal = routeLiteralForSearch(route);
|
|
1151
|
+
if (!literal || literal === "/") return true;
|
|
1152
|
+
const escaped = literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1153
|
+
const navigationPatterns = [
|
|
1154
|
+
new RegExp(`href\\s*=\\s*[{]?["'\`]${escaped}["'\`]`, "i"),
|
|
1155
|
+
new RegExp(`router\\.push\\(\\s*["'\`]${escaped}`, "i"),
|
|
1156
|
+
new RegExp(`navigate\\(\\s*["'\`]${escaped}`, "i"),
|
|
1157
|
+
new RegExp(`location\\.href\\s*=\\s*["'\`]${escaped}`, "i")
|
|
1158
|
+
];
|
|
1159
|
+
return sourceFiles.some((file) => {
|
|
1160
|
+
if (file.path === routeFile.path || !isUserFacingNavigationFile(file)) return false;
|
|
1161
|
+
return navigationPatterns.some((pattern) => pattern.test(file.content));
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function routeEntriesForSourceFiles(sourceFiles) {
|
|
1166
|
+
return sourceFiles.flatMap((file) => {
|
|
1167
|
+
const route = routeInfoForFile(file);
|
|
1168
|
+
if (!route) return [];
|
|
1169
|
+
const regions = regionsForRoute(file, route);
|
|
1170
|
+
return [
|
|
1171
|
+
{
|
|
1172
|
+
file,
|
|
1173
|
+
route,
|
|
1174
|
+
regions,
|
|
1175
|
+
entryless: !hasVisibleNavigationToRoute(route, file, sourceFiles)
|
|
1176
|
+
}
|
|
1177
|
+
];
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function matchingInternalSourcePaths(sourceFiles, definition) {
|
|
1182
|
+
return unique(
|
|
1183
|
+
sourceFiles
|
|
1184
|
+
.filter((file) => !isTestFile(file))
|
|
1185
|
+
.filter((file) => {
|
|
1186
|
+
const haystack = `${file.path}\n${file.content}`;
|
|
1187
|
+
return definition.match.some((pattern) => pattern.test(haystack));
|
|
1188
|
+
})
|
|
1189
|
+
.map((file) => file.path),
|
|
1190
|
+
10
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function detectInternalFeatures(sourceFiles, entries) {
|
|
1195
|
+
const availableRoutes = new Set(entries.map((entry) => entry.route.url_pattern));
|
|
1196
|
+
const fallbackRoute = entries[0]?.route.url_pattern;
|
|
1197
|
+
return INTERNAL_FEATURE_DEFINITIONS.flatMap((definition) => {
|
|
1198
|
+
const sourcePaths = matchingInternalSourcePaths(sourceFiles, definition);
|
|
1199
|
+
if (!sourcePaths.length) return [];
|
|
1200
|
+
const usedByRoutes = definition.used_by_routes.filter((route) => availableRoutes.has(route));
|
|
1201
|
+
const resolvedRoutes = usedByRoutes.length ? usedByRoutes : fallbackRoute ? [fallbackRoute] : [];
|
|
1202
|
+
return [
|
|
1203
|
+
{
|
|
1204
|
+
slug: definition.slug,
|
|
1205
|
+
title: definition.title,
|
|
1206
|
+
purpose: definition.purpose,
|
|
1207
|
+
used_by_routes: resolvedRoutes,
|
|
1208
|
+
related_regions: definition.related_regions,
|
|
1209
|
+
triggers: definition.triggers,
|
|
1210
|
+
outcomes: definition.outcomes,
|
|
1211
|
+
failure_impact: definition.failure_impact,
|
|
1212
|
+
verification: definition.verification,
|
|
1213
|
+
source_paths: sourcePaths,
|
|
1214
|
+
shared: resolvedRoutes.length > 1
|
|
1215
|
+
}
|
|
1216
|
+
];
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function internalFeaturesForRoute(features, route) {
|
|
1221
|
+
return features.filter((feature) => feature.used_by_routes.includes(route.url_pattern));
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function internalFeaturesForRegion(features, region) {
|
|
1225
|
+
return features.filter((feature) => feature.related_regions.includes(region.slug));
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function internalFeatureDocPath(feature, entries) {
|
|
1229
|
+
if (feature.shared || feature.used_by_routes.length !== 1) return `natural-git/shared-internals/${feature.slug}.md`;
|
|
1230
|
+
const entry = entries.find((item) => item.route.url_pattern === feature.used_by_routes[0]);
|
|
1231
|
+
return `${entry?.route.directory ?? "natural-git/shared-internals"}/internals/${feature.slug}.md`;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function internalFeatureDoc(feature, entries) {
|
|
1235
|
+
return {
|
|
1236
|
+
path: internalFeatureDocPath(feature, entries),
|
|
1237
|
+
title: feature.title,
|
|
1238
|
+
content: [
|
|
1239
|
+
`# ${feature.title}`,
|
|
1240
|
+
NATURAL_SPEC_FORMAT_COMMENT,
|
|
1241
|
+
"",
|
|
1242
|
+
"## 성격",
|
|
1243
|
+
"이 문서는 화면에 직접 보이는 버튼이나 카드가 아니라, 화면 기능을 가능하게 하는 내부/백그라운드 기능 명세다.",
|
|
1244
|
+
"",
|
|
1245
|
+
"## 목적",
|
|
1246
|
+
feature.purpose,
|
|
1247
|
+
"",
|
|
1248
|
+
"## 사용 화면",
|
|
1249
|
+
...feature.used_by_routes.map((route) => {
|
|
1250
|
+
const entry = entries.find((item) => item.route.url_pattern === route);
|
|
1251
|
+
return `- ${route}: ${entry ? `${entry.route.directory}/README.md` : "연결된 화면 명세"}`;
|
|
1252
|
+
}),
|
|
1253
|
+
"",
|
|
1254
|
+
"## 연결된 화면 영역",
|
|
1255
|
+
...feature.related_regions.map((region) => `- ${region}`),
|
|
1256
|
+
"",
|
|
1257
|
+
"## 트리거",
|
|
1258
|
+
...feature.triggers.map((item) => `- ${item}`),
|
|
1259
|
+
"",
|
|
1260
|
+
"## 결과",
|
|
1261
|
+
...feature.outcomes.map((item) => `- ${item}`),
|
|
1262
|
+
"",
|
|
1263
|
+
"## 실패 시 사용자 영향",
|
|
1264
|
+
...feature.failure_impact.map((item) => `- ${item}`),
|
|
1265
|
+
"",
|
|
1266
|
+
"## 검증 방법",
|
|
1267
|
+
...feature.verification.map((item) => `- ${item}`),
|
|
1268
|
+
"",
|
|
1269
|
+
"## 구현 근거",
|
|
1270
|
+
...feature.source_paths.map((sourcePath) => `- ${sourceEvidencePath({ path: sourcePath })}`)
|
|
1271
|
+
].join("\n")
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function entrylessScreenDoc(entry) {
|
|
1276
|
+
const slug = slugSegment(entry.route.url_pattern.replace(/^\//, "") || "root");
|
|
1277
|
+
return {
|
|
1278
|
+
path: `natural-git/entryless-screens/${slug}.md`,
|
|
1279
|
+
title: `진입점 없는 화면: ${entry.route.url_pattern}`,
|
|
1280
|
+
content: [
|
|
1281
|
+
`# 진입점 없는 화면: ${entry.route.url_pattern}`,
|
|
1282
|
+
NATURAL_SPEC_FORMAT_COMMENT,
|
|
1283
|
+
"",
|
|
1284
|
+
"## 의미",
|
|
1285
|
+
"이 URL은 화면 파일과 기능명세가 있지만, 다른 사용자 화면에서 명확한 버튼이나 링크 진입점을 발견하지 못했다.",
|
|
1286
|
+
"",
|
|
1287
|
+
"## 기준 화면 명세",
|
|
1288
|
+
`- ${entry.route.directory}/README.md`,
|
|
1289
|
+
"",
|
|
1290
|
+
"## 운영 원칙",
|
|
1291
|
+
"- 직접 URL, 관리자 전용, 실험용, MCP/Agent 전용 화면일 수 있다.",
|
|
1292
|
+
"- 사용자가 일반 화면에서 접근해야 하는 기능이면 별도 메뉴, 버튼, 링크를 추가해야 한다.",
|
|
1293
|
+
"- 진입점이 없는 상태 자체가 제품 의도인지 오너가 확인해야 한다.",
|
|
1294
|
+
"",
|
|
1295
|
+
"## 검증 방법",
|
|
1296
|
+
"- 이 화면으로 이동하는 실제 사용자 진입점이 있는지 확인한다.",
|
|
1297
|
+
"- 진입점이 의도적으로 없다면 이유를 이 문서 또는 화면 README에 기록한다."
|
|
1298
|
+
].join("\n")
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function screenReadmeDoc(file, route, regions, internals, entries) {
|
|
1303
|
+
const isWorkQDashboard = isWorkQDashboardPage(file);
|
|
969
1304
|
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 =
|
|
1305
|
+
const title = isWorkQDashboard ? "Work Q 대시보드 화면 명세" : isWorkQV2 ? "Work Q v2 대시보드 화면 명세" : route.title;
|
|
1306
|
+
const purpose = isWorkQDashboard
|
|
1307
|
+
? "사용자는 Work Q에서 프로젝트를 선택하고, 리포트를 등록하고, 기능명세 판단 근거와 Agent 큐 상태를 확인한다."
|
|
1308
|
+
: isWorkQV2
|
|
972
1309
|
? "사용자는 Work Q v2에서 프로젝트와 레포를 연결하고, natural-spec repo를 편집하며, 리포트와 자연어 커밋을 Agent 작업 큐로 연결한다."
|
|
973
1310
|
: `${route.url_pattern} URL에서 사용자가 보는 화면의 목적, 영역, 조작, 결과를 정의한다.`;
|
|
974
1311
|
return {
|
|
@@ -987,9 +1324,15 @@ function screenReadmeDoc(file, route, regions) {
|
|
|
987
1324
|
"## 화면 영역",
|
|
988
1325
|
...regions.map((region) => `- ${region.title}: ${region.purpose}`),
|
|
989
1326
|
"",
|
|
1327
|
+
"## 관련 내부 기능",
|
|
1328
|
+
...(internals.length
|
|
1329
|
+
? internals.map((feature) => `- ${feature.title}: ${feature.purpose} (${internalFeatureDocPath(feature, entries)})`)
|
|
1330
|
+
: ["- 이 화면에 연결된 내부/백그라운드 기능이 아직 발견되지 않았다."]),
|
|
1331
|
+
"",
|
|
990
1332
|
"## 핵심 원칙",
|
|
991
1333
|
"- URL 패턴을 최상위 화면 명세로 삼는다.",
|
|
992
1334
|
"- 화면 안의 사용자 인식 영역을 region 명세로 나눈다.",
|
|
1335
|
+
"- 화면에 보이지 않지만 이 화면 기능을 가능하게 하는 백그라운드 동작은 관련 내부 기능으로 연결한다.",
|
|
993
1336
|
"- 코드 파일 경로는 사용자 기능명세 제목으로 쓰지 않고 evidence로 분리한다.",
|
|
994
1337
|
"- 페이지네이션, 게시물 번호, 상세 id처럼 값만 바뀌는 URL은 하나의 URL 패턴으로 추상화한다.",
|
|
995
1338
|
"",
|
|
@@ -999,7 +1342,7 @@ function screenReadmeDoc(file, route, regions) {
|
|
|
999
1342
|
};
|
|
1000
1343
|
}
|
|
1001
1344
|
|
|
1002
|
-
function screenRulesDoc(route, regions) {
|
|
1345
|
+
function screenRulesDoc(route, regions, internals) {
|
|
1003
1346
|
return {
|
|
1004
1347
|
path: `${route.directory}/rules.md`,
|
|
1005
1348
|
title: `${route.url_pattern} 화면 규칙`,
|
|
@@ -1014,12 +1357,23 @@ function screenRulesDoc(route, regions) {
|
|
|
1014
1357
|
"- 코드에서 관찰된 구현만으로 제품 의도를 단정하지 않는다.",
|
|
1015
1358
|
"",
|
|
1016
1359
|
"## 영역별 핵심 규칙",
|
|
1017
|
-
...regions.flatMap((region) => [`### ${region.title}`, ...region.rules.map((rule) => `- ${rule}`), ""])
|
|
1360
|
+
...regions.flatMap((region) => [`### ${region.title}`, ...region.rules.map((rule) => `- ${rule}`), ""]),
|
|
1361
|
+
...(internals.length
|
|
1362
|
+
? [
|
|
1363
|
+
"## 관련 내부 기능 판단 기준",
|
|
1364
|
+
...internals.flatMap((feature) => [
|
|
1365
|
+
`### ${feature.title}`,
|
|
1366
|
+
`- 이 내부 기능은 ${feature.used_by_routes.join(", ")} 화면의 기능을 지원한다.`,
|
|
1367
|
+
...feature.failure_impact.map((item) => `- 실패 영향: ${item}`),
|
|
1368
|
+
""
|
|
1369
|
+
])
|
|
1370
|
+
]
|
|
1371
|
+
: [])
|
|
1018
1372
|
].join("\n")
|
|
1019
1373
|
};
|
|
1020
1374
|
}
|
|
1021
1375
|
|
|
1022
|
-
function screenVerificationDoc(route, regions) {
|
|
1376
|
+
function screenVerificationDoc(route, regions, internals) {
|
|
1023
1377
|
return {
|
|
1024
1378
|
path: `${route.directory}/verification.md`,
|
|
1025
1379
|
title: `${route.url_pattern} 화면 검증`,
|
|
@@ -1033,12 +1387,19 @@ function screenVerificationDoc(route, regions) {
|
|
|
1033
1387
|
"- Agent 완료 전에는 관련 영역의 verification 항목을 확인하고 Work Q 완료 기록에 남긴다.",
|
|
1034
1388
|
"",
|
|
1035
1389
|
"## 영역별 검증",
|
|
1036
|
-
...regions.flatMap((region) => [`### ${region.title}`, ...region.verification.map((item) => `- ${item}`), ""])
|
|
1390
|
+
...regions.flatMap((region) => [`### ${region.title}`, ...region.verification.map((item) => `- ${item}`), ""]),
|
|
1391
|
+
...(internals.length
|
|
1392
|
+
? [
|
|
1393
|
+
"## 관련 내부 기능 검증",
|
|
1394
|
+
...internals.flatMap((feature) => [`### ${feature.title}`, ...feature.verification.map((item) => `- ${item}`), ""])
|
|
1395
|
+
]
|
|
1396
|
+
: [])
|
|
1037
1397
|
].join("\n")
|
|
1038
1398
|
};
|
|
1039
1399
|
}
|
|
1040
1400
|
|
|
1041
|
-
function regionDoc(route, region) {
|
|
1401
|
+
function regionDoc(route, region, internals, entries) {
|
|
1402
|
+
const relatedInternals = internalFeaturesForRegion(internals, region);
|
|
1042
1403
|
return {
|
|
1043
1404
|
path: `${route.directory}/regions/${region.slug}.md`,
|
|
1044
1405
|
title: region.title,
|
|
@@ -1058,6 +1419,13 @@ function regionDoc(route, region) {
|
|
|
1058
1419
|
"## 핵심 규칙",
|
|
1059
1420
|
...region.rules.map((item) => `- ${item}`),
|
|
1060
1421
|
"",
|
|
1422
|
+
...(relatedInternals.length
|
|
1423
|
+
? [
|
|
1424
|
+
"## 관련 내부 기능",
|
|
1425
|
+
...relatedInternals.map((feature) => `- ${feature.title}: ${internalFeatureDocPath(feature, entries)}`),
|
|
1426
|
+
""
|
|
1427
|
+
]
|
|
1428
|
+
: []),
|
|
1061
1429
|
"## 검증 방법",
|
|
1062
1430
|
...region.verification.map((item) => `- ${item}`)
|
|
1063
1431
|
].join("\n")
|
|
@@ -1119,25 +1487,32 @@ function fallbackRegions(sourceFiles) {
|
|
|
1119
1487
|
}
|
|
1120
1488
|
|
|
1121
1489
|
function routeScreenDocs(sourceFiles) {
|
|
1122
|
-
const
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
const
|
|
1490
|
+
const entries = routeEntriesForSourceFiles(sourceFiles);
|
|
1491
|
+
const detectedInternals = detectInternalFeatures(sourceFiles, entries);
|
|
1492
|
+
const docs = entries.flatMap((entry) => {
|
|
1493
|
+
const internals = internalFeaturesForRoute(detectedInternals, entry.route);
|
|
1126
1494
|
return [
|
|
1127
|
-
screenReadmeDoc(file, route, regions),
|
|
1128
|
-
screenRulesDoc(route, regions),
|
|
1129
|
-
screenVerificationDoc(route, regions),
|
|
1130
|
-
...regions.map((region) => regionDoc(route, region))
|
|
1495
|
+
screenReadmeDoc(entry.file, entry.route, entry.regions, internals, entries),
|
|
1496
|
+
screenRulesDoc(entry.route, entry.regions, internals),
|
|
1497
|
+
screenVerificationDoc(entry.route, entry.regions, internals),
|
|
1498
|
+
...entry.regions.map((region) => regionDoc(entry.route, region, internals, entries))
|
|
1131
1499
|
];
|
|
1132
1500
|
});
|
|
1133
|
-
if (docs.length || !sourceFiles.length)
|
|
1501
|
+
if (docs.length || !sourceFiles.length) {
|
|
1502
|
+
return [...docs, ...detectedInternals.map((feature) => internalFeatureDoc(feature, entries)), ...entries.filter((entry) => entry.entryless).map((entry) => entrylessScreenDoc(entry))];
|
|
1503
|
+
}
|
|
1134
1504
|
const route = fallbackRouteInfo();
|
|
1135
1505
|
const regions = fallbackRegions(sourceFiles);
|
|
1506
|
+
const fallbackEntry = { file: sourceFiles[0], route, regions, entryless: false };
|
|
1507
|
+
const entriesWithFallback = [fallbackEntry];
|
|
1508
|
+
const internals = detectInternalFeatures(sourceFiles, entriesWithFallback);
|
|
1509
|
+
const routeInternals = internalFeaturesForRoute(internals, route);
|
|
1136
1510
|
return [
|
|
1137
|
-
screenReadmeDoc(sourceFiles[0], route, regions),
|
|
1138
|
-
screenRulesDoc(route, regions),
|
|
1139
|
-
screenVerificationDoc(route, regions),
|
|
1140
|
-
...regions.map((region) => regionDoc(route, region))
|
|
1511
|
+
screenReadmeDoc(sourceFiles[0], route, regions, routeInternals, entriesWithFallback),
|
|
1512
|
+
screenRulesDoc(route, regions, routeInternals),
|
|
1513
|
+
screenVerificationDoc(route, regions, routeInternals),
|
|
1514
|
+
...regions.map((region) => regionDoc(route, region, routeInternals, entriesWithFallback)),
|
|
1515
|
+
...internals.map((feature) => internalFeatureDoc(feature, entriesWithFallback))
|
|
1141
1516
|
];
|
|
1142
1517
|
}
|
|
1143
1518
|
|
|
@@ -1164,13 +1539,18 @@ function overviewDoc({ projectKey, projectName, repository, root, sourceFiles })
|
|
|
1164
1539
|
"## 디렉토리 구조",
|
|
1165
1540
|
"- `natural-git/screens/`: URL 패턴별 최상위 화면 명세",
|
|
1166
1541
|
"- `natural-git/screens/<url>/regions/`: 화면 안의 사용자 인식 영역 명세",
|
|
1542
|
+
"- `natural-git/screens/<url>/internals/`: 특정 화면 기능을 가능하게 하는 내부/백그라운드 기능 명세",
|
|
1167
1543
|
"- `natural-git/screens/<url>/components/`: 여러 화면에서 재사용되는 컴포넌트 명세",
|
|
1168
1544
|
"- `natural-git/screens/<url>/dialogs/`: 모달, 팝업, 드로어 상태 명세",
|
|
1545
|
+
"- `natural-git/shared-internals/`: 여러 화면에서 같이 쓰는 내부/백그라운드 기능 명세",
|
|
1546
|
+
"- `natural-git/entryless-screens/`: 화면은 있지만 메뉴나 버튼 진입점이 명확하지 않은 URL 명세",
|
|
1169
1547
|
"- `natural-git/verification/`: 기능이 실제로 동작하는지 확인하는 검증 시나리오 명세",
|
|
1170
1548
|
"- `natural-git/_evidence/`: 코드 파일, 설정, 테스트의 구현 근거. 기본 사용자 명세가 아니라 traceability 근거다.",
|
|
1171
1549
|
"",
|
|
1172
1550
|
"## 운영 규칙",
|
|
1173
1551
|
"- 자연어 문서는 URL 화면과 사용자 인식 영역 중심으로 작성한다.",
|
|
1552
|
+
"- 화면에 보이지 않는 동기화, 분류, 큐, 임베딩, 백그라운드 작업은 그 기능을 사용하는 화면에 관련 내부 기능으로 연결한다.",
|
|
1553
|
+
"- 여러 화면에서 쓰는 내부 기능은 `shared-internals`에 두고, 어느 화면에서도 진입점이 없는 화면은 `entryless-screens`에 기록한다.",
|
|
1174
1554
|
"- 코드 파일 문서는 사용자 기능명세가 아니라 `_evidence` 아래 구현 근거로 보존한다.",
|
|
1175
1555
|
"- 코드의 현재 동작이 곧 제품 의도라는 뜻은 아니다.",
|
|
1176
1556
|
"- 오너가 자연어 커밋으로 승인한 내용만 다음 코드 변경의 기준이 된다.",
|