@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.
- package/LICENSE +21 -0
- package/README.md +304 -0
- package/dist/acceptance-evaluator.js +330 -0
- package/dist/acceptance-model.js +440 -0
- package/dist/acceptance-report.js +335 -0
- package/dist/acceptance-tools.js +318 -0
- package/dist/audit-tools.js +61 -0
- package/dist/audit.js +364 -0
- package/dist/budget.js +71 -0
- package/dist/dashboard.js +241 -0
- package/dist/governance-audit.js +126 -0
- package/dist/governance-model.js +306 -0
- package/dist/governance-tools.js +240 -0
- package/dist/health.js +58 -0
- package/dist/index-db.js +97 -0
- package/dist/index-store.js +381 -0
- package/dist/index.js +210 -0
- package/dist/init.js +40 -0
- package/dist/knowledge-tools.js +204 -0
- package/dist/language-adapters.js +450 -0
- package/dist/language-dependencies.js +351 -0
- package/dist/license.js +261 -0
- package/dist/paths.js +51 -0
- package/dist/polyglot-ast.js +329 -0
- package/dist/portfolio.js +349 -0
- package/dist/project-fingerprint.js +83 -0
- package/dist/project-tools.js +217 -0
- package/dist/quality-store.js +77 -0
- package/dist/registry.js +38 -0
- package/dist/roadmap.js +96 -0
- package/dist/scan.js +271 -0
- package/dist/search.js +312 -0
- package/dist/security.js +269 -0
- package/dist/semantic-evidence-store.js +133 -0
- package/dist/semantic-evidence.js +100 -0
- package/dist/semantic-graph-algorithms.js +86 -0
- package/dist/semantic-graph.js +390 -0
- package/dist/semantic-parsers.js +311 -0
- package/dist/store.js +185 -0
- package/dist/task-tools.js +198 -0
- package/dist/tool-base.js +32 -0
- package/dist/tools.js +21 -0
- package/dist/types.js +208 -0
- package/dist/typescript-semantic.js +282 -0
- package/package.json +70 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { parse, registerDynamicLanguage } from "@ast-grep/napi";
|
|
2
|
+
import csharpGrammar from "@ast-grep/lang-csharp";
|
|
3
|
+
import goGrammar from "@ast-grep/lang-go";
|
|
4
|
+
import javaGrammar from "@ast-grep/lang-java";
|
|
5
|
+
import kotlinGrammar from "@ast-grep/lang-kotlin";
|
|
6
|
+
import pythonGrammar from "@ast-grep/lang-python";
|
|
7
|
+
import rustGrammar from "@ast-grep/lang-rust";
|
|
8
|
+
export const POLYGLOT_AST_LANGUAGES = ["python", "go", "rust", "java", "kotlin", "csharp"];
|
|
9
|
+
const NAPI_VERSION = "0.45.3";
|
|
10
|
+
const GRAMMAR_VERSIONS = {
|
|
11
|
+
python: "0.0.6",
|
|
12
|
+
go: "0.0.6",
|
|
13
|
+
rust: "0.0.7",
|
|
14
|
+
java: "0.0.7",
|
|
15
|
+
kotlin: "0.0.7",
|
|
16
|
+
csharp: "0.0.6",
|
|
17
|
+
};
|
|
18
|
+
// ast-grep requires one process-wide registration call. ESM module evaluation is
|
|
19
|
+
// cached, so importing this provider repeatedly cannot re-register the grammars.
|
|
20
|
+
registerDynamicLanguage({
|
|
21
|
+
python: pythonGrammar,
|
|
22
|
+
go: goGrammar,
|
|
23
|
+
rust: rustGrammar,
|
|
24
|
+
java: javaGrammar,
|
|
25
|
+
kotlin: kotlinGrammar,
|
|
26
|
+
csharp: csharpGrammar,
|
|
27
|
+
});
|
|
28
|
+
const STRING_KINDS = new Set([
|
|
29
|
+
"string", "string_literal", "interpreted_string_literal", "raw_string_literal",
|
|
30
|
+
]);
|
|
31
|
+
function isSupportedLanguage(value) {
|
|
32
|
+
return POLYGLOT_AST_LANGUAGES.includes(value);
|
|
33
|
+
}
|
|
34
|
+
export function supportsPolyglotAstLanguage(value) {
|
|
35
|
+
return isSupportedLanguage(value);
|
|
36
|
+
}
|
|
37
|
+
function analyzerFor(language) {
|
|
38
|
+
const grammarVersion = GRAMMAR_VERSIONS[language];
|
|
39
|
+
return {
|
|
40
|
+
id: `tree-sitter:${language}@${grammarVersion}`,
|
|
41
|
+
family: "structured-parser",
|
|
42
|
+
assurance: "ast",
|
|
43
|
+
engine: "ast-grep Tree-sitter",
|
|
44
|
+
version: `${NAPI_VERSION}+grammar-${grammarVersion}`,
|
|
45
|
+
capabilities: ["imports", "calls", "contracts", "ffi"],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function allNodes(root) {
|
|
49
|
+
const nodes = [];
|
|
50
|
+
const visit = (node) => {
|
|
51
|
+
nodes.push(node);
|
|
52
|
+
for (const child of node.children())
|
|
53
|
+
visit(child);
|
|
54
|
+
};
|
|
55
|
+
visit(root);
|
|
56
|
+
return nodes;
|
|
57
|
+
}
|
|
58
|
+
function syntaxDiagnostics(root) {
|
|
59
|
+
const diagnostics = [];
|
|
60
|
+
for (const node of allNodes(root).slice(1)) {
|
|
61
|
+
if (node.kind() !== "ERROR" && node.text().length > 0)
|
|
62
|
+
continue;
|
|
63
|
+
const start = node.range().start;
|
|
64
|
+
const label = node.kind() === "ERROR" ? "syntax error" : `missing ${node.kind()}`;
|
|
65
|
+
diagnostics.push(`${start.line + 1}:${start.column + 1} ${label}`);
|
|
66
|
+
}
|
|
67
|
+
return [...new Set(diagnostics)];
|
|
68
|
+
}
|
|
69
|
+
function stringValue(text) {
|
|
70
|
+
const trimmed = text.trim();
|
|
71
|
+
const prefixed = trimmed.match(/^(?:[rubf]+|@)?("""|'''|"|'|`)([\s\S]*)(?:\1)$/i);
|
|
72
|
+
if (!prefixed)
|
|
73
|
+
return null;
|
|
74
|
+
const quote = prefixed[1];
|
|
75
|
+
const body = prefixed[2];
|
|
76
|
+
if (quote === '"') {
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(`"${body}"`);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return body;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return body.replace(/\\(['`\\])/g, "$1");
|
|
85
|
+
}
|
|
86
|
+
function firstString(node) {
|
|
87
|
+
if (STRING_KINDS.has(String(node.kind())))
|
|
88
|
+
return stringValue(node.text());
|
|
89
|
+
for (const child of node.children()) {
|
|
90
|
+
const value = firstString(child);
|
|
91
|
+
if (value !== null)
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
function contractSpecifier(value) {
|
|
97
|
+
const clean = value.trim();
|
|
98
|
+
const pathPart = clean.split("#", 1)[0].replace(/\\/g, "/");
|
|
99
|
+
const basename = pathPart.split("/").at(-1) ?? "";
|
|
100
|
+
if (/^[^.\s][^/\s]*\.(?:proto|graphql|gql)$/i.test(basename))
|
|
101
|
+
return clean;
|
|
102
|
+
const structured = basename.match(/^([^/\s]+)\.(?:ya?ml|json)$/i);
|
|
103
|
+
if (structured && /(?:^|[-_.])(?:openapi|swagger|asyncapi|contracts?|schemas?)(?:$|[-_.])/i.test(structured[1]))
|
|
104
|
+
return clean;
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
function pythonImports(text) {
|
|
108
|
+
const from = text.match(/^\s*from\s+([.\w]+)\s+import\s+([\s\S]+)$/);
|
|
109
|
+
if (from) {
|
|
110
|
+
let specifier = from[1];
|
|
111
|
+
if (/^\.+$/.test(specifier)) {
|
|
112
|
+
const imported = from[2].replace(/[()]/g, "").split(",", 1)[0].trim().split(/\s+as\s+/, 1)[0];
|
|
113
|
+
if (imported && imported !== "*")
|
|
114
|
+
specifier += imported;
|
|
115
|
+
}
|
|
116
|
+
return [specifier];
|
|
117
|
+
}
|
|
118
|
+
const direct = text.match(/^\s*import\s+([\s\S]+)$/);
|
|
119
|
+
return direct
|
|
120
|
+
? direct[1].replace(/[()]/g, "").split(",").map((item) => item.trim().split(/\s+as\s+/, 1)[0]).filter(Boolean)
|
|
121
|
+
: [];
|
|
122
|
+
}
|
|
123
|
+
function importSpecifiers(language, node) {
|
|
124
|
+
const text = node.text().trim();
|
|
125
|
+
if (language === "python" && (node.kind() === "import_statement" || node.kind() === "import_from_statement")) {
|
|
126
|
+
return pythonImports(text).map((value) => ({ value, forceLocal: value.startsWith(".") }));
|
|
127
|
+
}
|
|
128
|
+
if (language === "go" && node.kind() === "import_spec") {
|
|
129
|
+
const value = firstString(node);
|
|
130
|
+
return value && value !== "C" ? [{ value, forceLocal: false }] : [];
|
|
131
|
+
}
|
|
132
|
+
if (language === "rust" && node.kind() === "use_declaration") {
|
|
133
|
+
const value = text.replace(/^use\s+/, "").replace(/;\s*$/, "").replace(/\s+as\s+[A-Za-z_]\w*\s*$/, "").trim();
|
|
134
|
+
return value ? [{ value, forceLocal: /^(?:crate|self|super)::/.test(value) }] : [];
|
|
135
|
+
}
|
|
136
|
+
if (language === "rust" && node.kind() === "mod_item" && /;\s*$/.test(text)) {
|
|
137
|
+
const value = text.match(/^mod\s+([A-Za-z_]\w*)\s*;/)?.[1];
|
|
138
|
+
return value ? [{ value, forceLocal: true }] : [];
|
|
139
|
+
}
|
|
140
|
+
if (language === "java" && node.kind() === "import_declaration") {
|
|
141
|
+
const value = text.replace(/^import\s+/, "").replace(/^static\s+/, "").replace(/;\s*$/, "").trim();
|
|
142
|
+
return value ? [{ value, forceLocal: false }] : [];
|
|
143
|
+
}
|
|
144
|
+
if (language === "kotlin" && node.kind() === "import_header") {
|
|
145
|
+
const value = text.replace(/^import\s+/, "").replace(/\s+as\s+\w+\s*$/, "").trim();
|
|
146
|
+
return value ? [{ value, forceLocal: false }] : [];
|
|
147
|
+
}
|
|
148
|
+
if (language === "csharp" && node.kind() === "using_directive") {
|
|
149
|
+
const body = text.replace(/^global\s+/, "").replace(/^using\s+/, "").replace(/^static\s+/, "").replace(/;\s*$/, "").trim();
|
|
150
|
+
const value = body.includes("=") ? body.slice(body.indexOf("=") + 1).trim() : body;
|
|
151
|
+
return value ? [{ value, forceLocal: false }] : [];
|
|
152
|
+
}
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
function pythonBindings(text) {
|
|
156
|
+
const from = text.match(/^\s*from\s+([.\w]+)\s+import\s+([\s\S]+)$/);
|
|
157
|
+
if (from) {
|
|
158
|
+
const base = from[1];
|
|
159
|
+
return from[2].replace(/[()]/g, "").split(",").flatMap((raw) => {
|
|
160
|
+
const parts = raw.trim().split(/\s+as\s+/);
|
|
161
|
+
const imported = parts[0]?.trim() ?? "";
|
|
162
|
+
if (!imported || imported === "*")
|
|
163
|
+
return [];
|
|
164
|
+
const local = (parts[1] ?? imported.split(".").at(-1) ?? "").trim();
|
|
165
|
+
const specifier = /^\.+$/.test(base) ? `${base}${imported}` : base;
|
|
166
|
+
return local ? [{ local, specifier, symbol: imported }] : [];
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const direct = text.match(/^\s*import\s+([\s\S]+)$/);
|
|
170
|
+
if (!direct)
|
|
171
|
+
return [];
|
|
172
|
+
return direct[1].replace(/[()]/g, "").split(",").flatMap((raw) => {
|
|
173
|
+
const parts = raw.trim().split(/\s+as\s+/);
|
|
174
|
+
const specifier = parts[0]?.trim() ?? "";
|
|
175
|
+
const local = (parts[1] ?? specifier.split(".")[0] ?? "").trim();
|
|
176
|
+
return specifier && local ? [{ local, specifier, symbol: "*" }] : [];
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
function importedBindings(language, node) {
|
|
180
|
+
const kind = String(node.kind());
|
|
181
|
+
const text = node.text().trim();
|
|
182
|
+
if (language === "python" && (kind === "import_statement" || kind === "import_from_statement"))
|
|
183
|
+
return pythonBindings(text);
|
|
184
|
+
if (language === "go" && kind === "import_spec") {
|
|
185
|
+
const specifier = firstString(node);
|
|
186
|
+
if (!specifier || specifier === "C")
|
|
187
|
+
return [];
|
|
188
|
+
const quote = text.search(/["`]/);
|
|
189
|
+
const explicit = quote < 0 ? "" : text.slice(0, quote).trim().split(/\s+/).at(-1) ?? "";
|
|
190
|
+
if (explicit === "_" || explicit === ".")
|
|
191
|
+
return [];
|
|
192
|
+
const local = explicit || (specifier.replace(/\/$/, "").split("/").at(-1) ?? "");
|
|
193
|
+
return local ? [{ local, specifier, symbol: "*" }] : [];
|
|
194
|
+
}
|
|
195
|
+
if (language === "rust" && kind === "use_declaration") {
|
|
196
|
+
const body = text.replace(/^use\s+/, "").replace(/;\s*$/, "").trim();
|
|
197
|
+
if (!body || /[{}*]/.test(body))
|
|
198
|
+
return [];
|
|
199
|
+
const alias = body.match(/\s+as\s+([A-Za-z_]\w*)\s*$/)?.[1];
|
|
200
|
+
const specifier = body.replace(/\s+as\s+[A-Za-z_]\w*\s*$/, "");
|
|
201
|
+
const symbol = specifier.split("::").at(-1) ?? "";
|
|
202
|
+
const local = alias ?? symbol;
|
|
203
|
+
return local ? [{ local, specifier, symbol }] : [];
|
|
204
|
+
}
|
|
205
|
+
if (language === "java" && kind === "import_declaration") {
|
|
206
|
+
const specifier = importSpecifiers(language, node)[0]?.value ?? "";
|
|
207
|
+
if (!specifier || specifier.endsWith(".*"))
|
|
208
|
+
return [];
|
|
209
|
+
const local = specifier.split(".").at(-1) ?? "";
|
|
210
|
+
return local ? [{ local, specifier, symbol: local }] : [];
|
|
211
|
+
}
|
|
212
|
+
if (language === "kotlin" && kind === "import_header") {
|
|
213
|
+
const specifier = importSpecifiers(language, node)[0]?.value ?? "";
|
|
214
|
+
const alias = text.match(/\s+as\s+([A-Za-z_]\w*)\s*$/)?.[1];
|
|
215
|
+
const symbol = specifier.split(".").at(-1) ?? "";
|
|
216
|
+
const local = alias ?? symbol;
|
|
217
|
+
return local ? [{ local, specifier, symbol }] : [];
|
|
218
|
+
}
|
|
219
|
+
if (language === "csharp" && kind === "using_directive" && text.includes("=")) {
|
|
220
|
+
const body = text.replace(/^global\s+/, "").replace(/^using\s+/, "").replace(/;\s*$/, "").trim();
|
|
221
|
+
const equal = body.indexOf("=");
|
|
222
|
+
const local = body.slice(0, equal).trim();
|
|
223
|
+
const specifier = body.slice(equal + 1).trim();
|
|
224
|
+
return /^[A-Za-z_]\w*$/.test(local) && specifier ? [{ local, specifier, symbol: "*" }] : [];
|
|
225
|
+
}
|
|
226
|
+
return [];
|
|
227
|
+
}
|
|
228
|
+
function callBindingName(language, node) {
|
|
229
|
+
const callKinds = {
|
|
230
|
+
python: "call",
|
|
231
|
+
go: "call_expression",
|
|
232
|
+
rust: "call_expression",
|
|
233
|
+
java: "method_invocation",
|
|
234
|
+
kotlin: "call_expression",
|
|
235
|
+
csharp: "invocation_expression",
|
|
236
|
+
};
|
|
237
|
+
if (String(node.kind()) !== callKinds[language])
|
|
238
|
+
return null;
|
|
239
|
+
const callee = node.text().split("(", 1)[0].trim();
|
|
240
|
+
return callee.match(/^([A-Za-z_]\w*)/)?.[1] ?? null;
|
|
241
|
+
}
|
|
242
|
+
function ffiSpecifier(language, node) {
|
|
243
|
+
const kind = node.kind();
|
|
244
|
+
const text = node.text().trim();
|
|
245
|
+
if (language === "go" && kind === "import_spec" && firstString(node) === "C")
|
|
246
|
+
return "C ABI";
|
|
247
|
+
if (language === "rust" && kind === "foreign_mod_item" && /^extern\s+"C"/.test(text))
|
|
248
|
+
return "C ABI";
|
|
249
|
+
if (language === "python" && kind === "call" && /^(?:ctypes\.(?:CDLL|PyDLL)|(?:cffi\.)?dlopen)\s*\(/.test(text))
|
|
250
|
+
return firstString(node);
|
|
251
|
+
if (language === "rust" && kind === "call_expression" && /(?:\bdlopen|libloading::Library::new)\s*\(/.test(text))
|
|
252
|
+
return firstString(node);
|
|
253
|
+
if (language === "java" && kind === "method_invocation" && /(?:System|Runtime)\.(?:load|loadLibrary)\s*\(/.test(text))
|
|
254
|
+
return firstString(node);
|
|
255
|
+
if (language === "kotlin" && kind === "call_expression" && /(?:System|Runtime)\.(?:load|loadLibrary)\s*\(/.test(text))
|
|
256
|
+
return firstString(node);
|
|
257
|
+
if (language === "csharp" && kind === "attribute" && /(?:^|\.)DllImport\s*\(/.test(text))
|
|
258
|
+
return firstString(node);
|
|
259
|
+
if (language === "csharp" && kind === "invocation_expression" && /NativeLibrary\.Load\s*\(/.test(text))
|
|
260
|
+
return firstString(node);
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Parses a single non-TS source file with a bundled Tree-sitter grammar.
|
|
265
|
+
* Syntax recovery is deliberately rejected: an ERROR/missing node makes the
|
|
266
|
+
* result partial and suppresses every edge from the recovered tree.
|
|
267
|
+
*/
|
|
268
|
+
export function analyzePolyglotAstSource(rel, language, content) {
|
|
269
|
+
if (!isSupportedLanguage(language))
|
|
270
|
+
throw new Error(`Unsupported polyglot AST language: ${language}`);
|
|
271
|
+
const analyzer = analyzerFor(language);
|
|
272
|
+
const root = parse(language, content).root();
|
|
273
|
+
const diagnostics = syntaxDiagnostics(root);
|
|
274
|
+
if (diagnostics.length > 0) {
|
|
275
|
+
return {
|
|
276
|
+
references: [], exports: [], analyzer, status: "partial", diagnostics,
|
|
277
|
+
limitations: ["Tree-sitter recovered from invalid syntax; fail-closed mode discarded all recovered relations."],
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const references = [];
|
|
281
|
+
const add = (node, kind, specifier, confidence, forceLocal = false, symbol) => {
|
|
282
|
+
if (!specifier.trim())
|
|
283
|
+
return;
|
|
284
|
+
references.push({
|
|
285
|
+
kind, specifier: specifier.trim(), line: node.range().start.line + 1,
|
|
286
|
+
parser: analyzer.id, confidence, forceLocal, offset: node.range().start.index, symbol,
|
|
287
|
+
});
|
|
288
|
+
};
|
|
289
|
+
const nodes = allNodes(root);
|
|
290
|
+
const bindings = new Map();
|
|
291
|
+
for (const node of nodes) {
|
|
292
|
+
for (const binding of importedBindings(language, node)) {
|
|
293
|
+
const previous = bindings.get(binding.local);
|
|
294
|
+
bindings.set(binding.local, previous === undefined || (previous && previous.specifier === binding.specifier && previous.symbol === binding.symbol) ? binding : null);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
for (const node of nodes) {
|
|
298
|
+
for (const item of importSpecifiers(language, node))
|
|
299
|
+
add(node, "import", item.value, 0.98, item.forceLocal);
|
|
300
|
+
const bindingName = callBindingName(language, node);
|
|
301
|
+
const binding = bindingName ? bindings.get(bindingName) : undefined;
|
|
302
|
+
if (binding)
|
|
303
|
+
add(node, "call", binding.specifier, 0.96, binding.specifier.startsWith("."), binding.symbol);
|
|
304
|
+
if (STRING_KINDS.has(String(node.kind()))) {
|
|
305
|
+
const value = stringValue(node.text());
|
|
306
|
+
const contract = value === null ? null : contractSpecifier(value);
|
|
307
|
+
if (contract)
|
|
308
|
+
add(node, "contract", contract, 0.98, true);
|
|
309
|
+
}
|
|
310
|
+
const ffi = ffiSpecifier(language, node);
|
|
311
|
+
if (ffi)
|
|
312
|
+
add(node, "ffi", ffi, 0.98, ffi !== "C ABI" && /[\\/]/.test(ffi));
|
|
313
|
+
}
|
|
314
|
+
const unique = [...new Map(references.map((reference) => [
|
|
315
|
+
`${reference.kind}|${reference.line}|${reference.specifier}|${reference.offset ?? ""}`,
|
|
316
|
+
reference,
|
|
317
|
+
])).values()].sort((a, b) => a.line - b.line || a.kind.localeCompare(b.kind) || a.specifier.localeCompare(b.specifier));
|
|
318
|
+
return {
|
|
319
|
+
references: unique,
|
|
320
|
+
exports: [],
|
|
321
|
+
analyzer,
|
|
322
|
+
status: "complete",
|
|
323
|
+
diagnostics: [],
|
|
324
|
+
limitations: [
|
|
325
|
+
"Tree-sitter AST proves source syntax, not compiler symbol resolution or runtime execution.",
|
|
326
|
+
`Source identity (${rel}) and content hash are bound by the semantic evidence layer when results are imported.`,
|
|
327
|
+
],
|
|
328
|
+
};
|
|
329
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { GovernanceSchema, loadGovernance } from "./governance-model.js";
|
|
4
|
+
import { pmPath, registryFile, snapshotsDir } from "./paths.js";
|
|
5
|
+
import { loadProject, loadRoadmap, loadSecurity, loadTasks } from "./store.js";
|
|
6
|
+
import { RegistrySchema, SnapshotSchema } from "./types.js";
|
|
7
|
+
const SEMVER_SOURCE = "(?:v)?(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-([0-9A-Za-z.-]+))?(?:\\+[0-9A-Za-z.-]+)?";
|
|
8
|
+
const SEMVER_RE = new RegExp(`^${SEMVER_SOURCE}$`);
|
|
9
|
+
const CONSTRAINT_RE = new RegExp(`^(\\^|~|>=|<=|>|<)?\\s*(${SEMVER_SOURCE})$`);
|
|
10
|
+
function parseSemVer(value) {
|
|
11
|
+
const match = value.trim().match(SEMVER_RE);
|
|
12
|
+
if (!match)
|
|
13
|
+
return null;
|
|
14
|
+
const prerelease = [];
|
|
15
|
+
if (match[4]) {
|
|
16
|
+
for (const identifier of match[4].split(".")) {
|
|
17
|
+
if (!identifier || (/^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith("0")))
|
|
18
|
+
return null;
|
|
19
|
+
prerelease.push(/^\d+$/.test(identifier) ? Number(identifier) : identifier);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), prerelease };
|
|
23
|
+
}
|
|
24
|
+
function compareSemVer(left, right) {
|
|
25
|
+
for (const field of ["major", "minor", "patch"]) {
|
|
26
|
+
if (left[field] !== right[field])
|
|
27
|
+
return left[field] < right[field] ? -1 : 1;
|
|
28
|
+
}
|
|
29
|
+
if (left.prerelease.length === 0 || right.prerelease.length === 0) {
|
|
30
|
+
return left.prerelease.length === right.prerelease.length ? 0 : left.prerelease.length === 0 ? 1 : -1;
|
|
31
|
+
}
|
|
32
|
+
const length = Math.max(left.prerelease.length, right.prerelease.length);
|
|
33
|
+
for (let index = 0; index < length; index += 1) {
|
|
34
|
+
const a = left.prerelease[index];
|
|
35
|
+
const b = right.prerelease[index];
|
|
36
|
+
if (a === undefined || b === undefined)
|
|
37
|
+
return a === undefined ? -1 : 1;
|
|
38
|
+
if (a === b)
|
|
39
|
+
continue;
|
|
40
|
+
if (typeof a === "number" && typeof b === "string")
|
|
41
|
+
return -1;
|
|
42
|
+
if (typeof a === "string" && typeof b === "number")
|
|
43
|
+
return 1;
|
|
44
|
+
return a < b ? -1 : 1;
|
|
45
|
+
}
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
function upperBound(operator, base) {
|
|
49
|
+
if (operator === "~")
|
|
50
|
+
return { major: base.major, minor: base.minor + 1, patch: 0, prerelease: [] };
|
|
51
|
+
if (base.major > 0)
|
|
52
|
+
return { major: base.major + 1, minor: 0, patch: 0, prerelease: [] };
|
|
53
|
+
if (base.minor > 0)
|
|
54
|
+
return { major: 0, minor: base.minor + 1, patch: 0, prerelease: [] };
|
|
55
|
+
return { major: 0, minor: 0, patch: base.patch + 1, prerelease: [] };
|
|
56
|
+
}
|
|
57
|
+
/** Evaluate the deliberately small, deterministic semver constraint subset. */
|
|
58
|
+
export function checkVersionConstraint(version, constraint) {
|
|
59
|
+
const normalizedConstraint = constraint.trim();
|
|
60
|
+
if (normalizedConstraint === "*")
|
|
61
|
+
return { status: "satisfied", version, constraint: normalizedConstraint };
|
|
62
|
+
const target = parseSemVer(version);
|
|
63
|
+
if (!target) {
|
|
64
|
+
return { status: "unresolved", version, constraint: normalizedConstraint, reason: `目标版本不是完整 semver: ${version}` };
|
|
65
|
+
}
|
|
66
|
+
const match = normalizedConstraint.match(CONSTRAINT_RE);
|
|
67
|
+
if (!match) {
|
|
68
|
+
return { status: "unresolved", version, constraint: normalizedConstraint, reason: `不支持的版本约束: ${constraint}` };
|
|
69
|
+
}
|
|
70
|
+
const operator = (match[1] || "exact");
|
|
71
|
+
const base = parseSemVer(match[2]);
|
|
72
|
+
if (!base) {
|
|
73
|
+
return { status: "unresolved", version, constraint: normalizedConstraint, reason: `约束版本不是完整 semver: ${match[2]}` };
|
|
74
|
+
}
|
|
75
|
+
const compared = compareSemVer(target, base);
|
|
76
|
+
let satisfied;
|
|
77
|
+
switch (operator) {
|
|
78
|
+
case "exact":
|
|
79
|
+
satisfied = compared === 0;
|
|
80
|
+
break;
|
|
81
|
+
case ">=":
|
|
82
|
+
satisfied = compared >= 0;
|
|
83
|
+
break;
|
|
84
|
+
case "<=":
|
|
85
|
+
satisfied = compared <= 0;
|
|
86
|
+
break;
|
|
87
|
+
case ">":
|
|
88
|
+
satisfied = compared > 0;
|
|
89
|
+
break;
|
|
90
|
+
case "<":
|
|
91
|
+
satisfied = compared < 0;
|
|
92
|
+
break;
|
|
93
|
+
case "^":
|
|
94
|
+
satisfied = compared >= 0 && compareSemVer(target, upperBound("^", base)) < 0;
|
|
95
|
+
break;
|
|
96
|
+
case "~":
|
|
97
|
+
satisfied = compared >= 0 && compareSemVer(target, upperBound("~", base)) < 0;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
return { status: satisfied ? "satisfied" : "mismatch", version, constraint: normalizedConstraint };
|
|
101
|
+
}
|
|
102
|
+
function canonicalCycle(cycle) {
|
|
103
|
+
const body = cycle.slice(0, -1);
|
|
104
|
+
if (body.length === 0)
|
|
105
|
+
return "";
|
|
106
|
+
const rotations = body.map((_, index) => [...body.slice(index), ...body.slice(0, index)]);
|
|
107
|
+
rotations.sort((a, b) => a.join("\0").localeCompare(b.join("\0")));
|
|
108
|
+
return [...rotations[0], rotations[0][0]].join(" -> ");
|
|
109
|
+
}
|
|
110
|
+
function findCycles(ids, adjacency) {
|
|
111
|
+
const state = new Map();
|
|
112
|
+
const stack = [];
|
|
113
|
+
const cycles = new Map();
|
|
114
|
+
const visit = (id) => {
|
|
115
|
+
state.set(id, 1);
|
|
116
|
+
stack.push(id);
|
|
117
|
+
for (const target of adjacency.get(id) ?? []) {
|
|
118
|
+
if ((state.get(target) ?? 0) === 0)
|
|
119
|
+
visit(target);
|
|
120
|
+
else if (state.get(target) === 1) {
|
|
121
|
+
const start = stack.lastIndexOf(target);
|
|
122
|
+
const cycle = [...stack.slice(start), target];
|
|
123
|
+
cycles.set(canonicalCycle(cycle), cycle);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
stack.pop();
|
|
127
|
+
state.set(id, 2);
|
|
128
|
+
};
|
|
129
|
+
for (const id of [...ids].sort())
|
|
130
|
+
if ((state.get(id) ?? 0) === 0)
|
|
131
|
+
visit(id);
|
|
132
|
+
return [...cycles.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, cycle]) => cycle);
|
|
133
|
+
}
|
|
134
|
+
export function buildRepositoryGraph(governances) {
|
|
135
|
+
const violations = [];
|
|
136
|
+
const sources = [];
|
|
137
|
+
governances.forEach((governance, index) => {
|
|
138
|
+
const parsed = GovernanceSchema.safeParse(governance);
|
|
139
|
+
if (!parsed.success) {
|
|
140
|
+
violations.push({
|
|
141
|
+
code: "governance-invalid",
|
|
142
|
+
severity: "error",
|
|
143
|
+
message: `治理模型 ${index + 1} 无效: ${parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
sources.push({ governance: parsed.data, source: `governance:${index + 1}` });
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
const repositories = [];
|
|
151
|
+
const specs = new Map();
|
|
152
|
+
for (const source of sources) {
|
|
153
|
+
for (const repository of source.governance.repositories) {
|
|
154
|
+
repositories.push({
|
|
155
|
+
id: repository.id,
|
|
156
|
+
name: repository.name,
|
|
157
|
+
root: repository.root,
|
|
158
|
+
version: repository.version,
|
|
159
|
+
source: source.source,
|
|
160
|
+
});
|
|
161
|
+
const entries = specs.get(repository.id) ?? [];
|
|
162
|
+
entries.push({ source, repository });
|
|
163
|
+
specs.set(repository.id, entries);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
repositories.sort((a, b) => a.id.localeCompare(b.id) || a.source.localeCompare(b.source));
|
|
167
|
+
for (const [id, entries] of specs) {
|
|
168
|
+
if (entries.length > 1) {
|
|
169
|
+
violations.push({ code: "duplicate-repository-id", severity: "error", repository: id, message: `跨治理文件重复 repository ID "${id}"(${entries.length} 处)` });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const dependencies = [];
|
|
173
|
+
const adjacency = new Map();
|
|
174
|
+
for (const entries of specs.values()) {
|
|
175
|
+
for (const { source, repository } of entries) {
|
|
176
|
+
for (const dependency of repository.dependencies) {
|
|
177
|
+
const targets = specs.get(dependency.repository) ?? [];
|
|
178
|
+
if (targets.length === 0) {
|
|
179
|
+
dependencies.push({ from: repository.id, to: dependency.repository, constraint: dependency.constraint, status: "missing" });
|
|
180
|
+
violations.push({ code: "missing-repository-target", severity: "error", repository: repository.id, target: dependency.repository, constraint: dependency.constraint, message: `仓库 "${repository.id}" 依赖的目标 "${dependency.repository}" 不存在` });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (targets.length > 1) {
|
|
184
|
+
dependencies.push({ from: repository.id, to: dependency.repository, constraint: dependency.constraint, status: "ambiguous" });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const target = targets[0].repository;
|
|
188
|
+
const checked = checkVersionConstraint(target.version, dependency.constraint);
|
|
189
|
+
dependencies.push({ from: repository.id, to: target.id, constraint: dependency.constraint, target_version: target.version, status: checked.status });
|
|
190
|
+
const adjacent = adjacency.get(repository.id) ?? [];
|
|
191
|
+
adjacent.push(target.id);
|
|
192
|
+
adjacency.set(repository.id, adjacent);
|
|
193
|
+
if (checked.status === "unresolved") {
|
|
194
|
+
const fail = source.governance.policies.fail_on_unresolved;
|
|
195
|
+
violations.push({ code: "version-constraint-unresolved", severity: fail ? "error" : "warning", repository: repository.id, target: target.id, constraint: dependency.constraint, target_version: target.version, message: `仓库 "${repository.id}" → "${target.id}" 版本约束无法解析: ${checked.reason}` });
|
|
196
|
+
}
|
|
197
|
+
else if (checked.status === "mismatch") {
|
|
198
|
+
violations.push({ code: "version-constraint-mismatch", severity: "error", repository: repository.id, target: target.id, constraint: dependency.constraint, target_version: target.version, message: `仓库 "${repository.id}" 要求 ${dependency.constraint},目标 "${target.id}" 当前为 ${target.version}` });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
dependencies.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
|
|
204
|
+
const uniqueIds = [...specs].filter(([, entries]) => entries.length === 1).map(([id]) => id);
|
|
205
|
+
const cycles = findCycles(uniqueIds, adjacency);
|
|
206
|
+
for (const cycle of cycles) {
|
|
207
|
+
violations.push({ code: "repository-cycle", severity: "error", repository: cycle[0], message: `仓库依赖循环: ${cycle.join(" -> ")}` });
|
|
208
|
+
}
|
|
209
|
+
return { repositories, dependencies, cycles, violations };
|
|
210
|
+
}
|
|
211
|
+
function countByStatus(items, status) {
|
|
212
|
+
return items.filter((item) => item.status === status).length;
|
|
213
|
+
}
|
|
214
|
+
export function buildPortfolioReport(input) {
|
|
215
|
+
const projects = [...(input.projects ?? [])];
|
|
216
|
+
const projectFailures = [...(input.projectFailures ?? [])];
|
|
217
|
+
const governances = [...(input.governanceFiles ?? [])];
|
|
218
|
+
const seenGovernance = new Set(governances);
|
|
219
|
+
for (const project of projects) {
|
|
220
|
+
if (!seenGovernance.has(project.governance)) {
|
|
221
|
+
governances.push(project.governance);
|
|
222
|
+
seenGovernance.add(project.governance);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const graph = buildRepositoryGraph(governances);
|
|
226
|
+
const summaries = [];
|
|
227
|
+
const risks = [];
|
|
228
|
+
for (const project of projects) {
|
|
229
|
+
const openTasks = project.tasks.filter((task) => task.status !== "done" && task.status !== "cancelled");
|
|
230
|
+
const openFindings = project.findings.filter((finding) => finding.status === "open");
|
|
231
|
+
const languages = [...new Set(project.governance.modules.flatMap((module) => module.languages))].sort();
|
|
232
|
+
const debtOpen = openTasks.filter((task) => task.type === "debt").length;
|
|
233
|
+
const blocked = countByStatus(project.tasks, "blocked");
|
|
234
|
+
const high = openFindings.filter((finding) => finding.severity === "high").length;
|
|
235
|
+
const medium = openFindings.filter((finding) => finding.severity === "medium").length;
|
|
236
|
+
const low = openFindings.filter((finding) => finding.severity === "low").length;
|
|
237
|
+
summaries.push({
|
|
238
|
+
id: project.id,
|
|
239
|
+
name: project.name,
|
|
240
|
+
root: project.root,
|
|
241
|
+
phase: project.phase,
|
|
242
|
+
milestones: { total: project.milestones.length, planned: countByStatus(project.milestones, "planned"), active: countByStatus(project.milestones, "active"), done: countByStatus(project.milestones, "done"), paused: countByStatus(project.milestones, "paused") },
|
|
243
|
+
tasks: { total: project.tasks.length, open: openTasks.length, done: countByStatus(project.tasks, "done"), blocked, debt_open: debtOpen },
|
|
244
|
+
security: { open: openFindings.length, high, medium, low },
|
|
245
|
+
modules: project.governance.modules.length,
|
|
246
|
+
languages,
|
|
247
|
+
code_snapshot: project.code_snapshot,
|
|
248
|
+
});
|
|
249
|
+
if (debtOpen > 0)
|
|
250
|
+
risks.push({ code: "open-debt", severity: "warning", project: project.id, count: debtOpen, message: `${debtOpen} 个未完成债务任务` });
|
|
251
|
+
if (blocked > 0)
|
|
252
|
+
risks.push({ code: "blocked-tasks", severity: "warning", project: project.id, count: blocked, message: `${blocked} 个阻塞任务` });
|
|
253
|
+
if (openFindings.length > 0)
|
|
254
|
+
risks.push({ code: "open-security", severity: high > 0 ? "error" : "warning", project: project.id, count: openFindings.length, message: `${openFindings.length} 个未关闭安全发现(高危 ${high})` });
|
|
255
|
+
if (!project.code_snapshot)
|
|
256
|
+
risks.push({ code: "missing-code-snapshot", severity: "warning", project: project.id, count: 1, message: "缺少代码快照" });
|
|
257
|
+
const missingLanguage = project.governance.modules.filter((module) => module.languages.length === 0).length;
|
|
258
|
+
if (missingLanguage > 0)
|
|
259
|
+
risks.push({ code: "module-language-missing", severity: "warning", project: project.id, count: missingLanguage, message: `${missingLanguage} 个模块未声明语言` });
|
|
260
|
+
}
|
|
261
|
+
summaries.sort((a, b) => a.id.localeCompare(b.id));
|
|
262
|
+
const languageCounts = new Map();
|
|
263
|
+
const modules = governances.flatMap((governance) => governance.modules);
|
|
264
|
+
for (const module of modules)
|
|
265
|
+
for (const language of new Set(module.languages))
|
|
266
|
+
languageCounts.set(language, (languageCounts.get(language) ?? 0) + 1);
|
|
267
|
+
const projectFailureViolations = projectFailures.map((failure) => ({ code: "project-load-failed", severity: "error", message: `项目 "${failure.name}" 加载失败 (${failure.root}): ${failure.error}` }));
|
|
268
|
+
const violations = [...projectFailureViolations, ...graph.violations];
|
|
269
|
+
const coverage = {
|
|
270
|
+
projects_requested: input.projectsRequested ?? projects.length + projectFailures.length,
|
|
271
|
+
projects_loaded: projects.length,
|
|
272
|
+
projects_failed: projectFailures.length,
|
|
273
|
+
governance_files: governances.length,
|
|
274
|
+
code_snapshots: projects.filter((project) => project.code_snapshot !== null).length,
|
|
275
|
+
modules: modules.length,
|
|
276
|
+
modules_with_owners: modules.filter((module) => module.owners.length > 0).length,
|
|
277
|
+
languages: [...languageCounts].sort(([a], [b]) => a.localeCompare(b)).map(([language, count]) => ({ language, modules: count })),
|
|
278
|
+
repositories: graph.repositories.length,
|
|
279
|
+
repository_dependencies: graph.dependencies.length,
|
|
280
|
+
resolved_repository_dependencies: graph.dependencies.filter((dependency) => dependency.status !== "missing" && dependency.status !== "ambiguous" && dependency.status !== "unresolved").length,
|
|
281
|
+
};
|
|
282
|
+
const ok = violations.every((violation) => violation.severity !== "error") && risks.every((risk) => risk.severity !== "error");
|
|
283
|
+
return { generated_at: new Date().toISOString(), ok, projects: summaries, projectFailures, repository_graph: graph, coverage, risks, violations };
|
|
284
|
+
}
|
|
285
|
+
function latestSnapshotStrict(root) {
|
|
286
|
+
const dir = snapshotsDir(root);
|
|
287
|
+
if (!fs.existsSync(dir))
|
|
288
|
+
return null;
|
|
289
|
+
const names = fs.readdirSync(dir).filter((name) => name.startsWith("snap-") && name.endsWith(".json")).sort();
|
|
290
|
+
if (names.length === 0)
|
|
291
|
+
return null;
|
|
292
|
+
const file = path.join(dir, names[names.length - 1]);
|
|
293
|
+
let raw;
|
|
294
|
+
try {
|
|
295
|
+
raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
throw new Error(`代码快照 JSON 解析失败: ${file}: ${error.message}`);
|
|
299
|
+
}
|
|
300
|
+
const parsed = SnapshotSchema.safeParse(raw);
|
|
301
|
+
if (!parsed.success) {
|
|
302
|
+
throw new Error(`代码快照数据校验失败: ${file}: ${parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`);
|
|
303
|
+
}
|
|
304
|
+
return parsed.data;
|
|
305
|
+
}
|
|
306
|
+
export function loadPortfolioProject(root) {
|
|
307
|
+
const absoluteRoot = path.resolve(root);
|
|
308
|
+
const required = ["project.json", "roadmap.json", "tasks.json", "security.json", "governance.json"];
|
|
309
|
+
const missing = required.filter((file) => !fs.existsSync(pmPath(absoluteRoot, file)));
|
|
310
|
+
if (missing.length > 0)
|
|
311
|
+
throw new Error(`项目账本缺失: ${missing.map((file) => `.pm/${file}`).join(", ")}`);
|
|
312
|
+
const project = loadProject(absoluteRoot);
|
|
313
|
+
const governance = loadGovernance(absoluteRoot);
|
|
314
|
+
const localRepository = governance.repositories.find((repository) => path.resolve(absoluteRoot, repository.root) === absoluteRoot);
|
|
315
|
+
return {
|
|
316
|
+
id: localRepository?.id ?? `${project.name}@${path.basename(absoluteRoot)}`,
|
|
317
|
+
name: project.name,
|
|
318
|
+
root: absoluteRoot,
|
|
319
|
+
phase: project.phase,
|
|
320
|
+
milestones: loadRoadmap(absoluteRoot).milestones,
|
|
321
|
+
tasks: loadTasks(absoluteRoot).tasks,
|
|
322
|
+
findings: loadSecurity(absoluteRoot).findings,
|
|
323
|
+
code_snapshot: latestSnapshotStrict(absoluteRoot),
|
|
324
|
+
governance,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
export function buildPortfolioFromRegistry(file = registryFile()) {
|
|
328
|
+
if (!fs.existsSync(file)) {
|
|
329
|
+
return buildPortfolioReport({ projectFailures: [{ name: "全局注册表", root: file, error: "注册表文件不存在" }], projectsRequested: 1 });
|
|
330
|
+
}
|
|
331
|
+
let registry;
|
|
332
|
+
try {
|
|
333
|
+
registry = RegistrySchema.parse(JSON.parse(fs.readFileSync(file, "utf8")));
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
return buildPortfolioReport({ projectFailures: [{ name: "全局注册表", root: file, error: `注册表损坏: ${error.message}` }], projectsRequested: 1 });
|
|
337
|
+
}
|
|
338
|
+
const projects = [];
|
|
339
|
+
const failures = [];
|
|
340
|
+
for (const entry of registry.projects) {
|
|
341
|
+
try {
|
|
342
|
+
projects.push(loadPortfolioProject(entry.root));
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
failures.push({ name: entry.name, root: entry.root, error: error.message });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return buildPortfolioReport({ projects, projectFailures: failures, projectsRequested: registry.projects.length });
|
|
349
|
+
}
|