@whyshu/argent-engine 0.0.0-stage → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (340) hide show
  1. package/MANIFEST.json +341 -0
  2. package/engine/VERSION +1 -0
  3. package/engine/agents/catalog.json +454 -0
  4. package/engine/configs/_aliases.proposed.json +651 -0
  5. package/engine/configs/_migration-plan.proposed.json +899 -0
  6. package/engine/configs/account.json +272 -0
  7. package/engine/configs/advanced.json +205 -0
  8. package/engine/configs/host.json +149 -0
  9. package/engine/configs/hr.json +27 -0
  10. package/engine/configs/notify.json +264 -0
  11. package/engine/core/cli/argent.mjs +277 -0
  12. package/engine/core/contracts/argent.contracts.json +1065 -0
  13. package/engine/core/contracts/verbs.schema.json +2539 -0
  14. package/engine/core/lib/argent-datadir.mjs +223 -0
  15. package/engine/core/lib/audit.mjs +187 -0
  16. package/engine/core/lib/claim.mjs +258 -0
  17. package/engine/core/lib/config-catalog.mjs +1172 -0
  18. package/engine/core/lib/doctor-customer.mjs +577 -0
  19. package/engine/core/lib/flow-list.mjs +138 -0
  20. package/engine/core/lib/flow-registry.mjs +48 -0
  21. package/engine/core/lib/init.mjs +247 -0
  22. package/engine/core/lib/ledger.mjs +124 -0
  23. package/engine/core/lib/license-activate.mjs +813 -0
  24. package/engine/core/lib/license-device.mjs +314 -0
  25. package/engine/core/lib/license-file.mjs +349 -0
  26. package/engine/core/lib/license-guard.mjs +123 -0
  27. package/engine/core/lib/license-ops.mjs +1645 -0
  28. package/engine/core/lib/license-verdict.mjs +133 -0
  29. package/engine/core/lib/license.mjs +577 -0
  30. package/engine/core/lib/notify/dedup.mjs +101 -0
  31. package/engine/core/lib/notify/feishu.mjs +38 -0
  32. package/engine/core/lib/notify/index.mjs +95 -0
  33. package/engine/core/lib/notify/verb.mjs +327 -0
  34. package/engine/core/lib/notify/webhook.mjs +135 -0
  35. package/engine/core/lib/notify/wecom.mjs +36 -0
  36. package/engine/core/lib/python.mjs +145 -0
  37. package/engine/core/lib/sop.mjs +116 -0
  38. package/engine/core/lib/versions.mjs +205 -0
  39. package/engine/core/mcp/client.mjs +67 -0
  40. package/engine/core/mcp/http.mjs +138 -0
  41. package/engine/core/mcp/server.mjs +702 -0
  42. package/engine/scripts/_engine_rules_probe.py +15 -0
  43. package/engine/scripts/analyze-full-day.py +568 -0
  44. package/engine/scripts/archive/feishu-bridge.js +246 -0
  45. package/engine/scripts/argent-agents.mjs +527 -0
  46. package/engine/scripts/argent-capabilities.mjs +776 -0
  47. package/engine/scripts/argent-catalog.mjs +186 -0
  48. package/engine/scripts/argent-datadir.py +62 -0
  49. package/engine/scripts/argent-doctor.mjs +624 -0
  50. package/engine/scripts/argent-env-write.mjs +302 -0
  51. package/engine/scripts/argent-schedule.mjs +620 -0
  52. package/engine/scripts/argent-setup-check.mjs +369 -0
  53. package/engine/scripts/argent-upgrade.mjs +1046 -0
  54. package/engine/scripts/base-bootstrap.sh +576 -0
  55. package/engine/scripts/billing-mode-daily.sh +13 -0
  56. package/engine/scripts/build-engine-package.mjs +126 -0
  57. package/engine/scripts/capability-packs.lock.json +484 -0
  58. package/engine/scripts/capability_pack.py +1242 -0
  59. package/engine/scripts/check-1.0-criteria.mjs +355 -0
  60. package/engine/scripts/check-agent-catalog.mjs +141 -0
  61. package/engine/scripts/check-billing-mode.sh +378 -0
  62. package/engine/scripts/check-child-sessions.mjs +83 -0
  63. package/engine/scripts/check-config-catalog.mjs +677 -0
  64. package/engine/scripts/check-docs-drift.mjs +522 -0
  65. package/engine/scripts/check-dsh-web-expose.sh +98 -0
  66. package/engine/scripts/check-judgment-drift.mjs +403 -0
  67. package/engine/scripts/check-license.mjs +199 -0
  68. package/engine/scripts/check-model-auth.py +443 -0
  69. package/engine/scripts/check-pack-closure.mjs +661 -0
  70. package/engine/scripts/check-portable-symbols.mjs +116 -0
  71. package/engine/scripts/check-preset-landing.mjs +124 -0
  72. package/engine/scripts/check-production-home.mjs +602 -0
  73. package/engine/scripts/check-role-mapping.mjs +461 -0
  74. package/engine/scripts/check-sample-redaction.mjs +320 -0
  75. package/engine/scripts/check-skill-classification.mjs +188 -0
  76. package/engine/scripts/check-sop-consistency.mjs +431 -0
  77. package/engine/scripts/check-test-env-safety.mjs +627 -0
  78. package/engine/scripts/check-version-consistency.mjs +452 -0
  79. package/engine/scripts/ci-run.sh +558 -0
  80. package/engine/scripts/collect-evidence.mjs +42 -0
  81. package/engine/scripts/config-explain.mjs +278 -0
  82. package/engine/scripts/configure-provider.rb +29 -0
  83. package/engine/scripts/customer-config-migrate.mjs +179 -0
  84. package/engine/scripts/customer-preflight.py +892 -0
  85. package/engine/scripts/customer-readiness.sh +135 -0
  86. package/engine/scripts/daily-report-inbox.py +242 -0
  87. package/engine/scripts/daily-report-reconcile.mjs +312 -0
  88. package/engine/scripts/daily-report.mjs +1310 -0
  89. package/engine/scripts/daily-report.sh +47 -0
  90. package/engine/scripts/data/lip-product-dict.json +306 -0
  91. package/engine/scripts/deliver-customer.mjs +932 -0
  92. package/engine/scripts/delivery-faces.txt +31 -0
  93. package/engine/scripts/desktop-trial.sh +146 -0
  94. package/engine/scripts/dev-window.sh +261 -0
  95. package/engine/scripts/drill-clean-machine.sh +315 -0
  96. package/engine/scripts/dsh-api-client.mjs +215 -0
  97. package/engine/scripts/dsh-backup-scope.sh +83 -0
  98. package/engine/scripts/dsh-offline-pack.mjs +77 -0
  99. package/engine/scripts/dsh-overlay-pin.sh +87 -0
  100. package/engine/scripts/dsh-plane.mjs +153 -0
  101. package/engine/scripts/dsh-portforward.py +223 -0
  102. package/engine/scripts/dsh-preflight.mjs +179 -0
  103. package/engine/scripts/dsh-release-watch-daily.sh +19 -0
  104. package/engine/scripts/dsh-release-watch.mjs +458 -0
  105. package/engine/scripts/dsh-remote-check.sh +99 -0
  106. package/engine/scripts/dsh-restart.sh +179 -0
  107. package/engine/scripts/dsh-restore.sh +111 -0
  108. package/engine/scripts/dsh-scope-rewire.mjs +803 -0
  109. package/engine/scripts/dsh-snapshot.mjs +116 -0
  110. package/engine/scripts/dsh-upgrade-readiness.mjs +165 -0
  111. package/engine/scripts/dsh-upgrade.mjs +612 -0
  112. package/engine/scripts/dsh-url.sh +86 -0
  113. package/engine/scripts/dsh-web-launch.mjs +155 -0
  114. package/engine/scripts/ensure-store-windows.py +509 -0
  115. package/engine/scripts/entitlements.py +458 -0
  116. package/engine/scripts/env_probe.py +411 -0
  117. package/engine/scripts/feishu-card.mjs +252 -0
  118. package/engine/scripts/final-check.mjs +43 -0
  119. package/engine/scripts/fix-skill-frontmatter.py +209 -0
  120. package/engine/scripts/flow-auto-retry.py +454 -0
  121. package/engine/scripts/flow-requirements.json +735 -0
  122. package/engine/scripts/flow-scaffold.py +275 -0
  123. package/engine/scripts/flow-timers-plist.sh +87 -0
  124. package/engine/scripts/flow-timers.sh +184 -0
  125. package/engine/scripts/flow-trigger-wake.mjs +284 -0
  126. package/engine/scripts/flow-trigger.sh +571 -0
  127. package/engine/scripts/flow_billing.py +354 -0
  128. package/engine/scripts/flow_capability_stats.py +314 -0
  129. package/engine/scripts/flow_case.py +194 -0
  130. package/engine/scripts/flow_claim.py +497 -0
  131. package/engine/scripts/flow_config.py +1025 -0
  132. package/engine/scripts/flow_failure.py +588 -0
  133. package/engine/scripts/flow_learn.py +105 -0
  134. package/engine/scripts/flow_ledger.py +235 -0
  135. package/engine/scripts/flow_policy.py +238 -0
  136. package/engine/scripts/flow_preflight.py +339 -0
  137. package/engine/scripts/flow_registry.py +341 -0
  138. package/engine/scripts/flow_retry.py +304 -0
  139. package/engine/scripts/flow_return_to_ops.py +191 -0
  140. package/engine/scripts/flow_source.py +315 -0
  141. package/engine/scripts/flow_sources/__init__.py +53 -0
  142. package/engine/scripts/flow_sources/cases_json.py +106 -0
  143. package/engine/scripts/flow_sources/common.py +428 -0
  144. package/engine/scripts/flow_sources/feishu_base.py +273 -0
  145. package/engine/scripts/flow_stale.py +185 -0
  146. package/engine/scripts/flow_status_reconcile.py +344 -0
  147. package/engine/scripts/flow_triage.py +849 -0
  148. package/engine/scripts/flow_write_verify.py +496 -0
  149. package/engine/scripts/fulfillment.js +586 -0
  150. package/engine/scripts/gen-agent-bundles.mjs +276 -0
  151. package/engine/scripts/gen-preset-rows.mjs +259 -0
  152. package/engine/scripts/init-ml.sh +2278 -0
  153. package/engine/scripts/install-internal-role.sh +190 -0
  154. package/engine/scripts/install-license.mjs +641 -0
  155. package/engine/scripts/install-one-shot.mjs +665 -0
  156. package/engine/scripts/integration_contract.py +220 -0
  157. package/engine/scripts/integrations/wenyao/adapter.py +98 -0
  158. package/engine/scripts/integrations/xzh/adapter.py +75 -0
  159. package/engine/scripts/invoice-mapping.example.json +40 -0
  160. package/engine/scripts/invoice-table.sh +195 -0
  161. package/engine/scripts/invoice_carrier_notice.py +279 -0
  162. package/engine/scripts/invoice_fail_closeout.py +297 -0
  163. package/engine/scripts/invoice_lip_runner.py +1508 -0
  164. package/engine/scripts/invoice_lip_runner.py.bak-20260922-1710-freready +1090 -0
  165. package/engine/scripts/invoice_ready_audit.py +191 -0
  166. package/engine/scripts/invoice_runner.py +675 -0
  167. package/engine/scripts/invoice_stageb.py +516 -0
  168. package/engine/scripts/launchd/com.whyshu.argent-bridge.plist.example +42 -0
  169. package/engine/scripts/launchd/com.whyshu.fulfillment-poll.plist.example +29 -0
  170. package/engine/scripts/lease.py +377 -0
  171. package/engine/scripts/lib/agent-install.mjs +649 -0
  172. package/engine/scripts/lib/argent-datadir.mjs +16 -0
  173. package/engine/scripts/lib/argent-datadir.sh +18 -0
  174. package/engine/scripts/lib/argent_datadir.py +285 -0
  175. package/engine/scripts/lib/argent_filelock.py +507 -0
  176. package/engine/scripts/lib/bootcheck-wiring.mjs +510 -0
  177. package/engine/scripts/lib/cdp_ports.py +197 -0
  178. package/engine/scripts/lib/customer-env.mjs +102 -0
  179. package/engine/scripts/lib/daily-report-core.mjs +517 -0
  180. package/engine/scripts/lib/dep-declaration.mjs +81 -0
  181. package/engine/scripts/lib/deps-migrate.mjs +310 -0
  182. package/engine/scripts/lib/dsh-host-resolve.mjs +109 -0
  183. package/engine/scripts/lib/dsh-scope.mjs +500 -0
  184. package/engine/scripts/lib/dsh-snapshot-path.mjs +35 -0
  185. package/engine/scripts/lib/dsh-versions.mjs +96 -0
  186. package/engine/scripts/lib/dsh-web-probe.mjs +214 -0
  187. package/engine/scripts/lib/is-direct-run.mjs +61 -0
  188. package/engine/scripts/lib/patch-compose.mjs +241 -0
  189. package/engine/scripts/lib/patch-target.mjs +35 -0
  190. package/engine/scripts/lib/plugin-lifecycle.mjs +128 -0
  191. package/engine/scripts/lib/plugin-packages.mjs +332 -0
  192. package/engine/scripts/lib/preset-compat.mjs +127 -0
  193. package/engine/scripts/lib/preset-install.mjs +575 -0
  194. package/engine/scripts/lib/preset-mechanism.mjs +56 -0
  195. package/engine/scripts/lib/preset-sync.mjs +59 -0
  196. package/engine/scripts/lib/profile-retire.mjs +97 -0
  197. package/engine/scripts/lib/python-runtime.mjs +190 -0
  198. package/engine/scripts/lib/registry.mjs +72 -0
  199. package/engine/scripts/lib/repo-root.sh +56 -0
  200. package/engine/scripts/lib/schedule-mechanism.mjs +659 -0
  201. package/engine/scripts/lib/schedule-probe.mjs +835 -0
  202. package/engine/scripts/lib/scope-pointer.mjs +164 -0
  203. package/engine/scripts/lib/settings-panels.mjs +816 -0
  204. package/engine/scripts/lib/stdio_utf8.py +86 -0
  205. package/engine/scripts/lib/wake-auth.mjs +392 -0
  206. package/engine/scripts/lib/webdriver_ipc.py +264 -0
  207. package/engine/scripts/lib/zstd-frames.mjs +104 -0
  208. package/engine/scripts/license-e2e-drill.mjs +426 -0
  209. package/engine/scripts/license-gate.mjs +245 -0
  210. package/engine/scripts/lip_backlog.py +171 -0
  211. package/engine/scripts/lip_table.py +185 -0
  212. package/engine/scripts/local-usage.mjs +214 -0
  213. package/engine/scripts/migrate-providers.mjs +127 -0
  214. package/engine/scripts/migrate-skills.sh +65 -0
  215. package/engine/scripts/ml-oauth-setup.mjs +550 -0
  216. package/engine/scripts/ml-store-ids.json +26 -0
  217. package/engine/scripts/ml_artifact_attribution.py +333 -0
  218. package/engine/scripts/ml_capacity_probe.py +1158 -0
  219. package/engine/scripts/ml_hub_status.py +1556 -0
  220. package/engine/scripts/ml_schema_check.py +115 -0
  221. package/engine/scripts/ml_store_ids.py +139 -0
  222. package/engine/scripts/npm-publish-argent.mjs +499 -0
  223. package/engine/scripts/npm-publish-argent.sh +20 -0
  224. package/engine/scripts/orchestrator.py +13811 -0
  225. package/engine/scripts/pack-plugins.mjs +140 -0
  226. package/engine/scripts/patches/dsh-llm-deepseek--transport-diagnostics.patch +297 -0
  227. package/engine/scripts/patches/dsh-session--append-message-shape.patch +15 -0
  228. package/engine/scripts/patches/dsh-session--tool-result-block-017.patch +28 -0
  229. package/engine/scripts/patches/dsh-session-format-v0-to-v1--permission-preset-origin.patch +11 -0
  230. package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +20 -0
  231. package/engine/scripts/patches/dsh-session-persistence-jsonl--worker-bundled-migrators.patch +39 -0
  232. package/engine/scripts/patches/dsh-tools--render-return-shape.patch +50 -0
  233. package/engine/scripts/patches/verify-bundled-migrators.mjs +79 -0
  234. package/engine/scripts/patches/verify-hardening.mjs +167 -0
  235. package/engine/scripts/patches/verify-session-migration.mjs +149 -0
  236. package/engine/scripts/patches/verify-transport-diagnostics.mjs +227 -0
  237. package/engine/scripts/plan-role-rename.mjs +125 -0
  238. package/engine/scripts/plugin-bootcheck.mjs +451 -0
  239. package/engine/scripts/plugin-composition.json +15 -0
  240. package/engine/scripts/poc-mcp-host.mjs +455 -0
  241. package/engine/scripts/poll-fulfillment.sh +295 -0
  242. package/engine/scripts/poll-invoice.sh +76 -0
  243. package/engine/scripts/profile-doctor.mjs +686 -0
  244. package/engine/scripts/provider-select.sh +71 -0
  245. package/engine/scripts/provider-switch.sh +146 -0
  246. package/engine/scripts/provider-sync-target.check.mjs +64 -0
  247. package/engine/scripts/python-runtime.sh +78 -0
  248. package/engine/scripts/query_runner.py +310 -0
  249. package/engine/scripts/release-argent.sh +574 -0
  250. package/engine/scripts/repair-unresolved-tools.mjs +178 -0
  251. package/engine/scripts/retire-inventory.mjs +40 -0
  252. package/engine/scripts/run-tests.sh +257 -0
  253. package/engine/scripts/s1-audit.mjs +85 -0
  254. package/engine/scripts/self-sign-license.mjs +179 -0
  255. package/engine/scripts/session-health-scan.mjs +63 -0
  256. package/engine/scripts/session-repair-tool-result-content.mjs +181 -0
  257. package/engine/scripts/setup.sh +22 -0
  258. package/engine/scripts/skill-install.mjs +629 -0
  259. package/engine/scripts/sku_translate.py +148 -0
  260. package/engine/scripts/sync-plugins.sh +90 -0
  261. package/engine/scripts/sync-presets.sh +27 -0
  262. package/engine/scripts/sync-scripts.sh +445 -0
  263. package/engine/scripts/telemetry.py +873 -0
  264. package/engine/scripts/ui-copy-count.mjs +220 -0
  265. package/engine/scripts/usage-anchor.sh +137 -0
  266. package/engine/scripts/usage_report.py +531 -0
  267. package/engine/scripts/validate-preset-schema.mjs +208 -0
  268. package/engine/scripts/vertical-init.mjs +323 -0
  269. package/engine/scripts/vertical-lint.mjs +482 -0
  270. package/engine/scripts/wenyao-browser.sh +74 -0
  271. package/engine/scripts/wenyao-fixture-export.py +116 -0
  272. package/engine/scripts/wenyao-invoice-writeback.mjs +540 -0
  273. package/engine/scripts/wenyao-login.sh +35 -0
  274. package/engine/scripts/wenyao-template-map.json +292 -0
  275. package/engine/scripts/wenyao_attach.py +131 -0
  276. package/engine/scripts/wenyao_download_template.py +144 -0
  277. package/engine/scripts/wenyao_fill_template.py +568 -0
  278. package/engine/scripts/wenyao_login.py +145 -0
  279. package/engine/scripts/wenyao_portal.py +633 -0
  280. package/engine/scripts/wenyao_recon.py +230 -0
  281. package/engine/scripts/wenyao_run.py +503 -0
  282. package/engine/scripts/wenyao_shadow_diff.py +160 -0
  283. package/engine/scripts/wenyao_skill_contract.py +156 -0
  284. package/engine/scripts/wenyao_template_fetch.py +175 -0
  285. package/engine/scripts/win/dsh-web-autostart.ps1 +372 -0
  286. package/engine/scripts/ziniao-auth-probe.sh +268 -0
  287. package/engine/scripts/ziniao-keepalive.sh +217 -0
  288. package/engine/scripts/ziniao-post-upgrade-check.sh +121 -0
  289. package/engine/scripts/ziniao-windows.sh +154 -0
  290. package/engine/skills/README.md +86 -0
  291. package/engine/skills/argent-dev/SKILL.md +41 -0
  292. package/engine/skills/internal.json +37 -0
  293. package/engine/skills/lktop-pm/SKILL.md +35 -0
  294. package/engine/skills/lktopapp-dev/SKILL.md +34 -0
  295. package/engine/skills/ml-config-guide/SKILL.md +50 -0
  296. package/engine/skills/ml-fulfillment-sop/SKILL.md +178 -0
  297. package/engine/skills/ml-fulfillment-sop/references/andes-components.md +69 -0
  298. package/engine/skills/ml-fulfillment-sop/references/cdp-port-mapping.md +35 -0
  299. package/engine/skills/ml-fulfillment-sop/references/feishu-base-fulfillment-schema.md +136 -0
  300. package/engine/skills/ml-fulfillment-sop/references/multi-window-concurrency.md +69 -0
  301. package/engine/skills/ml-fulfillment-sop/references/pitfalls.md +77 -0
  302. package/engine/skills/ml-fulfillment-sop/references/selectors.md +243 -0
  303. package/engine/skills/ml-fulfillment-sop/scripts/fulfillment.js +586 -0
  304. package/engine/skills/ml-fulfillment-sop/scripts/poll-and-execute.sh +271 -0
  305. package/engine/skills/ml-fulfillment-sop/templates/report.md +55 -0
  306. package/engine/skills/ml-invoice/SKILL.md +506 -0
  307. package/engine/skills/ml-invoice/references/pitfalls.md +268 -0
  308. package/engine/skills/ml-invoice/references/portal-flow.md +360 -0
  309. package/engine/skills/ml-invoice/references/template-fill-contract.md +133 -0
  310. package/engine/skills/ml-invoice-xzh/SKILL.md +133 -0
  311. package/engine/skills/ml-invoice-xzh/references/pitfalls.md +230 -0
  312. package/engine/skills/ml-store-daily-report/SKILL.md +224 -0
  313. package/engine/skills/ml-store-daily-report/scripts/README.md +25 -0
  314. package/engine/skills/ml-store-daily-report/scripts/extract.js +691 -0
  315. package/engine/skills/ml-store-daily-report/scripts/generate.js +299 -0
  316. package/engine/skills/pet-dev/SKILL.md +42 -0
  317. package/engine/skills/whyshu-pm/SKILL.md +35 -0
  318. package/engine/skills/whyshu-web-dev/SKILL.md +50 -0
  319. package/engine/version.mjs +1 -0
  320. package/engine/verticals/README.md +103 -0
  321. package/engine/verticals/_engine-words.baseline.txt +237 -0
  322. package/engine/verticals/_engine-words.json +19 -0
  323. package/engine/verticals/mercadolibre/README.md +43 -0
  324. package/engine/verticals/mercadolibre/config.json +709 -0
  325. package/engine/verticals/mercadolibre/flows.json +738 -0
  326. package/engine/verticals/mercadolibre/manifest.json +538 -0
  327. package/engine/verticals/mercadolibre/migrate-machine-state.mjs +138 -0
  328. package/engine/verticals/mercadolibre/preflight.py +143 -0
  329. package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +69 -0
  330. package/engine/verticals/mercadolibre/prompts/daily-report.md +47 -0
  331. package/engine/verticals/mercadolibre/prompts/daily-report.scheduled.md +79 -0
  332. package/engine/verticals/mercadolibre/prompts/full.failure.md +125 -0
  333. package/engine/verticals/mercadolibre/prompts/full.md +91 -0
  334. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.failure.md +88 -0
  335. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.md +64 -0
  336. package/engine/verticals/mercadolibre/prompts/xzh-invoice.failure.md +91 -0
  337. package/engine/verticals/mercadolibre/prompts/xzh-invoice.md +46 -0
  338. package/package.json +22 -4
  339. package/requirements.txt +5 -0
  340. package/README.md +0 -3
@@ -0,0 +1,627 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-test-env-safety.mjs —— **测试环境卫生**门禁(2026-10-03;当天两次事故后补)。
4
+ *
5
+ * ══════════════════════════════════════════════════════════════════════════
6
+ * 一、它防的是什么(两次真实事故,都是"测试写进了真实数据目录")
7
+ * ══════════════════════════════════════════════════════════════════════════
8
+ * · 事故① `skin-display-prefs.test.mjs`:子进程只改 `HOME`,`ARGENT_DATA_DIR`/`DSH_HOME`
9
+ * 照抄调用方 ⇒ 显示偏好写进真实 `~/.dsh/argent/skin.json`。
10
+ * · 事故② 同类形态在**裸跑**(不经 `scripts/run-tests.sh`)时复现:跑测试的入口会
11
+ * `unset DSH_HOME` 兜底,而裸跑没有那层清理 ⇒ 三级链把子进程指到**真实实例**。
12
+ * 共同根因:**子进程环境是"调用方环境的副本 + 补丁"**,而不是显式构造的。
13
+ *
14
+ * ## 二、判据(全部机械可判;白名单必须带理由)
15
+ * | 规则 | 命中 | 为什么是红的 |
16
+ * |---|---|---|
17
+ * | **R1 `spread-unneutralized`** | 测试里 `...process.env` / `os.environ.copy()` / `**os.environ`,而**全文件没有任何一处** `sanitizedEnv(`、`ARGENT_DATA_DIR`、`DSH_HOME` | 子进程会继承调用方的**实例级开关** ⇒ 写目标随"谁跑的测试"改变(两次事故的形态) |
18
+ * | **R2 `resolve-datadir-without-assert`** | 调 `resolveDataDir(` / `resolve_data_dir(`,却没有 `assertInsideFixture(` | "假 HOME" 仍可能被链上另一级改道 ⇒ 必须**自证**判据算出来的路径在夹具内 |
19
+ * | **R3 `required-helper`** | `REQUIRED_HELPER` 名单里的测试(会触达数据目录的优先项)没引用助手 | 迁移是**可回归的**:名单里的文件不许退回手拼 env |
20
+ * | **R4 `real-home-literal`** | 代码行里用 `homedir()` 拼 `.dsh`/`argent` 路径(即解析**真实**数据目录) | 真实目录只允许"只读引用",且必须逐条给出理由(白名单) |
21
+ * | **R5 `real-datadir-unchanged`** | `--snapshot` / `--verify` 之间,真实数据目录**三层判据**任一有变化(见下) | 这条是**结果判据**:不管哪条路径漏了,写进真实目录就会被抓住 |
22
+ * | **W 过期白名单** | 白名单里的文件不存在、理由为空、或该规则在它身上**不再命中** | 名单和代码一样会腐烂(形态照 `skills/internal.json` 的 G5/G6) |
23
+ *
24
+ * ## 二·补 R5 的三层(2026-10-04 扩:从"6 个关键文件"到"目录级发现 + 文件级判定")
25
+ *
26
+ * 背景(盲区):原来只比对 `GUARDED_FILES` 6 个关键文件 ⇒ **"在真实目录里新建文件"完全抓不到**
27
+ * —— 实测 1016 份夹具备份(sha 前缀 `2ef8426e…`/`29bd6d40…`,各 508 份)就是在
28
+ * `~/.dsh/argent/backup/` 里被**新建**出来的,而 6 文件比对全程"绿"。
29
+ *
30
+ * | 层 | 判据 | 为什么这么定 |
31
+ * |---|---|---|
32
+ * | ① **关键文件面** | `GUARDED_FILES` 6 个的 `size/mtime/sha256`(**保持原样**) | 两次事故的直接判据;换了它就没法说"跟以前一样" |
33
+ * | ② **目录级发现面** | 真实数据目录**全树路径集合差** ⇒ **只报"新增"**(消失的单独提示,因为"删真实文件"比"新建"更该查) | 新增文件是最高信号形态;**不做整目录逐字节比对**——真实目录是**活的**(常驻进程会写日志/戳/锁),逐字节比对必然恒红 |
34
+ * | ③ **内容面** | 配置形态(`*.json`/`*.yml`/`*.yaml`/`*.toml`)的 `sha256`(**排除体积区**:`venv`/`node_modules`/`packs-cache`/`backup*`/浏览器 profile…,理由逐条写在 `BULK_EXCLUDE`) | "改配置"和"新建文件"是两种事故形态;`*.jsonl` 追加型台账**只走**②(不比对内容,否则每次追加都红) |
35
+ *
36
+ * **判定**:②③ 的每一条变化都要被 **`CHURN_ALLOW` 允许表**解释(`glob` + `kinds` + **必写理由** + **锚点**),
37
+ * 解释不了的 = `violation`(未允许的新增)。允许表的卫生沿用过期的规矩 ——
38
+ * **条目锚点消失 ⇒ `W-churn` 报出**(锚点两种:`path` 该 glob 在真实目录里已匹配不到任何路径 /
39
+ * `source` 仓库里点名"谁会造它"的源码文件或其内特征串已不在)⇒ 不留"假绿空间"。
40
+ *
41
+ * **姿态(2026-10-04 起步:report-only)**:②③ 的变化**默认只报告、exit 0**(允许表稳定前不判红,
42
+ * 也**不改 run-tests.sh 的整体退出码语义**);`--strict` 才让 `violation` 非零。① **不在此列**:
43
+ * 关键文件变了 ⇒ 仍然 exit 1(原语义,run-tests.sh 的失败判据靠它)。
44
+ *
45
+ * ## 三、用法与退出码
46
+ * ```
47
+ * node scripts/check-test-env-safety.mjs # 扫描(0 通过 / 1 有命中 / 2 用法错误)
48
+ * node scripts/check-test-env-safety.mjs --json
49
+ * node scripts/check-test-env-safety.mjs --root <dir> # 换根(夹具自证用)
50
+ * node scripts/check-test-env-safety.mjs --snapshot <file> # 跑测试前:快照真实数据目录(三层)
51
+ * node scripts/check-test-env-safety.mjs --verify <file> # 跑测试后:比对(关键文件变了 ⇒ exit 1)
52
+ * node scripts/check-test-env-safety.mjs --verify <file> --strict # 未允许的新增也判红(exit 1)
53
+ * node scripts/check-test-env-safety.mjs --churn-allow <file> # 换允许表(JSON;夹具自证用,输出会大声标注)
54
+ * node scripts/check-test-env-safety.mjs --list-churn # 只列当前允许表 + 锚点是否还活着
55
+ * ```
56
+ * `scripts/run-tests.sh` 已接线:跑前 `--snapshot`、跑后 `--verify`(**不带 `--strict`** ⇒ ②③ 只报告)。
57
+ *
58
+ * ## 四、纪律
59
+ * · **只读**:扫描只读文件;快照只 `stat`+`read`(绝不创建/修改/删除);不 spawn 任何被测脚本。
60
+ * · 数据目录由**内核唯一判据**算(`core/lib/argent-datadir.mjs#resolveDataDir`),本文件不另写链。
61
+ * · 允许表**只**解决"真实目录是活的"这一件事:每条都要能回答"**谁**在什么时候造它";
62
+ * 回答不上来 ⇒ 不许进表(那正是要报出来的东西)。
63
+ */
64
+ import { readFileSync, readdirSync, existsSync, writeFileSync } from 'node:fs'
65
+ import { dirname, join, relative, resolve, sep } from 'node:path'
66
+ import { fileURLToPath } from 'node:url'
67
+ import { isDirectRun } from './lib/is-direct-run.mjs'
68
+ import { resolveDataDir } from '../core/lib/argent-datadir.mjs'
69
+ import { diffSnapshots, snapshotPaths } from './tests/_env.mjs'
70
+
71
+ const HERE = dirname(fileURLToPath(import.meta.url))
72
+ const SELF_ROOT = resolve(HERE, '..')
73
+
74
+ /** 测试文件扫描面(**只扫用例**;`_*.mjs` 助手自己就是"构造环境"的地方,不在此列)。 */
75
+ export const TEST_GLOBS = [/\.test\.mjs$/, /\.test\.py$/]
76
+
77
+ /** R3:这些用例会触达数据目录(或曾出过事故)⇒ **必须**引用助手;新增同类用例请加进来。 */
78
+ export const REQUIRED_HELPER = [
79
+ 'argent-datadir-parity.test.mjs', 'argent-datadir.test.mjs', 'core-ledger-query.test.mjs',
80
+ 'core-mcp-shared.test.mjs', 'core-python-pick.test.mjs', 'env-writer.test.mjs',
81
+ 'flow-claim-verb.test.mjs', 'license-gate.test.mjs', 'notify-verb.test.mjs',
82
+ 'role-mapping.test.mjs', 'skin-display-prefs.test.mjs', 'argent-settings-entries.test.mjs',
83
+ 'daily-report-ask.test.mjs', 'daily-report-sections.test.mjs', 'doctor-customer.test.mjs',
84
+ // 2026-10-04:许可端到端用例(spawn 演练脚本,会写许可/重绑计数)⇒ 必须走助手构造环境
85
+ 'license-e2e-v2.test.mjs',
86
+ // 2026-10-04:一次性激活码兑换(会**真落盘**许可/密钥/审计 + 起 http 桩子进程)⇒ 必须走助手构造环境
87
+ 'license-activate.test.mjs',
88
+ // 2026-10-04:诊断上报同意步(**真跑安装器**写 config.json + 清本地队列)⇒ 一律走助手构造环境,
89
+ // 免得哪天有人图省事写成 `{...process.env, DSH_HOME: 夹具}`(那正是两次事故的形态)。
90
+ 'telemetry-consent.test.mjs',
91
+ ]
92
+
93
+ /**
94
+ * 白名单:**每条必须带理由**,且必须仍然命中对应规则(过期 ⇒ W 告警,形态照 `skills/internal.json`)。
95
+ * 允许的四类(评审时逐条问"它真的不会写真实数据目录吗"):
96
+ * · 只读被测对象(巡检/体检/门禁);· 目标路径由**显式参数**给定(不靠 env 链);
97
+ * · 只读引用真实目录(做"未被碰"的证据);· 结构性不可写(熔断/纯函数)。
98
+ */
99
+ export const ALLOW = [
100
+ { file: 'argent-doctor-lex.test.mjs', rules: ['R1'], reason: '被测对象 scripts/argent-doctor.mjs 是**只读巡检**(脚本头明写"只读,不改任何文件");子进程只读真实数据目录,写入面不在本用例范围' },
101
+ { file: 'billing-mode.test.mjs', rules: ['R1'], reason: '子进程 `check-billing-mode.sh` 是**只读巡检**(四态/写意图熔断);另一处 spread 只用来探测 `python -c "import yaml"`' },
102
+ { file: 'creds-records-placement.test.mjs', rules: ['R4'], reason: '只**读**真实 credentials 做三段结构核验(路径可由 `process.argv[2]` 覆盖;写路径全在夹具)' },
103
+ { file: 'ml-oauth-noninteractive.test.mjs', rules: ['R1'], reason: '授权表/凭据文件路径由 `ML_API_STORES_JSON`/`ML_API_ENV` **显式**指向夹具临时文件;被测脚本不读数据目录链' },
104
+ { file: 'model-auth-providers.test.mjs', rules: ['R1'], reason: '子进程 `check-model-auth.py --dsh-home <夹具>` 是**只读体检**,目标 home 由**显式参数**给定' },
105
+ { file: 'no-host-smoke.test.mjs', rules: ['R4'], reason: '正是"真实数据目录**未被碰**"的证据用例:只做只读快照/存在性比对,用于 M1 判据①' },
106
+ { file: 'plugin-python-paths.test.mjs', rules: ['R4'], reason: '`expandHome(\'~\')` 的**纯函数**语义断言(不触盘、不 spawn)' },
107
+ // profile-doctor.test.mjs 的 R1 豁免于 2026-10-03 删除:该用例新增「包管理器闸门」一节后改用
108
+ // `sanitizedEnv()`(显式构造子进程环境,不继承调用方实例开关)⇒ R1 **不再命中**,
109
+ // 留着就是门禁自己点名的"过期豁免 = 假绿空间"。
110
+ // 2026-10-04 补 R4:该用例要**只读**真实备份目录做"夹具没被写进去"的证据(哨兵串/夹具路径
111
+ // 精确搜,只归因本次运行);它的写路径已全部由 `--backup-dir` 显式改指夹具 ——
112
+ // 2026-10-04 取证的 1023 份 `*.doctor-<ts>` 就堆在这个目录里(正是缺了这条隔离)。
113
+ { file: 'profile-doctor.test.mjs', rules: ['R4'], reason: '只**读**真实备份目录做"夹具备份没写进真实目录"的证据(哨兵串精确搜);doctor 的写路径全部由 `--backup-dir` 显式改指夹具,不靠 env 链' },
114
+ { file: 'registry-merge.test.mjs', rules: ['R1'], reason: '`flow_registry.py` 合并是**只读**;注册表根由 `ARGENT_REGISTRY_ROOT` 显式指定' },
115
+ { file: 'repo-root.test.mjs', rules: ['R1'], reason: '`scripts/lib/repo-root.sh` 只做路径解析(纯函数式 shell 片段,零写盘)' },
116
+ { file: 'restart-trusted-host.test.mjs', rules: ['R1'], reason: '子进程是**假 `ps` 垫片** + `check-billing-mode.sh --fix` 熔断(结构上只能 exit 3,不写文件;不重启任何服务)' },
117
+ { file: 'source-port-contract.test.mjs', rules: ['R1'], reason: '`flow_sources/*` 端口契约探针(只读,参数全夹具)' },
118
+ { file: 'sync-scripts-core.test.mjs', rules: ['R1'], reason: '`sync-scripts.sh` 的投递面是**夹具克隆**(`ARGENT_DSH_REPO`/`ARGENT_DISH_HOME` 双显式),不落真实 `~/.argent-dsh`' },
119
+ { file: 'tests-isolation.test.mjs', rules: ['R4'], reason: '既有元测试:只 `grep` 真实台账找**夹具残留**(只读,不写)' },
120
+ ]
121
+
122
+ /** 真实数据目录里要盯住的关键文件(事故文件 + 许可/角色/配置;**只读比较**)。 */
123
+ export const GUARDED_FILES = ['skin.json', 'flow-ledger.jsonl', 'license-gate.jsonl', 'license.json', 'roles.json', 'gateway.json']
124
+
125
+ /** ③ 内容比对的扩展名 = "配置形态"。`*.jsonl` **不在**此列:追加型台账每写一行都变 ⇒ 只走②(新增/条目数)。 */
126
+ export const CONFIG_EXTS = ['.json', '.yml', '.yaml', '.toml']
127
+
128
+ /**
129
+ * ③ 内容比对的**排除区**(体积区:依赖树/缓存/整树副本/浏览器 profile)。
130
+ * 为什么排除它们(逐条给理由,不是"看着像就跳过"):这些目录里的 `*.json` 是**第三方产物或整树副本**,
131
+ * 内容变动**不指向**"测试写了真实配置";而"往里新建文件"仍由②发现面覆盖 —— 排除的只是内容面。
132
+ * 锚点是路径正则(`^` 起、目录名后必须带 `/`)⇒ 误排除会被 `BULK_EXCLUDE` 的回归用例抓住。
133
+ */
134
+ export const BULK_EXCLUDE = [
135
+ { re: /^venv\//, reason: 'Python 虚拟环境(第三方包自带 json/yaml,不是本站配置)' },
136
+ { re: /(?:^|\/)node_modules\//, reason: '依赖树(同上:第三方包自带 json/yaml)' },
137
+ { re: /^packs-cache\//, reason: '能力包缓存(整树副本,随包刷新)' },
138
+ { re: /^backup/, reason: '备份/整树副本区(`backup`、`backup-<ts>`、`*-backup-<ts>` 都在此)' },
139
+ { re: /backup-\d{8}/, reason: '同上:带时间戳的历史备份目录' },
140
+ { re: /^(?:chrome|firefox|\w+)-profile/, reason: '浏览器 profile(常驻进程随时落盘,且不是本站配置)' },
141
+ { re: /\/\.git\//, reason: '内嵌 git 元数据' },
142
+ ]
143
+
144
+ /**
145
+ * ②③ 的**允许表**:真实数据目录是**活的**(常驻进程会写日志/戳/锁、缓存会刷新)⇒
146
+ * 这些"合法的 churn"必须能解释掉,剩下的才是"测试写进了真实目录"。
147
+ *
148
+ * 每条 = `{ glob, kinds, reason, anchor }`:
149
+ * · `glob` —— 相对数据目录的路径通配(`**` 跨目录段;`a/**` 连 `a` 本身一起匹配);
150
+ * · `kinds` —— 这条覆盖哪些变化:`'add'` 新增 / `'remove'` 消失 / `'content'` 配置内容变化;
151
+ * · `reason` —— **必写**(≥ 8 字):谁会造它、什么时候造(写不清 ⇒ 不许进表);
152
+ * · `anchor` —— 过期判据(**必填**,两种):
153
+ * `{ kind: 'path' }` 该 glob 现在必须还能在真实数据目录里命中**至少一条**路径(来源消失 ⇒ 报出);
154
+ * `{ kind: 'source', file, must? }` 仓库里有"谁造它"的源码(文件必须存在;`must` 正则必须仍命中)。
155
+ * 纪律:**默认拒绝**。只有"能点名生产者"的 churn 才进表;回答不上"谁造的"就是判据要报的东西。
156
+ *
157
+ * 取数(2026-10-04 上线前实测,避免"凭想象放行"):本机真实数据目录 **22562 条路径 / 1913 个配置文件**,
158
+ * 一个 **240 秒纯背景窗口**(无任何测试,只留三个常驻 LaunchAgent:web/keepalive/portforward)
159
+ * 实测 **新增 0 / 消失 0 / 内容变化 0** ⇒ 背景本身是安静的;下表是"**可能**发生且**已点名生产者**"的
160
+ * churn —— 真发生时会被解释掉,并**在报告里带条目 glob + 理由回显**(不是静默忽略)。
161
+ */
162
+ export const CHURN_ALLOW = [
163
+ {
164
+ glob: 'telemetry/**',
165
+ kinds: ['add', 'remove', 'content'],
166
+ reason: '遥测队列/心跳由引擎自己写(`scripts/telemetry.py` 的 outbox 落点),测试期开着 ARGENT_TELEMETRY_OFF=1 也只关"发",不关"落"',
167
+ anchor: { kind: 'source', file: 'scripts/telemetry.py', must: 'outbox' },
168
+ },
169
+ {
170
+ glob: '**/*.lock',
171
+ kinds: ['add', 'remove'],
172
+ reason: '流程认领锁(`flow-claim*.lock`)由引擎按流程分文件建/删;本机数据目录里既存 10 个即其产物',
173
+ anchor: { kind: 'path' },
174
+ },
175
+ {
176
+ glob: '**/*.tmp',
177
+ kinds: ['add', 'remove', 'content'],
178
+ reason: '落盘的"先写临时文件再改名"形态(如 `.crontab.tmp`)',
179
+ anchor: { kind: 'path' },
180
+ },
181
+ {
182
+ glob: 'notify-outbox/**',
183
+ kinds: ['add', 'remove', 'content'],
184
+ reason: '通知幂等标记 + 投递审计(`core/lib/notify/dedup.mjs#OUTBOX_DIRNAME`);本机还没有这个目录 ⇒ 用 `source` 锚点',
185
+ anchor: { kind: 'source', file: 'core/lib/notify/dedup.mjs', must: "OUTBOX_DIRNAME = 'notify-outbox'" },
186
+ },
187
+ {
188
+ glob: '.DS_Store',
189
+ kinds: ['add', 'remove', 'content'],
190
+ reason: 'macOS Finder 自己往被浏览过的目录里写(数据目录根下既存一份)',
191
+ anchor: { kind: 'path' },
192
+ },
193
+ {
194
+ glob: '.*.stamp',
195
+ kinds: ['add', 'remove', 'content'],
196
+ reason: '**节流戳**这一类(`*.stamp` 由常驻巡检/触发器按周期刷新,用来"多久做一次";本机数据目录根下既存 4 个即其产物)',
197
+ anchor: { kind: 'path' },
198
+ },
199
+ {
200
+ glob: 'cdp-port-map.json',
201
+ kinds: ['content'],
202
+ reason: 'CDP 端口表由常驻巡检(`scripts/poll-fulfillment.sh`/`orchestrator.py`)按浏览器实际端口刷新',
203
+ anchor: { kind: 'path' },
204
+ },
205
+ {
206
+ glob: 'packs-cache/**',
207
+ kinds: ['add', 'remove', 'content'],
208
+ reason: '能力包**本地留档缓存**:`install-argent.mjs --from-packs` 装一版就多一个 `<能力>-<版本>/` 目录(回滚原料),换版本必然新增一大批路径',
209
+ anchor: { kind: 'path' },
210
+ },
211
+ ]
212
+
213
+ /** 去掉行注释(行级;粗略但足够——判据是"代码里出现",注释里的说明不算)。 */
214
+ function stripComments(text) {
215
+ return text.split('\n').filter((l) => {
216
+ const s = l.trim()
217
+ return !(s.startsWith('*') || s.startsWith('//') || s.startsWith('#'))
218
+ }).join('\n')
219
+ }
220
+
221
+ /**
222
+ * 文件内**局部豁免**:`// test-env-safety-allow: R4 —— 只读引用真实数据目录里的 XX 夹具`。
223
+ *
224
+ * 为什么两套豁免机制(ALLOW 表 + 文件内 pragma):
225
+ * · **ALLOW 表**(本文件上方)= 跨文件、需集中评审的例外;
226
+ * · **pragma** = 与**文件强绑定**的例外(改名不失效、就近可读)。
227
+ * 两者都**必须带理由**(pragma 理由 < 8 字视为无效 ⇒ 该规则照样命中),并在 `--json` 里逐条回显。
228
+ */
229
+ export function pragmasOf(text) {
230
+ const out = []
231
+ for (const m of String(text || '').matchAll(/test-env-safety-allow:\s*(R\d)\s*(?:——|--|:)\s*([^\n]*)/g)) {
232
+ const reason = m[2].replace(/\*\/\s*$/, '').trim()
233
+ if (reason.length >= 8) out.push({ rule: m[1], reason })
234
+ }
235
+ return out
236
+ }
237
+
238
+ const toPosix = (p) => p.split(sep).join('/')
239
+
240
+ /** 列出扫描面里的用例文件(仓库相对 POSIX 路径 → 绝对路径)。 */
241
+ export function listTests(root) {
242
+ const dir = join(root, 'scripts', 'tests')
243
+ if (!existsSync(dir)) return []
244
+ return readdirSync(dir).filter((f) => TEST_GLOBS.some((re) => re.test(f)) && !f.startsWith('_')).sort()
245
+ .map((f) => ({ name: f, abs: join(dir, f), rel: toPosix(relative(root, join(dir, f))) }))
246
+ }
247
+
248
+ /**
249
+ * 单文件判据(纯函数;`readFile` 可注入)。
250
+ * `applyAllow=false` ⇒ 返回**未过滤**命中(白名单过期检测要用它:豁免的规则必须**仍然命中**)。
251
+ * @returns {{file:string, hits:Array<{rule:string, detail:string, line:number}>}}
252
+ */
253
+ export function scanFile(entry, { readFile = (p) => readFileSync(p, 'utf8'), applyAllow = true } = {}) {
254
+ const raw = readFile(entry.abs)
255
+ const text = stripComments(raw || '')
256
+ const hits = []
257
+ const pragmas = pragmasOf(raw)
258
+ const allow = applyAllow
259
+ ? new Set([...ALLOW.filter((a) => a.file === entry.name).flatMap((a) => a.rules), ...pragmas.map((p) => p.rule)])
260
+ : new Set()
261
+ const spread = /\.\.\.process\.env|os\.environ\.copy\(\)|\*\*os\.environ/.test(text)
262
+ const neutralized = /sanitizedEnv\(|ARGENT_DATA_DIR|DSH_HOME/.test(text)
263
+ if (spread && !neutralized && !allow.has('R1')) {
264
+ hits.push({ rule: 'R1', line: lineOf(raw, /\.\.\.process\.env|os\.environ\.copy\(\)|\*\*os\.environ/), detail: 'spread 调用方 env 却全文件没有 sanitizedEnv(/ARGENT_DATA_DIR/DSH_HOME ⇒ 子进程会继承**实例级开关**' })
265
+ }
266
+ const resolves = /resolveDataDir\(|resolve_data_dir\(/.test(text)
267
+ const asserts = /assertInsideFixture\(/.test(text)
268
+ if (resolves && !asserts && !allow.has('R2')) {
269
+ hits.push({ rule: 'R2', line: lineOf(raw, /resolveDataDir\(|resolve_data_dir\(/), detail: '调了数据目录判据却没 assertInsideFixture( ⇒ 夹具未自证"算出来的路径在夹具内"' })
270
+ }
271
+ if (REQUIRED_HELPER.includes(entry.name) && !/sanitizedEnv\(|assertInsideFixture\(|_env\.mjs/.test(text)) {
272
+ hits.push({ rule: 'R3', line: 1, detail: '在 REQUIRED_HELPER 名单里(会触达数据目录)却没引用 scripts/tests/_env.mjs 助手' })
273
+ }
274
+ if (!allow.has('R4')) {
275
+ for (const [i, l] of text.split('\n').entries()) {
276
+ if (/homedir\(/.test(l) && /\.dsh|argent/i.test(l)) {
277
+ hits.push({ rule: 'R4', line: i + 1, detail: `代码行用 homedir() 拼真实数据目录路径:${l.trim().slice(0, 100)}` })
278
+ }
279
+ }
280
+ }
281
+ return { file: entry.name, hits, pragmas }
282
+ }
283
+
284
+ const lineOf = (text, re) => { const i = String(text || '').split('\n').findIndex((l) => re.test(l)); return i < 0 ? 1 : i + 1 }
285
+
286
+ /**
287
+ * 允许表(②③)的体检面:锚点是否还活着要靠**真实数据目录的路径集合** ⇒ 默认自己走一遍(只读)。
288
+ * 换根扫描(夹具自证)时数据目录由调用方给(`deps.dataDir`)。
289
+ */
290
+ export function churnTreePaths(deps = {}) {
291
+ if (Array.isArray(deps.treePaths)) return deps.treePaths
292
+ const dir = deps.dataDir !== undefined ? deps.dataDir : resolveDataDir({ env: deps.env || process.env }).dir
293
+ return existsSync(dir) ? walkTree(dir, deps.readdir ? { readdir: deps.readdir } : {}).rows.map((r) => r.path) : []
294
+ }
295
+
296
+ /** 全量扫描(`root` 可换 ⇒ 夹具自证)。 */
297
+ export function scanAll(root = SELF_ROOT, deps = {}) {
298
+ const entries = listTests(root)
299
+ const files = entries.map((e) => scanFile(e, deps))
300
+ const rawFiles = entries.map((e) => scanFile(e, { ...deps, applyAllow: false }))
301
+ const hits = files.flatMap((f) => f.hits.map((h) => ({ ...h, file: f.file })))
302
+ const pragmas = files.flatMap((f) => f.pragmas.map((p) => ({ ...p, file: f.file })))
303
+ const churn = deps.churnAllow || CHURN_ALLOW
304
+ // W-churn:允许表(②③)的过期/写法体检 —— 与白名单过期同形态(条目烂掉 ⇒ 假绿空间)
305
+ const staleChurn = auditChurnAllow(churn, churnTreePaths(deps), { root: SELF_ROOT })
306
+ // W:白名单过期(文件不存在 / 理由空 / 规则不再命中)
307
+ const stale = []
308
+ const present = new Set(entries.map((e) => e.name))
309
+ const isSelf = resolve(root) === resolve(SELF_ROOT) // 换根扫描(夹具自证)时白名单天然"对不上",不报腐烂
310
+ for (const p of pragmas) {
311
+ const raw = rawFiles.find((x) => x.file === p.file)
312
+ if (!raw || !raw.hits.some((h) => h.rule === p.rule)) stale.push({ file: p.file, why: `pragma ${p.rule} 已不再命中 ⇒ 该行应删除(过期豁免 = 假绿空间)` })
313
+ }
314
+ for (const a of ALLOW) {
315
+ if (!a.reason || !String(a.reason).trim()) stale.push({ file: a.file, why: '理由为空' })
316
+ if (!present.has(a.file)) {
317
+ if (isSelf) stale.push({ file: a.file, why: '白名单指向的测试文件不存在(名单腐烂)' })
318
+ continue
319
+ }
320
+ const raw = rawFiles.find((x) => x.file === a.file)
321
+ for (const r of a.rules) {
322
+ if (!raw || !raw.hits.some((h) => h.rule === r)) stale.push({ file: a.file, why: `${r} 已不再命中 ⇒ 该白名单条目应删除(过期豁免 = 假绿空间)` })
323
+ }
324
+ }
325
+ return { root, scanned: entries.length, hits, stale, pragmas, allow: ALLOW.length, required: REQUIRED_HELPER.length, churn: churn.length, staleChurn }
326
+ }
327
+
328
+ // ── R5 的三层:目录级发现(②)+ 配置内容面(③)──────────────────────────────
329
+ /**
330
+ * 路径通配 → 正则(无依赖)。语义(只在需要的范围内实现,够用且可测):
331
+ * `*` ⇒ 单层内任意字符(不跨 `/`);`?` ⇒ 单层内一个字符;
332
+ * `**` ⇒ 跨目录段(`**\/` 允许**零个**目录段 ⇒ `**\/*.lock` 也匹配根下的 `x.lock`);
333
+ * `a/**` ⇒ 连 `a` 本身一起匹配(目录被新建时也命中)。
334
+ */
335
+ export function globToRegExp(glob) {
336
+ const esc = (c) => c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
337
+ const g = String(glob || '')
338
+ let re = ''
339
+ for (let i = 0; i < g.length; i++) {
340
+ const c = g[i]
341
+ if (c === '*') {
342
+ if (g[i + 1] === '*') {
343
+ i++
344
+ if (g[i + 1] === '/') { i++; re += '(?:.*/)?' } // `**/` ⇒ 零个或多个目录段
345
+ else if (re.endsWith('/')) { re = re.slice(0, -1) + '(?:/.*)?' } // `a/**` ⇒ 也匹配 `a` 本身
346
+ else re += '.*'
347
+ } else re += '[^/]*'
348
+ } else if (c === '?') re += '[^/]'
349
+ else re += esc(c)
350
+ }
351
+ return new RegExp(`^${re}$`)
352
+ }
353
+
354
+ /**
355
+ * ② 真实数据目录的**路径集合**(只读;`kind`:f 文件 / d 目录 / l 符号链接)。
356
+ * 符号链接**当叶子**处理(不跟进去 ⇒ 不会因为链成环而无限走)。
357
+ * @returns {{ rows: Array<{kind:string, path:string}>, counts: {files:number, dirs:number, links:number, total:number} }}
358
+ */
359
+ export function walkTree(dir, { readdir = readdirSync } = {}) {
360
+ const rows = []
361
+ const counts = { files: 0, dirs: 0, links: 0, total: 0 }
362
+ if (!existsSync(dir)) return { rows, counts }
363
+ const stack = ['']
364
+ while (stack.length) {
365
+ const rel = stack.pop()
366
+ const abs = rel ? join(dir, rel) : dir
367
+ let ents = []
368
+ try { ents = readdir(abs, { withFileTypes: true }) } catch { continue } // 读不动就跳过(只读门禁不许因为权限而崩)
369
+ for (const e of ents) {
370
+ const child = rel ? `${rel}/${e.name}` : e.name
371
+ if (e.isSymbolicLink()) { rows.push({ kind: 'l', path: child }); counts.links++; continue }
372
+ if (e.isDirectory()) { rows.push({ kind: 'd', path: child }); counts.dirs++; stack.push(child); continue }
373
+ rows.push({ kind: 'f', path: child }); counts.files++
374
+ }
375
+ }
376
+ rows.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
377
+ counts.total = rows.length
378
+ return { rows, counts }
379
+ }
380
+
381
+ /** 压缩成快照里的形态(`<kind>\t<path>`,省空间且可直接当集合用)。 */
382
+ export const treeKeys = (rows) => (rows || []).map((r) => `${r.kind}\t${r.path}`)
383
+ const parseTreeKey = (k) => { const i = String(k).indexOf('\t'); return { kind: String(k).slice(0, i), path: String(k).slice(i + 1) } }
384
+
385
+ /** ② 的差:**只报新增**(+ 单独报"消失",因为删真实文件比新建更该查)。纯函数。 */
386
+ export function diffTree(beforeKeys, afterKeys) {
387
+ const b = new Set(beforeKeys || [])
388
+ const a = new Set(afterKeys || [])
389
+ return {
390
+ added: (afterKeys || []).filter((k) => !b.has(k)).map(parseTreeKey),
391
+ removed: (beforeKeys || []).filter((k) => !a.has(k)).map(parseTreeKey),
392
+ }
393
+ }
394
+
395
+ /** ③ 内容面的取值面:配置形态扩展名 ∧ 不在 `BULK_EXCLUDE` 里(纯函数 ⇒ 可测)。 */
396
+ export function isConfigPath(relPath, exts = CONFIG_EXTS, excludes = BULK_EXCLUDE) {
397
+ const p = String(relPath || '')
398
+ if (!exts.some((e) => p.toLowerCase().endsWith(e))) return false
399
+ return !excludes.some((x) => x.re.test(p))
400
+ }
401
+
402
+ /** ③ 内容快照:只 `stat`+`read`(**只读**;不创建/修改/删除)。
403
+ * ⛔ **不含** `GUARDED_FILES`(只按**根下同名**精确排除,不是"任意层级同名文件"):那 6 个由①按
404
+ * **更严**的判据(size/mtime/sha256 + 判红)盯着 ⇒ 同一件事不在两层各报一次(否则 `--strict`
405
+ * 的"未解释变化"计数会虚高)。 */
406
+ export function configSnapshot(dir, rows, { snapshot = snapshotPaths } = {}) {
407
+ const keyPaths = new Set(GUARDED_FILES)
408
+ return snapshot((rows || [])
409
+ .filter((r) => r.kind === 'f' && isConfigPath(r.path) && !keyPaths.has(r.path))
410
+ .map((r) => join(dir, r.path)))
411
+ }
412
+
413
+ /** 允许表条目的**锚点**是否还活着(过期判据;`treePaths` = 真实目录的相对路径集合)。 */
414
+ export function churnAnchorStatus(entry, treePaths, { root = SELF_ROOT, readFile = (p) => readFileSync(p, 'utf8') } = {}) {
415
+ const a = entry.anchor
416
+ if (!a || !a.kind) return { ok: false, why: '没有锚点(anchor)—— 允许表条目必须说明"过期怎么判"' }
417
+ if (a.kind === 'source') {
418
+ const p = join(root, a.file)
419
+ if (!existsSync(p)) return { ok: false, why: `锚点来源文件已不在:${a.file}` }
420
+ if (a.must) {
421
+ let text = ''
422
+ try { text = readFile(p) } catch { return { ok: false, why: `锚点来源读不出来:${a.file}` } }
423
+ if (!new RegExp(a.must).test(text)) return { ok: false, why: `锚点来源 ${a.file} 里已找不到特征串 ${JSON.stringify(a.must)}` }
424
+ }
425
+ return { ok: true, detail: `source ${a.file}${a.must ? ` ∧ /${a.must}/` : ''}` }
426
+ }
427
+ const re = globToRegExp(entry.glob)
428
+ const hit = (treePaths || []).find((p) => re.test(p))
429
+ return hit ? { ok: true, detail: `path,命中 ${hit}` } : { ok: false, why: `该 glob 在真实数据目录里**已匹配不到任何路径**(来源消失)` }
430
+ }
431
+
432
+ /** ③ 的差:只认**内容**(sha256)变化 —— mtime 变了内容没变不算事故(`touch` 不该判红)。纯函数。 */
433
+ export function diffConfigs(beforeList, afterList) {
434
+ const b = new Map((beforeList || []).map((e) => [e.path, e]))
435
+ const changed = []
436
+ for (const a of afterList || []) {
437
+ const p = b.get(a.path)
438
+ if (!p || !p.exists || !a.exists) continue // 新增/消失归②(避免同一件事被报两次)
439
+ if (p.sha256 !== a.sha256) changed.push({ path: a.path, why: `内容 sha256 变了(${String(p.sha256).slice(0, 12)} → ${String(a.sha256).slice(0, 12)})` })
440
+ }
441
+ return { changed }
442
+ }
443
+
444
+ /** 允许表整体体检:理由为空 / glob 非法 / 锚点死了 ⇒ 报出来(**W-churn**,形态照白名单过期)。 */
445
+ export function auditChurnAllow(entries, treePaths, deps = {}) {
446
+ const stale = []
447
+ for (const e of entries || []) {
448
+ if (!e.reason || String(e.reason).trim().length < 8) stale.push({ glob: e.glob, why: '理由为空或不足 8 字(允许表条目必须写明"谁会造它")' })
449
+ if (!Array.isArray(e.kinds) || !e.kinds.length || e.kinds.some((k) => !['add', 'remove', 'content'].includes(k))) {
450
+ stale.push({ glob: e.glob, why: `kinds 缺失或含未知项(${JSON.stringify(e.kinds)};合法值 add/remove/content)` })
451
+ }
452
+ try { globToRegExp(e.glob) } catch { stale.push({ glob: e.glob, why: 'glob 解析失败' }) }
453
+ const st = churnAnchorStatus(e, treePaths, deps)
454
+ if (!st.ok) stale.push({ glob: e.glob, why: `${st.why} ⇒ 过期豁免(该条目应删除或更新)` })
455
+ }
456
+ return stale
457
+ }
458
+
459
+ /** 一条 churn 是否被允许表解释掉(返回命中的条目,或 null)。 */
460
+ export function matchChurn(entries, { path, kind }) {
461
+ for (const e of entries || []) {
462
+ if (!(e.kinds || []).includes(kind)) continue
463
+ if (globToRegExp(e.glob).test(path)) return e
464
+ }
465
+ return null
466
+ }
467
+
468
+ /**
469
+ * **真实数据目录的三层快照**(只读;数据目录由内核判据算,不另写链)。
470
+ * ① `entries` 关键文件(size/mtime/sha256)—— 2026-10-03 起就在,**语义不变**;
471
+ * ② `tree` 全树路径集合(目录级发现);
472
+ * ③ `configs` 配置形态内容 sha256(排除体积区)。
473
+ * 旧快照(没有 `tree`/`configs`)仍可 `--verify`:③② 跳过并在输出里点名(不假装比过)。
474
+ */
475
+ export function guardedSnapshot({ env = process.env, readdir = readdirSync, snapshot = snapshotPaths } = {}) {
476
+ const dir = resolveDataDir({ env }).dir
477
+ const tree = walkTree(dir, { readdir })
478
+ return {
479
+ dataDir: dir,
480
+ entries: snapshot(GUARDED_FILES.map((f) => join(dir, f))),
481
+ tree: treeKeys(tree.rows),
482
+ treeCounts: tree.counts,
483
+ configs: configSnapshot(dir, tree.rows, { snapshot }),
484
+ }
485
+ }
486
+
487
+ // ── CLI ────────────────────────────────────────────────────────────────────
488
+ const relOf = (dir, p) => relative(dir, p).split(sep).join('/')
489
+
490
+ /** 允许表来源:默认内置;`--churn-allow <file>` 可换(**输出会大声标注**,避免"悄悄放宽")。 */
491
+ function loadChurnAllow(file) {
492
+ if (!file) return { entries: CHURN_ALLOW, source: '内置 CHURN_ALLOW' }
493
+ const raw = JSON.parse(readFileSync(file, 'utf8'))
494
+ const entries = Array.isArray(raw) ? raw : raw.entries
495
+ if (!Array.isArray(entries)) throw new Error('允许表文件必须是数组,或 { entries: [...] }')
496
+ return { entries, source: `⚠️ 显式覆盖 --churn-allow ${file}(内置表**未生效**)` }
497
+ }
498
+
499
+ function main(argv) {
500
+ let root = SELF_ROOT
501
+ let asJson = false
502
+ let snapshot = ''
503
+ let verify = ''
504
+ let strict = false
505
+ let churnAllowFile = ''
506
+ let listChurn = false
507
+ for (let i = 0; i < argv.length; i++) {
508
+ const a = argv[i]
509
+ if (a === '--json') { asJson = true; continue }
510
+ if (a === '--strict') { strict = true; continue }
511
+ if (a === '--list-churn') { listChurn = true; continue }
512
+ if (a === '--root') { const v = argv[i + 1]; if (!v || v.startsWith('--')) return { code: 2, out: '❌ --root 缺值' }; root = resolve(v); i++; continue }
513
+ if (a === '--snapshot') { const v = argv[i + 1]; if (!v || v.startsWith('--')) return { code: 2, out: '❌ --snapshot 缺值' }; snapshot = resolve(v); i++; continue }
514
+ if (a === '--verify') { const v = argv[i + 1]; if (!v || v.startsWith('--')) return { code: 2, out: '❌ --verify 缺值' }; verify = resolve(v); i++; continue }
515
+ if (a === '--churn-allow') { const v = argv[i + 1]; if (!v || v.startsWith('--')) return { code: 2, out: '❌ --churn-allow 缺值' }; churnAllowFile = resolve(v); i++; continue }
516
+ if (a === '-h' || a === '--help') return { code: 0, out: readFileSync(fileURLToPath(import.meta.url), 'utf8').split('*/')[0].replace(/^\/\*\*?/, '').trim() }
517
+ return { code: 2, out: `❌ 未知参数:${a}(用法:node scripts/check-test-env-safety.mjs [--root <dir>] [--snapshot <file>|--verify <file>] [--strict] [--churn-allow <file>] [--list-churn] [--json])` }
518
+ }
519
+ if (!existsSync(root)) return { code: 2, out: `❌ 根不存在:${root}` }
520
+ let churn = { entries: CHURN_ALLOW, source: '内置 CHURN_ALLOW' }
521
+ try { churn = loadChurnAllow(churnAllowFile) } catch (e) { return { code: 2, out: `❌ 允许表读不出来:${churnAllowFile}(${(e && e.message) || e})` } }
522
+
523
+ if (listChurn) {
524
+ const treePaths = churnTreePaths({})
525
+ const stale = auditChurnAllow(churn.entries, treePaths)
526
+ const lines = ['', `【R5 允许表(churn)】${churn.entries.length} 条 | 来源:${churn.source}`, ` 锚点体检面:真实数据目录 ${treePaths.length} 条路径`]
527
+ for (const e of churn.entries) {
528
+ const st = churnAnchorStatus(e, treePaths)
529
+ lines.push(` ${st.ok ? '✅' : '⚠️'} [${(e.kinds || []).join('/')}] ${e.glob}${st.ok ? `(锚点活着:${st.detail})` : `(${st.why})`}`)
530
+ lines.push(` 理由:${e.reason}`)
531
+ }
532
+ for (const s of stale) lines.push(` ⚠️ [W-churn] ${s.glob}:${s.why}`)
533
+ lines.push(` 结论:${stale.length ? `⚠️ ${stale.length} 条过期/写法问题` : '✅ 全部活着(锚点可查、理由齐备)'}`)
534
+ return { code: 0, out: lines.join('\n'), json: { churn: churn.entries, source: churn.source, stale } }
535
+ }
536
+
537
+ if (snapshot) {
538
+ const snap = guardedSnapshot()
539
+ // 唯一一次"写":写的是**调用方指定的快照文件**(不在数据目录里),不是数据目录本身
540
+ writeFileSync(snapshot, JSON.stringify({ ...snap, at: new Date().toISOString() }, null, 2) + '\n')
541
+ const json = { ok: true, snapshot, ...snap }
542
+ const c = snap.treeCounts || {}
543
+ return {
544
+ code: 0, json,
545
+ out: asJson ? JSON.stringify(json, null, 2)
546
+ : `📸 真实数据目录快照已写:${snapshot}\n 目录 ${snap.dataDir}`
547
+ + `\n ① 关键文件 ${snap.entries.length} 个 | ② 全树路径 ${c.total || 0} 条(文件 ${c.files || 0} / 目录 ${c.dirs || 0} / 链接 ${c.links || 0})`
548
+ + ` | ③ 配置形态内容 ${snap.configs.length} 个 sha256`,
549
+ }
550
+ }
551
+ if (verify) {
552
+ let before = null
553
+ try { before = JSON.parse(readFileSync(verify, 'utf8')) } catch (e) { return { code: 2, out: `❌ 快照读不出来:${verify}(${(e && e.message) || e})` } }
554
+ const after = guardedSnapshot()
555
+ // ① 关键文件面(**原语义**:变了 ⇒ exit 1)
556
+ const d = diffSnapshots(before.entries || [], after.entries || [])
557
+ const keyChanged = d.changed
558
+ // ② 目录级发现面(只报新增;消失单独提示)
559
+ const hasTree = Array.isArray(before.tree)
560
+ const td = hasTree ? diffTree(before.tree, after.tree || []) : { added: [], removed: [] }
561
+ const judge = (rows, kind) => rows.map((r) => ({ ...r, allow: matchChurn(churn.entries, { path: r.path, kind }) }))
562
+ const added = judge(td.added, 'add')
563
+ const removed = judge(td.removed, 'remove')
564
+ const violations = added.filter((x) => !x.allow)
565
+ const allowedAdds = added.filter((x) => x.allow)
566
+ const removedUnallowed = removed.filter((x) => !x.allow)
567
+ // ③ 内容面(只认 sha256;`*.jsonl` 台账不在此列)
568
+ const hasConf = Array.isArray(before.configs)
569
+ const cfgChangedRaw = hasConf ? diffConfigs(before.configs, after.configs || []).changed : []
570
+ const cfgChanged = cfgChangedRaw.map((c) => ({ ...c, rel: relOf(after.dataDir, c.path), allow: matchChurn(churn.entries, { path: relOf(after.dataDir, c.path), kind: 'content' }) }))
571
+ const cfgViolations = cfgChanged.filter((x) => !x.allow)
572
+ // 允许表体检(锚点活着吗)
573
+ const staleChurn = auditChurnAllow(churn.entries, (after.tree || []).map((k) => parseTreeKey(k).path))
574
+ const okKey = keyChanged.length === 0
575
+ const okNew = violations.length === 0 && cfgViolations.length === 0
576
+ const code = (!okKey || (strict && !okNew)) ? 1 : 0
577
+ const json = {
578
+ ok: okKey && (strict ? okNew : true), dataDir: after.dataDir, verify, strict, churnSource: churn.source,
579
+ keyChanged, added, removed, removedUnallowed, configChanged: cfgChanged, violations, configViolations: cfgViolations,
580
+ allowedChurn: allowedAdds.length, staleChurn, treeCounts: after.treeCounts, configCount: (after.configs || []).length,
581
+ layers: { tree: hasTree, configs: hasConf },
582
+ // 兼容旧字段名:`changed` 一直是"①关键文件的变化明细",这里保持同值(别的脚本/会话可能按它取数)
583
+ changed: keyChanged,
584
+ note: okKey ? (okNew ? '未变' : '新增/改动未被允许表解释(默认只报告;--strict 才判红)') : '真实数据目录的关键文件在测试期间被改过',
585
+ }
586
+ if (asJson) return { code, json, out: JSON.stringify(json, null, 2) }
587
+
588
+ const c = after.treeCounts || {}
589
+ const lines = ['', `【R5 结果判据】真实数据目录 ${after.dataDir}`, ` ① 关键文件(${(after.entries || []).length} 个,size/mtime/sha256):${okKey ? '✅ 未变' : `❌ 变了 ${keyChanged.length} 个`}`]
590
+ for (const x of keyChanged) lines.push(` ❌ ${x.path}:${x.why}`)
591
+ if (okKey === false) lines.push(' ⇒ 有测试在用真实数据目录(不是夹具)——查 `node scripts/check-test-env-safety.mjs --json` 的命中项。')
592
+ lines.push(` ② 目录级发现(路径 ${hasTree ? c.total : '—'} 条:文件 ${c.files || 0} / 目录 ${c.dirs || 0} / 链接 ${c.links || 0}):新增 ${added.length} | 消失 ${removed.length}`
593
+ + (hasTree ? '' : ' ⚠️ 快照是旧格式(无 tree)⇒ 本层**没比**'))
594
+ for (const x of violations) lines.push(` ❌ 新增(未被允许表解释):${x.kind} ${x.path}`)
595
+ for (const x of allowedAdds) lines.push(` ℹ️ 新增(允许):${x.kind} ${x.path} ← [${x.allow.glob}] ${x.allow.reason}`)
596
+ for (const x of removed) lines.push(` ${x.allow ? 'ℹ️ ' : '⚠️ '} 消失${x.allow ? '(允许)' : '(**未**被允许表解释)'}:${x.kind} ${x.path}${x.allow ? ` ← [${x.allow.glob}]` : ''}`)
597
+ lines.push(` ③ 配置形态内容(${hasConf ? (after.configs || []).length : '—'} 个 sha256):${!hasConf ? '⚠️ 快照是旧格式(无 configs)⇒ 本层**没比**' : (cfgChanged.length ? `变化 ${cfgChanged.length} 个` : '✅ 未变')}`)
598
+ for (const x of cfgViolations) lines.push(` ❌ 内容变化(未被允许表解释):${x.rel} ${x.why}`)
599
+ for (const x of cfgChanged.filter((y) => y.allow)) lines.push(` ℹ️ 内容变化(允许):${x.rel} ← [${x.allow.glob}] ${x.allow.reason}`)
600
+ for (const s of staleChurn) lines.push(` ⚠️ [W-churn] ${s.glob}:${s.why}`)
601
+ lines.push(` 允许表:${churn.entries.length} 条(${churn.source})`)
602
+ lines.push(` 结论:${okKey ? '✅ 关键文件未变' : '❌ 关键文件被改(**判红**)'}`
603
+ + `;②③ ${okNew ? '无未解释变化' : `有 ${violations.length + cfgViolations.length} 条未解释变化(默认**只报告**${strict ? ';`--strict` 已开 ⇒ 判红' : ';加 `--strict` 才判红'})`}`
604
+ + `${staleChurn.length ? `;允许表过期 ${staleChurn.length} 条` : ''}`)
605
+ return { code, json, out: lines.join('\n') }
606
+ }
607
+
608
+ const r = scanAll(root, { churnAllow: churn.entries })
609
+ const bad = r.hits.length
610
+ if (asJson) return { code: bad ? 1 : 0, out: JSON.stringify({ ...r, code: bad ? 1 : 0 }, null, 2), json: { ...r, code: bad ? 1 : 0 } }
611
+ const lines = ['', '【测试环境卫生(2026-10-03 两次事故后补)】',
612
+ ` 扫描 ${r.scanned} 个用例 | 白名单 ${r.allow} 条 | 文件内 pragma ${r.pragmas.length} 条 | 必修助手名单 ${r.required} 个 | R5 允许表 ${r.churn} 条`]
613
+ for (const h of r.hits) lines.push(` ❌ [${h.rule}] ${h.file}:${h.line} ${h.detail}`)
614
+ for (const p of r.pragmas) lines.push(` ℹ️ [pragma] ${p.file} 豁免 ${p.rule}:${p.reason}`)
615
+ for (const s of r.stale) lines.push(` ⚠️ [W] ${s.file}:${s.why}`)
616
+ for (const s of r.staleChurn) lines.push(` ⚠️ [W-churn] ${s.glob}:${s.why}`)
617
+ lines.push(` 结论:${bad ? `❌ ${bad} 条命中` : '✅ 通过'}(未用助手就触达真实数据目录的用例 = 0)`
618
+ + (r.stale.length ? `;白名单过期 ${r.stale.length} 条` : '')
619
+ + (r.staleChurn.length ? `;R5 允许表过期 ${r.staleChurn.length} 条` : ''))
620
+ return { code: bad ? 1 : 0, out: lines.join('\n') }
621
+ }
622
+
623
+ if (isDirectRun(import.meta.url)) {
624
+ const { code, out } = main(process.argv.slice(2))
625
+ console.log(out)
626
+ process.exit(code)
627
+ }