progmune-runtime 3.7.42 → 3.7.45

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.
@@ -719,9 +719,16 @@ function isPureShaperExpr(expr, params, shapers, extraIdents) {
719
719
  }
720
720
  return true;
721
721
  }
722
- /** 收集模块级(top-level)**字面量**常量名 —— helper 的 return 引用它们仍是纯塑形 */
723
- function moduleLiteralConstNames(project) {
724
- const out = new Set();
722
+ /**
723
+ * 收集模块级(top-level)**字面量**常量:名字 → 字面量文本(引号已脱)。
724
+ *
725
+ * 两个用途:
726
+ * ① helper 的 return 引用它们仍是纯塑形(C4e);
727
+ * ② C4i:`{ [KEY]: (n) => … }` 这种计算属性名,只有 KEY 是字面量常量才解得
728
+ * 出来 —— 解不出一律不登记(与「看不见就不传播」同一条政策)。
729
+ */
730
+ function moduleConstTexts(project) {
731
+ const out = new Map();
725
732
  for (const sf of project.getSourceFiles()) {
726
733
  for (const vd of sf.getVariableDeclarations()) {
727
734
  // 只要模块作用域:VariableStatement 的父节点必须是 SourceFile
@@ -739,7 +746,7 @@ function moduleLiteralConstNames(project) {
739
746
  init.getKind() === ts.SyntaxKind.TrueKeyword ||
740
747
  init.getKind() === ts.SyntaxKind.FalseKeyword;
741
748
  if (isLiteral)
742
- out.add(vd.getName());
749
+ out.set(vd.getName(), init.getText().replace(/^["'`]|["'`]$/g, ""));
743
750
  }
744
751
  }
745
752
  return out;
@@ -772,6 +779,156 @@ function pathModuleAliases(project) {
772
779
  }
773
780
  return out;
774
781
  }
782
+ /**
783
+ * 形参名列表:解构形参(`function f({ name }) {…}`)不能用 getName() 直接取,
784
+ * ts-morph 给的是整个绑定模式,要把里面的 BindingElement 名字逐个抽出来。
785
+ */
786
+ function paramNamesOf(p) {
787
+ const nn = p.getNameNode();
788
+ if (ts_morph_1.Node.isIdentifier(nn))
789
+ return [nn.getText()];
790
+ const out = [];
791
+ for (const be of nn.getDescendantsOfKind(ts.SyntaxKind.BindingElement)) {
792
+ const n = be.getNameNode();
793
+ if (ts_morph_1.Node.isIdentifier(n))
794
+ out.push(n.getText());
795
+ }
796
+ return out;
797
+ }
798
+ /**
799
+ * 属性名:普通标识符 / 字符串字面量直接取;**计算属性名** `[KEY]` 只有在 KEY 是
800
+ * 模块级字面量常量时才解(C4i)。解不出返回 null —— 认不出的一律不登记。
801
+ */
802
+ function propertyNameOf(pa, constTexts) {
803
+ const nn = pa.getNameNode();
804
+ if (ts_morph_1.Node.isIdentifier(nn))
805
+ return nn.getText();
806
+ if (ts_morph_1.Node.isStringLiteral(nn) || ts_morph_1.Node.isNoSubstitutionTemplateLiteral(nn)) {
807
+ return nn.getText().replace(/^["'`]|["'`]$/g, "");
808
+ }
809
+ if (ts_morph_1.Node.isComputedPropertyName(nn)) {
810
+ const e = nn.getExpression();
811
+ // `const KEY = "toPath"; … { [KEY]: (n) => … }` —— KEY 的值必须看得见;
812
+ // `[key()]` 这种算不出来的,宁可整个不收(探针 K2 守的就是这条)。
813
+ if (ts_morph_1.Node.isIdentifier(e))
814
+ return constTexts.get(e.getText()) ?? null;
815
+ }
816
+ return null;
817
+ }
818
+ /**
819
+ * 方法声明的宿主限定名:`const Util = { toPath(){} }` ⇒ "Util"、
820
+ * `const Cfg = { inner: { toPath(){} } }` ⇒ "Cfg.inner"、`class Paths {…}` ⇒ "Paths"。
821
+ * 取不出来就返回 null —— 认不出宿主的宁可不登记(保守侧)。
822
+ */
823
+ function ownerQualifier(md, constTexts = new Map()) {
824
+ const parent = md.getParent();
825
+ if (!parent)
826
+ return null;
827
+ if (ts_morph_1.Node.isClassDeclaration(parent))
828
+ return parent.getName() ?? null;
829
+ if (!ts_morph_1.Node.isObjectLiteralExpression(parent))
830
+ return null;
831
+ const parts = [];
832
+ let cur = parent;
833
+ for (let guard = 0; guard < 5; guard++) {
834
+ const up = cur.getParent();
835
+ if (!up)
836
+ return null;
837
+ if (ts_morph_1.Node.isPropertyAssignment(up)) {
838
+ const keyName = propertyNameOf(up, constTexts);
839
+ if (keyName === null)
840
+ return null;
841
+ parts.unshift(keyName);
842
+ cur = up.getParent();
843
+ if (!cur)
844
+ return null;
845
+ continue;
846
+ }
847
+ if (ts_morph_1.Node.isVariableDeclaration(up)) {
848
+ parts.unshift(up.getName());
849
+ return parts.join(".");
850
+ }
851
+ return null;
852
+ }
853
+ return null;
854
+ }
855
+ /**
856
+ * C4i:函数声明的命名空间限定名 —— `namespace P { export function toPath(){} }` ⇒ "P"
857
+ * (嵌套 namespace 拼成 "A.B")。只认**纯 namespace 链**:函数套函数那种 helper
858
+ * 作用域与调用点不同,按同一名字发布会张冠李戴,一律返回 null 交给调用方跳过。
859
+ */
860
+ function moduleQualifier(fn) {
861
+ const parts = [];
862
+ let cur = fn;
863
+ for (let guard = 0; guard < 6; guard++) {
864
+ const up = cur.getParent();
865
+ if (!up)
866
+ return null;
867
+ // 注意(实测):`namespace P { export function f(){} }` 里函数的父节点是
868
+ // **ModuleBlock**(namespace 的体),ModuleDeclaration 在它的上一层。
869
+ if (ts_morph_1.Node.isModuleBlock(up)) {
870
+ const decl = up.getParent();
871
+ if (!decl || !ts_morph_1.Node.isModuleDeclaration(decl))
872
+ return null;
873
+ const n = decl.getName();
874
+ if (!n)
875
+ return null;
876
+ parts.unshift(n);
877
+ cur = decl;
878
+ continue;
879
+ }
880
+ if (ts_morph_1.Node.isModuleDeclaration(up)) {
881
+ const n = up.getName();
882
+ if (!n)
883
+ return null;
884
+ parts.unshift(n);
885
+ cur = up;
886
+ continue;
887
+ }
888
+ if (ts_morph_1.Node.isSourceFile(up))
889
+ return parts.length > 0 ? parts.join(".") : null;
890
+ return null; // 函数体内的嵌套声明:作用域不同,不收
891
+ }
892
+ return null;
893
+ }
894
+ /** 脱掉包住表达式的括号(可能多层)—— `((n) => n + ".md")` ⇒ 箭头本身 */
895
+ function unparen(n) {
896
+ let cur = n;
897
+ while (cur && ts_morph_1.Node.isParenthesizedExpression(cur))
898
+ cur = cur.getExpression();
899
+ return cur;
900
+ }
901
+ /**
902
+ * C4i:变量初值承载的 helper 体。除箭头 / 函数表达式外,再解一层 IIFE ——
903
+ * `const toPath = (() => (n) => n + ".md")();` 的初值是**调用**不是箭头,
904
+ * 真正的 helper 是被 IIFE 返回出来的那个内层箭头(旧代码整条族漏)。
905
+ */
906
+ function functionLikeOf(init) {
907
+ // 实测:`(() => (n) => n + ".md")()` 里被括号包住的**不止**内层箭头 ——
908
+ // 连被调用的 callee 都是 ParenthesizedExpression。两处都得脱。
909
+ const base = unparen(init);
910
+ if (!base)
911
+ return null;
912
+ if (ts_morph_1.Node.isArrowFunction(base) || ts_morph_1.Node.isFunctionExpression(base))
913
+ return base;
914
+ if (ts_morph_1.Node.isCallExpression(base)) {
915
+ const callee = unparen(base.getExpression());
916
+ if (!ts_morph_1.Node.isArrowFunction(callee) && !ts_morph_1.Node.isFunctionExpression(callee))
917
+ return null;
918
+ const b = callee.getBody();
919
+ if (!ts_morph_1.Node.isBlock(b)) {
920
+ // 简洁体:`(() => (n) => n + ".md")()` —— 括号包着也算(unparen)
921
+ const e = unparen(b);
922
+ return e && (ts_morph_1.Node.isArrowFunction(e) || ts_morph_1.Node.isFunctionExpression(e)) ? e : null;
923
+ }
924
+ for (const rs of b.getDescendantsOfKind(ts.SyntaxKind.ReturnStatement)) {
925
+ const e = unparen(rs.getExpression());
926
+ if (e && (ts_morph_1.Node.isArrowFunction(e) || ts_morph_1.Node.isFunctionExpression(e)))
927
+ return e;
928
+ }
929
+ }
930
+ return null;
931
+ }
775
932
  /** 按顶层逗号切分实参串(跳过字符串与已配平的括号,后者由调用方保证不含嵌套) */
776
933
  function splitTopLevelArgs(argText) {
777
934
  const out = [];
@@ -806,6 +963,110 @@ function splitTopLevelArgs(argText) {
806
963
  out.push(cur);
807
964
  return out;
808
965
  }
966
+ /**
967
+ * C4i:形参表里 rest 形参(`...parts`)的位置;没有返回 -1。
968
+ * rest 形参对应的是【一批】实参,不是一位 —— 位置对齐必须让它吃掉末尾所有位。
969
+ */
970
+ function restIndexOf(ps) {
971
+ return ps.findIndex((p) => p.isRestParameter());
972
+ }
973
+ /** 实参位 ⇒ 形参位(rest 形参吃掉从它开始的剩余位) */
974
+ function paramIndexOf(si, argIndex) {
975
+ return si.restAt >= 0 && argIndex >= si.restAt ? si.restAt : argIndex;
976
+ }
977
+ /**
978
+ * C4i:柯里化 helper —— 函数体【返回】另一个箭头 / 函数表达式
979
+ * (`const withExt = (ext) => (n) => n + ext`)。这时被调用的不是这一层,
980
+ * 而是它返回出来的内层函数;真正塑形的地方在内层,外层只负责捕获第一批实参。
981
+ * 内层无形参 ⇒ 不存在「经内层实参进来」的污点 ⇒ 返回 null(外层按普通 helper 走)。
982
+ */
983
+ function currySpecOf(outerParams, body) {
984
+ const inner = returnedFunctionOf(body);
985
+ if (!inner)
986
+ return null;
987
+ const innerParams = inner.getParameters().flatMap(paramNamesOf).filter(Boolean);
988
+ if (innerParams.length === 0)
989
+ return null;
990
+ const innerBody = inner.getBody();
991
+ if (!innerBody)
992
+ return null;
993
+ const rets = returnExprsOf(innerBody);
994
+ if (rets.length === 0)
995
+ return null;
996
+ return { outerParams, innerParams, rets };
997
+ }
998
+ /** 函数体返回出来的那个箭头 / 函数表达式(没有则 null) */
999
+ function returnedFunctionOf(body) {
1000
+ if (!ts_morph_1.Node.isBlock(body)) {
1001
+ // 箭头简洁体本身就是要返回的函数:`(ext) => (n) => n + ext`(可能被括号包住)
1002
+ const e = unparen(body);
1003
+ return e && (ts_morph_1.Node.isArrowFunction(e) || ts_morph_1.Node.isFunctionExpression(e)) ? e : null;
1004
+ }
1005
+ for (const rs of body.getDescendantsOfKind(ts.SyntaxKind.ReturnStatement)) {
1006
+ const e = unparen(rs.getExpression());
1007
+ if (e && (ts_morph_1.Node.isArrowFunction(e) || ts_morph_1.Node.isFunctionExpression(e)))
1008
+ return e;
1009
+ }
1010
+ return null;
1011
+ }
1012
+ /**
1013
+ * C4i:柯里化 helper 的调用点展开。`withExt(".md")(k)` 这种两跳调用,真正塑形的是
1014
+ * 内层函数 —— 这里把【内层 return 表达式】按两层实参代入后原地摊开,之后的污点检测
1015
+ * 直接看摊开后的串:`withExt(".md")(k)` ⇒ `k + ".md"`,`withExt(k)("name")` ⇒
1016
+ * `"name" + k`。丢形参的内层(`(_n) => "fixed.md"`)摊开后是常量 ⇒ 自然不传播。
1017
+ */
1018
+ function expandCurried(text, curried) {
1019
+ if (curried.size === 0)
1020
+ return text;
1021
+ let cur = text;
1022
+ for (let round = 0; round < 3; round++) {
1023
+ const next = expandCurriedOnce(cur, curried);
1024
+ if (next === null)
1025
+ break;
1026
+ cur = next;
1027
+ }
1028
+ return cur;
1029
+ }
1030
+ /** 摊开**一处**柯里化调用;没有可摊的返回 null(供调用方收敛) */
1031
+ function expandCurriedOnce(text, curried) {
1032
+ for (const [name, cs] of curried) {
1033
+ const re = new RegExp(`(?:^|[^\\w$.])${escapeRe(name)}\\s*\\(`, "g");
1034
+ let m;
1035
+ while ((m = re.exec(text)) !== null) {
1036
+ const open1 = m.index + m[0].length - 1;
1037
+ const close1 = matchingParen(text, open1);
1038
+ if (close1 < 0)
1039
+ continue;
1040
+ // 第二跳:紧跟其后的那个 `(`
1041
+ let j = close1 + 1;
1042
+ while (j < text.length && /\s/.test(text[j]))
1043
+ j++;
1044
+ if (text[j] !== "(")
1045
+ continue;
1046
+ const close2 = matchingParen(text, j);
1047
+ if (close2 < 0)
1048
+ continue;
1049
+ const args1 = splitTopLevelArgs(text.slice(open1 + 1, close1));
1050
+ const args2 = splitTopLevelArgs(text.slice(j + 1, close2));
1051
+ const map = new Map();
1052
+ args1.forEach((a, i) => {
1053
+ const p = cs.outerParams[i];
1054
+ if (p !== undefined)
1055
+ map.set(p, stripOuterParens(a.trim()));
1056
+ });
1057
+ args2.forEach((a, i) => {
1058
+ const p = cs.innerParams[i];
1059
+ if (p !== undefined && !map.has(p))
1060
+ map.set(p, stripOuterParens(a.trim()));
1061
+ });
1062
+ const inner = cs.rets.length === 1 ? cs.rets[0] : cs.rets.join(" + ");
1063
+ const expanded = foldDecidableTernary(substituteParams(inner, map));
1064
+ const nameStart = m.index + (m[0].startsWith(name) ? 0 : 1);
1065
+ return text.slice(0, nameStart) + expanded + text.slice(close2 + 1);
1066
+ }
1067
+ }
1068
+ return null;
1069
+ }
809
1070
  /**
810
1071
  * C4f:把 helper 调用里【返回值不依赖的那些实参】抹掉。
811
1072
  *
@@ -820,12 +1081,12 @@ function splitTopLevelArgs(argText) {
820
1081
  * 抹掉的是**证据**,`shaped` 判定仍看原式 —— helper 是不是塑形,与它的返回值
821
1082
  * 依不依赖实参是两件事。
822
1083
  */
823
- function maskNonDependentArgs(text, info) {
1084
+ function maskNonDependentArgs(text, info, memberNames = new Set()) {
824
1085
  if (info.size === 0)
825
1086
  return text;
826
1087
  let cur = text;
827
1088
  for (let round = 0; round < 3; round++) {
828
- const next = maskNonDependentArgsOnce(cur, info);
1089
+ const next = maskNonDependentArgsOnce(cur, info, memberNames);
829
1090
  if (next === null)
830
1091
  break;
831
1092
  cur = next;
@@ -833,12 +1094,15 @@ function maskNonDependentArgs(text, info) {
833
1094
  return cur;
834
1095
  }
835
1096
  /** 抹掉**一个**最内层调用;没有任何调用需要抹时返回 null(供调用方收敛) */
836
- function maskNonDependentArgsOnce(text, info) {
1097
+ function maskNonDependentArgsOnce(text, info, memberNames) {
837
1098
  for (const [name, si] of info) {
838
- const re = new RegExp(`(?:^|[^\\w$.])${escapeRe(name)}\\s*\\(`, "g");
1099
+ const re = memberNames.has(name)
1100
+ ? new RegExp(`\\.${escapeRe(name)}\\s*\\(`, "g")
1101
+ : new RegExp(`(?:^|[${name.includes(".") ? "^\\w$" : "^\\w$."}])${escapeRe(name)}\\s*\\(`, "g");
839
1102
  let m;
840
1103
  while ((m = re.exec(text)) !== null) {
841
- const nameStart = m.index + (m[0].startsWith(name) ? 0 : 1);
1104
+ // 成员形态的匹配从 `.` 起算,替换也连点一起换掉
1105
+ const nameStart = memberNames.has(name) ? m.index : m.index + (m[0].startsWith(name) ? 0 : 1);
842
1106
  const open = m.index + m[0].length - 1; // 开括号位置
843
1107
  let depth = 0;
844
1108
  let close = -1;
@@ -859,7 +1123,13 @@ function maskNonDependentArgsOnce(text, info) {
859
1123
  if (/[([{]/.test(argText))
860
1124
  continue; // 还嵌着别的调用 —— 下一轮再处理
861
1125
  const args = splitTopLevelArgs(argText);
862
- const kept = args.filter((_, i) => si.params[i] !== undefined && si.deps.has(si.params[i]));
1126
+ // C4h-①:按【本次调用】重算一下哪些位置会流出去 —— 名录里的 deps 是对所有
1127
+ // 实参形状都成立的最宽结论,本次若喂了常量,可能一个都流不出来。
1128
+ const flags = callSiteKeepFlags(si, args, info, memberNames);
1129
+ const kept = args.filter((_, i) => {
1130
+ const pi = paramIndexOf(si, i); // C4i:rest 形参吃掉剩余位
1131
+ return si.params[pi] !== undefined && flags[pi] === true;
1132
+ });
863
1133
  if (kept.length === args.length)
864
1134
  continue; // 每个形参都被依赖 ⇒ 无需抹
865
1135
  const replacement = kept.length > 0 ? `(${kept.join(" + ")})` : `""`;
@@ -869,13 +1139,437 @@ function maskNonDependentArgsOnce(text, info) {
869
1139
  return null;
870
1140
  }
871
1141
  /**
872
- * return 表达式真正依赖哪些形参:把「已知 helper 调用里不被依赖的实参」抹掉后,
1142
+ * C4h-①(2026-09-20):调用点专用的「哪些实参会流到返回值里」。
1143
+ *
1144
+ * 名录里的 deps 是**跨调用点**的并集 —— 对所有实参形状都成立的最宽结论。
1145
+ * 但本次调用喂的可能是常量:
1146
+ *
1147
+ * viaFlag(n, flag) { return flag ? "fixed.md" : n + ".md"; }
1148
+ * viaFlag(k, true) ⇒ 返回值就是字面量 —— n 位虽然进了函数也出不来。
1149
+ *
1150
+ * 于是按位置把**字面量实参**代进 return 表达式,试着解掉 cond 可判定的三元,
1151
+ * 再看还有哪些形参活着。两种情形一律退回名录的 deps(保守侧,宁可不收精度):
1152
+ * - 实参一个常量都没有;
1153
+ * - cond 代入后判不出真假 —— 认不出就不收精度。
1154
+ */
1155
+ function callSiteKeepFlags(si, args, info, memberNames) {
1156
+ const fallback = si.params.map((p) => si.deps.has(p));
1157
+ if (si.rets.length === 0)
1158
+ return fallback;
1159
+ const litArgs = new Map();
1160
+ args.forEach((a, i) => {
1161
+ // C4i:rest 形参拿到的是【一批】实参,不等于任意一个 —— 不代,否则
1162
+ // `parts.join("/")` 会被代成 `"out".join("/"` 从而误判「不依赖 parts」
1163
+ if (si.restAt >= 0 && i >= si.restAt)
1164
+ return;
1165
+ const p = si.params[i];
1166
+ if (p === undefined)
1167
+ return;
1168
+ const t = stripOuterParens(a.trim());
1169
+ // 只代【字面量】—— 变量名进来不等于知道它的值
1170
+ if (litKind(t) !== null)
1171
+ litArgs.set(p, t);
1172
+ });
1173
+ if (litArgs.size === 0)
1174
+ return fallback;
1175
+ const subbed = si.rets.map((r) => foldDecidableTernary(substituteParams(r, litArgs)));
1176
+ const remaining = si.params.filter((p) => p !== undefined && !litArgs.has(p));
1177
+ // 嵌套 helper 的支路仍由 returnDeps 负责抹
1178
+ const deps = returnDeps(subbed, remaining, info, memberNames);
1179
+ return si.params.map((p) => deps.has(p));
1180
+ }
1181
+ /** 把形参名替换成实参文本(跳字符串与模板字面量内部 —— 判不出就保守地留着形参) */
1182
+ function substituteParams(expr, litArgs) {
1183
+ let out = "";
1184
+ let quote = "";
1185
+ let i = 0;
1186
+ while (i < expr.length) {
1187
+ const ch = expr[i];
1188
+ if (quote) {
1189
+ out += ch;
1190
+ if (ch === "\\") {
1191
+ out += expr[i + 1] ?? "";
1192
+ i += 2;
1193
+ continue;
1194
+ }
1195
+ if (ch === quote)
1196
+ quote = "";
1197
+ i++;
1198
+ continue;
1199
+ }
1200
+ if (ch === '"' || ch === "'") {
1201
+ quote = ch;
1202
+ out += ch;
1203
+ i++;
1204
+ continue;
1205
+ }
1206
+ // C4i:模板串不能像普通字符串那样整段跳过 —— `${flag ? "fixed" : n}` 里的
1207
+ // flag 是要代的。字面量部分照抄,只对 `${}` 内部递归代入。
1208
+ if (ch === "`") {
1209
+ out += ch;
1210
+ i++;
1211
+ while (i < expr.length) {
1212
+ const c = expr[i];
1213
+ if (c === "`")
1214
+ break; // 模板串内不可能有裸反引号(转义的已被下面处理)
1215
+ if (c === "\\") {
1216
+ out += expr.slice(i, i + 2);
1217
+ i += 2;
1218
+ continue;
1219
+ }
1220
+ if (c === "$" && expr[i + 1] === "{") {
1221
+ let k = i + 2;
1222
+ let brace = 0;
1223
+ let inner = "";
1224
+ while (k < expr.length) {
1225
+ const d = expr[k];
1226
+ if (d === "{")
1227
+ brace++;
1228
+ else if (d === "}") {
1229
+ if (brace === 0)
1230
+ break;
1231
+ brace--;
1232
+ }
1233
+ inner += d;
1234
+ k++;
1235
+ }
1236
+ out += "${" + substituteParams(inner, litArgs) + "}";
1237
+ i = k + 1;
1238
+ continue;
1239
+ }
1240
+ out += c;
1241
+ i++;
1242
+ }
1243
+ if (i < expr.length && expr[i] === "`") {
1244
+ out += "`";
1245
+ i++;
1246
+ }
1247
+ continue;
1248
+ }
1249
+ const m = /^[A-Za-z_$][\w$]*/.exec(expr.slice(i));
1250
+ if (m && expr[i - 1] !== "." && litArgs.has(m[0])) {
1251
+ // 不加括号:代进去的永远是【原子字面量】(字符串/数字/布尔/null),
1252
+ // 不会改变优先级;而加括号会让 maskNonDependentArgsOnce 把参数串误判成嵌套
1253
+ // 调用(它的判据是「实参里还有 `(`」),内层就再也轮不到它来处理。
1254
+ out += litArgs.get(m[0]);
1255
+ i += m[0].length;
1256
+ continue;
1257
+ }
1258
+ out += ch;
1259
+ i++;
1260
+ }
1261
+ return out;
1262
+ }
1263
+ /**
1264
+ * 递归解掉 cond 可判定的三元 / 短路;判不出就保持原样(保守侧)。
1265
+ *
1266
+ * C4i(2026-09-21)补两类:
1267
+ * - 模板串内部的 `${...}` 是独立表达式,折叠必须能进去 —— 否则
1268
+ * `` `${flag ? "fixed" : n}.md` `` 这种写法(现代代码里比裸三元更常见)
1269
+ * 永远收不住;
1270
+ * - 短路运算符 `||` / `&&` / `??`:与三元同形,只是判据从 cond 换成了
1271
+ * 左操作数的真假。JS 求值是从左到右,短路一发生右边整段根本不执行。
1272
+ */
1273
+ function foldDecidableTernary(expr, depth = 0) {
1274
+ if (depth > 4)
1275
+ return expr;
1276
+ // 先脱掉包住整串的括号 —— 不脱的话 `(()flag ? a : b)` 这种写法切出来的 cond
1277
+ // 是 `((true)`,括号不配对 ⇒ 判不出真假 ⇒ 白白丢掉一次收精度的机会
1278
+ // (2026-09-20 探针:viaFlag(k, true) 一直没收住,根因就是这个)。
1279
+ const s = stripOuterParens(expr);
1280
+ // ① 模板串:先处理 `${}` 内部(外面那层字面量部分不参与判定)
1281
+ const tpl = foldTemplateSubsts(s, depth);
1282
+ if (tpl !== s)
1283
+ return tpl;
1284
+ // ② 三元
1285
+ const t = topLevelTernary(s);
1286
+ if (t) {
1287
+ const v = truthOf(t.cond);
1288
+ if (v !== null)
1289
+ return foldDecidableTernary(v ? t.a : t.b, depth + 1);
1290
+ }
1291
+ // ③ 短路
1292
+ const lg = topLevelLogical(s);
1293
+ if (lg) {
1294
+ const v = lg.op === "??" ? notNullish(lg.left) : truthOf(lg.left);
1295
+ if (v !== null) {
1296
+ // `||` 左为真 ⇒ 取左;`&&` 左为真 ⇒ 取右;`??` 左非空 ⇒ 取左
1297
+ const takeLeft = lg.op === "&&" ? !v : v;
1298
+ return foldDecidableTernary(takeLeft ? lg.left : lg.right, depth + 1);
1299
+ }
1300
+ }
1301
+ return expr === s ? expr : s;
1302
+ }
1303
+ /**
1304
+ * 折叠模板串里每个 `${...}` 的内部;没有任何一处可折叠就**原样返回**(让调用方
1305
+ * 知道这一层没做事,好继续试三元/短路)。
1306
+ */
1307
+ function foldTemplateSubsts(expr, depth) {
1308
+ if (!expr.includes("`"))
1309
+ return expr;
1310
+ let out = "";
1311
+ let changed = false;
1312
+ let i = 0;
1313
+ while (i < expr.length) {
1314
+ const ch = expr[i];
1315
+ if (ch !== "`") {
1316
+ out += ch;
1317
+ i++;
1318
+ continue;
1319
+ }
1320
+ let seg = "";
1321
+ let j = i + 1;
1322
+ while (j < expr.length) {
1323
+ const c = expr[j];
1324
+ if (c === "\\") {
1325
+ seg += expr.slice(j, j + 2);
1326
+ j += 2;
1327
+ continue;
1328
+ }
1329
+ if (c === "`")
1330
+ break;
1331
+ if (c === "$" && expr[j + 1] === "{") {
1332
+ // 找与 `${` 配对的 `}`(`${ {a:1}.a }` 里的花括号要跳过)
1333
+ let brace = 0;
1334
+ let k = j + 2;
1335
+ let inner = "";
1336
+ while (k < expr.length) {
1337
+ const d = expr[k];
1338
+ if (d === "{")
1339
+ brace++;
1340
+ else if (d === "}") {
1341
+ if (brace === 0)
1342
+ break;
1343
+ brace--;
1344
+ }
1345
+ inner += d;
1346
+ k++;
1347
+ }
1348
+ const folded = foldDecidableTernary(inner, depth + 1);
1349
+ if (folded !== inner)
1350
+ changed = true;
1351
+ seg += "${" + folded + "}";
1352
+ j = k + 1;
1353
+ continue;
1354
+ }
1355
+ seg += c;
1356
+ j++;
1357
+ }
1358
+ out += "`" + seg + "`";
1359
+ i = j + 1;
1360
+ }
1361
+ return changed ? out : expr;
1362
+ }
1363
+ /**
1364
+ * 顶层短路运算符:**最左边**的一个。优先级从低到高依次找 `||` → `??` → `&&`
1365
+ * (TS 里 `??` 与 `||`/`&&` 同级混用必须加括号,加了括号就不是顶层了)。
1366
+ * 只认左右都非空的那一个。
1367
+ */
1368
+ function topLevelLogical(expr) {
1369
+ for (const op of ["||", "??", "&&"]) {
1370
+ let paren = 0;
1371
+ let quote = "";
1372
+ let i = 0;
1373
+ while (i < expr.length) {
1374
+ const ch = expr[i];
1375
+ if (quote) {
1376
+ if (ch === "\\")
1377
+ i++;
1378
+ else if (ch === quote)
1379
+ quote = "";
1380
+ i++;
1381
+ continue;
1382
+ }
1383
+ if (ch === '"' || ch === "'" || ch === "`") {
1384
+ quote = ch;
1385
+ i++;
1386
+ continue;
1387
+ }
1388
+ if (ch === "(" || ch === "[" || ch === "{")
1389
+ paren++;
1390
+ else if (ch === ")" || ch === "]" || ch === "}")
1391
+ paren--;
1392
+ else if (paren === 0 && expr.startsWith(op, i)) {
1393
+ // `??`/`||`/`&&` 各占两字符,注意别把 `a ||= b` 这类当短路(赋值语义不同)
1394
+ if (expr[i + 2] === "=") {
1395
+ i += 3;
1396
+ continue;
1397
+ }
1398
+ const left = expr.slice(0, i).trim();
1399
+ const right = expr.slice(i + 2).trim();
1400
+ if (left !== "" && right !== "")
1401
+ return { left, right, op };
1402
+ }
1403
+ i++;
1404
+ }
1405
+ }
1406
+ return null;
1407
+ }
1408
+ /** 左操作数**确定不是** null/undefined 时返回 true;判不出返回 null */
1409
+ function notNullish(s) {
1410
+ const lit = litOf(s);
1411
+ if (!lit)
1412
+ return null;
1413
+ return lit.k !== "null";
1414
+ }
1415
+ /**
1416
+ * 顶层三元的三段。要点是"顶层":`a ? b ? c : d : e` 里第一个顶层 `:` 属于内层
1417
+ * 三元,不能拿来切 —— 否则会把 b 误当成整个真值分支。
1418
+ */
1419
+ function topLevelTernary(expr) {
1420
+ let paren = 0;
1421
+ let qDepth = 0;
1422
+ let qPos = -1;
1423
+ let cPos = -1;
1424
+ let quote = "";
1425
+ let i = 0;
1426
+ while (i < expr.length) {
1427
+ const ch = expr[i];
1428
+ if (quote) {
1429
+ if (ch === "\\")
1430
+ i++;
1431
+ else if (ch === quote)
1432
+ quote = "";
1433
+ i++;
1434
+ continue;
1435
+ }
1436
+ if (ch === '"' || ch === "'" || ch === "`") {
1437
+ quote = ch;
1438
+ i++;
1439
+ continue;
1440
+ }
1441
+ if (ch === "(" || ch === "[" || ch === "{")
1442
+ paren++;
1443
+ else if (ch === ")" || ch === "]" || ch === "}")
1444
+ paren--;
1445
+ else if (paren === 0) {
1446
+ if (ch === "?") {
1447
+ if (qDepth === 0)
1448
+ qPos = i;
1449
+ qDepth++;
1450
+ }
1451
+ else if (ch === ":" && qDepth > 0) {
1452
+ if (qDepth === 1) {
1453
+ cPos = i;
1454
+ break;
1455
+ }
1456
+ qDepth--;
1457
+ }
1458
+ }
1459
+ i++;
1460
+ }
1461
+ if (qPos < 0 || cPos < 0)
1462
+ return null;
1463
+ return { cond: expr.slice(0, qPos), a: expr.slice(qPos + 1, cPos), b: expr.slice(cPos + 1) };
1464
+ }
1465
+ /**
1466
+ * 脱掉**包住整串**的括号(`(x)` ⇒ `x`,可能多层)。只脱真的配到串尾的那种 ——
1467
+ * `(a) + (b)` 的首个括号不到底,不会被误脱。
1468
+ */
1469
+ function stripOuterParens(expr) {
1470
+ let cur = expr.trim();
1471
+ for (let guard = 0; guard < 8; guard++) {
1472
+ if (!cur.startsWith("("))
1473
+ break;
1474
+ const close = matchingParen(cur, 0);
1475
+ if (close !== cur.length - 1)
1476
+ break;
1477
+ const inner = cur.slice(1, -1).trim();
1478
+ if (inner === "")
1479
+ break;
1480
+ cur = inner;
1481
+ }
1482
+ return cur;
1483
+ }
1484
+ /** 判得出来就返回 true/false,判不出返回 null */
1485
+ function truthOf(cond) {
1486
+ const s = stripOuterParens(cond);
1487
+ if (/^!\s*\S/.test(s)) {
1488
+ const v = truthOf(s.replace(/^!\s*/, ""));
1489
+ return v === null ? null : !v;
1490
+ }
1491
+ const lit = litOf(s);
1492
+ if (lit) {
1493
+ if (lit.k === "bool")
1494
+ return lit.v === "true";
1495
+ if (lit.k === "null")
1496
+ return false; // null / undefined
1497
+ if (lit.k === "num")
1498
+ return Number(lit.v) !== 0;
1499
+ return lit.v.length > 0; // 字符串
1500
+ }
1501
+ const eq = /^(.+?)\s*(===|!==)\s*(.+)$/.exec(s);
1502
+ if (eq) {
1503
+ const l = litOf(eq[1]);
1504
+ const r = litOf(eq[3]);
1505
+ // 两边都是字面量但类型不同(`"1" === 1`)—— JS 语义下也是 false/true,可判
1506
+ if (l && r) {
1507
+ const same = l.k === r.k && l.v === r.v;
1508
+ return eq[2] === "===" ? same : !same;
1509
+ }
1510
+ }
1511
+ return null;
1512
+ }
1513
+ /** 字面量种类:字符串 / 数字 / 布尔 / null-undefined;不是字面量返回 null */
1514
+ function litOf(s) {
1515
+ const clean = stripOuterParens(s.trim()); // 代入时实参被套了括号:`(true) === true`
1516
+ return litKind(clean);
1517
+ }
1518
+ function litKind(s) {
1519
+ if (/^"(?:\\.|[^"\\])*"$/.test(s) || /^'(?:\\.|[^'\\])*'$/.test(s) || /^`(?:\\.|[^`\\])*`$/.test(s)) {
1520
+ return { k: "str", v: s.slice(1, -1) };
1521
+ }
1522
+ if (/^-?\d+(?:\.\d+)?$/.test(s))
1523
+ return { k: "num", v: s };
1524
+ if (s === "true" || s === "false")
1525
+ return { k: "bool", v: s };
1526
+ if (s === "null" || s === "undefined")
1527
+ return { k: "null", v: s };
1528
+ return null;
1529
+ }
1530
+ /** s[open] 是开括号时,返回与之配对的闭括号下标;不配对返回 -1 */
1531
+ function matchingParen(s, open) {
1532
+ let depth = 0;
1533
+ for (let i = open; i < s.length; i++) {
1534
+ if (s[i] === "(")
1535
+ depth++;
1536
+ else if (s[i] === ")") {
1537
+ depth--;
1538
+ if (depth === 0)
1539
+ return i;
1540
+ }
1541
+ }
1542
+ return -1;
1543
+ }
1544
+ /**
1545
+ * 同名多处的依赖集合并:按**形参位**取并集(OR)—— 任何一个实现在某个位置
1546
+ * 会流出污点,那个位置就得当成会流出(合并到保守侧)。
1547
+ * 形参表不一致时不合并 return 表达式(`rets=[]` ⇒ 调用点折叠自动退回,
1548
+ * 只按并集传播),因为把 A 的形参名代进 B 的 return 是错的。
1549
+ */
1550
+ function mergeShapers(infos) {
1551
+ if (infos.length === 0)
1552
+ return { params: [], deps: new Set(), rets: [], restAt: -1 };
1553
+ const widest = infos.reduce((a, b) => (b.params.length > a.params.length ? b : a));
1554
+ const params = widest.params;
1555
+ const flags = params.map(() => false);
1556
+ let sameShape = true;
1557
+ for (const si of infos) {
1558
+ if (si.params.length !== params.length || si.params.some((p, i) => p !== params[i]))
1559
+ sameShape = false;
1560
+ si.params.forEach((p, i) => { if (i < flags.length && si.deps.has(p))
1561
+ flags[i] = true; });
1562
+ }
1563
+ const rets = sameShape ? infos.flatMap((si) => si.rets) : [];
1564
+ return { params, deps: new Set(params.filter((_, i) => flags[i])), rets, restAt: widest.restAt };
1565
+ }
1566
+ /** return 表达式真正依赖哪些形参:把「已知 helper 调用里不被依赖的实参」抹掉后,
873
1567
  * 剩下的形参名就是会流进返回值的那些。`wrap(n) { return discard(n); }` ⇒ 空集。
874
1568
  */
875
- function returnDeps(exprs, params, info) {
1569
+ function returnDeps(exprs, params, info, memberNames = new Set()) {
876
1570
  const deps = new Set();
877
1571
  for (const raw of exprs) {
878
- const masked = maskNonDependentArgs(raw, info);
1572
+ const masked = maskNonDependentArgs(raw, info, memberNames);
879
1573
  for (const p of params) {
880
1574
  if (new RegExp(`(?:^|[^\\w$.])${escapeRe(p)}(?:[^\\w$]|$)`).test(masked))
881
1575
  deps.add(p);
@@ -899,8 +1593,12 @@ function returnExprsOf(body) {
899
1593
  function pureShaperFunctionNames(project) {
900
1594
  const sinkRe = tsSinkCallRegex();
901
1595
  const cands = [];
1596
+ const takenNames = new Set();
1597
+ const groups = new Map();
1598
+ // 模块级字面量常量(值一并留着:C4i 的计算属性名 `[KEY]` 要靠它解)
1599
+ const constTexts = moduleConstTexts(project);
902
1600
  /** 登记一个候选:三条判据 —— 有形参 / 有返回值 / 体内无 sink */
903
- const consider = (name, paramNodes, declText, bodyNode) => {
1601
+ const consider = (key, name, group, paramNodes, restAt, curry, declText, bodyNode) => {
904
1602
  const paramList = paramNodes.filter(Boolean);
905
1603
  const params = new Set(paramList);
906
1604
  if (params.size === 0)
@@ -911,73 +1609,215 @@ function pureShaperFunctionNames(project) {
911
1609
  // 用 AST 取 return 表达式,而不是正则 —— 模板字面量里的 `${}` 会
912
1610
  // 让「遇到花括号就停」的朴素正则在 `path.join(a,b) + \`.${c}\`` 上截断。
913
1611
  const rets = returnExprsOf(bodyNode);
914
- if (rets.length === 0)
1612
+ // 柯里化:return 出来的是函数 ⇒ 判据①看的是【内层】有没有返回表达式
1613
+ if (rets.length === 0 && curry === null)
915
1614
  return; // ①
916
- if (cands.some((c) => c.name === name))
917
- return; // 同名重复登记:先到先得
918
- cands.push({ name, params, paramList, rets });
1615
+ let pub = name;
1616
+ if (pub !== null) {
1617
+ if (takenNames.has(pub))
1618
+ pub = null; // 同名重复登记:先到先得,后者降到资格凭证
1619
+ else
1620
+ takenNames.add(pub);
1621
+ }
1622
+ cands.push({ key, name: pub, group, params, paramList, rets, restAt, curry });
1623
+ if (group !== null) {
1624
+ const list = groups.get(group) ?? [];
1625
+ list.push(key);
1626
+ groups.set(group, list);
1627
+ }
919
1628
  };
920
1629
  for (const sf of project.getSourceFiles()) {
921
- // (a) 函数声明
922
- for (const fn of sf.getFunctions()) {
1630
+ // (a) 函数声明 —— 顶层 + namespace 内
1631
+ // C4i:旧代码用 sf.getFunctions(),实测取不到 `namespace P { export
1632
+ // function toPath(){} }` 里的函数,整片漏(探针 F1)。改遍历声明节点,
1633
+ // 但**只收顶层 / namespace 链上的**:函数体内嵌套声明的作用域与调用点
1634
+ // 不同,按同一裸名发布会张冠李戴。
1635
+ for (const fn of sf.getDescendantsOfKind(ts.SyntaxKind.FunctionDeclaration)) {
1636
+ const parent = fn.getParent();
1637
+ // 顶层 / namespace 体内两种;函数体内嵌套的声明作用域与调用点不同,不收
1638
+ if (!ts_morph_1.Node.isSourceFile(parent) && !ts_morph_1.Node.isModuleBlock(parent))
1639
+ continue;
923
1640
  const name = fn.getName();
924
1641
  if (!name)
925
1642
  continue;
926
1643
  const body = fn.getBody();
927
1644
  if (!body)
928
1645
  continue; // 重载签名 / declare 只有签名
929
- consider(name, fn.getParameters().map((p) => p.getName()), fn.getText(), body);
1646
+ const ps = fn.getParameters();
1647
+ const paramNodes = ps.flatMap(paramNamesOf);
1648
+ const restAt = restIndexOf(ps);
1649
+ const curry = currySpecOf(paramNodes, body);
1650
+ const owner = moduleQualifier(fn);
1651
+ // namespace 内却认不出宿主链 ⇒ 不收(宁可漏,不可张冠李戴)
1652
+ if (!ts_morph_1.Node.isSourceFile(parent) && owner === null)
1653
+ continue;
1654
+ // namespace 内:`P.toPath(k)` 是主要写法。裸名同时登记是安全的 ——
1655
+ // takenNames 先到先得,一个名字只会落到一处定义上(另一处会降级为资格凭证)。
1656
+ if (owner !== null) {
1657
+ consider(`fn:${owner}.${name}`, `${owner}.${name}`, null, paramNodes, restAt, curry, fn.getText(), body);
1658
+ }
1659
+ consider(`fn:${name}`, name, null, paramNodes, restAt, curry, fn.getText(), body);
930
1660
  }
931
1661
  // (b) 变量承载的箭头 / 函数表达式 —— 现代 TS 里 helper 的主力写法
932
1662
  // `export const toPath = (n: string) => n + ".md";`
933
1663
  // sf.getFunctions() 收不到这些,2026-09-20 实测整片漏。
1664
+ // C4i 再解一层 IIFE(functionLikeOf)。
934
1665
  for (const vd of sf.getVariableDeclarations()) {
935
1666
  const init = vd.getInitializer();
936
1667
  if (!init)
937
1668
  continue;
938
- const isArrow = ts_morph_1.Node.isArrowFunction(init);
939
- const isFnExpr = ts_morph_1.Node.isFunctionExpression(init);
940
- if (!isArrow && !isFnExpr)
1669
+ const fnLike = functionLikeOf(init);
1670
+ if (!fnLike)
941
1671
  continue;
942
- const fnLike = init;
943
1672
  const body = fnLike.getBody();
944
1673
  if (!body)
945
1674
  continue;
946
- consider(vd.getName(), fnLike.getParameters().map((p) => p.getName()), init.getText(), body);
1675
+ const ps = fnLike.getParameters();
1676
+ const paramNodes = ps.flatMap(paramNamesOf);
1677
+ consider(`var:${vd.getName()}`, vd.getName(), null, paramNodes, restIndexOf(ps), currySpecOf(paramNodes, body), init.getText(), // IIFE 时含整条调用 ⇒ sink 检查仍覆盖得到
1678
+ body);
1679
+ }
1680
+ }
1681
+ // ── 成员形态的 helper(对象字面量方法 / 类方法 / 属性箭头 / 属性赋值箭头)──
1682
+ // `const Util = { toPath(n) {…} }`、`class Paths { toPath(n) {…} }`、
1683
+ // `const Util = { toPath: (n) => n + ".md" }`、`Util.toPath = (n) => …`
1684
+ // 都不在 getFunctions() 里。C4e 时刻意不做的理由仍在(R11):按裸名放行会
1685
+ // 让别的对象上的同名条目被误认成塑形。所以这里只登记**限定名** Owner.method;
1686
+ // 裸名(`.method(` 这种成员调用位)留给下面的 C4h-②,按「同名每一处都被
1687
+ // 确证」这条不变量统一放行。
1688
+ let memberSeq = 0;
1689
+ /** 登记一个成员形态的函数体;owner 认得出时按限定名发布 */
1690
+ const considerMember = (bare, owner, paramNodes, restAt, curry, declText, body) => {
1691
+ memberSeq += 1;
1692
+ consider(`mb:${memberSeq}`, owner !== null ? `${owner}.${bare}` : null, bare, paramNodes, restAt, curry, declText, body);
1693
+ };
1694
+ for (const sf of project.getSourceFiles()) {
1695
+ // (c) 对象字面量方法 / 类方法
1696
+ for (const md of sf.getDescendantsOfKind(ts.SyntaxKind.MethodDeclaration)) {
1697
+ const bare = propertyNameOf(md, constTexts);
1698
+ if (!bare || !/^[A-Za-z_$][\w$]*$/.test(bare))
1699
+ continue; // 算不出的属性名 ⇒ 不收
1700
+ const body = md.getBody();
1701
+ if (!body)
1702
+ continue; // 重载签名 / declare
1703
+ const ps = md.getParameters();
1704
+ const pns = ps.flatMap(paramNamesOf);
1705
+ considerMember(bare, ownerQualifier(md, constTexts), pns, restIndexOf(ps), currySpecOf(pns, body), md.getText(), body);
1706
+ }
1707
+ // (d) 属性承载的箭头 / 函数表达式 —— 现代工程里 Objects-as-namespace 的常态。
1708
+ // ownerQualifier 从父链走(对象字面量 → 属性赋值 → 变量声明),嵌套也认。
1709
+ for (const pa of sf.getDescendantsOfKind(ts.SyntaxKind.PropertyAssignment)) {
1710
+ const init = pa.getInitializer();
1711
+ if (!init)
1712
+ continue;
1713
+ if (!ts_morph_1.Node.isArrowFunction(init) && !ts_morph_1.Node.isFunctionExpression(init))
1714
+ continue;
1715
+ const body = init.getBody();
1716
+ if (!body)
1717
+ continue;
1718
+ const bare = propertyNameOf(pa, constTexts);
1719
+ if (!bare || !/^[A-Za-z_$][\w$]*$/.test(bare))
1720
+ continue;
1721
+ const ps = init.getParameters();
1722
+ const pns = ps.flatMap(paramNamesOf);
1723
+ considerMember(bare, ownerQualifier(pa, constTexts), pns, restIndexOf(ps), currySpecOf(pns, body), init.getText(), body);
1724
+ }
1725
+ // (e) 后挂上去的属性箭头 `Util.toPath = (n) => …`(monkey patch / 渐进导出)
1726
+ for (const be of sf.getDescendantsOfKind(ts.SyntaxKind.BinaryExpression)) {
1727
+ if (be.getOperatorToken().getKind() !== ts.SyntaxKind.EqualsToken)
1728
+ continue;
1729
+ const left = be.getLeft();
1730
+ const right = be.getRight();
1731
+ if (!ts_morph_1.Node.isPropertyAccessExpression(left))
1732
+ continue;
1733
+ if (!ts_morph_1.Node.isArrowFunction(right) && !ts_morph_1.Node.isFunctionExpression(right))
1734
+ continue;
1735
+ const body = right.getBody();
1736
+ if (!body)
1737
+ continue;
1738
+ const qname = left.getText();
1739
+ // 只收 `Name.member` 这种纯静态链(`this.x` / `a[b].x` 一律不认)
1740
+ if (!/^[A-Za-z_$][\w$]*(?:\.[\w$]+)+$/.test(qname))
1741
+ continue;
1742
+ const bare = qname.split(".").pop();
1743
+ const ps = right.getParameters();
1744
+ const pns = ps.flatMap(paramNamesOf);
1745
+ considerMember(bare, qname.slice(0, -(bare.length + 1)), pns, restIndexOf(ps), currySpecOf(pns, body), right.getText(), body);
947
1746
  }
948
1747
  }
949
1748
  // 形参之外允许出现的自由标识符 = 模块级字面量常量 ∪ 确证的 path 模块别名
950
- const extraIdents = moduleLiteralConstNames(project);
1749
+ const extraIdents = new Set(constTexts.keys());
951
1750
  for (const a of pathModuleAliases(project))
952
1751
  extraIdents.add(a);
953
1752
  const names = new Set();
954
1753
  const info = new Map();
1754
+ const acceptedKeys = new Set();
1755
+ const keyInfo = new Map();
1756
+ // 给 maskNonDependentArgsOnce 用的成员名候选(**尚未确证**)。这里宽一点是安全
1757
+ // 的:那里的替换只会针对 info 里确证过的名字发生,这个集合只决定「按成员位还是
1758
+ // 按裸名位去匹配」。
1759
+ const prelimMember = new Set(groups.keys());
1760
+ const publish = (name, si) => {
1761
+ if (names.has(name))
1762
+ return;
1763
+ names.add(name);
1764
+ info.set(name, si);
1765
+ };
955
1766
  for (let round = 0; round < 2; round++) {
956
1767
  let added = false;
957
1768
  for (const c of cands) {
958
- if (names.has(c.name))
1769
+ if (acceptedKeys.has(c.key))
959
1770
  continue;
960
- const ok = c.rets.every((e) => isPureShaperExpr(e, c.params, names, extraIdents));
961
- if (!ok)
962
- continue;
963
- names.add(c.name);
964
- info.set(c.name, { params: c.paramList, deps: returnDeps(c.rets, c.paramList, info) });
1771
+ let si;
1772
+ if (c.curry !== null) {
1773
+ // 柯里化:判据落在【内层】的 return 上,形参集合是两层并起来
1774
+ const all = new Set([...c.params, ...c.curry.innerParams]);
1775
+ if (!c.curry.rets.every((e) => isPureShaperExpr(e, all, names, extraIdents)))
1776
+ continue;
1777
+ acceptedKeys.add(c.key);
1778
+ // 外层名照发:闭包会捕获外层实参(`const g = withExt(k)` 也是污的)。
1779
+ // rets 留空 ⇒ 调用点折叠自动退回「全部依赖」(保守侧)。
1780
+ si = { params: c.paramList, deps: new Set(c.paramList), rets: [], restAt: c.restAt };
1781
+ }
1782
+ else {
1783
+ if (!c.rets.every((e) => isPureShaperExpr(e, c.params, names, extraIdents)))
1784
+ continue;
1785
+ acceptedKeys.add(c.key);
1786
+ si = {
1787
+ params: c.paramList,
1788
+ deps: returnDeps(c.rets, c.paramList, info, prelimMember),
1789
+ rets: c.rets,
1790
+ restAt: c.restAt,
1791
+ };
1792
+ }
1793
+ keyInfo.set(c.key, si);
1794
+ if (c.name !== null)
1795
+ publish(c.name, si);
965
1796
  added = true;
966
1797
  }
967
1798
  if (!added)
968
1799
  break;
969
1800
  }
1801
+ // C4i:柯里化名录。只有**发布了名字**的才登记 —— 名字被别处同名定义占用时
1802
+ // (pub 降为 null),`withExt(a)(b)` 里的 withExt 可能指的不是这个 helper,
1803
+ // 展开它会张冠李戴 ⇒ 宁可不展(保守侧,退化为漏报而非误报)。
1804
+ const curried = new Map();
1805
+ for (const c of cands) {
1806
+ if (c.curry !== null && c.name !== null && acceptedKeys.has(c.key)) {
1807
+ curried.set(c.name, c.curry);
1808
+ }
1809
+ }
970
1810
  // C4f:依赖分析有次序依赖 —— `wrap(n) { return discard(n); }` 若在 discard 之前
971
1811
  // 被接受,那一刻 info 里还没有 discard ⇒ deps 偏宽(偏宽 = 继续传播 = 保守侧,
972
1812
  // 不会误报,但收不紧)。名录定稿后按完整的 info 再算两轮,把次序依赖消掉。
973
- const byName = new Map(cands.map((c) => [c.name, c]));
1813
+ const candByKey = new Map(cands.map((c) => [c.key, c]));
974
1814
  for (let round = 0; round < 2; round++) {
975
1815
  let changed = false;
976
- for (const [name, si] of info) {
977
- const c = byName.get(name);
1816
+ for (const [key, si] of keyInfo) {
1817
+ const c = candByKey.get(key);
978
1818
  if (!c)
979
1819
  continue;
980
- const d = returnDeps(c.rets, si.params, info);
1820
+ const d = returnDeps(c.rets, si.params, info, prelimMember);
981
1821
  if (d.size !== si.deps.size || [...d].some((x) => !si.deps.has(x))) {
982
1822
  si.deps = d;
983
1823
  changed = true;
@@ -986,7 +1826,26 @@ function pureShaperFunctionNames(project) {
986
1826
  if (!changed)
987
1827
  break;
988
1828
  }
989
- return { names, info };
1829
+ // ── C4h-②(2026-09-20):同名【成员】多处,只要**每一处**都被确证为纯塑形 ──
1830
+ // 旧规则(C4g)是「该方法名全项目唯一才登记裸名」。唯一性只是【不可能认错】这个
1831
+ // 不变量的一个充分条件,不是必要条件:同名两处都被确证了,`.toPath(` 这个成员
1832
+ // 调用位同样不可能落到别的实现上。把"唯一"换成"全部确证",两个类各有一个同名
1833
+ // helper 时两边都能接上;依赖集按形参位取并集(保守侧)。
1834
+ //
1835
+ // 顺带修掉一处真实缺陷:旧代码在 consider() 之后【无条件】把裸名塞进
1836
+ // memberNames,于是「方法名唯一但三条判据不成立」的方法,其成员调用位照样被
1837
+ // 当成塑形 ⇒ 误标。探针实测(2026-09-20):
1838
+ // `class Reg { lookup(n) { return registry[n]; } }`(registry 是模块级非字面量
1839
+ // 对象 ⇒ 判据③不成立)之后写 `r.lookup(k)`,照样MARKED。
1840
+ // 现在改成确证之后才登记,这个口子自然封上。
1841
+ const memberNames = new Set();
1842
+ for (const [bare, keys] of groups) {
1843
+ if (!keys.every((k) => acceptedKeys.has(k)))
1844
+ continue;
1845
+ memberNames.add(bare);
1846
+ publish(bare, mergeShapers(keys.map((k) => keyInfo.get(k)).filter(Boolean)));
1847
+ }
1848
+ return { names, memberNames, info, curried };
990
1849
  }
991
1850
  const TS_FILE_SINK_NAMES = [
992
1851
  "readFile", "readFileSync", "writeFile", "writeFileSync",
@@ -1213,8 +2072,13 @@ function taintedViaShaper(text, tainted, shapers) {
1213
2072
  const localShaperRe = shapers && shapers.names.size > 0
1214
2073
  ? new RegExp(`(?:^|[^\\w$.])(?:${[...shapers.names].map(escapeRe).join("|")})\\s*\\(`)
1215
2074
  : null;
2075
+ // C4g:成员调用位(`.toPath(`)—— 宿主是变量、限定名匹配不到时用
2076
+ const memberShaperRe = shapers && shapers.memberNames.size > 0
2077
+ ? new RegExp(`\\.(?:${[...shapers.memberNames].map(escapeRe).join("|")})\\s*\\(`)
2078
+ : null;
1216
2079
  // C4f:helper 调用里【返回值不依赖的实参】不构成污点证据。
1217
2080
  const shaperInfo = shapers?.info;
2081
+ const memberNames = shapers?.memberNames;
1218
2082
  let grewAny = false;
1219
2083
  for (let depth = 0; depth < 3; depth++) {
1220
2084
  // 注意:这里**不能**以 `tainted.size === 0` 提前退出 —— 种子是根模式
@@ -1233,13 +2097,19 @@ function taintedViaShaper(text, tainted, shapers) {
1233
2097
  const rhs = m[2];
1234
2098
  if (tainted.has(name))
1235
2099
  continue;
1236
- const shaped = TS_PATH_SHAPER_RE.test(rhs) ||
1237
- TS_CONCAT_RE.test(rhs) ||
1238
- (localShaperRe !== null && localShaperRe.test(rhs));
2100
+ // C4i:柯里化调用先在证据里摊开 —— `withExt(".md")(k)` ⇒ `k + ".md"`,
2101
+ // 摊开后的串自己就是拼接,shaped 也顺理成章成立。
2102
+ const expanded = shapers ? expandCurried(rhs, shapers.curried) : rhs;
2103
+ const shaped = TS_PATH_SHAPER_RE.test(expanded) ||
2104
+ TS_CONCAT_RE.test(expanded) ||
2105
+ (localShaperRe !== null && localShaperRe.test(rhs)) ||
2106
+ (memberShaperRe !== null && memberShaperRe.test(rhs));
1239
2107
  // 证据看的是【抹掉不被依赖的实参之后】的 rhs:`discard(k)` 里 k 不流出,
1240
2108
  // 那条支路就不该算证据。`shaped` 仍看原式 —— helper 是不是塑形,与它的
1241
2109
  // 返回值依不依赖实参是两件事。
1242
- const evidence = shaperInfo ? maskNonDependentArgs(rhs, shaperInfo) : rhs;
2110
+ const evidence = shaperInfo && memberNames
2111
+ ? maskNonDependentArgs(expanded, shaperInfo, memberNames)
2112
+ : expanded;
1243
2113
  if (taintRe.test(evidence) && shaped) {
1244
2114
  tainted.add(name);
1245
2115
  added = true;