progmune-runtime 3.7.41 → 3.7.43

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/CHANGELOG.md CHANGED
@@ -1,5 +1,129 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.7.43] — 2026-09-20
4
+
5
+ ### C4g:helper 换个载体(对象字面量方法 / 类方法 / 解构形参)
6
+
7
+ helper 不一定是函数,也可能是方法:
8
+
9
+ ```ts
10
+ export const Util = { toPath(n: string): string { return n + ".md"; } }; // 对象字面量方法
11
+ export class Renderer { inst(n: string): string { return n + ".md"; } } // 类实例方法
12
+ ```
13
+
14
+ C4e 时刻意没做这一路(按裸名放行会让别的对象上的同名方法被误认成塑形,R11)。
15
+ 这一轮用**限定名 + 唯一性**解决:
16
+
17
+ - 宿主名能确定时登记 `Owner.method`,调用点 `Util.toPath(k)` 直接对上;
18
+ - 实例方法的主要写法是 `r.inst(k)`(宿主是变量,限定名对不上),所以该方法名在
19
+ **全项目唯一**时额外允许 `.inst(` 这种成员调用位匹配 —— 唯一性是 R11 的替代品,
20
+ 不存在同名方法就不可能张冠李戴。同名方法出现两次时一律不登记,宁可漏报。
21
+
22
+ 另补**解构形参**:`function f({ name }) {…}` 的 `getName()` 给的是整个绑定模式
23
+ 而不是 `name`,要从 BindingElement 里逐个抽出来,否则这类 helper 一个都认不出。
24
+
25
+ R12 第四次生效:备忘里列的「依赖分析深层形态」(三元返回、默认参、多 return)
26
+ 探针实测**本来就是通的**,真漏的只有解构形参。
27
+
28
+ ### 语料踩到的坑:helper 名字不能撞 sink 名单
29
+
30
+ H 族第一版把静态方法写成 `Renderer.stat(k)`,闸门全绿;反向验证摘掉方法收集之后
31
+ 它**仍然标**。一查:`stat` 就在 `TS_FILE_SINK_NAMES` 里,调用点被 sink 兜底直接
32
+ 命中,与 helper 传播毫无关系 —— 实测 `Other.stat(k)`(接收者根本没定义)、以及类里
33
+ 压根没有 `stat` 方法,照样标。
34
+
35
+ 这是 R13 那条旁路的第二次出现,而且更阴:它让正例「标对了」,但标对的原因是另一
36
+ 回事。改名为 `toFile` 之后归因才干净。已登记为 **R16**。
37
+
38
+ ### 验收
39
+
40
+ | 门 | 结果 |
41
+ |---|---|
42
+ | taintpath 闸门 | **92/92**(新增 H 族 11 条:7 mark / 3 no-taint / 1 suppressed) |
43
+ | fr-007 真实语料 | pre 5 / post 0 **维持** |
44
+ | fr-016 真实语料 | pre 7 / post 0 **维持** —— 放宽召回后守卫侧仍无新增误报 |
45
+ | 定向用例 | **171 passed / 7 个测试文件**(新增 C4g 组 7 条:4 正 3 负) |
46
+ | TS 盲测 | 108 项目,path_traversal **LOST 0 / ADDED 27**(H 7 + E 9 + F 6 + G 4 + D 1),基线那 104 个项目**零漂移零丢失** |
47
+ | tsc | 零错误 |
48
+
49
+ ### 反向验证四刀
50
+
51
+ | 刀 | 改动 | 转红 |
52
+ |---|---|---|
53
+ | 1 | 摘掉方法候选收集 | 3 条方法正例 |
54
+ | 2 | 只摘掉成员调用位 `.method(` | emitClassInstance(宿主是变量那条) |
55
+ | 3 | 解构形参名提取退回整体文本 | emitDestructured |
56
+ | 4 | 关掉 C4f 收窄 | G 族 6 条 + H 族 `emitObjMethodDrop` |
57
+
58
+ 刀 4 顺带证明 C4f 的收窄对新载体同样生效 —— `Drop.fixed(k)` 不是空转对照。
59
+
60
+ ### 刻意不做:常量实参代入
61
+
62
+ `viaTernaryDrop(n, flag) { return flag ? "fixed.md" : n + ".md"; }` 在
63
+ `viaTernaryDrop(k, true)` 下走的是常量分支、形参不流出,现在仍会被判污染。要收这条
64
+ 得在调用点做常量传播(把实参字面量代入 return 再重算依赖),是另一个量级的改动,
65
+ 且代入出错的代价是漏报。登记为已知缺口。
66
+
67
+ ## [3.7.42] — 2026-09-20
68
+
69
+ ### C4f:名字级传播的精度收窄(形参-实参位置对齐 + 返回值依赖分析)
70
+
71
+ 前三轮(C4c/C4d/C4e)都在补召回,这一轮反过来**收精度**。
72
+
73
+ 传播此前是**名字级**的:赋值右侧出现污点名、且右侧看起来像塑形 ⇒ 新变量判为
74
+ 污染。它不看「这个 helper 的返回值到底依不依赖那个实参」:
75
+
76
+ ```ts
77
+ const dropParam = (n: string): string => "fixed.md"; // 形参被丢掉
78
+ const rel = dropParam(k); // k 流不出来,仍被判污染
79
+ ```
80
+
81
+ 现在给每个纯塑形 helper 多算一份 `deps` = **返回值真正依赖的形参集**,调用点按
82
+ 形参-实参位置对齐后才认证据:`dropParam(k)` ⇒ 无证据;`pickFirst("safe", k)`
83
+ (只依赖第一个形参)⇒ 只留 `"safe"`。
84
+
85
+ | 形态 | 收窄前 | 语义 |
86
+ |---|---|---|
87
+ | `dropParam(k)`(return 常量) | 误标 → 不标 | 依赖集为空 |
88
+ | `pickFirst("safe", k)` | 误标 → 不标 | 位置不对齐 |
89
+ | `path.join("out", dropParam(k))` | 误标 → 不标 | 该按支路算,不该整行一锅端 |
90
+ | `wrapDrop(k)`(= dropParam(n)) | 误标 → 不标 | 依赖集跨函数传递后为空 |
91
+ | `wrapPick("safe", k)` | 误标 → 不标 | 跨函数位置不对齐 |
92
+ | `swapPick(k, "safe")`(= pickFirst(y, x)) | 误标 → 不标 | 换序后真正被依赖的是 y |
93
+
94
+ 换序那条最能说明价值:`swapPick("safe", k)` 该标、`swapPick(k, "safe")` 不该标 ——
95
+ 只看「实参里有没有污点名」两条都标,只看「形参有没有被用到」两条也都标,
96
+ 必须按位置穿过一次换序才分得开。
97
+
98
+ `shaped`(右侧像不像塑形)仍看原式:helper 是不是塑形,与它的返回值依不依赖
99
+ 实参是两件事,不能互相替代。依赖分析有次序依赖(helper 若在它依赖的 helper 之前
100
+ 被接受,那一刻 deps 会偏宽),名录定稿后按完整的 info 再算两轮消掉 —— 偏宽是
101
+ 保守侧(继续传播),不会造成漏报。
102
+
103
+ ### 验收
104
+
105
+ | 门 | 结果 |
106
+ |---|---|
107
+ | taintpath 闸门 | **81/81**(新增 G 族 12 条:4 mark / 7 no-taint / 1 suppressed) |
108
+ | fr-007 真实语料 | pre 5 / post 0 **维持** |
109
+ | fr-016 真实语料 | pre 7 / post 0 **维持** —— 收窄没有丢正例 |
110
+ | 定向用例 | **164 passed / 7 个测试文件**(新增 C4f 组 11 条:4 正 7 负;taint-guard、structural、ssrf-loop、project-ir、C/Go/Java 提取组全绿) |
111
+ | TS 盲测 | 107 项目,path_traversal **LOST 0 / ADDED 20**(G 4 + E 9 + F 6 + D 1),基线那 104 个项目**零漂移零丢失** |
112
+ | tsc | 零错误 |
113
+
114
+ ### 门的方向也反过来了(R14 的一次镜像使用)
115
+
116
+ 前四轮 R7 咬的都是「语料零覆盖 ⇒ 门空过」。收窄轮最该防的是另外两件事:
117
+ 收窄过头把真阳性收掉(看 LOST);以及那批 no-taint 用例其实空转(它们本来就不该
118
+ 标,旧代码也不会让它们红)。反向验证切了三刀:
119
+
120
+ - **刀 A**:调用点退回「不按依赖抹实参」(等价收窄前)⇒ G 族 6 条 no-taint 全部
121
+ 转红 —— 证明这批对照真的咬得住;
122
+ - **刀 B**:只关掉跨函数传递(deps 只算直接依赖)⇒ 只有两跳相关的 3 条转红,
123
+ 直接依赖那 3 条不动 —— 归因清晰;
124
+ - **刀 C**:把收窄做得更激进(一律抹空)⇒ 3 条位置对齐正例转漏报,而
125
+ `emitPlainShaper`(常规塑形)不受影响 —— 正说明收窄没波及常规路径。
126
+
3
127
  ## [3.7.41] — 2026-09-20
4
128
 
5
129
  ### C4e:helper 的现代写法(箭头 / 函数表达式 / 模块常量)
@@ -1314,3 +1314,126 @@ const WRITE_LAYER = [
1314
1314
  }, 150000);
1315
1315
  }
1316
1316
  });
1317
+ (0, vitest_1.describe)("C4f:只有落在「返回值真依赖的形参位」上的污点才算流过去", () => {
1318
+ // 与 C4e 同源的隔离写法:sink 实参里不出现污点变量 k,污点只能经 helper 返回值进来。
1319
+ const emit = (relExpr) => [
1320
+ 'import * as fs from "fs";',
1321
+ 'import { dropParam, pickFirst, withExt, wrapDrop, wrapPick, swapPick } from "./helpers";',
1322
+ "export function emit(doc: Record<string, any>, outDir: string) {",
1323
+ " Object.keys(doc).forEach((k) => {",
1324
+ ` const rel = ${relExpr};`,
1325
+ ' fs.writeFileSync(outDir + "/" + rel, "x");',
1326
+ " });",
1327
+ "}",
1328
+ ].join("\n");
1329
+ const HELPERS = [
1330
+ '// 依赖集为空:丢弃形参,返回常量',
1331
+ 'export const dropParam = (n: string): string => "fixed.md";',
1332
+ '// 只依赖第一个形参',
1333
+ 'export const pickFirst = (a: string, b: string): string => a + ".md";',
1334
+ '// 常规塑形(正对照)',
1335
+ 'export const withExt = (name: string): string => name + ".md";',
1336
+ '// 两跳丢弃',
1337
+ 'export const wrapDrop = (n: string): string => dropParam(n);',
1338
+ '// 两跳对齐',
1339
+ 'export const wrapPick = (a: string, b: string): string => pickFirst(a, b);',
1340
+ '// 换序两跳:真正被依赖的是第二个形参',
1341
+ 'export const swapPick = (x: string, y: string): string => pickFirst(y, x);',
1342
+ ].join("\n");
1343
+ const cases = [
1344
+ { name: "正例·常规塑形", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit("withExt(k)") },
1345
+ expectMark: true, why: "收精度不得收过头 —— 这条必须照标" },
1346
+ { name: "正例·污点在被依赖的形参位", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit('pickFirst(k, "safe")') },
1347
+ expectMark: true, why: "第一个形参真的影响返回值" },
1348
+ { name: "正例·两跳仍在被依赖的位置", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit('wrapPick(k, "safe")') },
1349
+ expectMark: true, why: "依赖集要能跨函数传递" },
1350
+ { name: "正例·换序两跳", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit('swapPick("safe", k)') },
1351
+ expectMark: true, why: "swapPick 真正依赖的是 y(第二个形参),位置对齐要跟着换" },
1352
+ { name: "负对照·helper 丢弃形参", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit("dropParam(k)") },
1353
+ expectMark: false, why: "return 常量 —— 实参带污点也流不出来;这是收窄前的真实误报" },
1354
+ { name: "负对照·污点在不被依赖的形参位", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit('pickFirst("safe", k)') },
1355
+ expectMark: false, why: "第二个形参进了函数也出不来;正对照 = 正例·污点在被依赖的形参位" },
1356
+ { name: "负对照·嵌套在 concat 里", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit('path.join("out", dropParam(k))') },
1357
+ expectMark: false, why: "整条 rhs 含 k,但那条支路不流出 —— 证据必须按支路算,不能整行一锅端" },
1358
+ { name: "负对照·两跳后依赖集为空", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit("wrapDrop(k)") },
1359
+ expectMark: false, why: "wrapDrop(n) = dropParam(n),依赖集同样为空(跨函数对齐)" },
1360
+ { name: "负对照·两跳位置不对齐", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit('wrapPick("safe", k)') },
1361
+ expectMark: false, why: "正对照 = 正例·两跳仍在被依赖的位置" },
1362
+ { name: "负对照·换序的反向", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit('swapPick(k, "safe")') },
1363
+ expectMark: false, why: "k 落在不被依赖的 x 位;正对照 = 正例·换序两跳" },
1364
+ { name: "负对照·实参本身无污点", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit('dropParam("lit")') },
1365
+ expectMark: false, why: "无污点流入" },
1366
+ ];
1367
+ for (const c of cases) {
1368
+ (0, vitest_1.it)(`${c.name} —— ${c.why}`, () => {
1369
+ const dir = makeProject(c.files);
1370
+ try {
1371
+ const marks = marksFor(dir, c.fn);
1372
+ if (c.expectMark)
1373
+ (0, vitest_1.expect)(marks).toContain(PATH_MARK);
1374
+ else
1375
+ (0, vitest_1.expect)(marks).not.toContain(PATH_MARK);
1376
+ }
1377
+ finally {
1378
+ fs.rmSync(dir, { recursive: true, force: true });
1379
+ }
1380
+ }, 150000);
1381
+ }
1382
+ });
1383
+ (0, vitest_1.describe)("C4g:helper 换个载体(对象字面量方法 / 类方法 / 解构形参)也要能传播", () => {
1384
+ const emit = (relExpr, pre = "") => [
1385
+ 'import * as fs from "fs";',
1386
+ 'import { Util, Drop, Renderer, viaName } from "./helpers";',
1387
+ "export function emit(doc: Record<string, any>, outDir: string) {",
1388
+ ...(pre ? [` ${pre}`] : []),
1389
+ " Object.keys(doc).forEach((k) => {",
1390
+ ` const rel = ${relExpr};`,
1391
+ ' fs.writeFileSync(outDir + "/" + rel, "x");',
1392
+ " });",
1393
+ "}",
1394
+ ].join("\n");
1395
+ const HELPERS = [
1396
+ "export const Util = {",
1397
+ ' toPath(n: string): string { return n + ".md"; },',
1398
+ "};",
1399
+ "export const Drop = {",
1400
+ ' fixed(n: string): string { return "fixed.md"; },',
1401
+ "};",
1402
+ "export class Renderer {",
1403
+ ' inst(n: string): string { return n + ".md"; }',
1404
+ ' static stat(n: string): string { return n + ".txt"; }',
1405
+ "}",
1406
+ 'export function viaName({ name }: any): string { return name + ".md"; }',
1407
+ ].join("\n");
1408
+ const cases = [
1409
+ { name: "正例·对象字面量方法", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit("Util.toPath(k)") },
1410
+ expectMark: true, why: "宿主名可确证 ⇒ 走限定名 Owner.method" },
1411
+ { name: "正例·类实例方法", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit("r.inst(k)", "const r = new Renderer();") },
1412
+ expectMark: true, why: "宿主是变量,限定名对不上 ⇒ 靠方法名唯一 + 成员调用位" },
1413
+ { name: "正例·静态类方法", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit("Renderer.stat(k)") },
1414
+ expectMark: true, why: "与对象字面量同形,走限定名" },
1415
+ { name: "正例·解构形参", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit("viaName({ name: k })") },
1416
+ expectMark: true, why: "形参名要从绑定模式里抽出来 —— getName() 给的是整个模式而不是 name" },
1417
+ { name: "负对照·对象字面量方法丢弃形参", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit("Drop.fixed(k)") },
1418
+ expectMark: false, why: "召回放宽到方法之后,C4f 的收窄也要跟得上新载体;正对照 = 正例·对象字面量方法" },
1419
+ { name: "负对照·解构形参喂字面量", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit('viaName({ name: "lit" })') },
1420
+ expectMark: false, why: "无污点流入;正对照 = 正例·解构形参" },
1421
+ { name: "负对照·方法是真塑形但实参是字面量", fn: "emit", files: { "helpers.ts": HELPERS, "it.ts": emit('Util.toPath("static")') },
1422
+ expectMark: false, why: "传播得靠实参带污点" },
1423
+ ];
1424
+ for (const c of cases) {
1425
+ (0, vitest_1.it)(`${c.name} —— ${c.why}`, () => {
1426
+ const dir = makeProject(c.files);
1427
+ try {
1428
+ const marks = marksFor(dir, c.fn);
1429
+ if (c.expectMark)
1430
+ (0, vitest_1.expect)(marks).toContain(PATH_MARK);
1431
+ else
1432
+ (0, vitest_1.expect)(marks).not.toContain(PATH_MARK);
1433
+ }
1434
+ finally {
1435
+ fs.rmSync(dir, { recursive: true, force: true });
1436
+ }
1437
+ }, 150000);
1438
+ }
1439
+ });
@@ -772,6 +772,174 @@ function pathModuleAliases(project) {
772
772
  }
773
773
  return out;
774
774
  }
775
+ /**
776
+ * 形参名列表:解构形参(`function f({ name }) {…}`)不能用 getName() 直接取,
777
+ * ts-morph 给的是整个绑定模式,要把里面的 BindingElement 名字逐个抽出来。
778
+ */
779
+ function paramNamesOf(p) {
780
+ const nn = p.getNameNode();
781
+ if (ts_morph_1.Node.isIdentifier(nn))
782
+ return [nn.getText()];
783
+ const out = [];
784
+ for (const be of nn.getDescendantsOfKind(ts.SyntaxKind.BindingElement)) {
785
+ const n = be.getNameNode();
786
+ if (ts_morph_1.Node.isIdentifier(n))
787
+ out.push(n.getText());
788
+ }
789
+ return out;
790
+ }
791
+ /**
792
+ * 方法声明的宿主限定名:`const Util = { toPath(){} }` ⇒ "Util"、
793
+ * `const Cfg = { inner: { toPath(){} } }` ⇒ "Cfg.inner"、`class Paths {…}` ⇒ "Paths"。
794
+ * 取不出来就返回 null —— 认不出宿主的宁可不登记(保守侧)。
795
+ */
796
+ function ownerQualifier(md) {
797
+ const parent = md.getParent();
798
+ if (!parent)
799
+ return null;
800
+ if (ts_morph_1.Node.isClassDeclaration(parent))
801
+ return parent.getName() ?? null;
802
+ if (!ts_morph_1.Node.isObjectLiteralExpression(parent))
803
+ return null;
804
+ const parts = [];
805
+ let cur = parent;
806
+ for (let guard = 0; guard < 5; guard++) {
807
+ const up = cur.getParent();
808
+ if (!up)
809
+ return null;
810
+ if (ts_morph_1.Node.isPropertyAssignment(up)) {
811
+ const key = up.getNameNode?.();
812
+ const keyName = key
813
+ ? key.getText().replace(/^["'`]|["'`]$/g, "")
814
+ : up.getChildAtIndex(0).getText().replace(/^["'`]|["'`]$/g, "");
815
+ parts.unshift(keyName);
816
+ cur = up.getParent();
817
+ if (!cur)
818
+ return null;
819
+ continue;
820
+ }
821
+ if (ts_morph_1.Node.isVariableDeclaration(up)) {
822
+ parts.unshift(up.getName());
823
+ return parts.join(".");
824
+ }
825
+ return null;
826
+ }
827
+ return null;
828
+ }
829
+ /** 按顶层逗号切分实参串(跳过字符串与已配平的括号,后者由调用方保证不含嵌套) */
830
+ function splitTopLevelArgs(argText) {
831
+ const out = [];
832
+ let depth = 0;
833
+ let quote = "";
834
+ let cur = "";
835
+ for (let i = 0; i < argText.length; i++) {
836
+ const ch = argText[i];
837
+ if (quote) {
838
+ cur += ch;
839
+ if (ch === quote && argText[i - 1] !== "\\")
840
+ quote = "";
841
+ continue;
842
+ }
843
+ if (ch === '"' || ch === "'" || ch === "`") {
844
+ quote = ch;
845
+ cur += ch;
846
+ continue;
847
+ }
848
+ if (ch === "(" || ch === "[" || ch === "{")
849
+ depth++;
850
+ else if (ch === ")" || ch === "]" || ch === "}")
851
+ depth--;
852
+ if (ch === "," && depth === 0) {
853
+ out.push(cur);
854
+ cur = "";
855
+ continue;
856
+ }
857
+ cur += ch;
858
+ }
859
+ if (cur.trim() !== "" || out.length > 0)
860
+ out.push(cur);
861
+ return out;
862
+ }
863
+ /**
864
+ * C4f:把 helper 调用里【返回值不依赖的那些实参】抹掉。
865
+ *
866
+ * 名字级传播的老问题是:`discard(k) { return "fixed.md"; }` 明明把形参丢了,
867
+ * 只要 `k` 出现在赋值右侧就会被当成污点证据 ⇒ 误标。返回值既然不依赖那个形参,
868
+ * 那条支路就没有数据流。按形参-实参位置对齐后:
869
+ *
870
+ * discard(k) ⇒ `""` (不依赖任何形参)
871
+ * pick("safe", k) ⇒ `("safe")` (只依赖第一个形参)
872
+ *
873
+ * 只处理实参里不再嵌套括号的调用(最内层优先),有界 3 轮向外。
874
+ * 抹掉的是**证据**,`shaped` 判定仍看原式 —— helper 是不是塑形,与它的返回值
875
+ * 依不依赖实参是两件事。
876
+ */
877
+ function maskNonDependentArgs(text, info, memberNames = new Set()) {
878
+ if (info.size === 0)
879
+ return text;
880
+ let cur = text;
881
+ for (let round = 0; round < 3; round++) {
882
+ const next = maskNonDependentArgsOnce(cur, info, memberNames);
883
+ if (next === null)
884
+ break;
885
+ cur = next;
886
+ }
887
+ return cur;
888
+ }
889
+ /** 抹掉**一个**最内层调用;没有任何调用需要抹时返回 null(供调用方收敛) */
890
+ function maskNonDependentArgsOnce(text, info, memberNames) {
891
+ for (const [name, si] of info) {
892
+ const re = memberNames.has(name)
893
+ ? new RegExp(`\\.${escapeRe(name)}\\s*\\(`, "g")
894
+ : new RegExp(`(?:^|[${name.includes(".") ? "^\\w$" : "^\\w$."}])${escapeRe(name)}\\s*\\(`, "g");
895
+ let m;
896
+ while ((m = re.exec(text)) !== null) {
897
+ // 成员形态的匹配从 `.` 起算,替换也连点一起换掉
898
+ const nameStart = memberNames.has(name) ? m.index : m.index + (m[0].startsWith(name) ? 0 : 1);
899
+ const open = m.index + m[0].length - 1; // 开括号位置
900
+ let depth = 0;
901
+ let close = -1;
902
+ for (let i = open; i < text.length; i++) {
903
+ if (text[i] === "(")
904
+ depth++;
905
+ else if (text[i] === ")") {
906
+ depth--;
907
+ if (depth === 0) {
908
+ close = i;
909
+ break;
910
+ }
911
+ }
912
+ }
913
+ if (close < 0)
914
+ continue;
915
+ const argText = text.slice(open + 1, close);
916
+ if (/[([{]/.test(argText))
917
+ continue; // 还嵌着别的调用 —— 下一轮再处理
918
+ const args = splitTopLevelArgs(argText);
919
+ const kept = args.filter((_, i) => si.params[i] !== undefined && si.deps.has(si.params[i]));
920
+ if (kept.length === args.length)
921
+ continue; // 每个形参都被依赖 ⇒ 无需抹
922
+ const replacement = kept.length > 0 ? `(${kept.join(" + ")})` : `""`;
923
+ return text.slice(0, nameStart) + replacement + text.slice(close + 1);
924
+ }
925
+ }
926
+ return null;
927
+ }
928
+ /**
929
+ * return 表达式真正依赖哪些形参:把「已知 helper 调用里不被依赖的实参」抹掉后,
930
+ * 剩下的形参名就是会流进返回值的那些。`wrap(n) { return discard(n); }` ⇒ 空集。
931
+ */
932
+ function returnDeps(exprs, params, info, memberNames = new Set()) {
933
+ const deps = new Set();
934
+ for (const raw of exprs) {
935
+ const masked = maskNonDependentArgs(raw, info, memberNames);
936
+ for (const p of params) {
937
+ if (new RegExp(`(?:^|[^\\w$.])${escapeRe(p)}(?:[^\\w$]|$)`).test(masked))
938
+ deps.add(p);
939
+ }
940
+ }
941
+ return deps;
942
+ }
775
943
  /** return 表达式列表:块体取 return 语句,箭头简洁体把整个体当作返回值 */
776
944
  function returnExprsOf(body) {
777
945
  if (!ts_morph_1.Node.isBlock(body))
@@ -784,13 +952,14 @@ function returnExprsOf(body) {
784
952
  }
785
953
  return rets;
786
954
  }
787
- /** 扫描全项目,收集「纯塑形」helper 的名字(有界两轮不动点) */
955
+ /** 扫描全项目,收集「纯塑形」helper 的名字 + 返回值依赖(有界两轮不动点) */
788
956
  function pureShaperFunctionNames(project) {
789
957
  const sinkRe = tsSinkCallRegex();
790
958
  const cands = [];
791
959
  /** 登记一个候选:三条判据 —— 有形参 / 有返回值 / 体内无 sink */
792
960
  const consider = (name, paramNodes, declText, bodyNode) => {
793
- const params = new Set(paramNodes.filter(Boolean));
961
+ const paramList = paramNodes.filter(Boolean);
962
+ const params = new Set(paramList);
794
963
  if (params.size === 0)
795
964
  return;
796
965
  sinkRe.lastIndex = 0;
@@ -801,7 +970,9 @@ function pureShaperFunctionNames(project) {
801
970
  const rets = returnExprsOf(bodyNode);
802
971
  if (rets.length === 0)
803
972
  return; // ①
804
- cands.push({ name, params, rets });
973
+ if (cands.some((c) => c.name === name))
974
+ return; // 同名重复登记:先到先得
975
+ cands.push({ name, params, paramList, rets });
805
976
  };
806
977
  for (const sf of project.getSourceFiles()) {
807
978
  // (a) 函数声明
@@ -812,7 +983,7 @@ function pureShaperFunctionNames(project) {
812
983
  const body = fn.getBody();
813
984
  if (!body)
814
985
  continue; // 重载签名 / declare 只有签名
815
- consider(name, fn.getParameters().map((p) => p.getName()), fn.getText(), body);
986
+ consider(name, fn.getParameters().flatMap(paramNamesOf), fn.getText(), body);
816
987
  }
817
988
  // (b) 变量承载的箭头 / 函数表达式 —— 现代 TS 里 helper 的主力写法
818
989
  // `export const toPath = (n: string) => n + ".md";`
@@ -829,29 +1000,91 @@ function pureShaperFunctionNames(project) {
829
1000
  const body = fnLike.getBody();
830
1001
  if (!body)
831
1002
  continue;
832
- consider(vd.getName(), fnLike.getParameters().map((p) => p.getName()), init.getText(), body);
1003
+ consider(vd.getName(), fnLike.getParameters().flatMap(paramNamesOf), init.getText(), body);
1004
+ }
1005
+ }
1006
+ // (c) 对象字面量方法 / 类方法 —— C4g。
1007
+ // `const Util = { toPath(n) {…} }`、`class Paths { toPath(n) {…} }` 都不在
1008
+ // getFunctions() 里,C4e 时刻意没做:按裸名放行会让别的对象上的同名方法被
1009
+ // 误认成塑形(R11)。这里只登记**限定名** Owner.method;只有当该方法名在全
1010
+ // 项目唯一(不存在同名方法)时,才额外允许 `.method(` 这种不写宿主名的
1011
+ // 成员调用 —— 实例方法的主要写法正是这个。
1012
+ const methodCands = [];
1013
+ for (const sf of project.getSourceFiles()) {
1014
+ for (const md of sf.getDescendantsOfKind(ts.SyntaxKind.MethodDeclaration)) {
1015
+ const bare = md.getName();
1016
+ if (!bare)
1017
+ continue;
1018
+ const body = md.getBody();
1019
+ if (!body)
1020
+ continue; // 重载签名 / declare
1021
+ const owner = ownerQualifier(md);
1022
+ methodCands.push({ qualified: owner ? `${owner}.${bare}` : null, bare, md });
1023
+ }
1024
+ }
1025
+ const bareCount = new Map();
1026
+ for (const m of methodCands)
1027
+ bareCount.set(m.bare, (bareCount.get(m.bare) ?? 0) + 1);
1028
+ const memberNames = new Set();
1029
+ for (const m of methodCands) {
1030
+ const params = m.md.getParameters().flatMap(paramNamesOf);
1031
+ const text = m.md.getText();
1032
+ const body = m.md.getBody();
1033
+ if (!body)
1034
+ continue;
1035
+ // 实例方法的主要写法是 `p.toPath(k)` —— 宿主名是变量,限定名匹配不到,
1036
+ // 所以唯一时额外登记「按成员调用位匹配」的裸名。
1037
+ if (bareCount.get(m.bare) === 1) {
1038
+ consider(m.bare, params, text, body);
1039
+ memberNames.add(m.bare);
833
1040
  }
1041
+ if (m.qualified)
1042
+ consider(m.qualified, params, text, body);
834
1043
  }
835
1044
  // 形参之外允许出现的自由标识符 = 模块级字面量常量 ∪ 确证的 path 模块别名
836
1045
  const extraIdents = moduleLiteralConstNames(project);
837
1046
  for (const a of pathModuleAliases(project))
838
1047
  extraIdents.add(a);
839
- const shapers = new Set();
1048
+ const names = new Set();
1049
+ const info = new Map();
840
1050
  for (let round = 0; round < 2; round++) {
841
1051
  let added = false;
842
1052
  for (const c of cands) {
843
- if (shapers.has(c.name))
1053
+ if (names.has(c.name))
844
1054
  continue;
845
- const ok = c.rets.every((e) => isPureShaperExpr(e, c.params, shapers, extraIdents));
1055
+ const ok = c.rets.every((e) => isPureShaperExpr(e, c.params, names, extraIdents));
846
1056
  if (!ok)
847
1057
  continue;
848
- shapers.add(c.name);
1058
+ names.add(c.name);
1059
+ info.set(c.name, {
1060
+ params: c.paramList,
1061
+ deps: returnDeps(c.rets, c.paramList, info, memberNames),
1062
+ });
849
1063
  added = true;
850
1064
  }
851
1065
  if (!added)
852
1066
  break;
853
1067
  }
854
- return shapers;
1068
+ // C4f:依赖分析有次序依赖 —— `wrap(n) { return discard(n); }` 若在 discard 之前
1069
+ // 被接受,那一刻 info 里还没有 discard ⇒ deps 偏宽(偏宽 = 继续传播 = 保守侧,
1070
+ // 不会误报,但收不紧)。名录定稿后按完整的 info 再算两轮,把次序依赖消掉。
1071
+ const byName = new Map(cands.map((c) => [c.name, c]));
1072
+ for (let round = 0; round < 2; round++) {
1073
+ let changed = false;
1074
+ for (const [name, si] of info) {
1075
+ const c = byName.get(name);
1076
+ if (!c)
1077
+ continue;
1078
+ const d = returnDeps(c.rets, si.params, info, memberNames);
1079
+ if (d.size !== si.deps.size || [...d].some((x) => !si.deps.has(x))) {
1080
+ si.deps = d;
1081
+ changed = true;
1082
+ }
1083
+ }
1084
+ if (!changed)
1085
+ break;
1086
+ }
1087
+ return { names, memberNames, info };
855
1088
  }
856
1089
  const TS_FILE_SINK_NAMES = [
857
1090
  "readFile", "readFileSync", "writeFile", "writeFileSync",
@@ -1075,9 +1308,16 @@ function collectTaintedNames(text, shapers) {
1075
1308
  function taintedViaShaper(text, tainted, shapers) {
1076
1309
  // C4b:项目自有「纯塑形」helper 的调用也算子(如 getFileNamePath(dir, name, ext))。
1077
1310
  // 见 pureShaperFunctionNames —— 只认三条判据全部成立的函数,认不出就不传播。
1078
- const localShaperRe = shapers && shapers.size > 0
1079
- ? new RegExp(`(?:^|[^\\w$.])(?:${[...shapers].map(escapeRe).join("|")})\\s*\\(`)
1311
+ const localShaperRe = shapers && shapers.names.size > 0
1312
+ ? new RegExp(`(?:^|[^\\w$.])(?:${[...shapers.names].map(escapeRe).join("|")})\\s*\\(`)
1313
+ : null;
1314
+ // C4g:成员调用位(`.toPath(`)—— 宿主是变量、限定名匹配不到时用
1315
+ const memberShaperRe = shapers && shapers.memberNames.size > 0
1316
+ ? new RegExp(`\\.(?:${[...shapers.memberNames].map(escapeRe).join("|")})\\s*\\(`)
1080
1317
  : null;
1318
+ // C4f:helper 调用里【返回值不依赖的实参】不构成污点证据。
1319
+ const shaperInfo = shapers?.info;
1320
+ const memberNames = shapers?.memberNames;
1081
1321
  let grewAny = false;
1082
1322
  for (let depth = 0; depth < 3; depth++) {
1083
1323
  // 注意:这里**不能**以 `tainted.size === 0` 提前退出 —— 种子是根模式
@@ -1098,8 +1338,13 @@ function taintedViaShaper(text, tainted, shapers) {
1098
1338
  continue;
1099
1339
  const shaped = TS_PATH_SHAPER_RE.test(rhs) ||
1100
1340
  TS_CONCAT_RE.test(rhs) ||
1101
- (localShaperRe !== null && localShaperRe.test(rhs));
1102
- if (taintRe.test(rhs) && shaped) {
1341
+ (localShaperRe !== null && localShaperRe.test(rhs)) ||
1342
+ (memberShaperRe !== null && memberShaperRe.test(rhs));
1343
+ // 证据看的是【抹掉不被依赖的实参之后】的 rhs:`discard(k)` 里 k 不流出,
1344
+ // 那条支路就不该算证据。`shaped` 仍看原式 —— helper 是不是塑形,与它的
1345
+ // 返回值依不依赖实参是两件事。
1346
+ const evidence = shaperInfo && memberNames ? maskNonDependentArgs(rhs, shaperInfo, memberNames) : rhs;
1347
+ if (taintRe.test(evidence) && shaped) {
1103
1348
  tainted.add(name);
1104
1349
  added = true;
1105
1350
  }
package/dist/sdk.js CHANGED
@@ -20,7 +20,7 @@ const risk_model_1 = require("./risk-model");
20
20
  const protocol_knowledge_1 = require("./protocol-knowledge");
21
21
  const evidence_repository_1 = require("./evidence-repository");
22
22
  /** Runtime version — stable public identifier. Internal layers evolve underneath. */
23
- exports.RUNTIME_VERSION = "3.7.41";
23
+ exports.RUNTIME_VERSION = "3.7.43";
24
24
  function verify(filePath) {
25
25
  const cert = (0, certify_1.certify)(filePath);
26
26
  const kb = (0, protocol_knowledge_1.buildKnowledgeBase)();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "progmune-runtime",
3
- "version": "3.7.41",
3
+ "version": "3.7.43",
4
4
  "description": "Progmune — AI Trust Decision Engine. Verify AI-generated code before it reaches production. Outputs APPROVED / NEEDS_REVIEW / BLOCKED with evidence.",
5
5
  "files": [
6
6
  "dist/",