workq-mcp 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,370 @@
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 lineKind(line) {
156
+ if (/^(\/\/|#|\/\*|\*|<!--)/.test(line)) return "comment";
157
+ if (/^(import\s|from\s+|const\s+\w+\s*=\s*require\(|require\(|#include|using\s+|use\s+)/.test(line)) return "import";
158
+ if (/^(export\s|module\.exports|exports\.)/.test(line)) return "export";
159
+ if (/^(?:async\s+)?function\s+|^(?:export\s+)?(?:const|let|var)\s+\w+\s*=.*=>|^def\s+|^function\s+/.test(line)) return "function";
160
+ if (/^class\s+|^(?:export\s+)?class\s+|^(?:public\s+)?(?:final\s+)?class\s+/.test(line)) return "class";
161
+ if (/\bif\s*\(|^if\s+|^elif\s+|\belse\s+if\s*\(/.test(line)) return "if";
162
+ if (/\bfor\s*\(|^for\s+|\bwhile\s*\(|^while\s+/.test(line)) return "loop";
163
+ if (/\bswitch\s*\(|^match\s+/.test(line)) return "switch";
164
+ if (/\bcatch\s*\(|^except\s+/.test(line)) return "catch";
165
+ if (/^return\b/.test(line)) return "return";
166
+ if (/^throw\b|^raise\b/.test(line)) return "throw";
167
+ if (/^(const|let|var)\s+\w+\s*=|^[A-Za-z_$][\w$]*\s*=/.test(line)) return "assignment";
168
+ if (/\b(app|router)\.(get|post|put|patch|delete|use)\s*\(/i.test(line)) return "route";
169
+ if (/\.addEventListener\(/.test(line)) return "event";
170
+ if (/^SELECT\b|^INSERT\b|^UPDATE\b|^DELETE\b|^CREATE\b|^ALTER\b/i.test(line)) return "sql";
171
+ if (/^\{|\}|\]|\[|,$/.test(line)) return "structure";
172
+ return "statement";
173
+ }
174
+
175
+ function naturalSentenceForLine(line, lineNumber) {
176
+ const cleaned = cleanLine(line);
177
+ if (!cleaned) return "";
178
+ const text = compact(cleaned);
179
+ const kind = lineKind(cleaned);
180
+ if (kind === "comment") return `- ${lineNumber}행: 주석 \`${text}\`은 코드 작성자가 남긴 설명 또는 메모이다.`;
181
+ if (kind === "import") return `- ${lineNumber}행: \`${text}\` 구문으로 외부 모듈, 표준 라이브러리, 또는 다른 파일의 기능을 가져온다.`;
182
+ if (kind === "export") return `- ${lineNumber}행: \`${text}\` 구문으로 이 파일의 값을 다른 파일에서 사용할 수 있게 공개한다.`;
183
+ if (kind === "function") return `- ${lineNumber}행: \`${text}\` 형태의 함수 또는 콜백을 정의한다.`;
184
+ if (kind === "class") return `- ${lineNumber}행: \`${text}\` 형태의 클래스를 정의해 관련 상태와 동작을 객체 단위로 묶는다.`;
185
+ if (kind === "if") return `- ${lineNumber}행: 만약 \`${compact(cleaned.replace(/^else\s+/, ""))}\` 조건이 참이면 이어지는 블록의 동작을 실행한다.`;
186
+ if (kind === "loop") return `- ${lineNumber}행: \`${text}\` 반복 조건에 따라 여러 값 또는 여러 시도에 같은 처리를 반복한다.`;
187
+ if (kind === "switch") return `- ${lineNumber}행: \`${text}\` 값에 따라 여러 분기 중 하나의 흐름을 선택한다.`;
188
+ if (kind === "catch") return `- ${lineNumber}행: \`${text}\` 예외 흐름을 받아 실패 처리, 로깅, 복구 중 하나를 수행한다.`;
189
+ if (kind === "return") return `- ${lineNumber}행: \`${text}\` 값을 호출자에게 반환하고 현재 함수의 결과로 삼는다.`;
190
+ if (kind === "throw") return `- ${lineNumber}행: \`${text}\` 오류를 발생시켜 정상 흐름을 중단하고 실패 흐름으로 이동한다.`;
191
+ if (kind === "assignment") return `- ${lineNumber}행: \`${text}\` 값을 변수, 상수, 객체 속성, 또는 상태에 대입한다.`;
192
+ if (kind === "route") return `- ${lineNumber}행: \`${text}\` HTTP 라우트 또는 미들웨어를 등록해 요청이 들어왔을 때 실행될 처리를 연결한다.`;
193
+ if (kind === "event") return `- ${lineNumber}행: \`${text}\` 사용자 또는 브라우저 이벤트가 발생했을 때 실행될 동작을 연결한다.`;
194
+ if (kind === "sql") return `- ${lineNumber}행: \`${text}\` SQL 명령으로 데이터 조회, 생성, 수정, 삭제, 또는 스키마 변경을 수행한다.`;
195
+ if (kind === "structure") return `- ${lineNumber}행: \`${text}\` 문법 구조를 열거나 닫아 객체, 배열, 블록, 또는 설정 항목의 범위를 정한다.`;
196
+ return `- ${lineNumber}행: \`${text}\` 문장을 실행해 현재 파일의 동작, 설정, 화면, 데이터 처리 중 일부를 구성한다.`;
197
+ }
198
+
199
+ function extractStructure(lines) {
200
+ const functions = [];
201
+ const variables = [];
202
+ const conditions = [];
203
+ for (let index = 0; index < lines.length; index += 1) {
204
+ const line = cleanLine(lines[index]);
205
+ if (!line) continue;
206
+ const lineNumber = index + 1;
207
+ 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*=>/);
208
+ if (fn) functions.push({ line: lineNumber, name: fn[1] || fn[2] || fn[3], signature: compact(line) });
209
+ const variable = line.match(/^(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/);
210
+ if (variable) variables.push({ line: lineNumber, name: variable[1], signature: compact(line) });
211
+ 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)) {
212
+ conditions.push({ line: lineNumber, signature: compact(line) });
213
+ }
214
+ }
215
+ return { functions, variables, conditions };
216
+ }
217
+
218
+ function packageJsonDoc(content) {
219
+ try {
220
+ const parsed = JSON.parse(content);
221
+ const scripts = Object.entries(parsed.scripts ?? {});
222
+ const deps = Object.keys(parsed.dependencies ?? {});
223
+ const devDeps = Object.keys(parsed.devDependencies ?? {});
224
+ return [
225
+ "## package.json 해석",
226
+ `- 패키지 이름: ${parsed.name ?? "정의 없음"}`,
227
+ scripts.length ? "### 실행 스크립트" : "",
228
+ ...scripts.map(([name, command]) => `- \`${name}\`: \`${command}\` 명령을 실행한다.`),
229
+ deps.length ? "### 런타임 의존성" : "",
230
+ deps.length ? `- ${deps.join(", ")}` : "",
231
+ devDeps.length ? "### 개발 의존성" : "",
232
+ devDeps.length ? `- ${devDeps.join(", ")}` : ""
233
+ ].filter(Boolean).join("\n");
234
+ } catch {
235
+ return "";
236
+ }
237
+ }
238
+
239
+ function sourceFileToDoc(file) {
240
+ const structure = extractStructure(file.lines);
241
+ const lineSpec = file.lines.map((line, index) => naturalSentenceForLine(line, index + 1)).filter(Boolean);
242
+ const purpose =
243
+ file.path === "package.json"
244
+ ? "패키지 이름, 실행 스크립트, 의존성, Node 런타임 조건을 정의한다."
245
+ : file.extension === ".css"
246
+ ? "화면 요소의 레이아웃, 색상, 반응형 배치, 상호작용 상태를 정의한다."
247
+ : file.extension === ".html"
248
+ ? "브라우저에 표시되는 정적 화면 구조와 연결된 스크립트/스타일 진입점을 정의한다."
249
+ : structure.functions.length
250
+ ? "함수 단위로 데이터를 계산하거나 화면/파일/네트워크 동작을 수행한다."
251
+ : "레포 동작을 구성하는 설정, 데이터, 또는 보조 텍스트를 제공한다.";
252
+ return [
253
+ `# ${file.path}`,
254
+ "",
255
+ "## 자연어 명세",
256
+ `이 파일은 ${file.language} 파일이다. ${purpose}`,
257
+ "",
258
+ "## 파일 정보",
259
+ `- 원본 경로: \`${file.path}\``,
260
+ `- 언어/형식: ${file.language}`,
261
+ `- 총 라인 수: ${file.lines.length}`,
262
+ `- 감지된 함수/메서드: ${structure.functions.length}개`,
263
+ `- 감지된 조건/반복/예외 흐름: ${structure.conditions.length}개`,
264
+ "",
265
+ structure.functions.length ? "## 함수와 메서드" : "",
266
+ ...structure.functions.map((item) => `- ${item.line}행: \`${item.name}\` 함수는 \`${item.signature}\` 형태로 정의된다.`),
267
+ "",
268
+ structure.variables.length ? "## 주요 변수와 상수" : "",
269
+ ...structure.variables.map((item) => `- ${item.line}행: \`${item.name}\` 값은 \`${item.signature}\` 구문으로 정의된다.`),
270
+ "",
271
+ structure.conditions.length ? "## 조건, 반복, 예외 흐름" : "",
272
+ ...structure.conditions.map((item) => `- ${item.line}행: 만약 또는 반복 조건 \`${item.signature}\`에 따라 실행 흐름이 달라진다.`),
273
+ "",
274
+ file.path === "package.json" ? packageJsonDoc(file.content) : "",
275
+ "",
276
+ "## 라인별 자연어 실행 흐름",
277
+ "이 섹션은 코드의 각 의미 있는 라인을 자연어 명세로 해석한다.",
278
+ ...lineSpec,
279
+ "",
280
+ "## 검증 관점",
281
+ "- 이 파일의 자연어 명세는 코드의 현재 구조를 해석한 것이다.",
282
+ "- 라인별 자연어 실행 흐름은 원본 코드의 의미 있는 모든 라인을 대상으로 한다.",
283
+ "- 제품 의도와 코드가 다를 수 있으므로, 코드에서 관찰된 동작은 승인된 제품 의도가 아니라 검증 근거로 취급한다.",
284
+ "- 리포트 분류 시 이 문서의 함수, 조건, 라우트, 이벤트 설명을 근거로 실제 구현 여부를 판단한다."
285
+ ].filter((line, index, array) => line || array[index - 1]).join("\n");
286
+ }
287
+
288
+ function overviewDoc({ projectKey, projectName, repository, root, sourceFiles }) {
289
+ const byLanguage = new Map();
290
+ for (const file of sourceFiles) byLanguage.set(file.language, (byLanguage.get(file.language) ?? 0) + 1);
291
+ return [
292
+ `# ${projectKey} 자연어 git 명세`,
293
+ "",
294
+ "## 의미",
295
+ "이 명세는 레포에 존재하는 코드 파일을 사람이 읽을 수 있는 자연어로 해석한 것이다. 함수, 변수, 조건, 반복, 라우트, 이벤트, 설정 파일, 의미 있는 코드 라인을 Markdown 문서로 풀어 쓰며, Work Q v2에서는 이 자연어 문서를 기준으로 리포트와 코드 변경을 판단한다.",
296
+ "",
297
+ "## 프로젝트",
298
+ `- 프로젝트 키: ${projectKey}`,
299
+ `- 프로젝트 이름: ${projectName || projectKey}`,
300
+ `- 레포: ${repository ? `${repository.owner}/${repository.repo}` : "연결된 레포 없음"}`,
301
+ `- 로컬 분석 경로: \`${root}\``,
302
+ `- 분석된 코드/설정 파일: ${sourceFiles.length}개`,
303
+ "",
304
+ "## 언어별 파일 수",
305
+ ...Array.from(byLanguage.entries()).map(([language, count]) => `- ${language}: ${count}개`),
306
+ "",
307
+ "## 운영 규칙",
308
+ "- 자연어 문서는 코드에서 관찰한 구조를 설명한다.",
309
+ "- 각 코드 파일 문서는 `라인별 자연어 실행 흐름` 섹션을 포함해야 한다.",
310
+ "- 코드의 현재 동작이 곧 제품 의도라는 뜻은 아니다.",
311
+ "- 오너가 자연어 커밋으로 승인한 내용만 다음 코드 변경의 기준이 된다.",
312
+ "- 코드가 바뀌면 이 자연어 git 명세도 다시 분석하거나 수정해야 한다."
313
+ ].join("\n");
314
+ }
315
+
316
+ function indexDoc(sourceFiles) {
317
+ return [
318
+ "# 코드 파일 자연어 색인",
319
+ "",
320
+ "이 문서는 분석된 파일과 각 파일의 자연어 명세 문서를 연결한다.",
321
+ "",
322
+ ...sourceFiles.map((file) => `- \`${file.path}\` -> \`${docPathForSource(file.path)}\``)
323
+ ].join("\n");
324
+ }
325
+
326
+ export function generateNaturalGitDocs({ localPath, projectKey, projectName = "", repository = null }) {
327
+ const root = path.resolve(localPath);
328
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
329
+ throw new Error(`Local repository path does not exist or is not a directory: ${localPath}`);
330
+ }
331
+ const relativePaths = walkRepository(root);
332
+ const sourceFiles = relativePaths
333
+ .map((relativePath) => {
334
+ const extension = path.extname(relativePath).toLowerCase();
335
+ const absolutePath = path.join(root, relativePath);
336
+ const content = safeRead(absolutePath);
337
+ if (!content.trim()) return null;
338
+ return {
339
+ path: relativePath,
340
+ extension,
341
+ language: languageForExtension(extension),
342
+ content,
343
+ lines: content.split(/\r?\n/)
344
+ };
345
+ })
346
+ .filter(Boolean);
347
+ const docs = [
348
+ {
349
+ path: "natural-git/README.md",
350
+ title: "자연어 git 명세 개요",
351
+ content: overviewDoc({ projectKey, projectName, repository, root, sourceFiles })
352
+ },
353
+ {
354
+ path: "natural-git/code-index.md",
355
+ title: "코드 파일 자연어 색인",
356
+ content: indexDoc(sourceFiles)
357
+ },
358
+ ...sourceFiles.map((file) => ({
359
+ path: docPathForSource(file.path),
360
+ title: file.path,
361
+ content: sourceFileToDoc(file)
362
+ }))
363
+ ];
364
+ return {
365
+ root,
366
+ files_scanned: relativePaths.length,
367
+ code_files: sourceFiles.length,
368
+ docs
369
+ };
370
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workq-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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": {