workq-mcp 0.1.0 → 0.1.2
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 +53 -1
- package/bin/workq.mjs +174 -2
- package/lib/analyze-repo.mjs +1126 -0
- package/lib/natural-git.mjs +481 -0
- package/package.json +2 -1
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const IGNORE_DIRS = new Set([
|
|
5
|
+
".git",
|
|
6
|
+
".next",
|
|
7
|
+
".turbo",
|
|
8
|
+
".workq",
|
|
9
|
+
".gstack",
|
|
10
|
+
"build",
|
|
11
|
+
"coverage",
|
|
12
|
+
"dist",
|
|
13
|
+
"node_modules",
|
|
14
|
+
"output",
|
|
15
|
+
"playwright-report",
|
|
16
|
+
"test-results"
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const TEXT_EXTENSIONS = new Set([
|
|
20
|
+
".bash",
|
|
21
|
+
".c",
|
|
22
|
+
".cc",
|
|
23
|
+
".cjs",
|
|
24
|
+
".cpp",
|
|
25
|
+
".cs",
|
|
26
|
+
".css",
|
|
27
|
+
".go",
|
|
28
|
+
".h",
|
|
29
|
+
".hpp",
|
|
30
|
+
".html",
|
|
31
|
+
".java",
|
|
32
|
+
".js",
|
|
33
|
+
".json",
|
|
34
|
+
".jsx",
|
|
35
|
+
".kt",
|
|
36
|
+
".mjs",
|
|
37
|
+
".php",
|
|
38
|
+
".py",
|
|
39
|
+
".rb",
|
|
40
|
+
".rs",
|
|
41
|
+
".sh",
|
|
42
|
+
".sql",
|
|
43
|
+
".swift",
|
|
44
|
+
".toml",
|
|
45
|
+
".ts",
|
|
46
|
+
".tsx",
|
|
47
|
+
".xml",
|
|
48
|
+
".yaml",
|
|
49
|
+
".yml"
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const SECRET_LINE = /(api[_-]?key|secret|token|password|passwd|github_pat|ghp_|sk-[a-z0-9])/i;
|
|
53
|
+
const MAX_FILE_BYTES = 420_000;
|
|
54
|
+
const MAX_FILES = 700;
|
|
55
|
+
|
|
56
|
+
function normalizePath(filePath) {
|
|
57
|
+
return filePath.split(path.sep).join("/");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function safeRead(filePath) {
|
|
61
|
+
try {
|
|
62
|
+
const stat = fs.statSync(filePath);
|
|
63
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return "";
|
|
64
|
+
return fs.readFileSync(filePath, "utf8");
|
|
65
|
+
} catch {
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function shouldIgnoreDirectory(name) {
|
|
71
|
+
return IGNORE_DIRS.has(name) || /^\.workq[-_]/.test(name);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function walkRepository(rootDir) {
|
|
75
|
+
const files = [];
|
|
76
|
+
const stack = [rootDir];
|
|
77
|
+
while (stack.length && files.length < MAX_FILES) {
|
|
78
|
+
const current = stack.pop();
|
|
79
|
+
let entries = [];
|
|
80
|
+
try {
|
|
81
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
82
|
+
} catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
entries.sort((a, b) => b.name.localeCompare(a.name));
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
const fullPath = path.join(current, entry.name);
|
|
88
|
+
const relativePath = normalizePath(path.relative(rootDir, fullPath));
|
|
89
|
+
if (entry.isDirectory()) {
|
|
90
|
+
if (!shouldIgnoreDirectory(entry.name)) stack.push(fullPath);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!entry.isFile()) continue;
|
|
94
|
+
const extension = path.extname(entry.name).toLowerCase();
|
|
95
|
+
if (!TEXT_EXTENSIONS.has(extension)) continue;
|
|
96
|
+
files.push(relativePath);
|
|
97
|
+
if (files.length >= MAX_FILES) break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return files.sort();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function languageForExtension(extension) {
|
|
104
|
+
const map = {
|
|
105
|
+
".bash": "Shell",
|
|
106
|
+
".c": "C",
|
|
107
|
+
".cc": "C++",
|
|
108
|
+
".cjs": "CommonJS",
|
|
109
|
+
".cpp": "C++",
|
|
110
|
+
".cs": "C#",
|
|
111
|
+
".css": "CSS",
|
|
112
|
+
".go": "Go",
|
|
113
|
+
".h": "C/C++ header",
|
|
114
|
+
".hpp": "C++ header",
|
|
115
|
+
".html": "HTML",
|
|
116
|
+
".java": "Java",
|
|
117
|
+
".js": "JavaScript",
|
|
118
|
+
".json": "JSON",
|
|
119
|
+
".jsx": "React JSX",
|
|
120
|
+
".kt": "Kotlin",
|
|
121
|
+
".mjs": "JavaScript module",
|
|
122
|
+
".php": "PHP",
|
|
123
|
+
".py": "Python",
|
|
124
|
+
".rb": "Ruby",
|
|
125
|
+
".rs": "Rust",
|
|
126
|
+
".sh": "Shell",
|
|
127
|
+
".sql": "SQL",
|
|
128
|
+
".swift": "Swift",
|
|
129
|
+
".toml": "TOML",
|
|
130
|
+
".ts": "TypeScript",
|
|
131
|
+
".tsx": "React TSX",
|
|
132
|
+
".xml": "XML",
|
|
133
|
+
".yaml": "YAML",
|
|
134
|
+
".yml": "YAML"
|
|
135
|
+
};
|
|
136
|
+
return map[extension] ?? extension.replace(".", "").toUpperCase();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function docPathForSource(sourcePath) {
|
|
140
|
+
return `natural-git/code/${sourcePath.replace(/\//g, "__")}.md`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function cleanLine(line) {
|
|
144
|
+
const trimmed = line.trim();
|
|
145
|
+
if (!trimmed || SECRET_LINE.test(trimmed)) return "";
|
|
146
|
+
return trimmed;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function compact(value, maxLength = 220) {
|
|
150
|
+
const normalized = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
151
|
+
if (normalized.length <= maxLength) return normalized;
|
|
152
|
+
return `${normalized.slice(0, maxLength - 3).trim()}...`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function unique(values, limit = 40) {
|
|
156
|
+
return Array.from(new Set(values.filter(Boolean))).slice(0, limit);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const NATURAL_SPEC_FORMAT_COMMENT = "<!-- workq-natural-spec-format: feature-v2 -->";
|
|
160
|
+
|
|
161
|
+
function stripTags(value) {
|
|
162
|
+
return value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function extractVisibleTexts(file) {
|
|
166
|
+
const texts = [];
|
|
167
|
+
const markupContent = file.content.replace(/<script\b[\s\S]*?<\/script>/gi, "").replace(/<style\b[\s\S]*?<\/style>/gi, "");
|
|
168
|
+
if (file.extension === ".html") {
|
|
169
|
+
for (const match of file.content.matchAll(/<title[^>]*>([\s\S]*?)<\/title>/gi)) texts.push(stripTags(match[1]));
|
|
170
|
+
for (const match of markupContent.matchAll(/>([^<>{}][^<>{}]{1,80})</g)) texts.push(stripTags(match[1]));
|
|
171
|
+
}
|
|
172
|
+
for (const match of file.content.matchAll(/\b(?:textContent|innerText|placeholder|aria-label|title)\s*=\s*["'`]([^"'`]{1,100})["'`]/g)) {
|
|
173
|
+
texts.push(stripTags(match[1]));
|
|
174
|
+
}
|
|
175
|
+
for (const match of file.content.matchAll(/<button[^>]*>([\s\S]{1,160}?)<\/button>/gi)) {
|
|
176
|
+
texts.push(stripTags(match[1]));
|
|
177
|
+
}
|
|
178
|
+
return unique(texts.map((item) => compact(item, 100)).filter((item) => item && !/^[{}()[\];,.]+$/.test(item)), 18);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function extractDesignFacts(file) {
|
|
182
|
+
if (![".css", ".html", ".tsx", ".jsx"].includes(file.extension)) return [];
|
|
183
|
+
const facts = [];
|
|
184
|
+
const colors = unique(Array.from(file.content.matchAll(/#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\)/g)).map((match) => match[0]), 10);
|
|
185
|
+
const gradients = unique(Array.from(file.content.matchAll(/linear-gradient\([^)]+\)/g)).map((match) => match[0]), 6);
|
|
186
|
+
const radii = unique(Array.from(file.content.matchAll(/border-radius\s*:\s*([^;]+);/g)).map((match) => match[1].trim()), 6);
|
|
187
|
+
const shadows = unique(Array.from(file.content.matchAll(/box-shadow\s*:\s*([^;]+);/g)).map((match) => match[1].trim()), 6);
|
|
188
|
+
const layout = [];
|
|
189
|
+
if (/display\s*:\s*grid/.test(file.content)) layout.push("grid");
|
|
190
|
+
if (/display\s*:\s*flex/.test(file.content)) layout.push("flex");
|
|
191
|
+
if (/min-height\s*:\s*100vh/.test(file.content)) layout.push("full viewport height");
|
|
192
|
+
if (/justify-content\s*:\s*center|align-items\s*:\s*center/.test(file.content)) layout.push("centered alignment");
|
|
193
|
+
if (colors.length) facts.push(`색상 팔레트는 ${colors.join(", ")} 값을 포함한다.`);
|
|
194
|
+
if (gradients.length) facts.push(`그라데이션 배경/강조는 ${gradients.join(", ")} 형태를 사용한다.`);
|
|
195
|
+
if (layout.length) facts.push(`레이아웃은 ${unique(layout).join(", ")} 배치를 사용한다.`);
|
|
196
|
+
if (radii.length) facts.push(`둥근 모서리 값은 ${radii.join(", ")} 등이 관찰된다.`);
|
|
197
|
+
if (shadows.length) facts.push(`그림자 표현은 ${shadows.slice(0, 3).join(", ")} 값을 사용한다.`);
|
|
198
|
+
if (/@keyframes\s+([A-Za-z0-9_-]+)/.test(file.content)) {
|
|
199
|
+
facts.push(`애니메이션은 ${unique(Array.from(file.content.matchAll(/@keyframes\s+([A-Za-z0-9_-]+)/g)).map((match) => match[1])).join(", ")} keyframes를 사용한다.`);
|
|
200
|
+
}
|
|
201
|
+
return facts;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function extractDataModelFacts(file) {
|
|
205
|
+
const facts = [];
|
|
206
|
+
const schemaMatch = file.content.match(/new\s+mongoose\.Schema\s*\(\s*\{([\s\S]*?)\}\s*\)/m);
|
|
207
|
+
if (schemaMatch) {
|
|
208
|
+
const fields = [];
|
|
209
|
+
for (const match of schemaMatch[1].matchAll(/^\s*([A-Za-z_$][\w$]*)\s*:\s*([^,\n]+)/gm)) {
|
|
210
|
+
fields.push(`${match[1]}(${compact(match[2], 80)})`);
|
|
211
|
+
}
|
|
212
|
+
facts.push(fields.length ? `이 파일은 데이터 모델 스키마를 정의하며 필드는 ${fields.join(", ")} 이다.` : "이 파일은 데이터 모델 스키마를 정의한다.");
|
|
213
|
+
}
|
|
214
|
+
if (file.path === "package.json") {
|
|
215
|
+
const packageDoc = packageJsonDoc(file.content).split(/\r?\n/).filter((line) => line.startsWith("- `") || line.startsWith("- 패키지 이름"));
|
|
216
|
+
facts.push(...packageDoc.slice(0, 12));
|
|
217
|
+
}
|
|
218
|
+
return facts;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function extractEnvironmentFacts(file) {
|
|
222
|
+
const envVars = unique(
|
|
223
|
+
Array.from(file.content.matchAll(/process\.env(?:\.([A-Za-z_][A-Za-z0-9_]*)|\[["'`]([^"'`]+)["'`]\])/g)).map((match) => match[1] || match[2]),
|
|
224
|
+
18
|
|
225
|
+
);
|
|
226
|
+
const facts = [];
|
|
227
|
+
if (!envVars.length && !/get(DataDir|DbDir|WorkDir|AppUrl)|isMcpConfigured|getMiniAiConfig|isGithubCrudConfigured|requireEnv/.test(file.content)) return facts;
|
|
228
|
+
if (envVars.length) facts.push(`환경 변수 ${envVars.map((item) => `\`${item}\``).join(", ")} 값을 읽어 런타임 설정을 결정한다.`);
|
|
229
|
+
if (/getDataDir|getDbDir|getWorkDir/.test(file.content)) facts.push("데이터 저장 위치, SQLite DB 위치, 작업 파일 위치를 환경 변수와 기본값으로 분리한다.");
|
|
230
|
+
if (/getAppUrl/.test(file.content)) facts.push("앱 외부 URL은 환경 변수로 지정하고, 없으면 로컬 개발 URL을 기본값으로 사용한다.");
|
|
231
|
+
if (/isMcpConfigured|WORKQ_MCP_TOKEN/.test(file.content)) facts.push("MCP 토큰 설정 여부를 판단해 로컬 에이전트 연결 가능 상태를 구분한다.");
|
|
232
|
+
if (/getMiniAiConfig|isMiniAiConfigured|WORKQ_MINI_AI/.test(file.content)) facts.push("Mini AI endpoint, model, API key 설정을 하나의 구성값으로 묶어 triage 사용 가능 여부를 판단한다.");
|
|
233
|
+
if (/isGithubCrudConfigured|WORKQ_GITHUB_TOKEN/.test(file.content)) facts.push("GitHub CRUD token 존재 여부로 GitHub 작업 자동화 가능성을 판단한다.");
|
|
234
|
+
if (/requireEnv/.test(file.content)) facts.push("필수 환경 변수가 없으면 조용히 진행하지 않고 명시적인 설정 오류를 발생시킨다.");
|
|
235
|
+
return unique(facts, 10);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function extractFunctionalFacts(file, structure) {
|
|
239
|
+
const facts = [];
|
|
240
|
+
const environmentFacts = extractEnvironmentFacts(file);
|
|
241
|
+
const visibleTexts = extractVisibleTexts(file);
|
|
242
|
+
const conditionSummaries = structure.conditions
|
|
243
|
+
.map((item) => item.expression ?? item.signature ?? "")
|
|
244
|
+
.filter((item) => item && item.length <= 180 && !/[<>]/.test(item))
|
|
245
|
+
.slice(0, 10);
|
|
246
|
+
facts.push(...environmentFacts);
|
|
247
|
+
if (visibleTexts.length) facts.push(`사용자에게 보이는 주요 문구는 ${visibleTexts.map((item) => `"${item}"`).join(", ")} 이다.`);
|
|
248
|
+
if (structure.events.length) {
|
|
249
|
+
facts.push(`사용자 이벤트는 ${structure.events.map((item) => `${item.target}의 ${item.event}`).join(", ")} 흐름을 처리한다.`);
|
|
250
|
+
}
|
|
251
|
+
if (!environmentFacts.length && structure.functions.length) {
|
|
252
|
+
facts.push(`내부 동작 단위는 ${structure.functions.slice(0, 8).map((item) => `${item.name}()`).join(", ")} 등이며, 세부 구현 근거는 코드 근거 섹션에서 추적한다.`);
|
|
253
|
+
}
|
|
254
|
+
if (structure.routes.length) {
|
|
255
|
+
facts.push(`네트워크/라우트 흐름은 ${structure.routes.slice(0, 8).map((item) => `${item.kind} ${item.expression}`).join(", ")} 로 연결된다.`);
|
|
256
|
+
}
|
|
257
|
+
if (!environmentFacts.length && conditionSummaries.length) {
|
|
258
|
+
facts.push(`분기와 반복은 ${conditionSummaries.join("; ")} 조건을 기준으로 동작한다.`);
|
|
259
|
+
}
|
|
260
|
+
if (/\bfor\s*\(\s*let\s+([A-Za-z_$][\w$]*)\s*=\s*(\d+)\s*;\s*\1\s*<=\s*(\d+)/.test(file.content)) {
|
|
261
|
+
const loop = file.content.match(/\bfor\s*\(\s*let\s+([A-Za-z_$][\w$]*)\s*=\s*(\d+)\s*;\s*\1\s*<=\s*(\d+)/);
|
|
262
|
+
facts.push(`반복문은 ${loop[2]}부터 ${loop[3]}까지의 선택지 또는 결과 행을 자동 생성한다.`);
|
|
263
|
+
}
|
|
264
|
+
if (/document\.createElement\(["']button["']\)/.test(file.content)) facts.push("버튼 요소를 코드에서 자동 생성한다.");
|
|
265
|
+
if (/classList\.add\(["']active["']\)|classList\.remove\(["']active["']\)/.test(file.content)) facts.push("선택 상태는 active 클래스로 표시하고 이전 선택은 해제한다.");
|
|
266
|
+
if (/innerHTML\s*=/.test(file.content)) facts.push("사용자 선택 이후 결과 영역의 HTML을 새 내용으로 교체한다.");
|
|
267
|
+
return facts;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function buildVerificationFacts(file, structure) {
|
|
271
|
+
const facts = [];
|
|
272
|
+
if (extractVisibleTexts(file).length) facts.push("화면에 노출되는 문구와 버튼이 의도한 사용자 흐름을 설명하는지 확인한다.");
|
|
273
|
+
if (structure.events.length) facts.push("사용자 이벤트 실행 후 화면 상태와 데이터가 기대값으로 바뀌는지 확인한다.");
|
|
274
|
+
if (structure.conditions.length) facts.push("반복/조건의 경계값과 예외 흐름을 테스트한다.");
|
|
275
|
+
if (extractDataModelFacts(file).length) facts.push("저장되는 필드 이름, 타입, 필수 여부가 실제 데이터와 맞는지 확인한다.");
|
|
276
|
+
if (!facts.length) facts.push("이 파일이 제공하는 설정 또는 보조 로직이 연결된 기능에서 실제로 사용되는지 확인한다.");
|
|
277
|
+
return facts;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function sourceEvidenceFacts(file, structure) {
|
|
281
|
+
const evidence = [];
|
|
282
|
+
if (structure.functions.length) evidence.push(`함수/메서드: ${structure.functions.slice(0, 12).map((item) => `${item.name}(${item.line}행)`).join(", ")}`);
|
|
283
|
+
if (structure.variables.length) evidence.push(`주요 값: ${structure.variables.slice(0, 12).map((item) => `${item.name}(${item.line}행)`).join(", ")}`);
|
|
284
|
+
if (structure.events.length) evidence.push(`이벤트: ${structure.events.slice(0, 12).map((item) => `${item.event}(${item.line}행)`).join(", ")}`);
|
|
285
|
+
if (structure.selectors.length) evidence.push(`스타일 선택자: ${structure.selectors.slice(0, 12).join(", ")}`);
|
|
286
|
+
return evidence;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function extractStructure(lines) {
|
|
290
|
+
const functions = [];
|
|
291
|
+
const variables = [];
|
|
292
|
+
const conditions = [];
|
|
293
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
294
|
+
const line = cleanLine(lines[index]);
|
|
295
|
+
if (!line) continue;
|
|
296
|
+
const lineNumber = index + 1;
|
|
297
|
+
const fn = line.match(/(?:async\s+)?function\s+([A-Za-z_$][\w$]*)|def\s+([A-Za-z_][\w]*)|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/);
|
|
298
|
+
if (fn) functions.push({ line: lineNumber, name: fn[1] || fn[2] || fn[3], signature: compact(line) });
|
|
299
|
+
const variable = line.match(/^(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/);
|
|
300
|
+
if (variable) variables.push({ line: lineNumber, name: variable[1], signature: compact(line) });
|
|
301
|
+
if (/\bif\s*\(|^if\s+|^elif\s+|\belse\s+if\s*\(|\bfor\s*\(|^for\s+|\bwhile\s*\(|^while\s+|\bcatch\s*\(|^except\s+/.test(line)) {
|
|
302
|
+
conditions.push({ line: lineNumber, signature: compact(line) });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return { functions, variables, conditions };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function packageJsonDoc(content) {
|
|
309
|
+
try {
|
|
310
|
+
const parsed = JSON.parse(content);
|
|
311
|
+
const scripts = Object.entries(parsed.scripts ?? {});
|
|
312
|
+
const deps = Object.keys(parsed.dependencies ?? {});
|
|
313
|
+
const devDeps = Object.keys(parsed.devDependencies ?? {});
|
|
314
|
+
return [
|
|
315
|
+
"## package.json 해석",
|
|
316
|
+
`- 패키지 이름: ${parsed.name ?? "정의 없음"}`,
|
|
317
|
+
scripts.length ? "### 실행 스크립트" : "",
|
|
318
|
+
...scripts.map(([name, command]) => `- \`${name}\`: \`${command}\` 명령을 실행한다.`),
|
|
319
|
+
deps.length ? "### 런타임 의존성" : "",
|
|
320
|
+
deps.length ? `- ${deps.join(", ")}` : "",
|
|
321
|
+
devDeps.length ? "### 개발 의존성" : "",
|
|
322
|
+
devDeps.length ? `- ${devDeps.join(", ")}` : ""
|
|
323
|
+
].filter(Boolean).join("\n");
|
|
324
|
+
} catch {
|
|
325
|
+
return "";
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function sourceFileToDoc(file) {
|
|
330
|
+
const structure = {
|
|
331
|
+
imports: [],
|
|
332
|
+
exports: [],
|
|
333
|
+
classes: [],
|
|
334
|
+
routes: [],
|
|
335
|
+
events: [],
|
|
336
|
+
selectors: [],
|
|
337
|
+
...extractStructure(file.lines)
|
|
338
|
+
};
|
|
339
|
+
const purpose =
|
|
340
|
+
file.path === "package.json"
|
|
341
|
+
? "패키지 이름, 실행 스크립트, 의존성, Node 런타임 조건을 정의한다."
|
|
342
|
+
: extractEnvironmentFacts(file).length
|
|
343
|
+
? "런타임 환경 변수와 기본값을 해석해 저장소, URL, MCP, Mini AI, GitHub 연결 상태를 결정한다."
|
|
344
|
+
: file.extension === ".css"
|
|
345
|
+
? "화면 요소의 레이아웃, 색상, 반응형 배치, 상호작용 상태를 정의한다."
|
|
346
|
+
: file.extension === ".html"
|
|
347
|
+
? "브라우저에 표시되는 정적 화면 구조와 연결된 스크립트/스타일 진입점을 정의한다."
|
|
348
|
+
: structure.functions.length
|
|
349
|
+
? "함수 단위로 데이터를 계산하거나 화면/파일/네트워크 동작을 수행한다."
|
|
350
|
+
: "레포 동작을 구성하는 설정, 데이터, 또는 보조 텍스트를 제공한다.";
|
|
351
|
+
const dataModelFacts = extractDataModelFacts(file);
|
|
352
|
+
const functionalFacts = extractFunctionalFacts(file, structure);
|
|
353
|
+
const designFacts = extractDesignFacts(file);
|
|
354
|
+
const verificationFacts = buildVerificationFacts(file, structure);
|
|
355
|
+
const evidenceFacts = sourceEvidenceFacts(file, structure);
|
|
356
|
+
const coreRules = unique(
|
|
357
|
+
[
|
|
358
|
+
...dataModelFacts,
|
|
359
|
+
...extractEnvironmentFacts(file),
|
|
360
|
+
...functionalFacts.filter((item) => /반복문|선택 상태|자동 생성|결과 영역|라우트|네트워크|분기/.test(item))
|
|
361
|
+
],
|
|
362
|
+
12
|
|
363
|
+
);
|
|
364
|
+
return [
|
|
365
|
+
`# ${file.path}`,
|
|
366
|
+
NATURAL_SPEC_FORMAT_COMMENT,
|
|
367
|
+
"",
|
|
368
|
+
"## 기능 중심 자연어 명세",
|
|
369
|
+
`이 파일은 ${file.language} 파일이며, 제품 관점에서는 ${purpose}`,
|
|
370
|
+
"",
|
|
371
|
+
"## 파일 정보",
|
|
372
|
+
`- 원본 경로: \`${file.path}\``,
|
|
373
|
+
`- 언어/형식: ${file.language}`,
|
|
374
|
+
`- 총 라인 수: ${file.lines.length}`,
|
|
375
|
+
"",
|
|
376
|
+
functionalFacts.length ? "## 사용자 기능과 동작" : "",
|
|
377
|
+
...functionalFacts.map((item) => `- ${item}`),
|
|
378
|
+
"",
|
|
379
|
+
coreRules.length ? "## 핵심 규칙" : "",
|
|
380
|
+
...coreRules.map((item) => `- ${item}`),
|
|
381
|
+
"",
|
|
382
|
+
designFacts.length ? "## 화면과 디자인 명세" : "",
|
|
383
|
+
...designFacts.map((item) => `- ${item}`),
|
|
384
|
+
"",
|
|
385
|
+
dataModelFacts.length && file.path !== "package.json" ? "## 데이터와 상태" : "",
|
|
386
|
+
...(file.path !== "package.json" ? dataModelFacts.map((item) => `- ${item}`) : []),
|
|
387
|
+
"",
|
|
388
|
+
evidenceFacts.length ? "## 코드 근거" : "",
|
|
389
|
+
...evidenceFacts.map((item) => `- ${item}`),
|
|
390
|
+
"",
|
|
391
|
+
"## 검증 관점",
|
|
392
|
+
...verificationFacts.map((item) => `- ${item}`),
|
|
393
|
+
"- 제품 의도와 코드가 다를 수 있으므로, 코드에서 관찰된 동작은 승인된 제품 의도가 아니라 검증 근거로 취급한다.",
|
|
394
|
+
"- 리포트 분류 시 이 문서는 기능 존재, 화면 동작, 입력/출력, 핵심 규칙의 근거로 사용한다."
|
|
395
|
+
].filter((line, index, array) => line || array[index - 1]).join("\n");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function overviewDoc({ projectKey, projectName, repository, root, sourceFiles }) {
|
|
399
|
+
const byLanguage = new Map();
|
|
400
|
+
for (const file of sourceFiles) byLanguage.set(file.language, (byLanguage.get(file.language) ?? 0) + 1);
|
|
401
|
+
return [
|
|
402
|
+
`# ${projectKey} 자연어 git 명세`,
|
|
403
|
+
NATURAL_SPEC_FORMAT_COMMENT,
|
|
404
|
+
"",
|
|
405
|
+
"## 의미",
|
|
406
|
+
"이 명세는 레포에 존재하는 코드 파일을 제품 기능, 사용자 동작, 입력/출력, 화면/디자인, 데이터 규칙 중심의 자연어 Markdown 문서로 해석한 것이다. Work Q v2에서는 이 자연어 문서를 기준으로 리포트와 코드 변경을 판단한다.",
|
|
407
|
+
"",
|
|
408
|
+
"## 프로젝트",
|
|
409
|
+
`- 프로젝트 키: ${projectKey}`,
|
|
410
|
+
`- 프로젝트 이름: ${projectName || projectKey}`,
|
|
411
|
+
`- 레포: ${repository ? `${repository.owner}/${repository.repo}` : "연결된 레포 없음"}`,
|
|
412
|
+
`- 로컬 분석 경로: \`${root}\``,
|
|
413
|
+
`- 분석된 코드/설정 파일: ${sourceFiles.length}개`,
|
|
414
|
+
"",
|
|
415
|
+
"## 언어별 파일 수",
|
|
416
|
+
...Array.from(byLanguage.entries()).map(([language, count]) => `- ${language}: ${count}개`),
|
|
417
|
+
"",
|
|
418
|
+
"## 운영 규칙",
|
|
419
|
+
"- 자연어 문서는 코드에서 관찰한 제품 기능과 검증 가능한 동작을 설명한다.",
|
|
420
|
+
"- 각 코드 파일 문서는 라인별 번역이 아니라 기능, 규칙, 화면, 데이터, 검증 관점 중심으로 작성한다.",
|
|
421
|
+
"- 코드의 현재 동작이 곧 제품 의도라는 뜻은 아니다.",
|
|
422
|
+
"- 오너가 자연어 커밋으로 승인한 내용만 다음 코드 변경의 기준이 된다.",
|
|
423
|
+
"- 코드가 바뀌면 이 자연어 git 명세도 다시 분석하거나 수정해야 한다."
|
|
424
|
+
].join("\n");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function indexDoc(sourceFiles) {
|
|
428
|
+
return [
|
|
429
|
+
"# 코드 파일 자연어 색인",
|
|
430
|
+
"",
|
|
431
|
+
"이 문서는 분석된 파일과 각 파일의 자연어 명세 문서를 연결한다.",
|
|
432
|
+
"",
|
|
433
|
+
...sourceFiles.map((file) => `- \`${file.path}\` -> \`${docPathForSource(file.path)}\``)
|
|
434
|
+
].join("\n");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function generateNaturalGitDocs({ localPath, projectKey, projectName = "", repository = null }) {
|
|
438
|
+
const root = path.resolve(localPath);
|
|
439
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
440
|
+
throw new Error(`Local repository path does not exist or is not a directory: ${localPath}`);
|
|
441
|
+
}
|
|
442
|
+
const relativePaths = walkRepository(root);
|
|
443
|
+
const sourceFiles = relativePaths
|
|
444
|
+
.map((relativePath) => {
|
|
445
|
+
const extension = path.extname(relativePath).toLowerCase();
|
|
446
|
+
const absolutePath = path.join(root, relativePath);
|
|
447
|
+
const content = safeRead(absolutePath);
|
|
448
|
+
if (!content.trim()) return null;
|
|
449
|
+
return {
|
|
450
|
+
path: relativePath,
|
|
451
|
+
extension,
|
|
452
|
+
language: languageForExtension(extension),
|
|
453
|
+
content,
|
|
454
|
+
lines: content.split(/\r?\n/)
|
|
455
|
+
};
|
|
456
|
+
})
|
|
457
|
+
.filter(Boolean);
|
|
458
|
+
const docs = [
|
|
459
|
+
{
|
|
460
|
+
path: "natural-git/README.md",
|
|
461
|
+
title: "자연어 git 명세 개요",
|
|
462
|
+
content: overviewDoc({ projectKey, projectName, repository, root, sourceFiles })
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
path: "natural-git/code-index.md",
|
|
466
|
+
title: "코드 파일 자연어 색인",
|
|
467
|
+
content: indexDoc(sourceFiles)
|
|
468
|
+
},
|
|
469
|
+
...sourceFiles.map((file) => ({
|
|
470
|
+
path: docPathForSource(file.path),
|
|
471
|
+
title: file.path,
|
|
472
|
+
content: sourceFileToDoc(file)
|
|
473
|
+
}))
|
|
474
|
+
];
|
|
475
|
+
return {
|
|
476
|
+
root,
|
|
477
|
+
files_scanned: relativePaths.length,
|
|
478
|
+
code_files: sourceFiles.length,
|
|
479
|
+
docs
|
|
480
|
+
};
|
|
481
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "workq-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Work Q MCP stdio bridge and local repository connection CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"bin/",
|
|
12
|
+
"lib/",
|
|
12
13
|
"README.md"
|
|
13
14
|
],
|
|
14
15
|
"engines": {
|