progmune-runtime 3.7.31 → 3.7.32

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.7.32] — 2026-09-19
4
+
5
+ ### IR 陈旧性判定(两次会话级误判的根因修复)
6
+
7
+ - **断点**:trust 引擎自动提取是「缺才提取」——项目已有 ir.json 就以文件为准不重生成。源码改了、ir.json 没更新时静默吃旧 IR:fr-005 首扫报 0(`rm ir.json` 后才报出)、污点试点「标记注入但 trust 报 0」同源
8
+ - **修复**(`src/ir-staleness.ts` + `src/trust/engine.ts` 集成):mtime 比对——仅当有源码新于 ir.json 才重提,不做每次全量重提(skyvern 级项目 5-10 分钟成本)
9
+ - **三档策略 `PROGMUNE_IR_REEXTRACT`**:auto(陈旧 + 遍历完整 + 源码 ≤5000 文件才自动重提,否则**只警告不重提**)/always(语料复测自担成本)/never(旧语义,但必须打「IR 可能陈旧」警告——**不接受静默**)
10
+ - **双闸设计**:完整遍历(不做「找到就早退」——早退会抹掉 truncated 信号,本仓库 22k 源文件实测会把单测推过 30s 超时)+ evidenceComplete + 规模预算闸;stat 一次遍历的常量成本换可靠的 fresh 判定
11
+ - **验收**:`src/ir-staleness.test.ts` 12 green + DSH 陷阱回归 + SSRF 死循环锁 35 passed;**TS 795 零漂移(3086 LOST 0/ADDED 0)**;build ✓
12
+
3
13
  ## [3.7.31] — 2026-09-18
4
14
 
5
15
  ### 提取器 SSRF 标记正则死循环修复(P0,真实工程可用性故障)
@@ -385,21 +385,49 @@ const TS_FILE_SINK_NAMES = [
385
385
  "createWriteStream", "existsSync", "readdir", "readdirSync",
386
386
  "copyFile", "rename", "stat", "lstat", "rmdir", "truncate",
387
387
  ];
388
+ // ═══════════════════════════════════════════════════════════════
389
+ // 不可信入口根表 —— 污点数据流试点(2026-09-18)
390
+ //
391
+ // 此前整条污点链路只锚定 Express 形态的
392
+ // `(?:req|request)\.(?:params|query|body|headers|cookies)`,
393
+ // 即**根集合只有一种来源**。后果是 MCP 工具实参、CLI 参数、URL 解码产物
394
+ // 等来源天然不可见:fr-012 的 `args.file_path`(← params.arguments)与
395
+ // fr-015 的 `font.path`(← URL 解码)都因此 MISS。往 URL_PARAM_NAME 里加
396
+ // 名字解决不了——那只是在已经认错了根的前提下扩充变量名清单(打地鼠)。
397
+ //
398
+ // 本表改为按**传输面**声明根:判定依据是值的来源性质,而不是变量叫什么。
399
+ // 每条根必须窄且可解释,新增需附 `why`(closes #taint-pilot)。
400
+ // ═══════════════════════════════════════════════════════════════
401
+ const UNTRUSTED_ROOTS = [
402
+ {
403
+ id: "http_request",
404
+ expr: String.raw `\b(?:req|request)\.(?:params|query|body|headers|cookies)\b[.[]?`,
405
+ why: "HTTP 请求对象字段,Express/Koa/Fastify/Nest 等通用形态",
406
+ },
407
+ {
408
+ id: "mcp_tool_args",
409
+ expr: String.raw `\bparams\.arguments\b[.[]?`,
410
+ why: "MCP CallToolRequest.params.arguments —— 工具调用实参,调用方可控(fr-012)",
411
+ },
412
+ ];
413
+ /** 所有根的来源片段,供 **无标志** 的布尔探测正则使用 */
414
+ const UNTRUSTED_ROOT_SRC = UNTRUSTED_ROOTS.map((r) => `(?:${r.expr})`).join("|");
415
+ /** 既有的 request 根语义,改由根表统一提供 */
416
+ function hasRequestRootedExpr(text) {
417
+ return new RegExp(UNTRUSTED_ROOT_SRC).test(text);
418
+ }
388
419
  function tsSinkCallRegex() {
389
420
  return new RegExp(`(?:^|[^\\w.$])(?:await\\s+)?(?:[\\w$]+\\.)?(${TS_FILE_SINK_NAMES.join("|")})\\s*\\(([\\s\\S]{0,250}?)\\)`, "g");
390
421
  }
391
- /** request 根表达式:req.params / request.body / req.query['x'] … */
392
- function hasRequestRootedExpr(text) {
393
- return /\b(?:req|request)\.(?:params|query|body|headers|cookies)\b[.[]?/.test(text);
394
- }
395
- /** 函数体内被 request 污染的局部名(含解构与单跳赋值,深度 ≤2) */
422
+ /** 函数体内被不可信根污染的局部名(含解构与单跳赋值,深度 ≤2) */
396
423
  function collectTaintedNames(text) {
397
424
  const tainted = new Set();
398
425
  let m;
399
- const direct = /(?:const|let|var)\s+([\w$]+)\s*=\s*(?:await\s+)?(?:req|request)\.(?:params|query|body|headers|cookies)\b[.[]?/g;
426
+ const root = UNTRUSTED_ROOT_SRC;
427
+ const direct = new RegExp(`(?:const|let|var)\\s+([\\w$]+)\\s*=\\s*(?:await\\s+)?(?:${root})`, "g");
400
428
  while ((m = direct.exec(text)) !== null)
401
429
  tainted.add(m[1]);
402
- const destr = /(?:const|let|var)\s*\{\s*([^}=]*?)\s*\}\s*=\s*(?:await\s+)?(?:req|request)\.(?:params|query|body|headers|cookies)\b/g;
430
+ const destr = new RegExp(`(?:const|let|var)\\s*\\{\\s*([^}=]*?)\\s*\\}\\s*=\\s*(?:await\\s+)?(?:${root})`, "g");
403
431
  while ((m = destr.exec(text)) !== null) {
404
432
  for (const part of m[1].split(",")) {
405
433
  const name = part.trim().split(":")[0].trim();
@@ -407,7 +435,7 @@ function collectTaintedNames(text) {
407
435
  tainted.add(name);
408
436
  }
409
437
  }
410
- const bareAssign = /(?:^|[^\w$.])([\w$]+)\s*=\s*(?:req|request)\.(?:params|query|body|headers|cookies)\b[.[]?/g;
438
+ const bareAssign = new RegExp(`(?:^|[^\\w$.])([\\w$]+)\\s*=\\s*(?:await\\s+)?(?:${root})`, "g");
411
439
  while ((m = bareAssign.exec(text)) !== null)
412
440
  tainted.add(m[1]);
413
441
  // 单跳传播(深度 ≤2)
@@ -429,9 +457,7 @@ function collectTaintedNames(text) {
429
457
  return tainted;
430
458
  }
431
459
  function taintPattern(tainted) {
432
- const parts = [
433
- /\b(?:req|request)\.(?:params|query|body|headers|cookies)\b[.[]?/.source,
434
- ];
460
+ const parts = [UNTRUSTED_ROOT_SRC];
435
461
  if (tainted.size > 0) {
436
462
  parts.push(`\\b(?:${[...tainted].join("|")})\\b`);
437
463
  }
@@ -474,6 +500,27 @@ function splitArgWindow(win) {
474
500
  /** 项目方法名 → 方法体内流入文件 sink 的形参下标集合 */
475
501
  function methodSinkParamMap(project, absRoot) {
476
502
  const map = new Map();
503
+ const register = (keyName, fullName, text, relPath, params) => {
504
+ const sinkRe = tsSinkCallRegex();
505
+ let sm;
506
+ while ((sm = sinkRe.exec(text)) !== null) {
507
+ const win = sm[2] || "";
508
+ params.forEach((pname, idx) => {
509
+ if (!pname)
510
+ return;
511
+ if (!new RegExp(`\\b${pname}\\b`).test(win))
512
+ return;
513
+ if (!map.has(keyName)) {
514
+ map.set(keyName, { idxs: new Set(), entries: [] });
515
+ }
516
+ const rec = map.get(keyName);
517
+ rec.idxs.add(idx);
518
+ if (!rec.entries.some((e) => e.name === fullName)) {
519
+ rec.entries.push({ name: fullName, file: relPath });
520
+ }
521
+ });
522
+ }
523
+ };
477
524
  for (const sf of project.getSourceFiles()) {
478
525
  const relPath = path.relative(absRoot, sf.getFilePath());
479
526
  for (const cls of sf.getClasses()) {
@@ -484,27 +531,23 @@ function methodSinkParamMap(project, absRoot) {
484
531
  const params = m.getParameters().map((p) => p.getName());
485
532
  if (params.length === 0)
486
533
  continue;
487
- const text = m.getText();
488
- const sinkRe = tsSinkCallRegex();
489
- let sm;
490
- while ((sm = sinkRe.exec(text)) !== null) {
491
- const win = sm[2] || "";
492
- params.forEach((pname, idx) => {
493
- if (new RegExp(`\\b${pname}\\b`).test(win)) {
494
- if (!map.has(m.getName())) {
495
- map.set(m.getName(), { idxs: new Set(), entries: [] });
496
- }
497
- const rec = map.get(m.getName());
498
- rec.idxs.add(idx);
499
- const fullName = `${cn}.${m.getName()}`;
500
- if (!rec.entries.some((e) => e.name === fullName)) {
501
- rec.entries.push({ name: fullName, file: relPath });
502
- }
503
- }
504
- });
505
- }
534
+ register(m.getName(), `${cn}.${m.getName()}`, m.getText(), relPath, params);
506
535
  }
507
536
  }
537
+ // ── 污点数据流试点(2026-09-18)──
538
+ // 此前只登记**类方法**。真实 TS 工程大量使用顶层函数声明(MCP handler、
539
+ // Nuxt runtime binding、工具函数),跨函数一跳对它们完全不生效——fr-012 的
540
+ // `markdownUpload` 正是顶层函数,因此从未进入本表,即使上游 taint 已识别,
541
+ // 也无法传导到 sink。这里按同样的「形参 → 文件 sink」规则纳入函数声明。
542
+ for (const fn of sf.getFunctions()) {
543
+ const fnName = fn.getName();
544
+ if (!fnName)
545
+ continue;
546
+ const params = fn.getParameters().map((p) => p.getName());
547
+ if (params.length === 0)
548
+ continue;
549
+ register(fnName, fnName, fn.getText(), relPath, params);
550
+ }
508
551
  }
509
552
  return map;
510
553
  }
@@ -552,18 +595,26 @@ function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit) {
552
595
  }
553
596
  else {
554
597
  const methodCallRe = new RegExp(`\\.([\\w$]+)\\s*\\(([\\s\\S]{0,250}?)\\)`, "g");
598
+ // ── 污点数据流试点(2026-09-18)──
599
+ // 此前只识别成员调用(`obj.sink(...)`)。顶层/模块内函数是真实 TS 工程
600
+ // 的主要形态(fr-012 的 `markdownUpload(...)` 就是裸调用),缺这一则
601
+ // 即使该函数的形参已登记为 sink 形参,taint 也无法传导过去。
602
+ const directCallRe = new RegExp(`(?:^|[^\\w$.])([\\w$]+)\\s*\\(([\\s\\S]{0,250}?)\\)`, "g");
555
603
  const taint = taintPattern(tainted);
556
604
  if (taint) {
557
- let m;
558
- while ((m = methodCallRe.exec(text)) !== null) {
559
- const rec = sinkParams.get(m[1]);
560
- if (!rec || rec.idxs.size === 0)
561
- continue;
562
- const args = splitArgWindow(m[2] || "");
563
- const hit = args.some((arg, i) => rec.idxs.has(i) && taint.test(arg));
564
- if (hit) {
565
- markers.push("__progmune_path_traversal__");
566
- onMethodHit(rec);
605
+ for (const callRe of [methodCallRe, directCallRe]) {
606
+ callRe.lastIndex = 0;
607
+ let m;
608
+ while ((m = callRe.exec(text)) !== null) {
609
+ const rec = sinkParams.get(m[1]);
610
+ if (!rec || rec.idxs.size === 0)
611
+ continue;
612
+ const args = splitArgWindow(m[2] || "");
613
+ const hit = args.some((arg, i) => rec.idxs.has(i) && taint.test(arg));
614
+ if (hit) {
615
+ markers.push("__progmune_path_traversal__");
616
+ onMethodHit(rec);
617
+ }
567
618
  }
568
619
  }
569
620
  }
@@ -580,7 +631,7 @@ function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit) {
580
631
  taintParts.push(`\\b(?:${urlParams.join("|")})\\b`);
581
632
  if (reqTainted.size > 0)
582
633
  taintParts.push(`\\b(?:${[...reqTainted].join("|")})\\b`);
583
- taintParts.push(/\b(?:req|request)\.(?:params|query|body|headers|cookies)\b[.[]?/.source);
634
+ taintParts.push(UNTRUSTED_ROOT_SRC);
584
635
  const taint = new RegExp(taintParts.join("|"));
585
636
  const fetchSinkIter = tsHttpFetchSinkIter();
586
637
  let m;
@@ -0,0 +1,233 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.inspectIrFreshness = inspectIrFreshness;
37
+ exports.reextractMode = reextractMode;
38
+ exports.reextractEnabled = reextractEnabled;
39
+ const fs = __importStar(require("fs"));
40
+ const path = __importStar(require("path"));
41
+ /**
42
+ * ir.json 陈旧性判定(2026-09-19,独立于污点试点立项)。
43
+ *
44
+ * 背景(真实事故,两次会话级误判):
45
+ * - trust 引擎的自动提取是「缺才提取」:项目已有 ir.json 就以文件为准、不重生成。
46
+ * - 该语义在【源码变了但 ir.json 没变】的场景下静默过期——
47
+ * ① 分析器读的是旧调用序列(fix-regression 复测结论可能是哑值);
48
+ * ② P4.5 注解合并读同一个 ir.json,旧文件让新加的 @protocol 注解不生效。
49
+ * - fr-005 首扫报 0(`rm ir.json` 后才报出)、污点试点 V1 的「标记注入但 trust 报 0」
50
+ * 根因都是同一处。
51
+ *
52
+ * 政策:mtime 比对,不做「每次全量重提」。
53
+ * - 全量重提对 skyvern 级项目是 5–10 分钟量级成本,不能默认付;
54
+ * - 只在【存在源文件 mtime > ir.json mtime】时才重提,常量级开销(一次 stat 遍历)。
55
+ * 政策:mtime 比对,不做「每次全量重提」。
56
+ * - 全量重提对 skyvern 级项目是 5–10 分钟量级成本,不能默认付;对本仓库
57
+ * (22k 源文件)足以触发单测 30s 超时——实测教训,见 tests/trust/engine.test.ts。
58
+ * - 只在【存在源文件 mtime > ir.json mtime】时才重提(一次 stat 遍历,恒定成本)。
59
+ * - 超大仓库(遍历被 MAX_STATS 截断)默认【只警告不重提】:此时 freshness 证据
60
+ * 不完整,自动重提会把 CLI 拖到分钟级。确需重提时显式 PROGMUNE_IR_REEXTRACT=always。
61
+ *
62
+ * 策略阀 `PROGMUNE_IR_REEXTRACT`:
63
+ * - 未设(auto):遍历完整且陈旧 → 重提;遍历被截断 → 只警告
64
+ * - always :只要陈旧就重提(自担成本,用于批量语料复测)
65
+ * - never :回到旧语义(永不重提),但会打「IR 可能陈旧」警告——不允许静默。
66
+ */
67
+ /** 会被提取器读取的源码后缀(跨语言;不在表内的文件不影响 IR 内容)。 */
68
+ const SOURCE_EXT = new Set([
69
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
70
+ ".py", ".c", ".h", ".cc", ".cpp", ".hpp",
71
+ ".go", ".java", ".rs", ".rb", ".php", ".cs", ".swift", ".kt", ".scala",
72
+ ]);
73
+ /** 不可能是业务源码、且体量足以毁掉遍历成本的目录。 */
74
+ const SKIP_DIRS = new Set([
75
+ "node_modules", ".git", "dist", "build", "out", "coverage", ".next",
76
+ ".nuxt", ".turbo", ".cache", "__pycache__", ".venv", "venv", "vendor",
77
+ "target", "bin", "obj", ".gradle", ".idea", ".vscode", ".workbuddy",
78
+ ]);
79
+ /** 防御性上限:超大仓库不做无界遍历(宁可漏判也不卡死)。 */
80
+ const MAX_STATS = 20000;
81
+ const MAX_DEPTH = 12;
82
+ /**
83
+ * auto 模式下愿意为「一次 IR 重提」买单的项目规模上限(源码文件数)。
84
+ * 超过则只警告不自动重提——重提成本随文件数超线性增长,本仓库(万级源文件)
85
+ * 实测足以把单测推过 30s 超时。
86
+ */
87
+ const AUTO_REEXTRACT_FILE_BUDGET = 5000;
88
+ /**
89
+ * 扫描项目目录,找出最新修改的源码文件。
90
+ *
91
+ * 必须【完整遍历】(不做「找到第一个更新的就早退」):早退会在超大仓库上
92
+ * 把 truncated 信号抹掉——本仓库(22k 源文件)就是活例:早退后 evidenceComplete
93
+ * 误为真 → 自动全量重提 → 单测 30s 超时。stat 扫一遍的成本远低于一次 IR 提取,
94
+ * 换来的是可靠的 fresh 判定与可靠的大仓信号。
95
+ *
96
+ * @requires PROJECT_PATH @produces NEWEST_SOURCE
97
+ * @tags ir, staleness, mtime
98
+ */
99
+ function newestSourceUnder(root, opts) {
100
+ const maxStats = opts.maxStats ?? MAX_STATS;
101
+ const maxDepth = opts.maxDepth ?? MAX_DEPTH;
102
+ let newestMs = null;
103
+ let newestFile = null;
104
+ let scanned = 0;
105
+ let truncated = false;
106
+ const walk = (dir, depth) => {
107
+ if (depth > maxDepth || truncated)
108
+ return;
109
+ let entries;
110
+ try {
111
+ entries = fs.readdirSync(dir, { withFileTypes: true });
112
+ }
113
+ catch {
114
+ return; // 权限 / 竞态删除:跳过
115
+ }
116
+ for (const ent of entries) {
117
+ if (truncated)
118
+ return;
119
+ if (ent.isDirectory()) {
120
+ if (SKIP_DIRS.has(ent.name))
121
+ continue;
122
+ if (ent.name.startsWith("."))
123
+ continue; // 隐藏目录多为工具产物
124
+ walk(path.join(dir, ent.name), depth + 1);
125
+ continue;
126
+ }
127
+ if (!ent.isFile())
128
+ continue;
129
+ if (!SOURCE_EXT.has(path.extname(ent.name).toLowerCase()))
130
+ continue;
131
+ if (scanned >= maxStats) {
132
+ truncated = true;
133
+ return;
134
+ }
135
+ const full = path.join(dir, ent.name);
136
+ let st;
137
+ try {
138
+ st = fs.statSync(full, { throwIfNoEntry: false }) ?? undefined;
139
+ }
140
+ catch {
141
+ st = undefined;
142
+ }
143
+ if (!st)
144
+ continue;
145
+ scanned++;
146
+ if (newestMs === null || st.mtimeMs > newestMs) {
147
+ newestMs = st.mtimeMs;
148
+ newestFile = full;
149
+ }
150
+ }
151
+ };
152
+ walk(root, 0);
153
+ return { mtimeMs: newestMs, file: newestFile, scannedFiles: scanned, truncated };
154
+ }
155
+ /**
156
+ * 判定 ir.json 是否需要重新生成。
157
+ *
158
+ * - ir.json 不存在 → missing(需提取)
159
+ * - 存在源码 mtime > ir.json mtime → source-newer(需重提)
160
+ * - 其余 → fresh / no-sources(沿用磁盘文件)
161
+ *
162
+ * @requires PROJECT_PATH @produces IR_FRESHNESS
163
+ * @tags ir, staleness, mtime
164
+ * @useWhen evaluateTrust 决定是否自动提取 IR
165
+ */
166
+ function inspectIrFreshness(projectPath, irPathOverride, opts = {}) {
167
+ const irPath = irPathOverride || path.join(projectPath, "ir.json");
168
+ let irStat;
169
+ try {
170
+ irStat = fs.statSync(irPath, { throwIfNoEntry: false }) ?? undefined;
171
+ }
172
+ catch {
173
+ irStat = undefined;
174
+ }
175
+ const irMtimeMs = irStat ? irStat.mtimeMs : null;
176
+ const src = newestSourceUnder(projectPath, opts);
177
+ const exceedsAutoBudget = src.scannedFiles > (opts.autoBudget ?? AUTO_REEXTRACT_FILE_BUDGET);
178
+ if (!irStat) {
179
+ return {
180
+ exists: false,
181
+ stale: true,
182
+ reason: "missing",
183
+ irMtimeMs: null,
184
+ newestSourceMtimeMs: src.mtimeMs,
185
+ newestSourcePath: src.file,
186
+ scannedFiles: src.scannedFiles,
187
+ truncated: src.truncated,
188
+ evidenceComplete: false, // 是否有证据都不影响:缺就必须提
189
+ exceedsAutoBudget,
190
+ };
191
+ }
192
+ if (src.mtimeMs === null) {
193
+ // 扫不到源码(目标目录不是真源码根,或全是跳过后缀):无证据 → 保守沿用。
194
+ return {
195
+ exists: true,
196
+ stale: false,
197
+ reason: "no-sources",
198
+ irMtimeMs,
199
+ newestSourceMtimeMs: null,
200
+ newestSourcePath: null,
201
+ scannedFiles: 0,
202
+ truncated: src.truncated,
203
+ evidenceComplete: !src.truncated,
204
+ exceedsAutoBudget,
205
+ };
206
+ }
207
+ // 严格大于:ir.json 与源码同刻写出(写源码→随后写 ir.json 的常见顺序)
208
+ // 判为 fresh,避免把正常管线推入全量重提。
209
+ const stale = irMtimeMs !== null && src.mtimeMs > irMtimeMs;
210
+ return {
211
+ exists: true,
212
+ stale,
213
+ reason: stale ? "source-newer" : "fresh",
214
+ irMtimeMs,
215
+ newestSourceMtimeMs: src.mtimeMs,
216
+ newestSourcePath: src.file,
217
+ scannedFiles: src.scannedFiles,
218
+ truncated: src.truncated,
219
+ evidenceComplete: !src.truncated,
220
+ exceedsAutoBudget,
221
+ };
222
+ }
223
+ function reextractMode() {
224
+ const v = (process.env.PROGMUNE_IR_REEXTRACT || "").toLowerCase();
225
+ if (v === "always")
226
+ return "always";
227
+ if (v === "never" || v === "off" || v === "0" || v === "false")
228
+ return "never";
229
+ return "auto";
230
+ }
231
+ function reextractEnabled() {
232
+ return reextractMode() !== "never";
233
+ }
@@ -0,0 +1,228 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ const vitest_1 = require("vitest");
37
+ const fs = __importStar(require("fs"));
38
+ const os = __importStar(require("os"));
39
+ const path = __importStar(require("path"));
40
+ const ir_staleness_1 = require("./ir-staleness");
41
+ /**
42
+ * ir.json 陈旧性判定的回归测试(2026-09-19)。
43
+ *
44
+ * 锁三件事:
45
+ * 1. ir.json 缺失 → missing(需提取)——保住原有的自动提取语义;
46
+ * 2. 源码比 ir.json 新 → source-newer(需重提)——本次新增的判别力;
47
+ * 3. ir.json 不早于源码 → fresh(沿用磁盘文件)——不做无谓的全量重提,
48
+ * 否则 skyvern 级项目每次扫描要多付分钟级成本。
49
+ */
50
+ function tmpdir(tag) {
51
+ return fs.mkdtempSync(path.join(os.tmpdir(), `pm-ir-stale-${tag}-`));
52
+ }
53
+ /** 把文件的 mtime 显式钉到某个 epoch ms(避免同一毫秒内写完两文件的歧义)。 */
54
+ function touch(file, content, mtimeMs) {
55
+ fs.writeFileSync(file, content);
56
+ fs.utimesSync(file, mtimeMs / 1000, mtimeMs / 1000);
57
+ }
58
+ (0, vitest_1.describe)("inspectIrFreshness", () => {
59
+ (0, vitest_1.it)("ir.json 缺失 → missing,stale=true(触发自动提取)", () => {
60
+ const dir = tmpdir("missing");
61
+ try {
62
+ touch(path.join(dir, "app.ts"), "export const x = 1;", 1700000000000);
63
+ const r = (0, ir_staleness_1.inspectIrFreshness)(dir);
64
+ (0, vitest_1.expect)(r.exists).toBe(false);
65
+ (0, vitest_1.expect)(r.stale).toBe(true);
66
+ (0, vitest_1.expect)(r.reason).toBe("missing");
67
+ (0, vitest_1.expect)(r.irMtimeMs).toBeNull();
68
+ }
69
+ finally {
70
+ fs.rmSync(dir, { recursive: true, force: true });
71
+ }
72
+ });
73
+ (0, vitest_1.it)("源码比 ir.json 新 → source-newer(本次修复的核心:原先会被静默沿用)", () => {
74
+ const dir = tmpdir("newer");
75
+ try {
76
+ const irFile = path.join(dir, "ir.json");
77
+ const srcFile = path.join(dir, "app.ts");
78
+ touch(irFile, "[]", 1700000000000);
79
+ touch(srcFile, "export const x = 1;", 1700000100000); // 晚 100s
80
+ const r = (0, ir_staleness_1.inspectIrFreshness)(dir);
81
+ (0, vitest_1.expect)(r.exists).toBe(true);
82
+ (0, vitest_1.expect)(r.stale).toBe(true);
83
+ (0, vitest_1.expect)(r.reason).toBe("source-newer");
84
+ (0, vitest_1.expect)(r.newestSourcePath).toBe(srcFile);
85
+ }
86
+ finally {
87
+ fs.rmSync(dir, { recursive: true, force: true });
88
+ }
89
+ });
90
+ (0, vitest_1.it)("ir.json 比源码新 → fresh(不重提,避免分钟级全量重提)", () => {
91
+ const dir = tmpdir("fresh");
92
+ try {
93
+ touch(path.join(dir, "app.ts"), "export const x = 1;", 1700000000000);
94
+ touch(path.join(dir, "ir.json"), "[]", 1700000100000);
95
+ const r = (0, ir_staleness_1.inspectIrFreshness)(dir);
96
+ (0, vitest_1.expect)(r.stale).toBe(false);
97
+ (0, vitest_1.expect)(r.reason).toBe("fresh");
98
+ (0, vitest_1.expect)(r.scannedFiles).toBe(1);
99
+ }
100
+ finally {
101
+ fs.rmSync(dir, { recursive: true, force: true });
102
+ }
103
+ });
104
+ (0, vitest_1.it)("同刻写出(源码→ir.json 的常见顺序)→ fresh(不得推入全量重提)", () => {
105
+ const dir = tmpdir("same");
106
+ try {
107
+ touch(path.join(dir, "app.ts"), "export const x = 1;", 1700000000000);
108
+ touch(path.join(dir, "ir.json"), "[]", 1700000000000);
109
+ const r = (0, ir_staleness_1.inspectIrFreshness)(dir);
110
+ (0, vitest_1.expect)(r.stale).toBe(false);
111
+ (0, vitest_1.expect)(r.reason).toBe("fresh");
112
+ }
113
+ finally {
114
+ fs.rmSync(dir, { recursive: true, force: true });
115
+ }
116
+ });
117
+ (0, vitest_1.it)("子目录源码也算源码(只看根目录下的话会漏判)", () => {
118
+ const dir = tmpdir("nested");
119
+ try {
120
+ touch(path.join(dir, "ir.json"), "[]", 1700000000000);
121
+ const nested = path.join(dir, "src", "deep");
122
+ fs.mkdirSync(nested, { recursive: true });
123
+ touch(path.join(nested, "handler.ts"), "export function handle() {}", 1700000500000);
124
+ const r = (0, ir_staleness_1.inspectIrFreshness)(dir);
125
+ (0, vitest_1.expect)(r.stale).toBe(true);
126
+ (0, vitest_1.expect)(r.newestSourcePath).toContain(path.join("src", "deep", "handler.ts"));
127
+ }
128
+ finally {
129
+ fs.rmSync(dir, { recursive: true, force: true });
130
+ }
131
+ });
132
+ (0, vitest_1.it)("node_modules 等非源码目录被跳过(成本约束:不得遍历依赖树)", () => {
133
+ const dir = tmpdir("skip");
134
+ try {
135
+ touch(path.join(dir, "ir.json"), "[]", 1700000500000);
136
+ const nm = path.join(dir, "node_modules", "pkg");
137
+ fs.mkdirSync(nm, { recursive: true });
138
+ touch(path.join(nm, "index.js"), "module.exports = 1;", 1700000900000);
139
+ const r = (0, ir_staleness_1.inspectIrFreshness)(dir);
140
+ (0, vitest_1.expect)(r.stale).toBe(false);
141
+ (0, vitest_1.expect)(r.reason).toBe("no-sources");
142
+ (0, vitest_1.expect)(r.scannedFiles).toBe(0);
143
+ }
144
+ finally {
145
+ fs.rmSync(dir, { recursive: true, force: true });
146
+ }
147
+ });
148
+ (0, vitest_1.it)("非源码后缀(png/md/json)不影响判定", () => {
149
+ const dir = tmpdir("ext");
150
+ try {
151
+ touch(path.join(dir, "ir.json"), "[]", 1700000500000);
152
+ touch(path.join(dir, "README.md"), "hello", 1700000900000);
153
+ touch(path.join(dir, "logo.png"), "x", 1700000900000);
154
+ const r = (0, ir_staleness_1.inspectIrFreshness)(dir);
155
+ (0, vitest_1.expect)(r.stale).toBe(false);
156
+ (0, vitest_1.expect)(r.reason).toBe("no-sources");
157
+ }
158
+ finally {
159
+ fs.rmSync(dir, { recursive: true, force: true });
160
+ }
161
+ });
162
+ (0, vitest_1.it)("源码规模超预算 → exceedsAutoBudget(auto 模式只警告,不自动全量重提)", () => {
163
+ const dir = tmpdir("budget");
164
+ try {
165
+ touch(path.join(dir, "ir.json"), "[]", 1700000000000);
166
+ for (let i = 0; i < 5; i++) {
167
+ touch(path.join(dir, `f${i}.ts`), "export const x = 1;", 1700000100000);
168
+ }
169
+ const r = (0, ir_staleness_1.inspectIrFreshness)(dir, undefined, { autoBudget: 3 });
170
+ (0, vitest_1.expect)(r.stale).toBe(true); // 陈旧判定不受预算影响
171
+ (0, vitest_1.expect)(r.exceedsAutoBudget).toBe(true);
172
+ (0, vitest_1.expect)(r.scannedFiles).toBe(5);
173
+ }
174
+ finally {
175
+ fs.rmSync(dir, { recursive: true, force: true });
176
+ }
177
+ });
178
+ (0, vitest_1.it)("遍历被上限截断时标记 evidenceComplete=false(超大仓库不得静默全量重提)", () => {
179
+ const dir = tmpdir("trunc");
180
+ try {
181
+ touch(path.join(dir, "ir.json"), "[]", 1700000000000);
182
+ for (let i = 0; i < 5; i++) {
183
+ touch(path.join(dir, `f${i}.ts`), "export const x = 1;", 1700000100000);
184
+ }
185
+ // maxStats=2:扫到 2 个就停 —— stale 仍为真(证据已经够),但 fresh 不可信
186
+ const r = (0, ir_staleness_1.inspectIrFreshness)(dir, undefined, { maxStats: 2 });
187
+ (0, vitest_1.expect)(r.stale).toBe(true);
188
+ (0, vitest_1.expect)(r.truncated).toBe(true);
189
+ (0, vitest_1.expect)(r.evidenceComplete).toBe(false);
190
+ }
191
+ finally {
192
+ fs.rmSync(dir, { recursive: true, force: true });
193
+ }
194
+ });
195
+ (0, vitest_1.it)("正常仓库(未截断)fresh 判定证据完整", () => {
196
+ const dir = tmpdir("complete");
197
+ try {
198
+ touch(path.join(dir, "app.ts"), "export const x = 1;", 1700000000000);
199
+ touch(path.join(dir, "ir.json"), "[]", 1700000100000);
200
+ const r = (0, ir_staleness_1.inspectIrFreshness)(dir);
201
+ (0, vitest_1.expect)(r.evidenceComplete).toBe(true);
202
+ (0, vitest_1.expect)(r.reason).toBe("fresh");
203
+ }
204
+ finally {
205
+ fs.rmSync(dir, { recursive: true, force: true });
206
+ }
207
+ });
208
+ (0, vitest_1.it)("逃逸阀:默认 auto", () => {
209
+ const prev = process.env.PROGMUNE_IR_REEXTRACT;
210
+ delete process.env.PROGMUNE_IR_REEXTRACT;
211
+ (0, vitest_1.expect)((0, ir_staleness_1.reextractMode)()).toBe("auto");
212
+ (0, vitest_1.expect)((0, ir_staleness_1.reextractEnabled)()).toBe(true);
213
+ if (prev !== undefined)
214
+ process.env.PROGMUNE_IR_REEXTRACT = prev;
215
+ });
216
+ (0, vitest_1.it)("逃生阀:always / never 语义", () => {
217
+ const prev = process.env.PROGMUNE_IR_REEXTRACT;
218
+ process.env.PROGMUNE_IR_REEXTRACT = "always";
219
+ (0, vitest_1.expect)((0, ir_staleness_1.reextractMode)()).toBe("always");
220
+ process.env.PROGMUNE_IR_REEXTRACT = "never";
221
+ (0, vitest_1.expect)((0, ir_staleness_1.reextractMode)()).toBe("never");
222
+ (0, vitest_1.expect)((0, ir_staleness_1.reextractEnabled)()).toBe(false);
223
+ if (prev === undefined)
224
+ delete process.env.PROGMUNE_IR_REEXTRACT;
225
+ else
226
+ process.env.PROGMUNE_IR_REEXTRACT = prev;
227
+ });
228
+ });
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.30";
23
+ exports.RUNTIME_VERSION = "3.7.32";
24
24
  function verify(filePath) {
25
25
  const cert = (0, certify_1.certify)(filePath);
26
26
  const kb = (0, protocol_knowledge_1.buildKnowledgeBase)();
@@ -62,6 +62,7 @@ const api_semantic_mapper_1 = require("./api-semantic-mapper");
62
62
  const protocol_domain_validator_1 = require("./protocol-domain-validator");
63
63
  const annotation_suggest_1 = require("../annotation-suggest");
64
64
  const call_graph_propagator_1 = require("./call-graph-propagator");
65
+ const ir_staleness_1 = require("../ir-staleness");
65
66
  const fastapi_detector_1 = require("../frameworks/fastapi-detector");
66
67
  const django_detector_1 = require("../frameworks/django-detector");
67
68
  const flask_detector_1 = require("../frameworks/flask-detector");
@@ -87,7 +88,10 @@ const extract_ir_java_1 = require("../extract-ir-java");
87
88
  * C → extractIRC 合并形态 { typeMap, functions })——注解合并(P4.5)依赖
88
89
  * ir.json,调用方【无需】手动 extractProjectIR/写盘(曾是对 C 注解静默
89
90
  * 失效的文档/API 陷阱,回归测试见 tests/trust/engine.test.ts「DSH 陷阱」)。
90
- * 若项目已有 ir.json,以现有文件为准(不覆盖)。
91
+ * 若项目已有 ir.json:原语义是「以现有文件为准(不覆盖)」——该语义会让
92
+ * 【源码已改、ir.json 未更新】的场景静默读旧 IR(2026-09-19 修正):
93
+ * mtime 比对发现有源码新于 ir.json 时重提;否则沿用磁盘文件(不做无谓的全量重提)。
94
+ * 逃生阀 PROGMUNE_IR_REEXTRACT=never 可回到旧语义(会打「IR 可能陈旧」警告)。
91
95
  */
92
96
  async function evaluateTrust(ctx) {
93
97
  const engineVersion = "trust-runtime-v1.0.0";
@@ -1228,7 +1232,29 @@ async function collectProtocolViolations(ctx, callGraph) {
1228
1232
  if (extractFn) {
1229
1233
  try {
1230
1234
  const fs = require("fs");
1231
- if (!fs.existsSync(path.join(ctx.projectPath, "ir.json"))) {
1235
+ // 陈旧性判定(2026-09-19):原来只看 existsSync——源码改了、ir.json 没更新时
1236
+ // 引擎静默吃旧 IR(fr-005 首扫报 0、污点试点「标记注入但 trust 报 0」同源)。
1237
+ // 改为 mtime 比对:仅当有源码比 ir.json 新时才重提,避免全量重提的分钟级成本。
1238
+ const freshness = (0, ir_staleness_1.inspectIrFreshness)(ctx.projectPath);
1239
+ const mode = (0, ir_staleness_1.reextractMode)();
1240
+ // 陈旧的判定由 mtime 给出;是否【自动】重提还要过成本闸:
1241
+ // ① evidenceComplete 为假 → 遍历被截断,证据不可信;
1242
+ // ② exceedsAutoBudget → 源码规模超预算(万级源文件的本仓库实测把
1243
+ // 单次 evaluateTrust 推过 30s 超时),重提成本不该由默认路径承担。
1244
+ // 两种情况都改为显式警告;语料复测等场景用 PROGMUNE_IR_REEXTRACT=always 承担。
1245
+ const shouldExtract = !freshness.exists ||
1246
+ (freshness.stale &&
1247
+ (mode === "always" ||
1248
+ (mode === "auto" &&
1249
+ freshness.evidenceComplete &&
1250
+ !freshness.exceedsAutoBudget)));
1251
+ if (freshness.exists && freshness.stale && !shouldExtract) {
1252
+ console.warn(`[progmune] ⚠️ IR 可能陈旧:${freshness.newestSourcePath} 新于 ir.json` +
1253
+ `(mode=${mode}${freshness.exceedsAutoBudget ? ",源码超出重提预算" : ""}` +
1254
+ `${freshness.truncated ? ",遍历被截断" : ""};` +
1255
+ `需重提取运行 PROGMUNE_IR_REEXTRACT=always 或删除 ir.json)`);
1256
+ }
1257
+ if (shouldExtract) {
1232
1258
  const ir = extractFn();
1233
1259
  // C 走合并形态(与 execute/MCP 写盘一致);TS/Python 保持裸数组形态
1234
1260
  const payload = lang === "c" ? { typeMap: {}, functions: ir } : ir;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "progmune-runtime",
3
- "version": "3.7.31",
3
+ "version": "3.7.32",
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/",