@luckychen1993/pm-mcp 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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +304 -0
  3. package/dist/acceptance-evaluator.js +330 -0
  4. package/dist/acceptance-model.js +440 -0
  5. package/dist/acceptance-report.js +335 -0
  6. package/dist/acceptance-tools.js +318 -0
  7. package/dist/audit-tools.js +61 -0
  8. package/dist/audit.js +364 -0
  9. package/dist/budget.js +71 -0
  10. package/dist/dashboard.js +241 -0
  11. package/dist/governance-audit.js +126 -0
  12. package/dist/governance-model.js +306 -0
  13. package/dist/governance-tools.js +240 -0
  14. package/dist/health.js +58 -0
  15. package/dist/index-db.js +97 -0
  16. package/dist/index-store.js +381 -0
  17. package/dist/index.js +210 -0
  18. package/dist/init.js +40 -0
  19. package/dist/knowledge-tools.js +204 -0
  20. package/dist/language-adapters.js +450 -0
  21. package/dist/language-dependencies.js +351 -0
  22. package/dist/license.js +261 -0
  23. package/dist/paths.js +51 -0
  24. package/dist/polyglot-ast.js +329 -0
  25. package/dist/portfolio.js +349 -0
  26. package/dist/project-fingerprint.js +83 -0
  27. package/dist/project-tools.js +217 -0
  28. package/dist/quality-store.js +77 -0
  29. package/dist/registry.js +38 -0
  30. package/dist/roadmap.js +96 -0
  31. package/dist/scan.js +271 -0
  32. package/dist/search.js +312 -0
  33. package/dist/security.js +269 -0
  34. package/dist/semantic-evidence-store.js +133 -0
  35. package/dist/semantic-evidence.js +100 -0
  36. package/dist/semantic-graph-algorithms.js +86 -0
  37. package/dist/semantic-graph.js +390 -0
  38. package/dist/semantic-parsers.js +311 -0
  39. package/dist/store.js +185 -0
  40. package/dist/task-tools.js +198 -0
  41. package/dist/tool-base.js +32 -0
  42. package/dist/tools.js +21 -0
  43. package/dist/types.js +208 -0
  44. package/dist/typescript-semantic.js +282 -0
  45. package/package.json +70 -0
@@ -0,0 +1,204 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { foldLines, normSep } from "./budget.js";
5
+ import { refreshDerived } from "./dashboard.js";
6
+ import { decisionsDir, requireInitialized } from "./paths.js";
7
+ import { formatCodeSearch, searchCode, searchKnowledge } from "./search.js";
8
+ import { loadDebugLog, loadFeatures, loadFileNotes, loadProject, loadSessions, nextId, saveDebugLog, saveFeatures, saveFileNotes, saveSessions, } from "./store.js";
9
+ import { budgetLines, tool, toolW } from "./tool-base.js";
10
+ import { now } from "./types.js";
11
+ export function registerFeatureTools(server, root) {
12
+ toolW(server, root, "register_feature", "登记功能(回答「这个项目有哪些功能」)。entry_files=入口文件(漂移对账锚点),test_files=测试文件(测试背书)。", {
13
+ name: z.string().min(1),
14
+ description: z.string().optional(),
15
+ entry_files: z.array(z.string()).optional().describe("入口/实现文件相对路径"),
16
+ module: z.string().optional().describe("所属模块"),
17
+ test_files: z.array(z.string()).optional().describe("对应测试文件"),
18
+ status: z.enum(["planned", "implemented", "deprecated"]).optional().describe("默认 implemented"),
19
+ }, (args) => {
20
+ requireInitialized(root);
21
+ const data = loadFeatures(root);
22
+ const id = nextId("F", data.seq);
23
+ const t = now();
24
+ data.seq += 1;
25
+ data.features.push({
26
+ id,
27
+ name: args.name,
28
+ description: args.description ?? "",
29
+ module: args.module ?? "",
30
+ entry_files: (args.entry_files ?? []).map(normSep),
31
+ test_files: (args.test_files ?? []).map(normSep),
32
+ status: args.status ?? "implemented",
33
+ created: t,
34
+ updated: t,
35
+ });
36
+ saveFeatures(root, data);
37
+ refreshDerived(root);
38
+ const dup = data.features.find((f) => f.id !== id && f.name === args.name);
39
+ const hint = dup
40
+ ? `\n⚠️ 已存在同名功能 ${dup.id}(${dup.status})——若是一物重复登记请梳理,别让功能账出现双胞胎。`
41
+ : "";
42
+ return `✅ 功能 ${id}「${args.name}」已登记${args.module ? `(${args.module})` : ""}。audit_structure 会核对其入口文件是否真实存在(防幻觉对账)。${hint}`;
43
+ });
44
+ tool(server, "list_features", "列出功能清单(可按模块/状态过滤)。", { module: z.string().optional(), status: z.enum(["planned", "implemented", "deprecated"]).optional() }, (args) => {
45
+ requireInitialized(root);
46
+ let list = loadFeatures(root).features;
47
+ if (args.module)
48
+ list = list.filter((f) => f.module === args.module);
49
+ if (args.status)
50
+ list = list.filter((f) => f.status === args.status);
51
+ const L = list.map((f) => {
52
+ const missing = f.entry_files.filter((p) => !fs.existsSync(path.join(root, p)));
53
+ const mark = f.status !== "implemented" ? "○" : missing.length > 0 ? "⚠️漂移" : f.test_files.length === 0 ? "🧪无测试" : "✅";
54
+ return `${mark} ${f.id} ${f.name}${f.module ? `(${f.module})` : ""}${f.description ? ` — ${f.description.slice(0, 80)}` : ""}`;
55
+ });
56
+ return foldLines([`共 ${list.length} 个功能:`, ...L], { maxLines: budgetLines(root), hint: "用 module/status 过滤" });
57
+ });
58
+ }
59
+ export function registerDecisionTools(server, root) {
60
+ toolW(server, root, "record_decision", "记录架构决策(ADR):为什么这么选、决定了什么、有什么后果。认知债务的解药——半年后还能回答「当时为什么」。", {
61
+ title: z.string().min(1),
62
+ context: z.string().describe("背景与约束(为什么需要做选择)"),
63
+ decision: z.string().describe("决定与理由"),
64
+ consequences: z.string().optional().describe("后果/代价/被放弃的方案"),
65
+ }, (args) => {
66
+ requireInitialized(root);
67
+ ensureDecisionDir(root);
68
+ const existing = fs.readdirSync(decisionsDir(root)).filter((n) => n.endsWith(".md"));
69
+ const num = String(existing.length + 1).padStart(3, "0");
70
+ const slug = args.title
71
+ .replace(/[\\/:*?"<>|\s]+/g, "-")
72
+ .replace(/^-+|-+$/g, "")
73
+ .slice(0, 40) || "decision";
74
+ const file = `ADR-${num}-${slug}.md`;
75
+ const content = [
76
+ `# ADR-${num} ${args.title}`,
77
+ "",
78
+ `- 日期: ${new Date().toISOString().slice(0, 10)}`,
79
+ "- 状态: 已接受",
80
+ "",
81
+ "## 背景",
82
+ "",
83
+ args.context,
84
+ "",
85
+ "## 决定",
86
+ "",
87
+ args.decision,
88
+ "",
89
+ "## 后果",
90
+ "",
91
+ args.consequences ?? "(未记录)",
92
+ "",
93
+ ].join("\n");
94
+ fs.writeFileSync(path.join(decisionsDir(root), file), content, "utf8");
95
+ refreshDerived(root);
96
+ return `✅ 决策已记录: .pm/decisions/${file}(不可变编号,git 友好)。search_knowledge 可检索。`;
97
+ });
98
+ tool(server, "list_decisions", "列出全部架构决策(ADR)。", {}, () => {
99
+ requireInitialized(root);
100
+ const dir = decisionsDir(root);
101
+ const names = fs.existsSync(dir) ? fs.readdirSync(dir).filter((n) => n.endsWith(".md")).sort() : [];
102
+ if (names.length === 0)
103
+ return "(暂无决策记录。重要取舍用 record_decision 留痕。)";
104
+ return foldLines(names.map((n) => `- ${n.replace(/\.md$/, "")}`), { maxLines: budgetLines(root) });
105
+ });
106
+ }
107
+ function ensureDecisionDir(root) {
108
+ fs.mkdirSync(decisionsDir(root), { recursive: true });
109
+ }
110
+ export function registerSessionTools(server, root) {
111
+ toolW(server, root, "log_session", "收工仪式:记录本次会话(做了什么、改了哪些文件、下一步)。files 是变更足迹(churn 账与波及面告警的数据源),务必如实列出。", {
112
+ summary: z.string().min(1).describe("本次做了什么(一两句)"),
113
+ files: z.array(z.string()).optional().describe("本次改动/新增/删除的文件(相对路径)"),
114
+ next_steps: z.array(z.string()).optional().describe("留给下次的下一步"),
115
+ author: z.string().optional().describe("署名:zcode / codex / human:名字"),
116
+ }, (args) => {
117
+ requireInitialized(root);
118
+ const project = loadProject(root);
119
+ const data = loadSessions(root);
120
+ const id = nextId("S", data.seq, 4);
121
+ data.seq += 1;
122
+ data.sessions.push({
123
+ id,
124
+ date: now(),
125
+ author: args.author ?? "",
126
+ summary: args.summary,
127
+ files: (args.files ?? []).map(normSep),
128
+ next_steps: args.next_steps ?? [],
129
+ });
130
+ saveSessions(root, data);
131
+ refreshDerived(root);
132
+ const L = [`✅ 会话 ${id} 已记录,changelog.md 已更新。`];
133
+ if ((args.files ?? []).length > project.budgets.sessionBlastRadius) {
134
+ L.push(`⚠️ 本次波及 ${(args.files ?? []).length} 个文件,超过阈值 ${project.budgets.sessionBlastRadius}——波及面过大,考虑拆任务或立重构任务。`);
135
+ }
136
+ return L.join("\n");
137
+ });
138
+ toolW(server, root, "log_debug", "记录调试过程:症状 / 根因 / 修法 / 怎么验证的。调试知识账——下次同样的错不用从零猜,search_knowledge 可查。", {
139
+ symptom: z.string().describe("症状:什么现象、报什么错"),
140
+ root_cause: z.string().describe("根因(诊断出的真正原因,不是表面现象)"),
141
+ fix: z.string().describe("修法"),
142
+ verified_how: z.string().optional().describe("怎么确认修好了"),
143
+ files: z.array(z.string()).optional(),
144
+ task_id: z.string().optional().describe("关联任务 id"),
145
+ }, (args) => {
146
+ requireInitialized(root);
147
+ const data = loadDebugLog(root);
148
+ const id = nextId("D", data.seq, 4);
149
+ data.seq += 1;
150
+ data.entries.push({
151
+ id,
152
+ date: now(),
153
+ author: "",
154
+ symptom: args.symptom,
155
+ root_cause: args.root_cause,
156
+ fix: args.fix,
157
+ verified_how: args.verified_how ?? "",
158
+ files: (args.files ?? []).map(normSep),
159
+ task_id: args.task_id ?? null,
160
+ });
161
+ saveDebugLog(root, data);
162
+ refreshDerived(root);
163
+ return `✅ 调试记录 ${id} 已存档。这份诊断经验已进入项目知识库。`;
164
+ });
165
+ }
166
+ export function registerSearchTools(server, root) {
167
+ tool(server, "search_code", "在真实代码里检索(防幻觉事中防线):谈某个功能/文件之前先搜实码。返回 file:line 片段而非整文件(token 经济)。默认按字面量匹配;regex=true 时按 JavaScript 正则(仅在你确需时开启)。", {
168
+ query: z.string().min(1).describe("关键词(默认字面量匹配)"),
169
+ glob: z.string().optional().describe("限定文件范围,如 src/**/*.ts"),
170
+ max_results: z.number().int().min(1).max(100).optional().describe("默认 30"),
171
+ regex: z.boolean().optional().describe("默认 false;true 时 query 按正则解释"),
172
+ }, (args) => {
173
+ requireInitialized(root);
174
+ const res = searchCode(root, args.query, args.glob, args.max_results ?? 30, args.regex ?? false);
175
+ return formatCodeSearch(res, args.query);
176
+ });
177
+ tool(server, "search_knowledge", "检索六类项目知识源(ADR/任务/功能/会话/调试记录/文件索引)——回答「之前为什么这么做」「上次这个错怎么解的」,用历史结论代替重新推理。代码内容请用 search_code。", { query: z.string().min(1) }, (args) => {
178
+ requireInitialized(root);
179
+ return searchKnowledge(root, args.query);
180
+ });
181
+ toolW(server, root, "annotate_file", "给文件登记一行用途(文件用途索引):之后任何会话靠索引定位,不用打开文件。引用外部代码时可登记 source/license(provenance)。", {
182
+ path: z.string().min(1).describe("相对路径"),
183
+ purpose: z.string().min(1).describe("一句话用途"),
184
+ source: z.string().optional().describe("代码来源(URL/仓库),如引用自外部"),
185
+ license: z.string().optional().describe("该文件引用代码的许可证"),
186
+ }, (args) => {
187
+ requireInitialized(root);
188
+ const rel = normSep(args.path);
189
+ if (!fs.existsSync(path.join(root, rel))) {
190
+ throw new Error(`文件不存在: ${rel}(注意用相对路径,如 src/index.ts)`);
191
+ }
192
+ const data = loadFileNotes(root);
193
+ data.notes[rel] = {
194
+ ...(data.notes[rel] ?? {}),
195
+ purpose: args.purpose,
196
+ source: args.source ?? data.notes[rel]?.source ?? "",
197
+ license: args.license ?? data.notes[rel]?.license ?? "",
198
+ updated: now(),
199
+ };
200
+ saveFileNotes(root, data);
201
+ refreshDerived(root);
202
+ return `✅ 已登记 ${rel}: ${args.purpose}。索引覆盖率上升,后续会话省 token。`;
203
+ });
204
+ }
@@ -0,0 +1,450 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { parseManifestDependencies } from "./language-dependencies.js";
5
+ const DEFAULT_IGNORE_DIRS = new Set([
6
+ ".git", ".pm", ".idea", ".vscode", ".venv", "venv", "env", "__pycache__",
7
+ "node_modules", "dist", "build", "out", "target", ".gradle", ".next", ".nuxt",
8
+ ".output", ".turbo", "coverage", ".cache",
9
+ ]);
10
+ const LANGUAGE_ORDER = [
11
+ "javascript", "typescript", "python", "go", "rust", "java", "kotlin", "csharp",
12
+ ];
13
+ const KIND_ORDER = ["test", "build", "lint", "typecheck", "coverage", "security"];
14
+ const DEFAULT_TIMEOUT_MS = 5 * 60_000;
15
+ const DEFAULT_OUTPUT_BYTES = 64 * 1024;
16
+ function normalize(value) {
17
+ return value.replace(/\\/g, "/");
18
+ }
19
+ function manifestKind(name) {
20
+ const lower = name.toLowerCase();
21
+ if (lower === "package.json")
22
+ return "node";
23
+ if (lower === "pyproject.toml" || /^requirements(?:[-_.][^.]+)?\.txt$/i.test(name))
24
+ return "python";
25
+ if (lower === "go.mod")
26
+ return "go";
27
+ if (name === "Cargo.toml")
28
+ return "rust";
29
+ if (lower === "pom.xml")
30
+ return "maven";
31
+ if (/^(?:build|settings)\.gradle(?:\.kts)?$/i.test(name))
32
+ return "gradle";
33
+ if (/\.(?:sln|csproj)$/i.test(name))
34
+ return "dotnet";
35
+ return null;
36
+ }
37
+ function readJson(file) {
38
+ try {
39
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
40
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
41
+ throw new Error("root is not an object");
42
+ return parsed;
43
+ }
44
+ catch (error) {
45
+ throw new Error(`Cannot parse manifest ${file}: ${error instanceof Error ? error.message : String(error)}`);
46
+ }
47
+ }
48
+ function packageManager(unitRoot, repositoryRoot, pkg) {
49
+ const declared = typeof pkg.packageManager === "string" ? pkg.packageManager.split("@")[0].trim() : "";
50
+ if (["npm", "pnpm", "yarn", "bun"].includes(declared))
51
+ return declared;
52
+ let cursor = unitRoot;
53
+ while (true) {
54
+ if (fs.existsSync(path.join(cursor, "pnpm-lock.yaml")))
55
+ return "pnpm";
56
+ if (fs.existsSync(path.join(cursor, "yarn.lock")))
57
+ return "yarn";
58
+ if (fs.existsSync(path.join(cursor, "bun.lock")) || fs.existsSync(path.join(cursor, "bun.lockb")))
59
+ return "bun";
60
+ if (cursor === repositoryRoot)
61
+ break;
62
+ const parent = path.dirname(cursor);
63
+ const relativeParent = path.relative(repositoryRoot, parent);
64
+ if (parent === cursor || relativeParent.startsWith("..") || path.isAbsolute(relativeParent))
65
+ break;
66
+ cursor = parent;
67
+ }
68
+ return "npm";
69
+ }
70
+ function nodeToolInvocation(manager) {
71
+ if (process.platform === "win32" && manager === "npm") {
72
+ const candidates = [
73
+ process.env.npm_execpath,
74
+ path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"),
75
+ ].filter((candidate) => Boolean(candidate));
76
+ const cli = candidates.find((candidate) => fs.existsSync(candidate));
77
+ if (cli)
78
+ return { executable: process.execPath, prefix: [cli] };
79
+ }
80
+ return { executable: manager, prefix: [] };
81
+ }
82
+ function repositoryWrapper(unitRoot, repositoryRoot, posixName, windowsName) {
83
+ const names = process.platform === "win32" ? [windowsName, posixName] : [posixName, windowsName];
84
+ let cursor = unitRoot;
85
+ while (true) {
86
+ for (const name of names) {
87
+ const candidate = path.join(cursor, name);
88
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile())
89
+ return candidate;
90
+ }
91
+ if (cursor === repositoryRoot)
92
+ break;
93
+ const parent = path.dirname(cursor);
94
+ const relativeParent = path.relative(repositoryRoot, parent);
95
+ if (parent === cursor || relativeParent.startsWith("..") || path.isAbsolute(relativeParent))
96
+ break;
97
+ cursor = parent;
98
+ }
99
+ return null;
100
+ }
101
+ function command(cwd, kind, executable, args, requiredExecutable = executable) {
102
+ return {
103
+ command: executable,
104
+ args: [...args],
105
+ cwd,
106
+ kind,
107
+ requiredExecutable,
108
+ timeoutMs: DEFAULT_TIMEOUT_MS,
109
+ maxOutputBytes: DEFAULT_OUTPUT_BYTES,
110
+ };
111
+ }
112
+ function nodeCommands(unitRoot, repositoryRoot, packageFile) {
113
+ let pkg;
114
+ try {
115
+ pkg = readJson(packageFile);
116
+ }
117
+ catch {
118
+ return [];
119
+ }
120
+ const scripts = pkg.scripts && typeof pkg.scripts === "object" && !Array.isArray(pkg.scripts)
121
+ ? pkg.scripts
122
+ : {};
123
+ const manager = packageManager(unitRoot, repositoryRoot, pkg);
124
+ const invocation = nodeToolInvocation(manager);
125
+ const out = [];
126
+ // Deliberately exact and closed: arbitrary lifecycle scripts never enter the plan.
127
+ for (const kind of KIND_ORDER) {
128
+ if (typeof scripts[kind] === "string" && scripts[kind].trim()) {
129
+ out.push(command(unitRoot, kind, invocation.executable, [...invocation.prefix, "run", kind], manager));
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+ function pythonCommands(unitRoot, manifests) {
135
+ const pyproject = manifests.find((m) => path.basename(m.path).toLowerCase() === "pyproject.toml");
136
+ const out = [command(unitRoot, "test", "python", ["-m", "pytest"], "python")];
137
+ if (!pyproject)
138
+ return out;
139
+ const text = fs.readFileSync(pyproject.path, "utf8");
140
+ if (/^\s*\[build-system\]\s*$/m.test(text))
141
+ out.push(command(unitRoot, "build", "python", ["-m", "build"], "python"));
142
+ if (/^\s*\[tool\.ruff(?:\.|\])/.test(text) || /^\s*\[tool\.ruff(?:\.|\])/m.test(text)) {
143
+ out.push(command(unitRoot, "lint", "python", ["-m", "ruff", "check", "."], "python"));
144
+ }
145
+ if (/^\s*\[tool\.mypy(?:\.|\])/.test(text) || /^\s*\[tool\.mypy(?:\.|\])/m.test(text)) {
146
+ out.push(command(unitRoot, "typecheck", "python", ["-m", "mypy", "."], "python"));
147
+ }
148
+ if (/^\s*\[tool\.coverage(?:\.|\])/m.test(text) || /pytest-cov/.test(text)) {
149
+ out.push(command(unitRoot, "coverage", "python", ["-m", "coverage", "run", "-m", "pytest"], "python"));
150
+ }
151
+ if (/^\s*\[tool\.bandit(?:\.|\])/m.test(text) || /pip-audit/.test(text)) {
152
+ out.push(command(unitRoot, "security", "python", ["-m", "bandit", "-r", "."], "python"));
153
+ }
154
+ return out;
155
+ }
156
+ function commandsForUnit(unitRoot, repositoryRoot, manifests) {
157
+ const out = [];
158
+ const byKind = (kind) => manifests.filter((m) => m.kind === kind);
159
+ for (const pkg of byKind("node"))
160
+ out.push(...nodeCommands(unitRoot, repositoryRoot, pkg.path));
161
+ if (byKind("python").length > 0)
162
+ out.push(...pythonCommands(unitRoot, byKind("python")));
163
+ if (byKind("go").length > 0) {
164
+ out.push(command(unitRoot, "test", "go", ["test", "./..."]));
165
+ out.push(command(unitRoot, "build", "go", ["build", "./..."]));
166
+ out.push(command(unitRoot, "lint", "go", ["vet", "./..."]));
167
+ out.push(command(unitRoot, "coverage", "go", ["test", "-coverprofile=coverage.out", "./..."]));
168
+ }
169
+ if (byKind("rust").length > 0) {
170
+ out.push(command(unitRoot, "test", "cargo", ["test", "--all-targets"]));
171
+ out.push(command(unitRoot, "build", "cargo", ["build"]));
172
+ out.push(command(unitRoot, "lint", "cargo", ["clippy", "--all-targets", "--", "-D", "warnings"]));
173
+ out.push(command(unitRoot, "typecheck", "cargo", ["check"]));
174
+ }
175
+ if (byKind("maven").length > 0) {
176
+ const wrapper = repositoryWrapper(unitRoot, repositoryRoot, "mvnw", "mvnw.cmd");
177
+ const executable = wrapper ?? "mvn";
178
+ out.push(command(unitRoot, "test", executable, ["-B", "test"], executable));
179
+ out.push(command(unitRoot, "build", executable, ["-B", "-DskipTests", "package"], executable));
180
+ }
181
+ if (byKind("gradle").length > 0) {
182
+ const wrapper = repositoryWrapper(unitRoot, repositoryRoot, "gradlew", "gradlew.bat");
183
+ const executable = wrapper ?? "gradle";
184
+ out.push(command(unitRoot, "test", executable, ["test"], executable));
185
+ out.push(command(unitRoot, "build", executable, ["build"], executable));
186
+ }
187
+ const dotnet = byKind("dotnet");
188
+ if (dotnet.length > 0) {
189
+ const target = dotnet.find((m) => m.path.toLowerCase().endsWith(".sln")) ?? dotnet[0];
190
+ const rel = path.relative(unitRoot, target.path) || path.basename(target.path);
191
+ out.push(command(unitRoot, "test", "dotnet", ["test", rel]));
192
+ out.push(command(unitRoot, "build", "dotnet", ["build", rel]));
193
+ }
194
+ const seen = new Set();
195
+ return out.filter((item) => {
196
+ const key = JSON.stringify([item.kind, item.command, item.args, item.cwd]);
197
+ if (seen.has(key))
198
+ return false;
199
+ seen.add(key);
200
+ return true;
201
+ }).sort((a, b) => KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind));
202
+ }
203
+ function languagesFor(unitRoot, manifests) {
204
+ const found = new Set();
205
+ for (const item of manifests) {
206
+ switch (item.kind) {
207
+ case "node": {
208
+ found.add("javascript");
209
+ try {
210
+ const pkg = readJson(item.path);
211
+ const allDeps = { ...asRecord(pkg.dependencies), ...asRecord(pkg.devDependencies) };
212
+ if (fs.existsSync(path.join(unitRoot, "tsconfig.json")) || "typescript" in allDeps)
213
+ found.add("typescript");
214
+ }
215
+ catch {
216
+ if (fs.existsSync(path.join(unitRoot, "tsconfig.json")))
217
+ found.add("typescript");
218
+ }
219
+ break;
220
+ }
221
+ case "python":
222
+ found.add("python");
223
+ break;
224
+ case "go":
225
+ found.add("go");
226
+ break;
227
+ case "rust":
228
+ found.add("rust");
229
+ break;
230
+ case "maven":
231
+ found.add("java");
232
+ break;
233
+ case "gradle":
234
+ found.add("java");
235
+ if (item.path.toLowerCase().endsWith(".kts"))
236
+ found.add("kotlin");
237
+ break;
238
+ case "dotnet":
239
+ found.add("csharp");
240
+ break;
241
+ }
242
+ }
243
+ return LANGUAGE_ORDER.filter((lang) => found.has(lang));
244
+ }
245
+ function asRecord(value) {
246
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
247
+ }
248
+ /**
249
+ * Recursively discovers manifest-bearing project units. Node workspace packages are
250
+ * naturally separate units because each workspace package.json is discovered.
251
+ */
252
+ export function discoverProjectUnits(root, options = {}) {
253
+ const repositoryRoot = path.resolve(root);
254
+ const stat = fs.statSync(repositoryRoot);
255
+ if (!stat.isDirectory())
256
+ throw new Error(`Project root is not a directory: ${repositoryRoot}`);
257
+ const maxDepth = options.maxDepth ?? 64;
258
+ if (!Number.isInteger(maxDepth) || maxDepth < 0)
259
+ throw new Error("maxDepth must be a non-negative integer");
260
+ const ignored = new Set([...DEFAULT_IGNORE_DIRS, ...(options.ignoreDirs ?? [])]);
261
+ const grouped = new Map();
262
+ const walk = (dir, depth) => {
263
+ if (depth > maxDepth)
264
+ throw new Error(`Manifest discovery exceeded maxDepth=${maxDepth}: ${dir}`);
265
+ const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
266
+ for (const entry of entries) {
267
+ const absolute = path.join(dir, entry.name);
268
+ if (entry.isDirectory()) {
269
+ if (!ignored.has(entry.name))
270
+ walk(absolute, depth + 1);
271
+ continue;
272
+ }
273
+ if (!entry.isFile())
274
+ continue;
275
+ const kind = manifestKind(entry.name);
276
+ if (!kind)
277
+ continue;
278
+ const list = grouped.get(dir) ?? [];
279
+ list.push({ kind, path: absolute });
280
+ grouped.set(dir, list);
281
+ }
282
+ };
283
+ walk(repositoryRoot, 0);
284
+ return [...grouped.entries()].map(([unitRoot, manifest]) => {
285
+ manifest.sort((a, b) => a.path.localeCompare(b.path));
286
+ const relative = normalize(path.relative(repositoryRoot, unitRoot));
287
+ const dependencyResult = parseManifestDependencies(manifest);
288
+ return {
289
+ id: relative || ".",
290
+ root: unitRoot,
291
+ languages: languagesFor(unitRoot, manifest),
292
+ manifest,
293
+ ...dependencyResult,
294
+ qualityCommands: commandsForUnit(unitRoot, repositoryRoot, manifest),
295
+ };
296
+ }).sort((a, b) => a.id.localeCompare(b.id));
297
+ }
298
+ export function createQualityPlan(units) {
299
+ return units.flatMap((unit) => unit.qualityCommands.map((item) => ({ ...item, args: [...item.args] })));
300
+ }
301
+ export function assessQualityCoverage(units) {
302
+ const covered = units.filter((unit) => unit.qualityCommands.length > 0);
303
+ const languages = new Set();
304
+ const commandKinds = new Set();
305
+ for (const unit of units) {
306
+ for (const language of unit.languages)
307
+ languages.add(language);
308
+ for (const item of unit.qualityCommands)
309
+ commandKinds.add(item.kind);
310
+ }
311
+ return {
312
+ totalUnits: units.length,
313
+ unitsWithCommands: covered.length,
314
+ withoutCommands: units.filter((unit) => unit.qualityCommands.length === 0).map((unit) => unit.id),
315
+ languages: LANGUAGE_ORDER.filter((language) => languages.has(language)),
316
+ commandKinds: KIND_ORDER.filter((kind) => commandKinds.has(kind)),
317
+ coveragePct: units.length === 0 ? 0 : Math.round((covered.length / units.length) * 100),
318
+ };
319
+ }
320
+ function executableExists(executable) {
321
+ if (!executable.trim() || /[\r\n\0]/.test(executable))
322
+ return false;
323
+ const hasPath = path.isAbsolute(executable) || executable.includes("/") || executable.includes("\\");
324
+ const candidates = [];
325
+ if (hasPath)
326
+ candidates.push(path.resolve(executable));
327
+ else {
328
+ const pathDirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
329
+ const extensions = process.platform === "win32"
330
+ ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)
331
+ : [""];
332
+ for (const dir of pathDirs) {
333
+ for (const ext of extensions)
334
+ candidates.push(path.join(dir.replace(/^"|"$/g, ""), executable + ext));
335
+ candidates.push(path.join(dir.replace(/^"|"$/g, ""), executable));
336
+ }
337
+ }
338
+ return candidates.some((candidate) => {
339
+ try {
340
+ const st = fs.statSync(candidate);
341
+ if (!st.isFile())
342
+ return false;
343
+ if (process.platform !== "win32")
344
+ fs.accessSync(candidate, fs.constants.X_OK);
345
+ return true;
346
+ }
347
+ catch {
348
+ return false;
349
+ }
350
+ });
351
+ }
352
+ function validateCommand(item) {
353
+ if (!item.command.trim() || /[\r\n\0]/.test(item.command))
354
+ return "invalid command";
355
+ if (!Array.isArray(item.args) || item.args.some((arg) => typeof arg !== "string" || /\0/.test(arg)))
356
+ return "invalid args";
357
+ if (!path.isAbsolute(item.cwd) || !fs.existsSync(item.cwd) || !fs.statSync(item.cwd).isDirectory())
358
+ return "invalid cwd";
359
+ if (!Number.isFinite(item.timeoutMs) || item.timeoutMs < 1 || item.timeoutMs > 60 * 60_000)
360
+ return "invalid timeoutMs";
361
+ if (!Number.isFinite(item.maxOutputBytes) || item.maxOutputBytes < 1 || item.maxOutputBytes > 16 * 1024 * 1024)
362
+ return "invalid maxOutputBytes";
363
+ return null;
364
+ }
365
+ function baseResult(item, status) {
366
+ return { command: item, status, exitCode: null, signal: null, stdout: "", stderr: "", truncated: false, durationMs: 0 };
367
+ }
368
+ async function runOne(item) {
369
+ const invalid = validateCommand(item);
370
+ if (invalid)
371
+ return { ...baseResult(item, "failed"), error: invalid };
372
+ if (!executableExists(item.requiredExecutable)) {
373
+ return { ...baseResult(item, "missing"), error: `Required executable not found: ${item.requiredExecutable}` };
374
+ }
375
+ const started = Date.now();
376
+ return await new Promise((resolve) => {
377
+ let stdout = Buffer.alloc(0);
378
+ let stderr = Buffer.alloc(0);
379
+ let used = 0;
380
+ let truncated = false;
381
+ let timedOut = false;
382
+ let settled = false;
383
+ const capture = (stream, chunk) => {
384
+ const remaining = Math.max(0, item.maxOutputBytes - used);
385
+ if (chunk.length > remaining)
386
+ truncated = true;
387
+ const kept = chunk.subarray(0, remaining);
388
+ used += kept.length;
389
+ if (stream === "stdout")
390
+ stdout = Buffer.concat([stdout, kept]);
391
+ else
392
+ stderr = Buffer.concat([stderr, kept]);
393
+ };
394
+ let timer;
395
+ const finish = (result) => {
396
+ if (settled)
397
+ return;
398
+ settled = true;
399
+ if (timer)
400
+ clearTimeout(timer);
401
+ resolve({ ...result, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8"), truncated, durationMs: Date.now() - started });
402
+ };
403
+ // shell:false is intentional: arguments never undergo shell parsing.
404
+ let child;
405
+ try {
406
+ child = spawn(item.command, item.args, { cwd: item.cwd, shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
407
+ }
408
+ catch (error) {
409
+ const err = error;
410
+ finish({ ...baseResult(item, err.code === "ENOENT" ? "missing" : "failed"), error: err.message });
411
+ return;
412
+ }
413
+ child.stdout?.on("data", (chunk) => capture("stdout", chunk));
414
+ child.stderr?.on("data", (chunk) => capture("stderr", chunk));
415
+ child.once("error", (error) => {
416
+ finish({
417
+ ...baseResult(item, error.code === "ENOENT" ? "missing" : "failed"),
418
+ error: error.message,
419
+ });
420
+ });
421
+ child.once("close", (code, signal) => {
422
+ const status = timedOut ? "timed_out" : code === 0 ? "passed" : "failed";
423
+ finish({ ...baseResult(item, status), exitCode: code, signal });
424
+ });
425
+ timer = setTimeout(() => {
426
+ timedOut = true;
427
+ child.kill();
428
+ }, item.timeoutMs);
429
+ timer.unref();
430
+ });
431
+ }
432
+ /** Runs a plan only when execute:true. Missing tools, timeout and non-zero exits all make ok=false. */
433
+ export async function runQualityPlan(plan, options = {}) {
434
+ if (!options.execute) {
435
+ return { execute: false, ok: true, results: plan.map((item) => baseResult(item, "planned")) };
436
+ }
437
+ const results = [];
438
+ const stopOnFailure = options.stopOnFailure ?? true;
439
+ for (let index = 0; index < plan.length; index++) {
440
+ const result = await runOne(plan[index]);
441
+ results.push(result);
442
+ if (result.status !== "passed" && stopOnFailure) {
443
+ for (const blocked of plan.slice(index + 1)) {
444
+ results.push({ ...baseResult(blocked, "blocked"), error: "Blocked by an earlier failed quality command" });
445
+ }
446
+ break;
447
+ }
448
+ }
449
+ return { execute: true, ok: results.every((result) => result.status === "passed"), results };
450
+ }