progmune-runtime 3.7.43 → 3.7.46

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.
@@ -669,6 +669,21 @@ const VALUE_STRING_METHODS = new Set([
669
669
  const SHAPER_ALLOWED_FREE_IDENTS = new Set([
670
670
  "path", "sep", "undefined", "null", "true", "false", "String", "Number",
671
671
  ]);
672
+ /**
673
+ * C4j:**运算符关键字**——本身不携带数据,出现在 return 表达式里不该让整条 helper 落选。
674
+ *
675
+ * 缺陷实证(2026-09-21 探针 G1/G2):`(n, m) => typeof m === "string" || n + ".md"`
676
+ * 整条判据不通过 —— 单趟标识符扫描把 `typeof` 当成"未知的自由标识符"给否了。
677
+ * 后果不是"少收一点精度",而是**整个 helper 不进名录** ⇒ 调用点拿不到任何证据 ⇒ 漏报。
678
+ * 差别只在 typeof:G2 同形去掉 typeof 后 MARKED,G1 不标。
679
+ *
680
+ * `this` / `super` 刻意**不放行**:它们是接收者,可能携带数据(`this.prefix + n`),
681
+ * 属于另一个话题(要不要把接收者纳入证据),不能靠"关键字"这条口子捎带进来。
682
+ */
683
+ const SHAPER_ALLOWED_KEYWORDS = new Set([
684
+ "typeof", "void", "delete", "in", "instanceof", "keyof", "as", "satisfies",
685
+ "is", "asserts", "readonly", "infer", "new", "await", "yield", "of",
686
+ ]);
672
687
  /** 允许出现在调用位的值转换函数(不改变路径语义) */
673
688
  const VALUE_CONVERTER_CALLS = new Set(["String", "Number", "Boolean"]);
674
689
  /**
@@ -713,15 +728,24 @@ function isPureShaperExpr(expr, params, shapers, extraIdents) {
713
728
  // 自由标识符:必须是自己的形参、语言级常量白名单,或**已确证的字面量常量 / path 别名**
714
729
  if (params.has(n) || SHAPER_ALLOWED_FREE_IDENTS.has(n))
715
730
  continue;
731
+ if (SHAPER_ALLOWED_KEYWORDS.has(n))
732
+ continue; // C4j:运算符关键字不携带数据
716
733
  if (extraIdents && extraIdents.has(n))
717
734
  continue;
718
735
  return false;
719
736
  }
720
737
  return true;
721
738
  }
722
- /** 收集模块级(top-level)**字面量**常量名 —— helper 的 return 引用它们仍是纯塑形 */
723
- function moduleLiteralConstNames(project) {
724
- const out = new Set();
739
+ /**
740
+ * 收集模块级(top-level)**字面量**常量:名字 → 字面量文本(引号已脱)。
741
+ *
742
+ * 两个用途:
743
+ * ① helper 的 return 引用它们仍是纯塑形(C4e);
744
+ * ② C4i:`{ [KEY]: (n) => … }` 这种计算属性名,只有 KEY 是字面量常量才解得
745
+ * 出来 —— 解不出一律不登记(与「看不见就不传播」同一条政策)。
746
+ */
747
+ function moduleConstTexts(project) {
748
+ const out = new Map();
725
749
  for (const sf of project.getSourceFiles()) {
726
750
  for (const vd of sf.getVariableDeclarations()) {
727
751
  // 只要模块作用域:VariableStatement 的父节点必须是 SourceFile
@@ -739,7 +763,7 @@ function moduleLiteralConstNames(project) {
739
763
  init.getKind() === ts.SyntaxKind.TrueKeyword ||
740
764
  init.getKind() === ts.SyntaxKind.FalseKeyword;
741
765
  if (isLiteral)
742
- out.add(vd.getName());
766
+ out.set(vd.getName(), init.getText().replace(/^["'`]|["'`]$/g, ""));
743
767
  }
744
768
  }
745
769
  return out;
@@ -788,12 +812,32 @@ function paramNamesOf(p) {
788
812
  }
789
813
  return out;
790
814
  }
815
+ /**
816
+ * 属性名:普通标识符 / 字符串字面量直接取;**计算属性名** `[KEY]` 只有在 KEY 是
817
+ * 模块级字面量常量时才解(C4i)。解不出返回 null —— 认不出的一律不登记。
818
+ */
819
+ function propertyNameOf(pa, constTexts) {
820
+ const nn = pa.getNameNode();
821
+ if (ts_morph_1.Node.isIdentifier(nn))
822
+ return nn.getText();
823
+ if (ts_morph_1.Node.isStringLiteral(nn) || ts_morph_1.Node.isNoSubstitutionTemplateLiteral(nn)) {
824
+ return nn.getText().replace(/^["'`]|["'`]$/g, "");
825
+ }
826
+ if (ts_morph_1.Node.isComputedPropertyName(nn)) {
827
+ const e = nn.getExpression();
828
+ // `const KEY = "toPath"; … { [KEY]: (n) => … }` —— KEY 的值必须看得见;
829
+ // `[key()]` 这种算不出来的,宁可整个不收(探针 K2 守的就是这条)。
830
+ if (ts_morph_1.Node.isIdentifier(e))
831
+ return constTexts.get(e.getText()) ?? null;
832
+ }
833
+ return null;
834
+ }
791
835
  /**
792
836
  * 方法声明的宿主限定名:`const Util = { toPath(){} }` ⇒ "Util"、
793
837
  * `const Cfg = { inner: { toPath(){} } }` ⇒ "Cfg.inner"、`class Paths {…}` ⇒ "Paths"。
794
838
  * 取不出来就返回 null —— 认不出宿主的宁可不登记(保守侧)。
795
839
  */
796
- function ownerQualifier(md) {
840
+ function ownerQualifier(md, constTexts = new Map()) {
797
841
  const parent = md.getParent();
798
842
  if (!parent)
799
843
  return null;
@@ -808,10 +852,9 @@ function ownerQualifier(md) {
808
852
  if (!up)
809
853
  return null;
810
854
  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, "");
855
+ const keyName = propertyNameOf(up, constTexts);
856
+ if (keyName === null)
857
+ return null;
815
858
  parts.unshift(keyName);
816
859
  cur = up.getParent();
817
860
  if (!cur)
@@ -826,6 +869,107 @@ function ownerQualifier(md) {
826
869
  }
827
870
  return null;
828
871
  }
872
+ /**
873
+ * C4i:函数声明的命名空间限定名 —— `namespace P { export function toPath(){} }` ⇒ "P"
874
+ * (嵌套 namespace 拼成 "A.B")。只认**纯 namespace 链**:函数套函数那种 helper
875
+ * 作用域与调用点不同,按同一名字发布会张冠李戴,一律返回 null 交给调用方跳过。
876
+ */
877
+ function moduleQualifier(fn) {
878
+ const parts = [];
879
+ let cur = fn;
880
+ for (let guard = 0; guard < 6; guard++) {
881
+ const up = cur.getParent();
882
+ if (!up)
883
+ return null;
884
+ // 注意(实测):`namespace P { export function f(){} }` 里函数的父节点是
885
+ // **ModuleBlock**(namespace 的体),ModuleDeclaration 在它的上一层。
886
+ if (ts_morph_1.Node.isModuleBlock(up)) {
887
+ const decl = up.getParent();
888
+ if (!decl || !ts_morph_1.Node.isModuleDeclaration(decl))
889
+ return null;
890
+ const n = decl.getName();
891
+ if (!n)
892
+ return null;
893
+ parts.unshift(n);
894
+ cur = decl;
895
+ continue;
896
+ }
897
+ if (ts_morph_1.Node.isModuleDeclaration(up)) {
898
+ const n = up.getName();
899
+ if (!n)
900
+ return null;
901
+ parts.unshift(n);
902
+ cur = up;
903
+ continue;
904
+ }
905
+ if (ts_morph_1.Node.isSourceFile(up))
906
+ return parts.length > 0 ? parts.join(".") : null;
907
+ return null; // 函数体内的嵌套声明:作用域不同,不收
908
+ }
909
+ return null;
910
+ }
911
+ /**
912
+ * 剥掉包在**函数体外面的**那几层壳,露出真正的箭头 / 函数表达式:
913
+ * 括号 `((n) => …)`、类型断言 `((n) => …) as Fn`、`satisfies Fn`、`<Fn>(…)`。
914
+ *
915
+ * C4j 补后三种。实测(探针 F3b/F3c):`export const toPath = ((n) => n + ".md")
916
+ * satisfies Fn` 与 `{ toPath: ((n) => n + ".md") as any }` 都不传播 ——
917
+ * 旧代码只认「初值就是箭头」,断言把它们包了一层,整族漏。
918
+ */
919
+ function unwrapFns(n) {
920
+ let cur = n;
921
+ for (let i = 0; i < 6 && cur; i++) {
922
+ if (ts_morph_1.Node.isParenthesizedExpression(cur)) {
923
+ cur = cur.getExpression();
924
+ continue;
925
+ }
926
+ if (ts_morph_1.Node.isAsExpression(cur)) {
927
+ cur = cur.getExpression();
928
+ continue;
929
+ }
930
+ if (ts_morph_1.Node.isSatisfiesExpression(cur)) {
931
+ cur = cur.getExpression();
932
+ continue;
933
+ }
934
+ if (ts_morph_1.Node.isTypeAssertion(cur)) {
935
+ cur = cur.getExpression();
936
+ continue;
937
+ }
938
+ break;
939
+ }
940
+ return cur;
941
+ }
942
+ /**
943
+ * C4i:变量初值承载的 helper 体。除箭头 / 函数表达式外,再解一层 IIFE ——
944
+ * `const toPath = (() => (n) => n + ".md")();` 的初值是**调用**不是箭头,
945
+ * 真正的 helper 是被 IIFE 返回出来的那个内层箭头(旧代码整条族漏)。
946
+ */
947
+ function functionLikeOf(init) {
948
+ // 实测:`(() => (n) => n + ".md")()` 里被括号包住的**不止**内层箭头 ——
949
+ // 连被调用的 callee 都是 ParenthesizedExpression。两处都得脱。
950
+ const base = unwrapFns(init);
951
+ if (!base)
952
+ return null;
953
+ if (ts_morph_1.Node.isArrowFunction(base) || ts_morph_1.Node.isFunctionExpression(base))
954
+ return base;
955
+ if (ts_morph_1.Node.isCallExpression(base)) {
956
+ const callee = unwrapFns(base.getExpression());
957
+ if (!ts_morph_1.Node.isArrowFunction(callee) && !ts_morph_1.Node.isFunctionExpression(callee))
958
+ return null;
959
+ const b = callee.getBody();
960
+ if (!ts_morph_1.Node.isBlock(b)) {
961
+ // 简洁体:`(() => (n) => n + ".md")()` —— 括号包着也算(unparen)
962
+ const e = unwrapFns(b);
963
+ return e && (ts_morph_1.Node.isArrowFunction(e) || ts_morph_1.Node.isFunctionExpression(e)) ? e : null;
964
+ }
965
+ for (const rs of b.getDescendantsOfKind(ts.SyntaxKind.ReturnStatement)) {
966
+ const e = unwrapFns(rs.getExpression());
967
+ if (e && (ts_morph_1.Node.isArrowFunction(e) || ts_morph_1.Node.isFunctionExpression(e)))
968
+ return e;
969
+ }
970
+ }
971
+ return null;
972
+ }
829
973
  /** 按顶层逗号切分实参串(跳过字符串与已配平的括号,后者由调用方保证不含嵌套) */
830
974
  function splitTopLevelArgs(argText) {
831
975
  const out = [];
@@ -860,6 +1004,223 @@ function splitTopLevelArgs(argText) {
860
1004
  out.push(cur);
861
1005
  return out;
862
1006
  }
1007
+ /**
1008
+ * C4i:形参表里 rest 形参(`...parts`)的位置;没有返回 -1。
1009
+ * rest 形参对应的是【一批】实参,不是一位 —— 位置对齐必须让它吃掉末尾所有位。
1010
+ */
1011
+ function restIndexOf(ps) {
1012
+ return ps.findIndex((p) => p.isRestParameter());
1013
+ }
1014
+ /** 实参位 ⇒ 形参位(rest 形参吃掉从它开始的剩余位) */
1015
+ function paramIndexOf(si, argIndex) {
1016
+ return si.restAt >= 0 && argIndex >= si.restAt ? si.restAt : argIndex;
1017
+ }
1018
+ /**
1019
+ * C4i/C4j:柯里化 helper —— 函数体【返回】另一个函数
1020
+ * (`(ext) => (n) => n + ext`、`(a) => (b) => (n) => n + a + b`)。
1021
+ * 被调用的不是最外层,而是它一路返回出来的最内层;真正塑形在那里,
1022
+ * 外层只负责捕获自己那一批实参。返回的不是函数(含返回对象字面量的工厂形态)
1023
+ * 一律返回 null,交给 currySpecsOf 的另一条路。
1024
+ */
1025
+ function curryChainOf(outerParams, body) {
1026
+ const ret = returnedValueOf(body);
1027
+ if (!ret)
1028
+ return null;
1029
+ if (!ts_morph_1.Node.isArrowFunction(ret) && !ts_morph_1.Node.isFunctionExpression(ret))
1030
+ return null;
1031
+ const hops = [];
1032
+ let cur = ret;
1033
+ for (let depth = 0; depth < 4; depth++) {
1034
+ if (!cur)
1035
+ break;
1036
+ if (!ts_morph_1.Node.isArrowFunction(cur) && !ts_morph_1.Node.isFunctionExpression(cur))
1037
+ return null;
1038
+ const fbody = cur.getBody();
1039
+ if (!fbody)
1040
+ return null;
1041
+ const params = cur.getParameters().flatMap(paramNamesOf).filter(Boolean);
1042
+ hops.push({ kind: "call", name: "", params });
1043
+ const next = returnedFunctionOf(fbody);
1044
+ if (!next) {
1045
+ const rets = returnExprsOf(fbody);
1046
+ if (rets.length === 0)
1047
+ return null;
1048
+ // 内层无形参 ⇒ 没有「经内层实参进来」的污点,外层按普通 helper 处理
1049
+ if (hops[hops.length - 1].params.length === 0)
1050
+ return null;
1051
+ const allParams = new Set(outerParams);
1052
+ for (const h of hops)
1053
+ for (const p of h.params)
1054
+ allParams.add(p);
1055
+ return { outerParams, hops, allParams: [...allParams], rets };
1056
+ }
1057
+ cur = next;
1058
+ }
1059
+ return null;
1060
+ }
1061
+ /**
1062
+ * C4j:一个函数体对应的全部调用链。
1063
+ * - 「返回函数」形态 ⇒ 0 或 1 条(沿返回值爬到最内层);
1064
+ * - 「返回对象字面量」形态 ⇒ 每个**函数属性**各一条(工厂 / builder 的常态,
1065
+ * `factory(".md").toPath(k)` 里的 `toPath` 才是塑形那一层)。
1066
+ * 属性名算不出(计算属性名而常量不可见)或无形参 / 无返回的,跳过该条。
1067
+ */
1068
+ function currySpecsOf(outerParams, body) {
1069
+ const ret = returnedValueOf(body);
1070
+ if (!ret)
1071
+ return [];
1072
+ if (!ts_morph_1.Node.isObjectLiteralExpression(ret)) {
1073
+ const one = curryChainOf(outerParams, body);
1074
+ return one ? [one] : [];
1075
+ }
1076
+ const specs = [];
1077
+ for (const prop of ret.getProperties()) {
1078
+ let name = null;
1079
+ let fn;
1080
+ if (ts_morph_1.Node.isPropertyAssignment(prop)) {
1081
+ name = prop.getName().replace(/^["'`]|["'`]$/g, "");
1082
+ const inner = unwrapFns(prop.getInitializer());
1083
+ if (inner && (ts_morph_1.Node.isArrowFunction(inner) || ts_morph_1.Node.isFunctionExpression(inner))) {
1084
+ fn = inner;
1085
+ }
1086
+ }
1087
+ else if (ts_morph_1.Node.isMethodDeclaration(prop)) {
1088
+ name = prop.getName();
1089
+ fn = prop;
1090
+ }
1091
+ if (!fn || !name || !/^[A-Za-z_$][\w$]*$/.test(name))
1092
+ continue;
1093
+ const fbody = fn.getBody();
1094
+ if (!fbody)
1095
+ continue; // 重载签名 / declare
1096
+ const params = fn.getParameters().flatMap(paramNamesOf).filter(Boolean);
1097
+ if (params.length === 0)
1098
+ continue; // 判据①
1099
+ const rets = returnExprsOf(fbody);
1100
+ if (rets.length === 0)
1101
+ continue;
1102
+ specs.push({
1103
+ outerParams,
1104
+ hops: [{ kind: "member", name, params }],
1105
+ allParams: [...outerParams, ...params],
1106
+ rets,
1107
+ });
1108
+ }
1109
+ return specs;
1110
+ }
1111
+ /** 函数体返回出来的那个值(简洁体本身 / return 语句的表达式,都先解断言与括号) */
1112
+ function returnedValueOf(body) {
1113
+ if (!ts_morph_1.Node.isBlock(body))
1114
+ return unwrapFns(body);
1115
+ for (const rs of body.getDescendantsOfKind(ts.SyntaxKind.ReturnStatement)) {
1116
+ const e = unwrapFns(rs.getExpression());
1117
+ if (e)
1118
+ return e;
1119
+ }
1120
+ return undefined;
1121
+ }
1122
+ /** 函数体返回出来的那个箭头 / 函数表达式(没有则 null) */
1123
+ function returnedFunctionOf(body) {
1124
+ if (!ts_morph_1.Node.isBlock(body)) {
1125
+ // 箭头简洁体本身就是要返回的函数:`(ext) => (n) => n + ext`(可能被括号包住)
1126
+ const e = unwrapFns(body);
1127
+ return e && (ts_morph_1.Node.isArrowFunction(e) || ts_morph_1.Node.isFunctionExpression(e)) ? e : null;
1128
+ }
1129
+ for (const rs of body.getDescendantsOfKind(ts.SyntaxKind.ReturnStatement)) {
1130
+ const e = unwrapFns(rs.getExpression());
1131
+ if (e && (ts_morph_1.Node.isArrowFunction(e) || ts_morph_1.Node.isFunctionExpression(e)))
1132
+ return e;
1133
+ }
1134
+ return null;
1135
+ }
1136
+ /**
1137
+ * C4i/C4j:柯里化与工厂形态的调用点展开。`withExt(".md")(k)`、`lvl4(a)(b)(c)(k)`、
1138
+ * `factory(".md").toPath(k)` 里真正塑形的是链的**最后一跳** —— 这里按各跳的实参把
1139
+ * 最内层 return 表达式代入后原地摊开,之后的污点检测直接看摊开后的串:
1140
+ * `withExt(".md")(k)` ⇒ `k + ".md"`;`factory(".md").toPath(k)` ⇒ `k + ".md"`。
1141
+ * 丢形参的最后一跳(`(_n) => "fixed.md"`)摊开后是常量 ⇒ 自然不传播。
1142
+ */
1143
+ function expandCurried(text, curried) {
1144
+ if (curried.size === 0)
1145
+ return text;
1146
+ let cur = text;
1147
+ for (let round = 0; round < 4; round++) {
1148
+ const next = expandCurriedOnce(cur, curried);
1149
+ if (next === null)
1150
+ break;
1151
+ cur = next;
1152
+ }
1153
+ return cur;
1154
+ }
1155
+ /** 摊开**一处**柯里化 / 工厂形态调用;没有可摊的返回 null(供调用方收敛) */
1156
+ function expandCurriedOnce(text, curried) {
1157
+ for (const [name, specs] of curried) {
1158
+ const re = new RegExp(`(?:^|[^\\w$.])${escapeRe(name)}\\s*\\(`, "g");
1159
+ let m;
1160
+ while ((m = re.exec(text)) !== null) {
1161
+ const open1 = m.index + m[0].length - 1;
1162
+ const close1 = matchingParen(text, open1);
1163
+ if (close1 < 0)
1164
+ continue;
1165
+ const args0 = splitTopLevelArgs(text.slice(open1 + 1, close1));
1166
+ for (const cs of specs) {
1167
+ const map = new Map();
1168
+ args0.forEach((a, i) => {
1169
+ const p = cs.outerParams[i];
1170
+ if (p !== undefined)
1171
+ map.set(p, stripOuterParens(a.trim()));
1172
+ });
1173
+ // 沿链一路吃掉各跳:普通跳是紧跟的 `(`,成员跳是 `.name(`
1174
+ let cursor = close1 + 1;
1175
+ let ok = true;
1176
+ for (const hop of cs.hops) {
1177
+ let j = cursor;
1178
+ while (j < text.length && /\s/.test(text[j]))
1179
+ j++;
1180
+ if (hop.kind === "member") {
1181
+ if (text[j] === "?")
1182
+ j++; // 可选链 `?.toPath(`
1183
+ if (text[j] !== ".") {
1184
+ ok = false;
1185
+ break;
1186
+ }
1187
+ j++;
1188
+ const nm = /^[A-Za-z_$][\w$]*/.exec(text.slice(j));
1189
+ if (!nm || nm[0] !== hop.name) {
1190
+ ok = false;
1191
+ break;
1192
+ }
1193
+ j += nm[0].length;
1194
+ while (j < text.length && /\s/.test(text[j]))
1195
+ j++;
1196
+ }
1197
+ if (text[j] !== "(") {
1198
+ ok = false;
1199
+ break;
1200
+ }
1201
+ const cl = matchingParen(text, j);
1202
+ if (cl < 0) {
1203
+ ok = false;
1204
+ break;
1205
+ }
1206
+ splitTopLevelArgs(text.slice(j + 1, cl)).forEach((a, i) => {
1207
+ const p = hop.params[i];
1208
+ if (p !== undefined && !map.has(p))
1209
+ map.set(p, stripOuterParens(a.trim()));
1210
+ });
1211
+ cursor = cl + 1;
1212
+ }
1213
+ if (!ok)
1214
+ continue;
1215
+ const inner = cs.rets.length === 1 ? cs.rets[0] : cs.rets.join(" + ");
1216
+ const expanded = foldDecidableTernary(substituteParams(inner, map));
1217
+ const nameStart = m.index + (m[0].startsWith(name) ? 0 : 1);
1218
+ return text.slice(0, nameStart) + expanded + text.slice(cursor);
1219
+ }
1220
+ }
1221
+ }
1222
+ return null;
1223
+ }
863
1224
  /**
864
1225
  * C4f:把 helper 调用里【返回值不依赖的那些实参】抹掉。
865
1226
  *
@@ -916,7 +1277,13 @@ function maskNonDependentArgsOnce(text, info, memberNames) {
916
1277
  if (/[([{]/.test(argText))
917
1278
  continue; // 还嵌着别的调用 —— 下一轮再处理
918
1279
  const args = splitTopLevelArgs(argText);
919
- const kept = args.filter((_, i) => si.params[i] !== undefined && si.deps.has(si.params[i]));
1280
+ // C4h-①:按【本次调用】重算一下哪些位置会流出去 —— 名录里的 deps 是对所有
1281
+ // 实参形状都成立的最宽结论,本次若喂了常量,可能一个都流不出来。
1282
+ const flags = callSiteKeepFlags(si, args, info, memberNames);
1283
+ const kept = args.filter((_, i) => {
1284
+ const pi = paramIndexOf(si, i); // C4i:rest 形参吃掉剩余位
1285
+ return si.params[pi] !== undefined && flags[pi] === true;
1286
+ });
920
1287
  if (kept.length === args.length)
921
1288
  continue; // 每个形参都被依赖 ⇒ 无需抹
922
1289
  const replacement = kept.length > 0 ? `(${kept.join(" + ")})` : `""`;
@@ -926,7 +1293,431 @@ function maskNonDependentArgsOnce(text, info, memberNames) {
926
1293
  return null;
927
1294
  }
928
1295
  /**
929
- * return 表达式真正依赖哪些形参:把「已知 helper 调用里不被依赖的实参」抹掉后,
1296
+ * C4h-①(2026-09-20):调用点专用的「哪些实参会流到返回值里」。
1297
+ *
1298
+ * 名录里的 deps 是**跨调用点**的并集 —— 对所有实参形状都成立的最宽结论。
1299
+ * 但本次调用喂的可能是常量:
1300
+ *
1301
+ * viaFlag(n, flag) { return flag ? "fixed.md" : n + ".md"; }
1302
+ * viaFlag(k, true) ⇒ 返回值就是字面量 —— n 位虽然进了函数也出不来。
1303
+ *
1304
+ * 于是按位置把**字面量实参**代进 return 表达式,试着解掉 cond 可判定的三元,
1305
+ * 再看还有哪些形参活着。两种情形一律退回名录的 deps(保守侧,宁可不收精度):
1306
+ * - 实参一个常量都没有;
1307
+ * - cond 代入后判不出真假 —— 认不出就不收精度。
1308
+ */
1309
+ function callSiteKeepFlags(si, args, info, memberNames) {
1310
+ const fallback = si.params.map((p) => si.deps.has(p));
1311
+ if (si.rets.length === 0)
1312
+ return fallback;
1313
+ const litArgs = new Map();
1314
+ args.forEach((a, i) => {
1315
+ // C4i:rest 形参拿到的是【一批】实参,不等于任意一个 —— 不代,否则
1316
+ // `parts.join("/")` 会被代成 `"out".join("/"` 从而误判「不依赖 parts」
1317
+ if (si.restAt >= 0 && i >= si.restAt)
1318
+ return;
1319
+ const p = si.params[i];
1320
+ if (p === undefined)
1321
+ return;
1322
+ const t = stripOuterParens(a.trim());
1323
+ // 只代【字面量】—— 变量名进来不等于知道它的值
1324
+ if (litKind(t) !== null)
1325
+ litArgs.set(p, t);
1326
+ });
1327
+ if (litArgs.size === 0)
1328
+ return fallback;
1329
+ const subbed = si.rets.map((r) => foldDecidableTernary(substituteParams(r, litArgs)));
1330
+ const remaining = si.params.filter((p) => p !== undefined && !litArgs.has(p));
1331
+ // 嵌套 helper 的支路仍由 returnDeps 负责抹
1332
+ const deps = returnDeps(subbed, remaining, info, memberNames);
1333
+ return si.params.map((p) => deps.has(p));
1334
+ }
1335
+ /** 把形参名替换成实参文本(跳字符串与模板字面量内部 —— 判不出就保守地留着形参) */
1336
+ function substituteParams(expr, litArgs) {
1337
+ let out = "";
1338
+ let quote = "";
1339
+ let i = 0;
1340
+ while (i < expr.length) {
1341
+ const ch = expr[i];
1342
+ if (quote) {
1343
+ out += ch;
1344
+ if (ch === "\\") {
1345
+ out += expr[i + 1] ?? "";
1346
+ i += 2;
1347
+ continue;
1348
+ }
1349
+ if (ch === quote)
1350
+ quote = "";
1351
+ i++;
1352
+ continue;
1353
+ }
1354
+ if (ch === '"' || ch === "'") {
1355
+ quote = ch;
1356
+ out += ch;
1357
+ i++;
1358
+ continue;
1359
+ }
1360
+ // C4i:模板串不能像普通字符串那样整段跳过 —— `${flag ? "fixed" : n}` 里的
1361
+ // flag 是要代的。字面量部分照抄,只对 `${}` 内部递归代入。
1362
+ if (ch === "`") {
1363
+ out += ch;
1364
+ i++;
1365
+ while (i < expr.length) {
1366
+ const c = expr[i];
1367
+ if (c === "`")
1368
+ break; // 模板串内不可能有裸反引号(转义的已被下面处理)
1369
+ if (c === "\\") {
1370
+ out += expr.slice(i, i + 2);
1371
+ i += 2;
1372
+ continue;
1373
+ }
1374
+ if (c === "$" && expr[i + 1] === "{") {
1375
+ let k = i + 2;
1376
+ let brace = 0;
1377
+ let inner = "";
1378
+ while (k < expr.length) {
1379
+ const d = expr[k];
1380
+ if (d === "{")
1381
+ brace++;
1382
+ else if (d === "}") {
1383
+ if (brace === 0)
1384
+ break;
1385
+ brace--;
1386
+ }
1387
+ inner += d;
1388
+ k++;
1389
+ }
1390
+ out += "${" + substituteParams(inner, litArgs) + "}";
1391
+ i = k + 1;
1392
+ continue;
1393
+ }
1394
+ out += c;
1395
+ i++;
1396
+ }
1397
+ if (i < expr.length && expr[i] === "`") {
1398
+ out += "`";
1399
+ i++;
1400
+ }
1401
+ continue;
1402
+ }
1403
+ const m = /^[A-Za-z_$][\w$]*/.exec(expr.slice(i));
1404
+ if (m && expr[i - 1] !== "." && litArgs.has(m[0])) {
1405
+ // 不加括号:代进去的永远是【原子字面量】(字符串/数字/布尔/null),
1406
+ // 不会改变优先级;而加括号会让 maskNonDependentArgsOnce 把参数串误判成嵌套
1407
+ // 调用(它的判据是「实参里还有 `(`」),内层就再也轮不到它来处理。
1408
+ out += litArgs.get(m[0]);
1409
+ i += m[0].length;
1410
+ continue;
1411
+ }
1412
+ out += ch;
1413
+ i++;
1414
+ }
1415
+ return out;
1416
+ }
1417
+ /**
1418
+ * 递归解掉 cond 可判定的三元 / 短路;判不出就保持原样(保守侧)。
1419
+ *
1420
+ * C4i(2026-09-21)补两类:
1421
+ * - 模板串内部的 `${...}` 是独立表达式,折叠必须能进去 —— 否则
1422
+ * `` `${flag ? "fixed" : n}.md` `` 这种写法(现代代码里比裸三元更常见)
1423
+ * 永远收不住;
1424
+ * - 短路运算符 `||` / `&&` / `??`:与三元同形,只是判据从 cond 换成了
1425
+ * 左操作数的真假。JS 求值是从左到右,短路一发生右边整段根本不执行。
1426
+ */
1427
+ function foldDecidableTernary(expr, depth = 0) {
1428
+ if (depth > 4)
1429
+ return expr;
1430
+ // 先脱掉包住整串的括号 —— 不脱的话 `(()flag ? a : b)` 这种写法切出来的 cond
1431
+ // 是 `((true)`,括号不配对 ⇒ 判不出真假 ⇒ 白白丢掉一次收精度的机会
1432
+ // (2026-09-20 探针:viaFlag(k, true) 一直没收住,根因就是这个)。
1433
+ const s = stripOuterParens(expr);
1434
+ // ① 模板串:先处理 `${}` 内部(外面那层字面量部分不参与判定)
1435
+ const tpl = foldTemplateSubsts(s, depth);
1436
+ if (tpl !== s)
1437
+ return tpl;
1438
+ // ② 三元
1439
+ const t = topLevelTernary(s);
1440
+ if (t) {
1441
+ const v = truthOf(t.cond);
1442
+ if (v !== null)
1443
+ return foldDecidableTernary(v ? t.a : t.b, depth + 1);
1444
+ }
1445
+ // ③ 短路
1446
+ const lg = topLevelLogical(s);
1447
+ if (lg) {
1448
+ const v = lg.op === "??" ? notNullish(lg.left) : truthOf(lg.left);
1449
+ if (v !== null) {
1450
+ // `||` 左为真 ⇒ 取左;`&&` 左为真 ⇒ 取右;`??` 左非空 ⇒ 取左
1451
+ const takeLeft = lg.op === "&&" ? !v : v;
1452
+ return foldDecidableTernary(takeLeft ? lg.left : lg.right, depth + 1);
1453
+ }
1454
+ }
1455
+ return expr === s ? expr : s;
1456
+ }
1457
+ /**
1458
+ * 折叠模板串里每个 `${...}` 的内部;没有任何一处可折叠就**原样返回**(让调用方
1459
+ * 知道这一层没做事,好继续试三元/短路)。
1460
+ */
1461
+ function foldTemplateSubsts(expr, depth) {
1462
+ if (!expr.includes("`"))
1463
+ return expr;
1464
+ let out = "";
1465
+ let changed = false;
1466
+ let i = 0;
1467
+ while (i < expr.length) {
1468
+ const ch = expr[i];
1469
+ if (ch !== "`") {
1470
+ out += ch;
1471
+ i++;
1472
+ continue;
1473
+ }
1474
+ let seg = "";
1475
+ let j = i + 1;
1476
+ while (j < expr.length) {
1477
+ const c = expr[j];
1478
+ if (c === "\\") {
1479
+ seg += expr.slice(j, j + 2);
1480
+ j += 2;
1481
+ continue;
1482
+ }
1483
+ if (c === "`")
1484
+ break;
1485
+ if (c === "$" && expr[j + 1] === "{") {
1486
+ // 找与 `${` 配对的 `}`(`${ {a:1}.a }` 里的花括号要跳过)
1487
+ let brace = 0;
1488
+ let k = j + 2;
1489
+ let inner = "";
1490
+ while (k < expr.length) {
1491
+ const d = expr[k];
1492
+ if (d === "{")
1493
+ brace++;
1494
+ else if (d === "}") {
1495
+ if (brace === 0)
1496
+ break;
1497
+ brace--;
1498
+ }
1499
+ inner += d;
1500
+ k++;
1501
+ }
1502
+ const folded = foldDecidableTernary(inner, depth + 1);
1503
+ if (folded !== inner)
1504
+ changed = true;
1505
+ seg += "${" + folded + "}";
1506
+ j = k + 1;
1507
+ continue;
1508
+ }
1509
+ seg += c;
1510
+ j++;
1511
+ }
1512
+ out += "`" + seg + "`";
1513
+ i = j + 1;
1514
+ }
1515
+ return changed ? out : expr;
1516
+ }
1517
+ /**
1518
+ * 顶层短路运算符:**最左边**的一个。优先级从低到高依次找 `||` → `??` → `&&`
1519
+ * (TS 里 `??` 与 `||`/`&&` 同级混用必须加括号,加了括号就不是顶层了)。
1520
+ * 只认左右都非空的那一个。
1521
+ */
1522
+ function topLevelLogical(expr) {
1523
+ for (const op of ["||", "??", "&&"]) {
1524
+ let paren = 0;
1525
+ let quote = "";
1526
+ let i = 0;
1527
+ while (i < expr.length) {
1528
+ const ch = expr[i];
1529
+ if (quote) {
1530
+ if (ch === "\\")
1531
+ i++;
1532
+ else if (ch === quote)
1533
+ quote = "";
1534
+ i++;
1535
+ continue;
1536
+ }
1537
+ if (ch === '"' || ch === "'" || ch === "`") {
1538
+ quote = ch;
1539
+ i++;
1540
+ continue;
1541
+ }
1542
+ if (ch === "(" || ch === "[" || ch === "{")
1543
+ paren++;
1544
+ else if (ch === ")" || ch === "]" || ch === "}")
1545
+ paren--;
1546
+ else if (paren === 0 && expr.startsWith(op, i)) {
1547
+ // `??`/`||`/`&&` 各占两字符,注意别把 `a ||= b` 这类当短路(赋值语义不同)
1548
+ if (expr[i + 2] === "=") {
1549
+ i += 3;
1550
+ continue;
1551
+ }
1552
+ const left = expr.slice(0, i).trim();
1553
+ const right = expr.slice(i + 2).trim();
1554
+ if (left !== "" && right !== "")
1555
+ return { left, right, op };
1556
+ }
1557
+ i++;
1558
+ }
1559
+ }
1560
+ return null;
1561
+ }
1562
+ /** 左操作数**确定不是** null/undefined 时返回 true;判不出返回 null */
1563
+ function notNullish(s) {
1564
+ const lit = litOf(s);
1565
+ if (!lit)
1566
+ return null;
1567
+ return lit.k !== "null";
1568
+ }
1569
+ /**
1570
+ * 顶层三元的三段。要点是"顶层":`a ? b ? c : d : e` 里第一个顶层 `:` 属于内层
1571
+ * 三元,不能拿来切 —— 否则会把 b 误当成整个真值分支。
1572
+ */
1573
+ function topLevelTernary(expr) {
1574
+ let paren = 0;
1575
+ let qDepth = 0;
1576
+ let qPos = -1;
1577
+ let cPos = -1;
1578
+ let quote = "";
1579
+ let i = 0;
1580
+ while (i < expr.length) {
1581
+ const ch = expr[i];
1582
+ if (quote) {
1583
+ if (ch === "\\")
1584
+ i++;
1585
+ else if (ch === quote)
1586
+ quote = "";
1587
+ i++;
1588
+ continue;
1589
+ }
1590
+ if (ch === '"' || ch === "'" || ch === "`") {
1591
+ quote = ch;
1592
+ i++;
1593
+ continue;
1594
+ }
1595
+ if (ch === "(" || ch === "[" || ch === "{")
1596
+ paren++;
1597
+ else if (ch === ")" || ch === "]" || ch === "}")
1598
+ paren--;
1599
+ else if (paren === 0) {
1600
+ if (ch === "?") {
1601
+ if (qDepth === 0)
1602
+ qPos = i;
1603
+ qDepth++;
1604
+ }
1605
+ else if (ch === ":" && qDepth > 0) {
1606
+ if (qDepth === 1) {
1607
+ cPos = i;
1608
+ break;
1609
+ }
1610
+ qDepth--;
1611
+ }
1612
+ }
1613
+ i++;
1614
+ }
1615
+ if (qPos < 0 || cPos < 0)
1616
+ return null;
1617
+ return { cond: expr.slice(0, qPos), a: expr.slice(qPos + 1, cPos), b: expr.slice(cPos + 1) };
1618
+ }
1619
+ /**
1620
+ * 脱掉**包住整串**的括号(`(x)` ⇒ `x`,可能多层)。只脱真的配到串尾的那种 ——
1621
+ * `(a) + (b)` 的首个括号不到底,不会被误脱。
1622
+ */
1623
+ function stripOuterParens(expr) {
1624
+ let cur = expr.trim();
1625
+ for (let guard = 0; guard < 8; guard++) {
1626
+ if (!cur.startsWith("("))
1627
+ break;
1628
+ const close = matchingParen(cur, 0);
1629
+ if (close !== cur.length - 1)
1630
+ break;
1631
+ const inner = cur.slice(1, -1).trim();
1632
+ if (inner === "")
1633
+ break;
1634
+ cur = inner;
1635
+ }
1636
+ return cur;
1637
+ }
1638
+ /** 判得出来就返回 true/false,判不出返回 null */
1639
+ function truthOf(cond) {
1640
+ const s = stripOuterParens(cond);
1641
+ if (/^!\s*\S/.test(s)) {
1642
+ const v = truthOf(s.replace(/^!\s*/, ""));
1643
+ return v === null ? null : !v;
1644
+ }
1645
+ const lit = litOf(s);
1646
+ if (lit) {
1647
+ if (lit.k === "bool")
1648
+ return lit.v === "true";
1649
+ if (lit.k === "null")
1650
+ return false; // null / undefined
1651
+ if (lit.k === "num")
1652
+ return Number(lit.v) !== 0;
1653
+ return lit.v.length > 0; // 字符串
1654
+ }
1655
+ const eq = /^(.+?)\s*(===|!==)\s*(.+)$/.exec(s);
1656
+ if (eq) {
1657
+ const l = litOf(eq[1]);
1658
+ const r = litOf(eq[3]);
1659
+ // 两边都是字面量但类型不同(`"1" === 1`)—— JS 语义下也是 false/true,可判
1660
+ if (l && r) {
1661
+ const same = l.k === r.k && l.v === r.v;
1662
+ return eq[2] === "===" ? same : !same;
1663
+ }
1664
+ }
1665
+ return null;
1666
+ }
1667
+ /** 字面量种类:字符串 / 数字 / 布尔 / null-undefined;不是字面量返回 null */
1668
+ function litOf(s) {
1669
+ const clean = stripOuterParens(s.trim()); // 代入时实参被套了括号:`(true) === true`
1670
+ return litKind(clean);
1671
+ }
1672
+ function litKind(s) {
1673
+ if (/^"(?:\\.|[^"\\])*"$/.test(s) || /^'(?:\\.|[^'\\])*'$/.test(s) || /^`(?:\\.|[^`\\])*`$/.test(s)) {
1674
+ return { k: "str", v: s.slice(1, -1) };
1675
+ }
1676
+ if (/^-?\d+(?:\.\d+)?$/.test(s))
1677
+ return { k: "num", v: s };
1678
+ if (s === "true" || s === "false")
1679
+ return { k: "bool", v: s };
1680
+ if (s === "null" || s === "undefined")
1681
+ return { k: "null", v: s };
1682
+ return null;
1683
+ }
1684
+ /** s[open] 是开括号时,返回与之配对的闭括号下标;不配对返回 -1 */
1685
+ function matchingParen(s, open) {
1686
+ let depth = 0;
1687
+ for (let i = open; i < s.length; i++) {
1688
+ if (s[i] === "(")
1689
+ depth++;
1690
+ else if (s[i] === ")") {
1691
+ depth--;
1692
+ if (depth === 0)
1693
+ return i;
1694
+ }
1695
+ }
1696
+ return -1;
1697
+ }
1698
+ /**
1699
+ * 同名多处的依赖集合并:按**形参位**取并集(OR)—— 任何一个实现在某个位置
1700
+ * 会流出污点,那个位置就得当成会流出(合并到保守侧)。
1701
+ * 形参表不一致时不合并 return 表达式(`rets=[]` ⇒ 调用点折叠自动退回,
1702
+ * 只按并集传播),因为把 A 的形参名代进 B 的 return 是错的。
1703
+ */
1704
+ function mergeShapers(infos) {
1705
+ if (infos.length === 0)
1706
+ return { params: [], deps: new Set(), rets: [], restAt: -1 };
1707
+ const widest = infos.reduce((a, b) => (b.params.length > a.params.length ? b : a));
1708
+ const params = widest.params;
1709
+ const flags = params.map(() => false);
1710
+ let sameShape = true;
1711
+ for (const si of infos) {
1712
+ if (si.params.length !== params.length || si.params.some((p, i) => p !== params[i]))
1713
+ sameShape = false;
1714
+ si.params.forEach((p, i) => { if (i < flags.length && si.deps.has(p))
1715
+ flags[i] = true; });
1716
+ }
1717
+ const rets = sameShape ? infos.flatMap((si) => si.rets) : [];
1718
+ return { params, deps: new Set(params.filter((_, i) => flags[i])), rets, restAt: widest.restAt };
1719
+ }
1720
+ /** return 表达式真正依赖哪些形参:把「已知 helper 调用里不被依赖的实参」抹掉后,
930
1721
  * 剩下的形参名就是会流进返回值的那些。`wrap(n) { return discard(n); }` ⇒ 空集。
931
1722
  */
932
1723
  function returnDeps(exprs, params, info, memberNames = new Set()) {
@@ -952,130 +1743,342 @@ function returnExprsOf(body) {
952
1743
  }
953
1744
  return rets;
954
1745
  }
1746
+ /**
1747
+ * C4j:`export default (n) => n + ".md"` 这种**匿名**默认导出没有名字可登记,
1748
+ * 能用的名字只有「别的文件 import 它时起的本地名」。用 ts-morph 的模块解析找到
1749
+ * 真正指向该文件的默认导入(解析不出的模块一律跳过 —— 认不出就不收)。
1750
+ */
1751
+ function defaultImportLocalNames(project, target) {
1752
+ const out = new Set();
1753
+ const targetPath = target.getFilePath();
1754
+ for (const sf of project.getSourceFiles()) {
1755
+ if (sf.getFilePath() === targetPath)
1756
+ continue;
1757
+ for (const imp of sf.getImportDeclarations()) {
1758
+ let resolved;
1759
+ try {
1760
+ resolved = imp.getModuleSpecifierSourceFile() ?? undefined;
1761
+ }
1762
+ catch {
1763
+ continue; // 解析不了(路径写错 / 别名没配)—— 跳过
1764
+ }
1765
+ if (!resolved || resolved.getFilePath() !== targetPath)
1766
+ continue;
1767
+ const d = imp.getDefaultImport();
1768
+ if (d)
1769
+ out.add(d.getText());
1770
+ }
1771
+ }
1772
+ return out;
1773
+ }
955
1774
  /** 扫描全项目,收集「纯塑形」helper 的名字 + 返回值依赖(有界两轮不动点) */
956
1775
  function pureShaperFunctionNames(project) {
957
1776
  const sinkRe = tsSinkCallRegex();
958
1777
  const cands = [];
1778
+ const takenNames = new Set();
1779
+ const groups = new Map();
1780
+ // 模块级字面量常量(值一并留着:C4i 的计算属性名 `[KEY]` 要靠它解)
1781
+ const constTexts = moduleConstTexts(project);
1782
+ /**
1783
+ * 把一个**没通过判据**的实现记进它所属的成员组。
1784
+ *
1785
+ * C4j 修(探针 B4/B6 实测):C4h-② 的"全组确证"只在**进了组**的候选之间成立,
1786
+ * 而三条判据不成立的实现在 consider() 里就 return 了 —— 它压根不在组里,于是
1787
+ * 「同名三处,一处无形参、一处体内有 sink、一处是好 helper」会被当成"全组都确证",
1788
+ * 成员调用位被放行 ⇒ 误标。组必须记全:**存在过但没确证**也要算进"不是全组确证"。
1789
+ */
1790
+ const noteUnaccepted = (key, group) => {
1791
+ if (group === null)
1792
+ return;
1793
+ const list = groups.get(group) ?? [];
1794
+ if (!list.includes(key))
1795
+ list.push(key);
1796
+ groups.set(group, list);
1797
+ };
959
1798
  /** 登记一个候选:三条判据 —— 有形参 / 有返回值 / 体内无 sink */
960
- const consider = (name, paramNodes, declText, bodyNode) => {
1799
+ const consider = (key, name, group, paramNodes, restAt, curry, declText, bodyNode) => {
961
1800
  const paramList = paramNodes.filter(Boolean);
962
1801
  const params = new Set(paramList);
963
1802
  if (params.size === 0)
964
- return;
1803
+ return noteUnaccepted(key, group); // 判据①
965
1804
  sinkRe.lastIndex = 0;
966
1805
  if (sinkRe.test(declText))
967
- return; // ②
1806
+ return noteUnaccepted(key, group); // ②
968
1807
  // 用 AST 取 return 表达式,而不是正则 —— 模板字面量里的 `${}` 会
969
1808
  // 让「遇到花括号就停」的朴素正则在 `path.join(a,b) + \`.${c}\`` 上截断。
970
1809
  const rets = returnExprsOf(bodyNode);
971
- if (rets.length === 0)
972
- return; // ①
973
- if (cands.some((c) => c.name === name))
974
- return; // 同名重复登记:先到先得
975
- cands.push({ name, params, paramList, rets });
1810
+ // 柯里化 / 工厂形态:return 出来的是函数或装着函数的对象 ⇒ 判据①看的是最内层
1811
+ if (rets.length === 0 && curry.length === 0)
1812
+ return noteUnaccepted(key, group); // ③
1813
+ let pub = name;
1814
+ if (pub !== null) {
1815
+ if (takenNames.has(pub))
1816
+ pub = null; // 同名重复登记:先到先得,后者降到资格凭证
1817
+ else
1818
+ takenNames.add(pub);
1819
+ }
1820
+ cands.push({ key, name: pub, group, params, paramList, rets, restAt, curry });
1821
+ if (group !== null) {
1822
+ const list = groups.get(group) ?? [];
1823
+ list.push(key);
1824
+ groups.set(group, list);
1825
+ }
976
1826
  };
977
1827
  for (const sf of project.getSourceFiles()) {
978
- // (a) 函数声明
979
- for (const fn of sf.getFunctions()) {
1828
+ // (a) 函数声明 —— 顶层 + namespace 内
1829
+ // C4i:旧代码用 sf.getFunctions(),实测取不到 `namespace P { export
1830
+ // function toPath(){} }` 里的函数,整片漏(探针 F1)。改遍历声明节点,
1831
+ // 但**只收顶层 / namespace 链上的**:函数体内嵌套声明的作用域与调用点
1832
+ // 不同,按同一裸名发布会张冠李戴。
1833
+ for (const fn of sf.getDescendantsOfKind(ts.SyntaxKind.FunctionDeclaration)) {
1834
+ const parent = fn.getParent();
1835
+ // 顶层 / namespace 体内两种;函数体内嵌套的声明作用域与调用点不同,不收
1836
+ if (!ts_morph_1.Node.isSourceFile(parent) && !ts_morph_1.Node.isModuleBlock(parent))
1837
+ continue;
980
1838
  const name = fn.getName();
981
1839
  if (!name)
982
1840
  continue;
983
1841
  const body = fn.getBody();
984
1842
  if (!body)
985
1843
  continue; // 重载签名 / declare 只有签名
986
- consider(name, fn.getParameters().flatMap(paramNamesOf), fn.getText(), body);
1844
+ const ps = fn.getParameters();
1845
+ const paramNodes = ps.flatMap(paramNamesOf);
1846
+ const restAt = restIndexOf(ps);
1847
+ const curry = currySpecsOf(paramNodes, body);
1848
+ const owner = moduleQualifier(fn);
1849
+ // namespace 内却认不出宿主链 ⇒ 不收(宁可漏,不可张冠李戴)
1850
+ if (!ts_morph_1.Node.isSourceFile(parent) && owner === null)
1851
+ continue;
1852
+ // namespace 内:`P.toPath(k)` 是主要写法。裸名同时登记是安全的 ——
1853
+ // takenNames 先到先得,一个名字只会落到一处定义上(另一处会降级为资格凭证)。
1854
+ if (owner !== null) {
1855
+ consider(`fn:${owner}.${name}`, `${owner}.${name}`, null, paramNodes, restAt, curry, fn.getText(), body);
1856
+ }
1857
+ consider(`fn:${name}`, name, null, paramNodes, restAt, curry, fn.getText(), body);
987
1858
  }
988
1859
  // (b) 变量承载的箭头 / 函数表达式 —— 现代 TS 里 helper 的主力写法
989
1860
  // `export const toPath = (n: string) => n + ".md";`
990
1861
  // sf.getFunctions() 收不到这些,2026-09-20 实测整片漏。
1862
+ // C4i 再解一层 IIFE(functionLikeOf)。
991
1863
  for (const vd of sf.getVariableDeclarations()) {
992
1864
  const init = vd.getInitializer();
993
1865
  if (!init)
994
1866
  continue;
995
- const isArrow = ts_morph_1.Node.isArrowFunction(init);
996
- const isFnExpr = ts_morph_1.Node.isFunctionExpression(init);
997
- if (!isArrow && !isFnExpr)
1867
+ const fnLike = functionLikeOf(init);
1868
+ if (!fnLike)
998
1869
  continue;
999
- const fnLike = init;
1000
1870
  const body = fnLike.getBody();
1001
1871
  if (!body)
1002
1872
  continue;
1003
- consider(vd.getName(), fnLike.getParameters().flatMap(paramNamesOf), init.getText(), body);
1873
+ const ps = fnLike.getParameters();
1874
+ const paramNodes = ps.flatMap(paramNamesOf);
1875
+ consider(`var:${vd.getName()}`, vd.getName(), null, paramNodes, restIndexOf(ps), currySpecsOf(paramNodes, body), init.getText(), // IIFE 时含整条调用 ⇒ sink 检查仍覆盖得到
1876
+ body);
1004
1877
  }
1005
1878
  }
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 = [];
1879
+ // ── 成员形态的 helper(对象字面量方法 / 类方法 / 属性箭头 / 属性赋值箭头)──
1880
+ // `const Util = { toPath(n) {…} }`、`class Paths { toPath(n) {…} }`、
1881
+ // `const Util = { toPath: (n) => n + ".md" }`、`Util.toPath = (n) => …`
1882
+ // 都不在 getFunctions() 里。C4e 时刻意不做的理由仍在(R11):按裸名放行会
1883
+ // 让别的对象上的同名条目被误认成塑形。所以这里只登记**限定名** Owner.method;
1884
+ // 裸名(`.method(` 这种成员调用位)留给下面的 C4h-②,按「同名每一处都被
1885
+ // 确证」这条不变量统一放行。
1886
+ let memberSeq = 0;
1887
+ /** 登记一个成员形态的函数体;owner 认得出时按限定名发布 */
1888
+ const considerMember = (bare, owner, paramNodes, restAt, curry, declText, body) => {
1889
+ memberSeq += 1;
1890
+ consider(`mb:${memberSeq}`, owner !== null ? `${owner}.${bare}` : null, bare, paramNodes, restAt, curry, declText, body);
1891
+ };
1013
1892
  for (const sf of project.getSourceFiles()) {
1893
+ // (c) 对象字面量方法 / 类方法
1014
1894
  for (const md of sf.getDescendantsOfKind(ts.SyntaxKind.MethodDeclaration)) {
1015
- const bare = md.getName();
1016
- if (!bare)
1017
- continue;
1895
+ const bare = propertyNameOf(md, constTexts);
1896
+ if (!bare || !/^[A-Za-z_$][\w$]*$/.test(bare))
1897
+ continue; // 算不出的属性名 ⇒ 不收
1018
1898
  const body = md.getBody();
1019
1899
  if (!body)
1020
1900
  continue; // 重载签名 / declare
1021
- const owner = ownerQualifier(md);
1022
- methodCands.push({ qualified: owner ? `${owner}.${bare}` : null, bare, md });
1901
+ const ps = md.getParameters();
1902
+ const pns = ps.flatMap(paramNamesOf);
1903
+ considerMember(bare, ownerQualifier(md, constTexts), pns, restIndexOf(ps), currySpecsOf(pns, body), md.getText(), body);
1904
+ }
1905
+ // (d) 属性承载的箭头 / 函数表达式 —— 现代工程里 Objects-as-namespace 的常态。
1906
+ // ownerQualifier 从父链走(对象字面量 → 属性赋值 → 变量声明),嵌套也认。
1907
+ // C4j:初值外面可能还包着 as / satisfies(`{ toPath: ((n) => …) as any }`)。
1908
+ for (const pa of sf.getDescendantsOfKind(ts.SyntaxKind.PropertyAssignment)) {
1909
+ const rawInit = pa.getInitializer();
1910
+ if (!rawInit)
1911
+ continue;
1912
+ const init = unwrapFns(rawInit);
1913
+ if (!init)
1914
+ continue;
1915
+ if (!ts_morph_1.Node.isArrowFunction(init) && !ts_morph_1.Node.isFunctionExpression(init))
1916
+ continue;
1917
+ const fn = init;
1918
+ const body = fn.getBody();
1919
+ if (!body)
1920
+ continue;
1921
+ const bare = propertyNameOf(pa, constTexts);
1922
+ if (!bare || !/^[A-Za-z_$][\w$]*$/.test(bare))
1923
+ continue;
1924
+ const ps = fn.getParameters();
1925
+ const pns = ps.flatMap(paramNamesOf);
1926
+ considerMember(bare, ownerQualifier(pa, constTexts), pns, restIndexOf(ps), currySpecsOf(pns, body), rawInit.getText(), body);
1023
1927
  }
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);
1928
+ // (e) 后挂上去的属性箭头 `Util.toPath = (n) => …`(monkey patch / 渐进导出)
1929
+ for (const be of sf.getDescendantsOfKind(ts.SyntaxKind.BinaryExpression)) {
1930
+ if (be.getOperatorToken().getKind() !== ts.SyntaxKind.EqualsToken)
1931
+ continue;
1932
+ const left = be.getLeft();
1933
+ const right = unwrapFns(be.getRight());
1934
+ if (!ts_morph_1.Node.isPropertyAccessExpression(left))
1935
+ continue;
1936
+ if (!right || (!ts_morph_1.Node.isArrowFunction(right) && !ts_morph_1.Node.isFunctionExpression(right)))
1937
+ continue;
1938
+ const body = right.getBody();
1939
+ if (!body)
1940
+ continue;
1941
+ const qname = left.getText();
1942
+ // 只收 `Name.member` 这种纯静态链(`this.x` / `a[b].x` 一律不认)
1943
+ if (!/^[A-Za-z_$][\w$]*(?:\.[\w$]+)+$/.test(qname))
1944
+ continue;
1945
+ const bare = qname.split(".").pop();
1946
+ const ps = right.getParameters();
1947
+ const pns = ps.flatMap(paramNamesOf);
1948
+ considerMember(bare, qname.slice(0, -(bare.length + 1)), pns, restIndexOf(ps), currySpecsOf(pns, body), right.getText(), body);
1949
+ }
1950
+ // (f) C4j:getter 返回出来的箭头 —— `get mk() { return (n) => n + ".md"; }`。
1951
+ // 它既不是 MethodDeclaration(没有形参)也不是 PropertyAssignment(值是 getter),
1952
+ // 上一轮三种载体都收不到它,整族漏(探针 B1/B2)。真正塑形的是**返回出来的箭头**,
1953
+ // 所以判据看的是内层:无形参不收(B4)、体内有 sink 不收(B6)。
1954
+ // 注意这里**不**走 currySpecOf —— getter 的读取本身不消耗实参,调用点是
1955
+ // `o.mk(k)` 一次调用,不是 `mk()(k)` 两跳。
1956
+ for (const ga of sf.getDescendantsOfKind(ts.SyntaxKind.GetAccessor)) {
1957
+ const body = ga.getBody();
1958
+ if (!body)
1959
+ continue;
1960
+ const inner = returnedFunctionOf(body);
1961
+ if (!inner)
1962
+ continue; // getter 直接返回拼接(无形参)⇒ 判据①本就不成立
1963
+ const innerBody = inner.getBody();
1964
+ if (!innerBody)
1965
+ continue;
1966
+ const bare = propertyNameOf(ga, constTexts);
1967
+ if (!bare || !/^[A-Za-z_$][\w$]*$/.test(bare))
1968
+ continue;
1969
+ const ps = inner.getParameters();
1970
+ const pns = ps.flatMap(paramNamesOf);
1971
+ considerMember(bare, ownerQualifier(ga, constTexts), pns, restIndexOf(ps), currySpecsOf(pns, innerBody), ga.getText(), innerBody);
1972
+ }
1973
+ // (g) C4j:`export default (n) => …` —— 匿名默认导出。它既不是函数声明(没有名字)
1974
+ // 也不是变量声明,上一轮整族漏(探针 F9)。名字只能用**导入它的文件**里起的
1975
+ // 本地名;多个文件用不同名字导入 ⇒ 每个名字各登记一条(takenNames 仍然先到先得)。
1976
+ for (const ea of sf.getDescendantsOfKind(ts.SyntaxKind.ExportAssignment)) {
1977
+ if (ea.isExportEquals())
1978
+ continue;
1979
+ const fn = unwrapFns(ea.getExpression());
1980
+ if (!fn || (!ts_morph_1.Node.isArrowFunction(fn) && !ts_morph_1.Node.isFunctionExpression(fn)))
1981
+ continue;
1982
+ const fnBody = fn.getBody();
1983
+ if (!fnBody)
1984
+ continue;
1985
+ const localNames = defaultImportLocalNames(project, sf);
1986
+ if (localNames.size === 0)
1987
+ continue; // 没人默认导入 ⇒ 名字无从谈起
1988
+ const ps = fn.getParameters();
1989
+ const pns = ps.flatMap(paramNamesOf);
1990
+ const curry = currySpecsOf(pns, fnBody);
1991
+ for (const ln of localNames) {
1992
+ consider(`def:${sf.getFilePath()}:${ln}`, ln, null, pns, restIndexOf(ps), curry, ea.getText(), fnBody);
1993
+ }
1040
1994
  }
1041
- if (m.qualified)
1042
- consider(m.qualified, params, text, body);
1043
1995
  }
1044
1996
  // 形参之外允许出现的自由标识符 = 模块级字面量常量 ∪ 确证的 path 模块别名
1045
- const extraIdents = moduleLiteralConstNames(project);
1997
+ const extraIdents = new Set(constTexts.keys());
1046
1998
  for (const a of pathModuleAliases(project))
1047
1999
  extraIdents.add(a);
1048
2000
  const names = new Set();
1049
2001
  const info = new Map();
2002
+ const acceptedKeys = new Set();
2003
+ const keyInfo = new Map();
2004
+ // 给 maskNonDependentArgsOnce 用的成员名候选(**尚未确证**)。这里宽一点是安全
2005
+ // 的:那里的替换只会针对 info 里确证过的名字发生,这个集合只决定「按成员位还是
2006
+ // 按裸名位去匹配」。
2007
+ const prelimMember = new Set(groups.keys());
2008
+ const publish = (name, si) => {
2009
+ if (names.has(name))
2010
+ return;
2011
+ names.add(name);
2012
+ info.set(name, si);
2013
+ };
2014
+ /** C4j:确证通过的链(工厂可能有几条,脏的那条会被剔掉,见下) */
2015
+ const pureCurried = new Map();
1050
2016
  for (let round = 0; round < 2; round++) {
1051
2017
  let added = false;
1052
2018
  for (const c of cands) {
1053
- if (names.has(c.name))
1054
- continue;
1055
- const ok = c.rets.every((e) => isPureShaperExpr(e, c.params, names, extraIdents));
1056
- if (!ok)
2019
+ if (acceptedKeys.has(c.key))
1057
2020
  continue;
1058
- names.add(c.name);
1059
- info.set(c.name, {
1060
- params: c.paramList,
1061
- deps: returnDeps(c.rets, c.paramList, info, memberNames),
1062
- });
2021
+ let si;
2022
+ if (c.curry.length > 0) {
2023
+ // 柯里化 / 工厂形态:判据落在**最内层**的 return 上,形参集合是本链各层并起来。
2024
+ // 每个 spec 各自过判据:工厂返回多个方法时,只把**确证过的那些**收进名录,
2025
+ // 脏的那条不发 spec ⇒ 调用点不会摊开它 ⇒ 退化为漏报而不是误报(保守侧)。
2026
+ const pure = c.curry.filter((spec) => spec.rets.every((e) => isPureShaperExpr(e, new Set([...c.params, ...spec.allParams]), names, extraIdents)));
2027
+ if (pure.length === 0)
2028
+ continue;
2029
+ acceptedKeys.add(c.key);
2030
+ pureCurried.set(c.key, pure);
2031
+ // 外层名照发:闭包会捕获外层实参(`const g = withExt(k)` 也是污的)。
2032
+ // rets 留空 ⇒ 调用点折叠自动退回「全部依赖」(保守侧)。
2033
+ si = { params: c.paramList, deps: new Set(c.paramList), rets: [], restAt: c.restAt };
2034
+ }
2035
+ else {
2036
+ if (!c.rets.every((e) => isPureShaperExpr(e, c.params, names, extraIdents)))
2037
+ continue;
2038
+ acceptedKeys.add(c.key);
2039
+ si = {
2040
+ params: c.paramList,
2041
+ deps: returnDeps(c.rets, c.paramList, info, prelimMember),
2042
+ rets: c.rets,
2043
+ restAt: c.restAt,
2044
+ };
2045
+ }
2046
+ keyInfo.set(c.key, si);
2047
+ if (c.name !== null)
2048
+ publish(c.name, si);
1063
2049
  added = true;
1064
2050
  }
1065
2051
  if (!added)
1066
2052
  break;
1067
2053
  }
2054
+ // C4i/C4j:柯里化与工厂名录。只有**发布了名字**的才登记 —— 名字被别处同名定义占用时
2055
+ // (pub 降为 null),`withExt(a)(b)` 里的 withExt 可能指的不是这个 helper,
2056
+ // 展开它会张冠李戴 ⇒ 宁可不展(保守侧,退化为漏报而非误报)。
2057
+ // 一个名字可以对多条链(工厂返回含多个方法的对象)。
2058
+ const curried = new Map();
2059
+ for (const c of cands) {
2060
+ if (c.name === null || !acceptedKeys.has(c.key))
2061
+ continue;
2062
+ const pure = pureCurried.get(c.key);
2063
+ if (!pure || pure.length === 0)
2064
+ continue;
2065
+ const list = curried.get(c.name) ?? [];
2066
+ for (const spec of pure)
2067
+ if (!list.includes(spec))
2068
+ list.push(spec);
2069
+ curried.set(c.name, list);
2070
+ }
1068
2071
  // C4f:依赖分析有次序依赖 —— `wrap(n) { return discard(n); }` 若在 discard 之前
1069
2072
  // 被接受,那一刻 info 里还没有 discard ⇒ deps 偏宽(偏宽 = 继续传播 = 保守侧,
1070
2073
  // 不会误报,但收不紧)。名录定稿后按完整的 info 再算两轮,把次序依赖消掉。
1071
- const byName = new Map(cands.map((c) => [c.name, c]));
2074
+ const candByKey = new Map(cands.map((c) => [c.key, c]));
1072
2075
  for (let round = 0; round < 2; round++) {
1073
2076
  let changed = false;
1074
- for (const [name, si] of info) {
1075
- const c = byName.get(name);
2077
+ for (const [key, si] of keyInfo) {
2078
+ const c = candByKey.get(key);
1076
2079
  if (!c)
1077
2080
  continue;
1078
- const d = returnDeps(c.rets, si.params, info, memberNames);
2081
+ const d = returnDeps(c.rets, si.params, info, prelimMember);
1079
2082
  if (d.size !== si.deps.size || [...d].some((x) => !si.deps.has(x))) {
1080
2083
  si.deps = d;
1081
2084
  changed = true;
@@ -1084,7 +2087,26 @@ function pureShaperFunctionNames(project) {
1084
2087
  if (!changed)
1085
2088
  break;
1086
2089
  }
1087
- return { names, memberNames, info };
2090
+ // ── C4h-②(2026-09-20):同名【成员】多处,只要**每一处**都被确证为纯塑形 ──
2091
+ // 旧规则(C4g)是「该方法名全项目唯一才登记裸名」。唯一性只是【不可能认错】这个
2092
+ // 不变量的一个充分条件,不是必要条件:同名两处都被确证了,`.toPath(` 这个成员
2093
+ // 调用位同样不可能落到别的实现上。把"唯一"换成"全部确证",两个类各有一个同名
2094
+ // helper 时两边都能接上;依赖集按形参位取并集(保守侧)。
2095
+ //
2096
+ // 顺带修掉一处真实缺陷:旧代码在 consider() 之后【无条件】把裸名塞进
2097
+ // memberNames,于是「方法名唯一但三条判据不成立」的方法,其成员调用位照样被
2098
+ // 当成塑形 ⇒ 误标。探针实测(2026-09-20):
2099
+ // `class Reg { lookup(n) { return registry[n]; } }`(registry 是模块级非字面量
2100
+ // 对象 ⇒ 判据③不成立)之后写 `r.lookup(k)`,照样MARKED。
2101
+ // 现在改成确证之后才登记,这个口子自然封上。
2102
+ const memberNames = new Set();
2103
+ for (const [bare, keys] of groups) {
2104
+ if (!keys.every((k) => acceptedKeys.has(k)))
2105
+ continue;
2106
+ memberNames.add(bare);
2107
+ publish(bare, mergeShapers(keys.map((k) => keyInfo.get(k)).filter(Boolean)));
2108
+ }
2109
+ return { names, memberNames, info, curried };
1088
2110
  }
1089
2111
  const TS_FILE_SINK_NAMES = [
1090
2112
  "readFile", "readFileSync", "writeFile", "writeFileSync",
@@ -1336,14 +2358,19 @@ function taintedViaShaper(text, tainted, shapers) {
1336
2358
  const rhs = m[2];
1337
2359
  if (tainted.has(name))
1338
2360
  continue;
1339
- const shaped = TS_PATH_SHAPER_RE.test(rhs) ||
1340
- TS_CONCAT_RE.test(rhs) ||
2361
+ // C4i:柯里化调用先在证据里摊开 —— `withExt(".md")(k)` ⇒ `k + ".md"`,
2362
+ // 摊开后的串自己就是拼接,shaped 也顺理成章成立。
2363
+ const expanded = shapers ? expandCurried(rhs, shapers.curried) : rhs;
2364
+ const shaped = TS_PATH_SHAPER_RE.test(expanded) ||
2365
+ TS_CONCAT_RE.test(expanded) ||
1341
2366
  (localShaperRe !== null && localShaperRe.test(rhs)) ||
1342
2367
  (memberShaperRe !== null && memberShaperRe.test(rhs));
1343
2368
  // 证据看的是【抹掉不被依赖的实参之后】的 rhs:`discard(k)` 里 k 不流出,
1344
2369
  // 那条支路就不该算证据。`shaped` 仍看原式 —— helper 是不是塑形,与它的
1345
2370
  // 返回值依不依赖实参是两件事。
1346
- const evidence = shaperInfo && memberNames ? maskNonDependentArgs(rhs, shaperInfo, memberNames) : rhs;
2371
+ const evidence = shaperInfo && memberNames
2372
+ ? maskNonDependentArgs(expanded, shaperInfo, memberNames)
2373
+ : expanded;
1347
2374
  if (taintRe.test(evidence) && shaped) {
1348
2375
  tainted.add(name);
1349
2376
  added = true;