@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
package/dist/security.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { loadExtraRules, loadProject, loadSecurity, saveSecurity } from "./store.js";
|
|
3
|
+
import { latestSnapshot } from "./audit.js";
|
|
4
|
+
import { readDirectDeps } from "./scan.js";
|
|
5
|
+
import { ensureFresh, getIndex, iterateFileRows } from "./index-store.js";
|
|
6
|
+
import { readText } from "./search.js";
|
|
7
|
+
import { now } from "./types.js";
|
|
8
|
+
const SECRET_PATTERNS = [
|
|
9
|
+
{ id: "secret.aws-access-key", severity: "high", pattern: /AKIA[0-9A-Z]{16}/, message: "疑似 AWS Access Key" },
|
|
10
|
+
{ id: "secret.github-token", severity: "high", pattern: /gh[pousr]_[A-Za-z0-9]{20,}/, message: "疑似 GitHub Token" },
|
|
11
|
+
{ id: "secret.openai-key", severity: "high", pattern: /sk-[A-Za-z0-9_-]{24,}/, message: "疑似 OpenAI/兼容平台 API Key" },
|
|
12
|
+
{ id: "secret.google-api", severity: "high", pattern: /AIza[0-9A-Za-z_-]{35}/, message: "疑似 Google API Key" },
|
|
13
|
+
{ id: "secret.slack-token", severity: "high", pattern: /xox[baprs]-[0-9A-Za-z-]{10,}/, message: "疑似 Slack Token" },
|
|
14
|
+
{
|
|
15
|
+
id: "secret.private-key",
|
|
16
|
+
severity: "high",
|
|
17
|
+
pattern: /-----BEGIN (RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----/,
|
|
18
|
+
message: "内联私钥",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: "secret.generic-assignment",
|
|
22
|
+
severity: "medium",
|
|
23
|
+
pattern: /\b(api[_-]?key|apikey|secret|token|password|passwd)\b\s*[:=]\s*["'][^"'\s]{10,}["']/i,
|
|
24
|
+
message: "疑似硬编码凭据赋值",
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
const DANGEROUS_PATTERNS = [
|
|
28
|
+
{ id: "danger.eval", severity: "medium", pattern: /\beval\s*\(/, message: "eval() 动态执行(注入风险)", glob: /\.(js|ts|mjs|cjs|jsx|tsx)$/ },
|
|
29
|
+
{ id: "danger.new-function", severity: "medium", pattern: /new\s+Function\s*\(/, message: "new Function() 动态执行", glob: /\.(js|ts|mjs|cjs|jsx|tsx)$/ },
|
|
30
|
+
{ id: "danger.exec-sync", severity: "medium", pattern: /exec(ution)?Sync\s*\(/, message: "execSync 同步执行命令", glob: /\.(js|ts|mjs|cjs|jsx|tsx)$/ },
|
|
31
|
+
{ id: "danger.tls-disabled", severity: "high", pattern: /rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*["']?0/, message: "禁用 TLS 证书校验", glob: /\.(js|ts|mjs|cjs|jsx|tsx)$/ },
|
|
32
|
+
{ id: "danger.cors-wildcard", severity: "medium", pattern: /cors\s*\(\s*\{\s*origin\s*:\s*["']\*["']/, message: "CORS 通配放开", glob: /\.(js|ts|mjs|cjs|jsx|tsx)$/ },
|
|
33
|
+
{ id: "danger.sql-concat", severity: "high", pattern: /(select|insert\s+into|update|delete\s+from)\b[^;"'`]*("\s*\+|'\s*\+|\$\{|%\s*\(|f")/i, message: "SQL 与字符串拼接/插值(注入风险)", glob: /\.(js|ts|mjs|cjs|jsx|tsx|py)$/ },
|
|
34
|
+
{ id: "danger.innerhtml", severity: "medium", pattern: /\.innerHTML\s*=|document\.write\s*\(/, message: "直接写 innerHTML(XSS 风险)", glob: /\.(js|ts|mjs|cjs|jsx|tsx)$/ },
|
|
35
|
+
{ id: "danger.python-exec", severity: "medium", pattern: /\beval\s*\(|\bexec\s*\(/, message: "Python eval/exec 动态执行", glob: /\.py$/ },
|
|
36
|
+
{ id: "danger.python-shell-true", severity: "high", pattern: /shell\s*=\s*True/, message: "subprocess shell=True(注入风险)", glob: /\.py$/ },
|
|
37
|
+
{ id: "danger.python-pickle", severity: "medium", pattern: /pickle\.loads?\s*\(|yaml\.load\s*\((?![^)]*Loader)/, message: "不安全反序列化(pickle/yaml.load)", glob: /\.py$/ },
|
|
38
|
+
{ id: "danger.python-tls-off", severity: "high", pattern: /verify\s*=\s*False|CERT_NONE/, message: "禁用 TLS 证书校验", glob: /\.py$/ },
|
|
39
|
+
{ id: "danger.flask-debug", severity: "low", pattern: /app\.run\s*\([^)]*debug\s*=\s*True/, message: "Flask 生产开启 debug", glob: /\.py$/ },
|
|
40
|
+
];
|
|
41
|
+
/** 占位符判定:避免把示例/模板值当泄露 */
|
|
42
|
+
function looksPlaceholder(value) {
|
|
43
|
+
return /process\.env|os\.environ|\$\{|<[^>]+>|^\$\{|xxx|your[_-]|example|sample|template|changeme|change_me|placeholder|\{\{/i.test(value);
|
|
44
|
+
}
|
|
45
|
+
function shouldIgnoreSecretMatch(rule, matched) {
|
|
46
|
+
return rule.id === "secret.generic-assignment" && looksPlaceholder(matched);
|
|
47
|
+
}
|
|
48
|
+
function findRelevantMatch(rule, value) {
|
|
49
|
+
const flags = rule.pattern.flags.includes("g") ? rule.pattern.flags : `${rule.pattern.flags}g`;
|
|
50
|
+
const pattern = new RegExp(rule.pattern.source, flags);
|
|
51
|
+
for (const match of value.matchAll(pattern)) {
|
|
52
|
+
if (shouldIgnoreSecretMatch(rule, match[0]))
|
|
53
|
+
continue;
|
|
54
|
+
return match;
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 处置理由与代码扫描共用同一套内置秘密规则,避免新增规则后 note 校验漏防。
|
|
60
|
+
* 占位符沿用扫描口径,允许说明已脱敏的示例值。
|
|
61
|
+
*/
|
|
62
|
+
function findSecretRule(value) {
|
|
63
|
+
for (const rule of SECRET_PATTERNS) {
|
|
64
|
+
if (findRelevantMatch(rule, value))
|
|
65
|
+
return rule;
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
function fingerprint(ruleId, rel, matched) {
|
|
70
|
+
return createHash("sha1").update(ruleId + "|" + rel + "|" + matched).digest("hex").slice(0, 16);
|
|
71
|
+
}
|
|
72
|
+
function scanDetections(root) {
|
|
73
|
+
const out = [];
|
|
74
|
+
const rules = [...SECRET_PATTERNS, ...DANGEROUS_PATTERNS];
|
|
75
|
+
// 用户自定义规则
|
|
76
|
+
for (const r of loadExtraRules(root).rules) {
|
|
77
|
+
try {
|
|
78
|
+
rules.push({
|
|
79
|
+
id: r.id,
|
|
80
|
+
severity: r.severity,
|
|
81
|
+
pattern: new RegExp(r.pattern, "i"),
|
|
82
|
+
message: r.message || `自定义规则 ${r.id}`,
|
|
83
|
+
glob: r.glob ? new RegExp(r.glob) : undefined,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* 非法自定义规则跳过 */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
ensureFresh(root);
|
|
91
|
+
const db = getIndex(root);
|
|
92
|
+
// .env 类文件存在性检查(是否被 git 提交需人工确认)
|
|
93
|
+
for (const row of iterateFileRows(db, { excludeOversize: false })) {
|
|
94
|
+
const base = row.rel.split("/").pop() ?? "";
|
|
95
|
+
if (/^\.env(\.[^./]+)?$/.test(base) && !/example|sample|template/i.test(base)) {
|
|
96
|
+
out.push({
|
|
97
|
+
rule: "secret.env-file",
|
|
98
|
+
severity: "medium",
|
|
99
|
+
file: row.rel,
|
|
100
|
+
line: 1,
|
|
101
|
+
fingerprint: fingerprint("secret.env-file", row.rel, base),
|
|
102
|
+
message: "存在 .env 类文件——确认未被提交且已在 .gitignore",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const row of iterateFileRows(db)) {
|
|
107
|
+
const rel = row.rel;
|
|
108
|
+
if (row.oversize === 1)
|
|
109
|
+
continue;
|
|
110
|
+
if (rel.startsWith(".pm/"))
|
|
111
|
+
continue;
|
|
112
|
+
const content = readText(root, rel);
|
|
113
|
+
if (content === null)
|
|
114
|
+
continue;
|
|
115
|
+
const lines = content.split("\n");
|
|
116
|
+
for (const rule of rules) {
|
|
117
|
+
if (rule.glob && !rule.glob.test(rel))
|
|
118
|
+
continue;
|
|
119
|
+
for (let i = 0; i < lines.length; i++) {
|
|
120
|
+
const m = findRelevantMatch(rule, lines[i]);
|
|
121
|
+
if (!m)
|
|
122
|
+
continue;
|
|
123
|
+
const matched = m[0];
|
|
124
|
+
out.push({
|
|
125
|
+
rule: rule.id,
|
|
126
|
+
severity: rule.severity,
|
|
127
|
+
file: rel,
|
|
128
|
+
line: i + 1,
|
|
129
|
+
fingerprint: fingerprint(rule.id, rel, matched),
|
|
130
|
+
message: rule.message,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
/** 扫描并更新台账(幂等:重复扫描不产生重复条目) */
|
|
138
|
+
export function auditSecurity(root) {
|
|
139
|
+
const project = loadProject(root);
|
|
140
|
+
const stamp = now();
|
|
141
|
+
const detections = scanDetections(root);
|
|
142
|
+
const security = loadSecurity(root);
|
|
143
|
+
const existing = new Map(security.findings.map((f) => [f.fingerprint, f]));
|
|
144
|
+
const acceptedRefound = [];
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
let seq = security.findings.reduce((m, f) => {
|
|
147
|
+
const n = Number(f.id.replace("SEC-", ""));
|
|
148
|
+
return Math.max(m, n);
|
|
149
|
+
}, 0);
|
|
150
|
+
for (const d of detections) {
|
|
151
|
+
seen.add(d.fingerprint);
|
|
152
|
+
const old = existing.get(d.fingerprint);
|
|
153
|
+
if (old) {
|
|
154
|
+
old.last_seen = stamp;
|
|
155
|
+
// 钻空子防线:标了 fixed 却又检出 → 重开(fixed 不是免死金牌)
|
|
156
|
+
if (old.status === "fixed") {
|
|
157
|
+
old.status = "open";
|
|
158
|
+
old.note += "(自动:曾标记 fixed 但再次检出,已重开)";
|
|
159
|
+
}
|
|
160
|
+
else if (old.status === "accepted") {
|
|
161
|
+
acceptedRefound.push(old);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
seq += 1;
|
|
166
|
+
security.findings.push({
|
|
167
|
+
id: `SEC-${String(seq).padStart(3, "0")}`,
|
|
168
|
+
rule: d.rule,
|
|
169
|
+
severity: d.severity,
|
|
170
|
+
file: d.file,
|
|
171
|
+
line: d.line,
|
|
172
|
+
fingerprint: d.fingerprint,
|
|
173
|
+
status: "open",
|
|
174
|
+
note: d.message,
|
|
175
|
+
first_seen: stamp,
|
|
176
|
+
last_seen: stamp,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// 不再检出且仍 open 的 → 自动转 fixed(附注说明)
|
|
181
|
+
let autoFixed = 0;
|
|
182
|
+
for (const f of security.findings) {
|
|
183
|
+
if (f.status === "open" && !seen.has(f.fingerprint)) {
|
|
184
|
+
f.status = "fixed";
|
|
185
|
+
f.note += "(自动:最近一次扫描未再检出)";
|
|
186
|
+
autoFixed += 1;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
security.last_scan = stamp;
|
|
190
|
+
saveSecurity(root, security);
|
|
191
|
+
// 依赖风险(新增依赖 + 幻觉易发版本号)
|
|
192
|
+
const { depSpecs } = readDirectDeps(root);
|
|
193
|
+
const prev = latestSnapshot(root);
|
|
194
|
+
const prevDeps = prev?.deps ?? [];
|
|
195
|
+
const newDeps = prev ? depSpecs.map((d) => d.name).filter((n) => !prevDeps.includes(n)) : [];
|
|
196
|
+
const riskyDeps = depSpecs.filter((d) => d.risky).map((d) => `${d.name}@${d.version}`);
|
|
197
|
+
const fresh = security.findings.filter((f) => f.first_seen === stamp && f.status === "open");
|
|
198
|
+
const open = security.findings.filter((f) => f.status === "open");
|
|
199
|
+
const high = open.filter((f) => f.severity === "high");
|
|
200
|
+
const L = [];
|
|
201
|
+
L.push(`## 安全体检报告(exposure=${project.exposure})`);
|
|
202
|
+
// exposure 兑现:不同暴露面使用不同处置口径
|
|
203
|
+
if (project.exposure === "public") {
|
|
204
|
+
const mustFix = open.filter((f) => f.severity === "high" || f.severity === "medium");
|
|
205
|
+
L.push(`🚨 项目面向公网:中危及以上均须处置。需处置 ${mustFix.length} 个(高危 ${high.length},中危 ${open.filter((f) => f.severity === "medium").length};低危可带理由接受)。`);
|
|
206
|
+
}
|
|
207
|
+
else if (project.exposure === "network") {
|
|
208
|
+
L.push(`项目面向网络:高危须处置。未处理 ${open.length} 个(高危 ${high.length},中危 ${open.filter((f) => f.severity === "medium").length},低危 ${open.filter((f) => f.severity === "low").length})。`);
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
L.push(open.length === 0
|
|
212
|
+
? "✅ 当前无未处理安全发现(本地工具:低危可带理由接受)。"
|
|
213
|
+
: `未处理发现 ${open.length} 个(高危 ${high.length},中危 ${open.filter((f) => f.severity === "medium").length},低危 ${open.filter((f) => f.severity === "low").length})。`);
|
|
214
|
+
}
|
|
215
|
+
if (fresh.length > 0) {
|
|
216
|
+
L.push("");
|
|
217
|
+
L.push(`本次扫描新发现 ${fresh.length} 个:`);
|
|
218
|
+
for (const f of fresh.slice(0, 15)) {
|
|
219
|
+
L.push(`- [${f.severity.toUpperCase()}] ${f.file}:${f.line} ${f.rule} — ${f.note}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (acceptedRefound.length > 0) {
|
|
223
|
+
L.push("");
|
|
224
|
+
L.push(`⚠️ 已接受的风险再次检出 ${acceptedRefound.length} 个(接受≠永久免疫,请复核):`);
|
|
225
|
+
for (const f of acceptedRefound.slice(0, 10)) {
|
|
226
|
+
L.push(`- ${f.id} ${f.file}:${f.line} ${f.rule}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (autoFixed > 0)
|
|
230
|
+
L.push(`自动关闭 ${autoFixed} 个(代码中已不再出现)。`);
|
|
231
|
+
if (newDeps.length > 0)
|
|
232
|
+
L.push(`⚠️ 上次快照后新增依赖: ${newDeps.join(", ")}(AI 引入的依赖请审查来源与必要性)`);
|
|
233
|
+
if (riskyDeps.length > 0)
|
|
234
|
+
L.push(`⚠️ 幻觉易发版本号(* / latest): ${riskyDeps.join(", ")}`);
|
|
235
|
+
L.push("");
|
|
236
|
+
L.push("处理方式:修复后重扫自动关闭;`resolve_finding` 可带理由接受风险(accepted 必须留 note)。");
|
|
237
|
+
return {
|
|
238
|
+
newFindings: fresh.length,
|
|
239
|
+
openCount: open.length,
|
|
240
|
+
highCount: high.length,
|
|
241
|
+
autoFixed,
|
|
242
|
+
newDeps,
|
|
243
|
+
riskyDeps,
|
|
244
|
+
text: L,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
export function listFindings(root, status) {
|
|
248
|
+
const all = loadSecurity(root).findings;
|
|
249
|
+
return status ? all.filter((f) => f.status === status) : all;
|
|
250
|
+
}
|
|
251
|
+
export function resolveFinding(root, id, status, note) {
|
|
252
|
+
const security = loadSecurity(root);
|
|
253
|
+
const f = security.findings.find((x) => x.id === id);
|
|
254
|
+
if (!f)
|
|
255
|
+
throw new Error(`找不到安全发现 ${id},用 list_findings 查看现有条目。`);
|
|
256
|
+
const cleanNote = note.trim();
|
|
257
|
+
if (status === "accepted" && !cleanNote) {
|
|
258
|
+
throw new Error("接受风险必须填写理由(note):接受是显式选择,要留痕。");
|
|
259
|
+
}
|
|
260
|
+
const secretRule = findSecretRule(cleanNote);
|
|
261
|
+
if (secretRule) {
|
|
262
|
+
throw new Error(`note 不得包含密钥形态明文(命中 ${secretRule.id})——描述风险时引用位置与规则即可,绝不复述凭据本身。`);
|
|
263
|
+
}
|
|
264
|
+
f.status = status;
|
|
265
|
+
if (cleanNote)
|
|
266
|
+
f.note = cleanNote;
|
|
267
|
+
saveSecurity(root, security);
|
|
268
|
+
return f;
|
|
269
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pmPath } from "./paths.js";
|
|
4
|
+
import { atomicWrite, withLedgerLock } from "./store.js";
|
|
5
|
+
import { SemanticEvidenceDocumentSchema } from "./semantic-evidence.js";
|
|
6
|
+
export const SEMANTIC_EVIDENCE_DIR = "semantic-evidence";
|
|
7
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
8
|
+
const WINDOWS_DEVICE = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
|
|
9
|
+
export function validateSemanticEvidenceId(id) {
|
|
10
|
+
if (id !== id.trim() || !SAFE_ID.test(id) || id.endsWith(".") || WINDOWS_DEVICE.test(id)) {
|
|
11
|
+
throw new Error(`非法语义证据 ID: ${JSON.stringify(id)};仅允许 1-128 位 ASCII 字母、数字、点、下划线和连字符,且不得使用路径或 Windows 设备名`);
|
|
12
|
+
}
|
|
13
|
+
return id;
|
|
14
|
+
}
|
|
15
|
+
export function semanticEvidenceDir(root) {
|
|
16
|
+
return pmPath(path.resolve(root), SEMANTIC_EVIDENCE_DIR);
|
|
17
|
+
}
|
|
18
|
+
function isWithin(parent, child) {
|
|
19
|
+
const relative = path.relative(parent, child);
|
|
20
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
21
|
+
}
|
|
22
|
+
function assertRealDirectoryWithinRoot(root, directory, create) {
|
|
23
|
+
const absoluteRoot = path.resolve(root);
|
|
24
|
+
if (!fs.existsSync(absoluteRoot) || !fs.statSync(absoluteRoot).isDirectory())
|
|
25
|
+
throw new Error(`项目根目录不存在或不是目录: ${absoluteRoot}`);
|
|
26
|
+
if (create)
|
|
27
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
28
|
+
if (!fs.existsSync(directory))
|
|
29
|
+
return;
|
|
30
|
+
const realRoot = fs.realpathSync.native(absoluteRoot);
|
|
31
|
+
const realDirectory = fs.realpathSync.native(directory);
|
|
32
|
+
if (!isWithin(realRoot, realDirectory))
|
|
33
|
+
throw new Error(`语义证据目录逃逸项目根目录: ${directory}`);
|
|
34
|
+
}
|
|
35
|
+
export function semanticEvidencePath(root, id) {
|
|
36
|
+
const safeId = validateSemanticEvidenceId(id);
|
|
37
|
+
const directory = semanticEvidenceDir(root);
|
|
38
|
+
const file = path.resolve(directory, `${safeId}.json`);
|
|
39
|
+
if (!isWithin(path.resolve(directory), file))
|
|
40
|
+
throw new Error(`语义证据路径逃逸: ${id}`);
|
|
41
|
+
return file;
|
|
42
|
+
}
|
|
43
|
+
function parseDocument(file) {
|
|
44
|
+
let raw;
|
|
45
|
+
try {
|
|
46
|
+
raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
throw new Error(`语义证据 JSON 解析失败: ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
50
|
+
}
|
|
51
|
+
const parsed = SemanticEvidenceDocumentSchema.safeParse(raw);
|
|
52
|
+
if (!parsed.success) {
|
|
53
|
+
throw new Error(`语义证据 schema 校验失败: ${file}: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "document"}: ${issue.message}`).join("; ")}`);
|
|
54
|
+
}
|
|
55
|
+
return parsed.data;
|
|
56
|
+
}
|
|
57
|
+
function assertRegularEvidenceFile(root, file) {
|
|
58
|
+
const stat = fs.lstatSync(file);
|
|
59
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
60
|
+
throw new Error(`语义证据必须是普通文件,拒绝符号链接或目录: ${file}`);
|
|
61
|
+
const realRoot = fs.realpathSync.native(path.resolve(root));
|
|
62
|
+
const realFile = fs.realpathSync.native(file);
|
|
63
|
+
if (!isWithin(realRoot, realFile))
|
|
64
|
+
throw new Error(`语义证据文件逃逸项目根目录: ${file}`);
|
|
65
|
+
}
|
|
66
|
+
function evidenceIdsUnlocked(root) {
|
|
67
|
+
const directory = semanticEvidenceDir(root);
|
|
68
|
+
assertRealDirectoryWithinRoot(root, directory, false);
|
|
69
|
+
if (!fs.existsSync(directory))
|
|
70
|
+
return [];
|
|
71
|
+
return fs.readdirSync(directory, { withFileTypes: true })
|
|
72
|
+
.filter((entry) => entry.name.endsWith(".json"))
|
|
73
|
+
.map((entry) => {
|
|
74
|
+
if (!entry.isFile() || entry.isSymbolicLink())
|
|
75
|
+
throw new Error(`语义证据目录包含非普通 JSON 文件: ${entry.name}`);
|
|
76
|
+
return validateSemanticEvidenceId(entry.name.slice(0, -".json".length));
|
|
77
|
+
})
|
|
78
|
+
.sort((a, b) => a.localeCompare(b));
|
|
79
|
+
}
|
|
80
|
+
function loadUnlocked(root, id) {
|
|
81
|
+
const file = semanticEvidencePath(root, id);
|
|
82
|
+
const directory = semanticEvidenceDir(root);
|
|
83
|
+
assertRealDirectoryWithinRoot(root, directory, false);
|
|
84
|
+
if (!fs.existsSync(file))
|
|
85
|
+
throw new Error(`语义证据不存在: ${id}`);
|
|
86
|
+
assertRegularEvidenceFile(root, file);
|
|
87
|
+
return parseDocument(file);
|
|
88
|
+
}
|
|
89
|
+
/** Validate first, then atomically replace one evidence document under the project ledger lock. */
|
|
90
|
+
export function saveSemanticEvidence(root, id, input) {
|
|
91
|
+
const safeId = validateSemanticEvidenceId(id);
|
|
92
|
+
const parsed = SemanticEvidenceDocumentSchema.safeParse(input);
|
|
93
|
+
if (!parsed.success) {
|
|
94
|
+
throw new Error(`语义证据 schema 校验失败: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "document"}: ${issue.message}`).join("; ")}`);
|
|
95
|
+
}
|
|
96
|
+
const absoluteRoot = path.resolve(root);
|
|
97
|
+
const directory = semanticEvidenceDir(absoluteRoot);
|
|
98
|
+
assertRealDirectoryWithinRoot(absoluteRoot, directory, true);
|
|
99
|
+
return withLedgerLock(absoluteRoot, () => {
|
|
100
|
+
assertRealDirectoryWithinRoot(absoluteRoot, directory, false);
|
|
101
|
+
atomicWrite(semanticEvidencePath(absoluteRoot, safeId), `${JSON.stringify(parsed.data, null, 2)}\n`);
|
|
102
|
+
return parsed.data;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
export function loadSemanticEvidence(root, id) {
|
|
106
|
+
return loadUnlocked(path.resolve(root), validateSemanticEvidenceId(id));
|
|
107
|
+
}
|
|
108
|
+
/** List is schema-validating: a corrupt document makes the operation fail closed. */
|
|
109
|
+
export function listSemanticEvidence(root) {
|
|
110
|
+
const absoluteRoot = path.resolve(root);
|
|
111
|
+
return evidenceIdsUnlocked(absoluteRoot).map((id) => {
|
|
112
|
+
const document = loadUnlocked(absoluteRoot, id);
|
|
113
|
+
return {
|
|
114
|
+
id,
|
|
115
|
+
file: document.file,
|
|
116
|
+
language: document.language,
|
|
117
|
+
analyzer: document.analyzer.id,
|
|
118
|
+
assurance: document.analyzer.assurance,
|
|
119
|
+
status: document.status,
|
|
120
|
+
generated_at: document.generated_at,
|
|
121
|
+
content_sha256: document.content_sha256,
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/** Load a consistent, validated snapshot; no corrupt document is silently skipped. */
|
|
126
|
+
export function loadAllSemanticEvidence(root) {
|
|
127
|
+
const absoluteRoot = path.resolve(root);
|
|
128
|
+
const directory = semanticEvidenceDir(absoluteRoot);
|
|
129
|
+
assertRealDirectoryWithinRoot(absoluteRoot, directory, false);
|
|
130
|
+
if (!fs.existsSync(directory))
|
|
131
|
+
return [];
|
|
132
|
+
return withLedgerLock(absoluteRoot, () => evidenceIdsUnlocked(absoluteRoot).map((id) => loadUnlocked(absoluteRoot, id)));
|
|
133
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export const SEMANTIC_EVIDENCE_SCHEMA_VERSION = 1;
|
|
4
|
+
export const SemanticAssuranceSchema = z.enum(["heuristic", "ast", "runtime"]);
|
|
5
|
+
export const SemanticAnalyzerFamilySchema = z.enum([
|
|
6
|
+
"compiler-ast",
|
|
7
|
+
"language-native-ast",
|
|
8
|
+
"structured-parser",
|
|
9
|
+
"runtime-trace",
|
|
10
|
+
"regex-fallback",
|
|
11
|
+
]);
|
|
12
|
+
export const SemanticCapabilitySchema = z.enum(["imports", "exports", "calls", "contracts", "http", "grpc", "ffi"]);
|
|
13
|
+
export const SemanticReferenceKindSchema = z.enum(["import", "export", "call", "contract", "http", "grpc", "ffi"]);
|
|
14
|
+
export const AnalyzerProvenanceSchema = z.object({
|
|
15
|
+
id: z.string().trim().min(1),
|
|
16
|
+
family: SemanticAnalyzerFamilySchema,
|
|
17
|
+
assurance: SemanticAssuranceSchema,
|
|
18
|
+
engine: z.string().trim().min(1),
|
|
19
|
+
version: z.string().trim().min(1),
|
|
20
|
+
capabilities: z.array(SemanticCapabilitySchema).min(1),
|
|
21
|
+
/** Optional reproducibility detail. It must never contain source text or credentials. */
|
|
22
|
+
command: z.string().trim().min(1).optional(),
|
|
23
|
+
}).superRefine((analyzer, ctx) => {
|
|
24
|
+
const expected = analyzer.family === "runtime-trace"
|
|
25
|
+
? "runtime"
|
|
26
|
+
: analyzer.family === "regex-fallback"
|
|
27
|
+
? "heuristic"
|
|
28
|
+
: "ast";
|
|
29
|
+
if (analyzer.assurance !== expected) {
|
|
30
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["assurance"], message: `${analyzer.family} requires assurance=${expected}` });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
const EvidenceReferenceSchema = z.object({
|
|
34
|
+
kind: SemanticReferenceKindSchema,
|
|
35
|
+
specifier: z.string().trim().min(1),
|
|
36
|
+
line: z.number().int().positive(),
|
|
37
|
+
confidence: z.number().min(0).max(1),
|
|
38
|
+
force_local: z.boolean().default(false),
|
|
39
|
+
symbol: z.string().trim().min(1).optional(),
|
|
40
|
+
});
|
|
41
|
+
const EvidenceExportSchema = z.object({
|
|
42
|
+
name: z.string().trim().min(1),
|
|
43
|
+
line: z.number().int().positive(),
|
|
44
|
+
is_type_only: z.boolean().default(false),
|
|
45
|
+
source_specifier: z.string().trim().min(1).optional(),
|
|
46
|
+
confidence: z.number().min(0).max(1),
|
|
47
|
+
});
|
|
48
|
+
/**
|
|
49
|
+
* Portable interchange format for compiler/native-AST analyzers and runtime tracers.
|
|
50
|
+
* A content hash binds every claim to the exact source bytes that were analyzed.
|
|
51
|
+
*/
|
|
52
|
+
export const SemanticEvidenceDocumentSchema = z.object({
|
|
53
|
+
schema_version: z.literal(SEMANTIC_EVIDENCE_SCHEMA_VERSION),
|
|
54
|
+
file: z.string().trim().min(1),
|
|
55
|
+
language: z.string().trim().min(1),
|
|
56
|
+
content_sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
57
|
+
generated_at: z.string().datetime({ offset: true }),
|
|
58
|
+
status: z.enum(["complete", "partial"]),
|
|
59
|
+
analyzer: AnalyzerProvenanceSchema,
|
|
60
|
+
references: z.array(EvidenceReferenceSchema).default([]),
|
|
61
|
+
exports: z.array(EvidenceExportSchema).default([]),
|
|
62
|
+
diagnostics: z.array(z.string()).default([]),
|
|
63
|
+
}).superRefine((document, ctx) => {
|
|
64
|
+
const provided = new Set(document.analyzer.capabilities);
|
|
65
|
+
for (const [index, reference] of document.references.entries()) {
|
|
66
|
+
const capability = reference.kind === "import" || reference.kind === "export" || reference.kind === "call" || reference.kind === "contract"
|
|
67
|
+
? `${reference.kind}s`
|
|
68
|
+
: reference.kind;
|
|
69
|
+
if (!provided.has(capability)) {
|
|
70
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["references", index, "kind"], message: `reference requires analyzer capability ${capability}` });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (document.exports.length > 0 && !provided.has("exports")) {
|
|
74
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["exports"], message: "exports require analyzer capability exports" });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
export function semanticContentHash(content) {
|
|
78
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
79
|
+
}
|
|
80
|
+
function evidencePath(value) {
|
|
81
|
+
return value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
|
|
82
|
+
}
|
|
83
|
+
/** Parse and bind imported evidence to one exact repository-relative file and source revision. */
|
|
84
|
+
export function verifySemanticEvidence(input, expectedFile, content) {
|
|
85
|
+
const parsed = SemanticEvidenceDocumentSchema.safeParse(input);
|
|
86
|
+
if (!parsed.success) {
|
|
87
|
+
return { ok: false, errors: parsed.error.issues.map((issue) => `${issue.path.join(".") || "document"}: ${issue.message}`) };
|
|
88
|
+
}
|
|
89
|
+
const errors = [];
|
|
90
|
+
const expected = evidencePath(expectedFile);
|
|
91
|
+
if (evidencePath(parsed.data.file) !== expected)
|
|
92
|
+
errors.push(`file mismatch: expected ${expected}, received ${parsed.data.file}`);
|
|
93
|
+
const actualHash = semanticContentHash(content);
|
|
94
|
+
if (parsed.data.content_sha256 !== actualHash)
|
|
95
|
+
errors.push(`content hash mismatch for ${expected}`);
|
|
96
|
+
return errors.length === 0 ? { ok: true, document: parsed.data, errors: [] } : { ok: false, errors };
|
|
97
|
+
}
|
|
98
|
+
export function assuranceRank(value) {
|
|
99
|
+
return value === "runtime" ? 3 : value === "ast" ? 2 : 1;
|
|
100
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { relPath } from "./semantic-parsers.js";
|
|
2
|
+
/** Deterministic Tarjan strongly-connected-component detection. */
|
|
3
|
+
export function findModuleCycles(edges) {
|
|
4
|
+
const adjacency = new Map();
|
|
5
|
+
for (const edge of edges)
|
|
6
|
+
adjacency.set(edge.from, [...new Set([...(adjacency.get(edge.from) ?? []), edge.to])].sort());
|
|
7
|
+
let index = 0;
|
|
8
|
+
const stack = [];
|
|
9
|
+
const onStack = new Set();
|
|
10
|
+
const indices = new Map();
|
|
11
|
+
const low = new Map();
|
|
12
|
+
const cycles = [];
|
|
13
|
+
const visit = (node) => {
|
|
14
|
+
indices.set(node, index);
|
|
15
|
+
low.set(node, index);
|
|
16
|
+
index += 1;
|
|
17
|
+
stack.push(node);
|
|
18
|
+
onStack.add(node);
|
|
19
|
+
for (const next of adjacency.get(node) ?? []) {
|
|
20
|
+
if (!indices.has(next)) {
|
|
21
|
+
visit(next);
|
|
22
|
+
low.set(node, Math.min(low.get(node), low.get(next)));
|
|
23
|
+
}
|
|
24
|
+
else if (onStack.has(next))
|
|
25
|
+
low.set(node, Math.min(low.get(node), indices.get(next)));
|
|
26
|
+
}
|
|
27
|
+
if (low.get(node) === indices.get(node)) {
|
|
28
|
+
const component = [];
|
|
29
|
+
let popped;
|
|
30
|
+
do {
|
|
31
|
+
popped = stack.pop();
|
|
32
|
+
onStack.delete(popped);
|
|
33
|
+
component.push(popped);
|
|
34
|
+
} while (popped !== node);
|
|
35
|
+
if (component.length > 1)
|
|
36
|
+
cycles.push(component.sort());
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
for (const node of [...new Set(edges.flatMap((edge) => [edge.from, edge.to]))].sort())
|
|
40
|
+
if (!indices.has(node))
|
|
41
|
+
visit(node);
|
|
42
|
+
return cycles.sort((a, b) => a.join("/").localeCompare(b.join("/")));
|
|
43
|
+
}
|
|
44
|
+
/** Reverse closure over file targets and module-only contract/FFI edges. */
|
|
45
|
+
export function computeImpactAnalysis(graph, changedFiles) {
|
|
46
|
+
const known = new Set(graph.files.map((file) => file.path));
|
|
47
|
+
const changed = [...new Set(changedFiles.map(relPath))].sort();
|
|
48
|
+
const affectedFiles = new Set(changed.filter((file) => known.has(file)));
|
|
49
|
+
const affectedModules = new Set();
|
|
50
|
+
const moduleOf = new Map(graph.files.map((file) => [file.path, file.module]));
|
|
51
|
+
for (const file of affectedFiles) {
|
|
52
|
+
const module = moduleOf.get(file);
|
|
53
|
+
if (module)
|
|
54
|
+
affectedModules.add(module);
|
|
55
|
+
}
|
|
56
|
+
let progressed = true;
|
|
57
|
+
while (progressed) {
|
|
58
|
+
progressed = false;
|
|
59
|
+
for (const edge of graph.fileEdges) {
|
|
60
|
+
if ((edge.to && affectedFiles.has(edge.to)) || (edge.toModule && affectedModules.has(edge.toModule))) {
|
|
61
|
+
if (!affectedFiles.has(edge.from)) {
|
|
62
|
+
affectedFiles.add(edge.from);
|
|
63
|
+
progressed = true;
|
|
64
|
+
}
|
|
65
|
+
if (edge.fromModule && !affectedModules.has(edge.fromModule)) {
|
|
66
|
+
affectedModules.add(edge.fromModule);
|
|
67
|
+
progressed = true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const edge of graph.moduleEdges)
|
|
72
|
+
if (affectedModules.has(edge.to) && !affectedModules.has(edge.from)) {
|
|
73
|
+
affectedModules.add(edge.from);
|
|
74
|
+
progressed = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const impactedFiles = [...affectedFiles].sort();
|
|
78
|
+
const changedSet = new Set(changed);
|
|
79
|
+
return {
|
|
80
|
+
changedFiles: changed,
|
|
81
|
+
impactedFiles,
|
|
82
|
+
dependentFiles: impactedFiles.filter((file) => !changedSet.has(file)),
|
|
83
|
+
impactedModules: [...affectedModules].sort(),
|
|
84
|
+
unknownChangedFiles: changed.filter((file) => !known.has(file)),
|
|
85
|
+
};
|
|
86
|
+
}
|