@whyshu/argent-engine 0.0.0-stage → 0.6.0
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/MANIFEST.json +307 -0
- package/engine/VERSION +1 -0
- package/engine/agents/catalog.json +454 -0
- package/engine/configs/_aliases.proposed.json +651 -0
- package/engine/configs/_migration-plan.proposed.json +899 -0
- package/engine/configs/account.json +272 -0
- package/engine/configs/advanced.json +205 -0
- package/engine/configs/host.json +149 -0
- package/engine/configs/hr.json +27 -0
- package/engine/configs/notify.json +264 -0
- package/engine/core/cli/argent.mjs +277 -0
- package/engine/core/contracts/argent.contracts.json +1065 -0
- package/engine/core/contracts/verbs.schema.json +2539 -0
- package/engine/core/lib/argent-datadir.mjs +223 -0
- package/engine/core/lib/audit.mjs +187 -0
- package/engine/core/lib/claim.mjs +258 -0
- package/engine/core/lib/config-catalog.mjs +1172 -0
- package/engine/core/lib/doctor-customer.mjs +577 -0
- package/engine/core/lib/flow-list.mjs +138 -0
- package/engine/core/lib/flow-registry.mjs +48 -0
- package/engine/core/lib/init.mjs +247 -0
- package/engine/core/lib/ledger.mjs +124 -0
- package/engine/core/lib/license-activate.mjs +813 -0
- package/engine/core/lib/license-device.mjs +314 -0
- package/engine/core/lib/license-file.mjs +349 -0
- package/engine/core/lib/license-guard.mjs +123 -0
- package/engine/core/lib/license-ops.mjs +1645 -0
- package/engine/core/lib/license-verdict.mjs +133 -0
- package/engine/core/lib/license.mjs +577 -0
- package/engine/core/lib/notify/dedup.mjs +101 -0
- package/engine/core/lib/notify/feishu.mjs +38 -0
- package/engine/core/lib/notify/index.mjs +95 -0
- package/engine/core/lib/notify/verb.mjs +327 -0
- package/engine/core/lib/notify/webhook.mjs +135 -0
- package/engine/core/lib/notify/wecom.mjs +36 -0
- package/engine/core/lib/python.mjs +145 -0
- package/engine/core/lib/sop.mjs +116 -0
- package/engine/core/lib/versions.mjs +205 -0
- package/engine/core/mcp/client.mjs +67 -0
- package/engine/core/mcp/http.mjs +138 -0
- package/engine/core/mcp/server.mjs +702 -0
- package/engine/scripts/_engine_rules_probe.py +15 -0
- package/engine/scripts/analyze-full-day.py +568 -0
- package/engine/scripts/archive/feishu-bridge.js +246 -0
- package/engine/scripts/argent-agents.mjs +527 -0
- package/engine/scripts/argent-capabilities.mjs +776 -0
- package/engine/scripts/argent-catalog.mjs +186 -0
- package/engine/scripts/argent-datadir.py +62 -0
- package/engine/scripts/argent-doctor.mjs +624 -0
- package/engine/scripts/argent-env-write.mjs +302 -0
- package/engine/scripts/argent-schedule.mjs +620 -0
- package/engine/scripts/argent-setup-check.mjs +369 -0
- package/engine/scripts/argent-upgrade.mjs +1046 -0
- package/engine/scripts/base-bootstrap.sh +575 -0
- package/engine/scripts/billing-mode-daily.sh +12 -0
- package/engine/scripts/build-engine-package.mjs +123 -0
- package/engine/scripts/capability-packs.lock.json +484 -0
- package/engine/scripts/capability_pack.py +1242 -0
- package/engine/scripts/check-1.0-criteria.mjs +355 -0
- package/engine/scripts/check-agent-catalog.mjs +141 -0
- package/engine/scripts/check-billing-mode.sh +377 -0
- package/engine/scripts/check-child-sessions.mjs +83 -0
- package/engine/scripts/check-config-catalog.mjs +677 -0
- package/engine/scripts/check-docs-drift.mjs +522 -0
- package/engine/scripts/check-dsh-web-expose.sh +98 -0
- package/engine/scripts/check-judgment-drift.mjs +403 -0
- package/engine/scripts/check-license.mjs +199 -0
- package/engine/scripts/check-model-auth.py +443 -0
- package/engine/scripts/check-pack-closure.mjs +661 -0
- package/engine/scripts/check-portable-symbols.mjs +116 -0
- package/engine/scripts/check-preset-landing.mjs +124 -0
- package/engine/scripts/check-production-home.mjs +602 -0
- package/engine/scripts/check-role-mapping.mjs +461 -0
- package/engine/scripts/check-sample-redaction.mjs +320 -0
- package/engine/scripts/check-skill-classification.mjs +188 -0
- package/engine/scripts/check-sop-consistency.mjs +431 -0
- package/engine/scripts/check-test-env-safety.mjs +627 -0
- package/engine/scripts/check-version-consistency.mjs +452 -0
- package/engine/scripts/ci-run.sh +558 -0
- package/engine/scripts/config-explain.mjs +278 -0
- package/engine/scripts/configure-provider.rb +29 -0
- package/engine/scripts/customer-config-migrate.mjs +179 -0
- package/engine/scripts/customer-preflight.py +892 -0
- package/engine/scripts/customer-readiness.sh +134 -0
- package/engine/scripts/daily-report-inbox.py +242 -0
- package/engine/scripts/daily-report-reconcile.mjs +312 -0
- package/engine/scripts/daily-report.mjs +1310 -0
- package/engine/scripts/daily-report.sh +46 -0
- package/engine/scripts/data/lip-product-dict.json +306 -0
- package/engine/scripts/deliver-customer.mjs +932 -0
- package/engine/scripts/delivery-faces.txt +31 -0
- package/engine/scripts/desktop-trial.sh +146 -0
- package/engine/scripts/dev-window.sh +260 -0
- package/engine/scripts/drill-clean-machine.sh +315 -0
- package/engine/scripts/dsh-api-client.mjs +215 -0
- package/engine/scripts/dsh-backup-scope.sh +83 -0
- package/engine/scripts/dsh-offline-pack.mjs +77 -0
- package/engine/scripts/dsh-overlay-pin.sh +87 -0
- package/engine/scripts/dsh-plane.mjs +153 -0
- package/engine/scripts/dsh-portforward.py +223 -0
- package/engine/scripts/dsh-preflight.mjs +179 -0
- package/engine/scripts/dsh-release-watch-daily.sh +19 -0
- package/engine/scripts/dsh-release-watch.mjs +458 -0
- package/engine/scripts/dsh-remote-check.sh +99 -0
- package/engine/scripts/dsh-restart.sh +179 -0
- package/engine/scripts/dsh-restore.sh +111 -0
- package/engine/scripts/dsh-scope-rewire.mjs +803 -0
- package/engine/scripts/dsh-snapshot.mjs +116 -0
- package/engine/scripts/dsh-upgrade-readiness.mjs +165 -0
- package/engine/scripts/dsh-upgrade.mjs +612 -0
- package/engine/scripts/dsh-url.sh +86 -0
- package/engine/scripts/dsh-web-launch.mjs +155 -0
- package/engine/scripts/ensure-store-windows.py +509 -0
- package/engine/scripts/entitlements.py +458 -0
- package/engine/scripts/env_probe.py +411 -0
- package/engine/scripts/feishu-card.mjs +252 -0
- package/engine/scripts/fix-skill-frontmatter.py +209 -0
- package/engine/scripts/flow-auto-retry.py +454 -0
- package/engine/scripts/flow-requirements.json +720 -0
- package/engine/scripts/flow-scaffold.py +275 -0
- package/engine/scripts/flow-timers-plist.sh +86 -0
- package/engine/scripts/flow-timers.sh +182 -0
- package/engine/scripts/flow-trigger-wake.mjs +284 -0
- package/engine/scripts/flow-trigger.sh +571 -0
- package/engine/scripts/flow_billing.py +354 -0
- package/engine/scripts/flow_capability_stats.py +314 -0
- package/engine/scripts/flow_case.py +194 -0
- package/engine/scripts/flow_claim.py +497 -0
- package/engine/scripts/flow_config.py +1024 -0
- package/engine/scripts/flow_failure.py +588 -0
- package/engine/scripts/flow_learn.py +105 -0
- package/engine/scripts/flow_ledger.py +235 -0
- package/engine/scripts/flow_policy.py +238 -0
- package/engine/scripts/flow_preflight.py +339 -0
- package/engine/scripts/flow_registry.py +341 -0
- package/engine/scripts/flow_retry.py +304 -0
- package/engine/scripts/flow_return_to_ops.py +191 -0
- package/engine/scripts/flow_source.py +315 -0
- package/engine/scripts/flow_sources/__init__.py +53 -0
- package/engine/scripts/flow_sources/cases_json.py +106 -0
- package/engine/scripts/flow_sources/common.py +428 -0
- package/engine/scripts/flow_sources/feishu_base.py +273 -0
- package/engine/scripts/flow_stale.py +185 -0
- package/engine/scripts/flow_status_reconcile.py +344 -0
- package/engine/scripts/flow_triage.py +849 -0
- package/engine/scripts/flow_write_verify.py +496 -0
- package/engine/scripts/fulfillment.js +586 -0
- package/engine/scripts/gen-agent-bundles.mjs +276 -0
- package/engine/scripts/gen-preset-rows.mjs +259 -0
- package/engine/scripts/init-ml.sh +2275 -0
- package/engine/scripts/install-internal-role.sh +190 -0
- package/engine/scripts/install-license.mjs +641 -0
- package/engine/scripts/install-one-shot.mjs +665 -0
- package/engine/scripts/integration_contract.py +220 -0
- package/engine/scripts/integrations/wenyao/adapter.py +98 -0
- package/engine/scripts/integrations/xzh/adapter.py +75 -0
- package/engine/scripts/invoice-mapping.example.json +40 -0
- package/engine/scripts/invoice-table.sh +194 -0
- package/engine/scripts/invoice_carrier_notice.py +279 -0
- package/engine/scripts/invoice_fail_closeout.py +297 -0
- package/engine/scripts/invoice_lip_runner.py +1508 -0
- package/engine/scripts/invoice_lip_runner.py.bak-20260922-1710-freready +1090 -0
- package/engine/scripts/invoice_ready_audit.py +191 -0
- package/engine/scripts/invoice_runner.py +675 -0
- package/engine/scripts/invoice_stageb.py +516 -0
- package/engine/scripts/launchd/com.whyshu.argent-bridge.plist.example +42 -0
- package/engine/scripts/launchd/com.whyshu.fulfillment-poll.plist.example +29 -0
- package/engine/scripts/lease.py +377 -0
- package/engine/scripts/lib/agent-install.mjs +649 -0
- package/engine/scripts/lib/argent-datadir.mjs +16 -0
- package/engine/scripts/lib/argent_datadir.py +262 -0
- package/engine/scripts/lib/argent_filelock.py +507 -0
- package/engine/scripts/lib/bootcheck-wiring.mjs +510 -0
- package/engine/scripts/lib/cdp_ports.py +197 -0
- package/engine/scripts/lib/customer-env.mjs +102 -0
- package/engine/scripts/lib/daily-report-core.mjs +517 -0
- package/engine/scripts/lib/dep-declaration.mjs +81 -0
- package/engine/scripts/lib/deps-migrate.mjs +310 -0
- package/engine/scripts/lib/dsh-host-resolve.mjs +109 -0
- package/engine/scripts/lib/dsh-scope.mjs +500 -0
- package/engine/scripts/lib/dsh-snapshot-path.mjs +35 -0
- package/engine/scripts/lib/dsh-versions.mjs +96 -0
- package/engine/scripts/lib/dsh-web-probe.mjs +214 -0
- package/engine/scripts/lib/is-direct-run.mjs +61 -0
- package/engine/scripts/lib/patch-compose.mjs +241 -0
- package/engine/scripts/lib/patch-target.mjs +35 -0
- package/engine/scripts/lib/plugin-lifecycle.mjs +128 -0
- package/engine/scripts/lib/plugin-packages.mjs +332 -0
- package/engine/scripts/lib/preset-compat.mjs +127 -0
- package/engine/scripts/lib/preset-install.mjs +575 -0
- package/engine/scripts/lib/preset-mechanism.mjs +56 -0
- package/engine/scripts/lib/preset-sync.mjs +59 -0
- package/engine/scripts/lib/profile-retire.mjs +97 -0
- package/engine/scripts/lib/python-runtime.mjs +190 -0
- package/engine/scripts/lib/registry.mjs +72 -0
- package/engine/scripts/lib/repo-root.sh +56 -0
- package/engine/scripts/lib/schedule-mechanism.mjs +659 -0
- package/engine/scripts/lib/schedule-probe.mjs +835 -0
- package/engine/scripts/lib/scope-pointer.mjs +164 -0
- package/engine/scripts/lib/settings-panels.mjs +816 -0
- package/engine/scripts/lib/stdio_utf8.py +86 -0
- package/engine/scripts/lib/wake-auth.mjs +392 -0
- package/engine/scripts/lib/webdriver_ipc.py +264 -0
- package/engine/scripts/lib/zstd-frames.mjs +104 -0
- package/engine/scripts/license-e2e-drill.mjs +426 -0
- package/engine/scripts/license-gate.mjs +245 -0
- package/engine/scripts/lip_backlog.py +171 -0
- package/engine/scripts/lip_table.py +185 -0
- package/engine/scripts/local-usage.mjs +214 -0
- package/engine/scripts/migrate-providers.mjs +127 -0
- package/engine/scripts/migrate-skills.sh +65 -0
- package/engine/scripts/ml-oauth-setup.mjs +550 -0
- package/engine/scripts/ml-store-ids.json +26 -0
- package/engine/scripts/ml_artifact_attribution.py +333 -0
- package/engine/scripts/ml_capacity_probe.py +1158 -0
- package/engine/scripts/ml_hub_status.py +1556 -0
- package/engine/scripts/ml_schema_check.py +115 -0
- package/engine/scripts/ml_store_ids.py +139 -0
- package/engine/scripts/npm-publish-argent.mjs +499 -0
- package/engine/scripts/npm-publish-argent.sh +20 -0
- package/engine/scripts/orchestrator.py +13811 -0
- package/engine/scripts/pack-plugins.mjs +140 -0
- package/engine/scripts/patches/dsh-llm-deepseek--transport-diagnostics.patch +297 -0
- package/engine/scripts/patches/dsh-session--append-message-shape.patch +15 -0
- package/engine/scripts/patches/dsh-session--tool-result-block-017.patch +28 -0
- package/engine/scripts/patches/dsh-session-format-v0-to-v1--permission-preset-origin.patch +11 -0
- package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +20 -0
- package/engine/scripts/patches/dsh-session-persistence-jsonl--worker-bundled-migrators.patch +39 -0
- package/engine/scripts/patches/dsh-tools--render-return-shape.patch +50 -0
- package/engine/scripts/patches/verify-bundled-migrators.mjs +79 -0
- package/engine/scripts/patches/verify-hardening.mjs +167 -0
- package/engine/scripts/patches/verify-session-migration.mjs +149 -0
- package/engine/scripts/patches/verify-transport-diagnostics.mjs +227 -0
- package/engine/scripts/plan-role-rename.mjs +125 -0
- package/engine/scripts/plugin-bootcheck.mjs +451 -0
- package/engine/scripts/plugin-composition.json +15 -0
- package/engine/scripts/poc-mcp-host.mjs +455 -0
- package/engine/scripts/poll-fulfillment.sh +293 -0
- package/engine/scripts/poll-invoice.sh +74 -0
- package/engine/scripts/profile-doctor.mjs +686 -0
- package/engine/scripts/provider-select.sh +70 -0
- package/engine/scripts/provider-switch.sh +146 -0
- package/engine/scripts/provider-sync-target.check.mjs +64 -0
- package/engine/scripts/python-runtime.sh +78 -0
- package/engine/scripts/query_runner.py +310 -0
- package/engine/scripts/release-argent.sh +574 -0
- package/engine/scripts/repair-unresolved-tools.mjs +178 -0
- package/engine/scripts/run-tests.sh +256 -0
- package/engine/scripts/self-sign-license.mjs +179 -0
- package/engine/scripts/session-health-scan.mjs +63 -0
- package/engine/scripts/session-repair-tool-result-content.mjs +181 -0
- package/engine/scripts/setup.sh +22 -0
- package/engine/scripts/skill-install.mjs +629 -0
- package/engine/scripts/sku_translate.py +148 -0
- package/engine/scripts/sync-plugins.sh +90 -0
- package/engine/scripts/sync-presets.sh +27 -0
- package/engine/scripts/sync-scripts.sh +445 -0
- package/engine/scripts/telemetry.py +873 -0
- package/engine/scripts/ui-copy-count.mjs +220 -0
- package/engine/scripts/usage-anchor.sh +136 -0
- package/engine/scripts/usage_report.py +531 -0
- package/engine/scripts/validate-preset-schema.mjs +208 -0
- package/engine/scripts/vertical-init.mjs +323 -0
- package/engine/scripts/vertical-lint.mjs +482 -0
- package/engine/scripts/wenyao-browser.sh +73 -0
- package/engine/scripts/wenyao-fixture-export.py +116 -0
- package/engine/scripts/wenyao-invoice-writeback.mjs +540 -0
- package/engine/scripts/wenyao-login.sh +34 -0
- package/engine/scripts/wenyao-template-map.json +292 -0
- package/engine/scripts/wenyao_attach.py +131 -0
- package/engine/scripts/wenyao_download_template.py +144 -0
- package/engine/scripts/wenyao_fill_template.py +568 -0
- package/engine/scripts/wenyao_login.py +145 -0
- package/engine/scripts/wenyao_portal.py +633 -0
- package/engine/scripts/wenyao_recon.py +230 -0
- package/engine/scripts/wenyao_run.py +503 -0
- package/engine/scripts/wenyao_shadow_diff.py +160 -0
- package/engine/scripts/wenyao_skill_contract.py +156 -0
- package/engine/scripts/wenyao_template_fetch.py +175 -0
- package/engine/scripts/win/dsh-web-autostart.ps1 +372 -0
- package/engine/scripts/ziniao-auth-probe.sh +268 -0
- package/engine/scripts/ziniao-keepalive.sh +216 -0
- package/engine/scripts/ziniao-post-upgrade-check.sh +120 -0
- package/engine/scripts/ziniao-windows.sh +153 -0
- package/engine/version.mjs +1 -0
- package/engine/verticals/README.md +103 -0
- package/engine/verticals/_engine-words.baseline.txt +237 -0
- package/engine/verticals/_engine-words.json +19 -0
- package/engine/verticals/mercadolibre/README.md +43 -0
- package/engine/verticals/mercadolibre/config.json +709 -0
- package/engine/verticals/mercadolibre/flows.json +706 -0
- package/engine/verticals/mercadolibre/manifest.json +538 -0
- package/engine/verticals/mercadolibre/migrate-machine-state.mjs +138 -0
- package/engine/verticals/mercadolibre/preflight.py +143 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +69 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.md +47 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.scheduled.md +79 -0
- package/engine/verticals/mercadolibre/prompts/full.failure.md +125 -0
- package/engine/verticals/mercadolibre/prompts/full.md +91 -0
- package/engine/verticals/mercadolibre/prompts/wenyao-invoice.failure.md +88 -0
- package/engine/verticals/mercadolibre/prompts/wenyao-invoice.md +64 -0
- package/engine/verticals/mercadolibre/prompts/xzh-invoice.failure.md +91 -0
- package/engine/verticals/mercadolibre/prompts/xzh-invoice.md +46 -0
- package/package.json +21 -4
- package/README.md +0 -3
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-config-catalog.mjs —— **配置地基的四道门禁**(2026-10-09)
|
|
4
|
+
*
|
|
5
|
+
* 契约:`docs/Argent-宿主适配契约.md` §7(配置目录 + 通用读取器 + 四道门禁 + 分类树)。
|
|
6
|
+
* 读取器(**唯一实现**):`core/lib/config-catalog.mjs`;本文件只做**扫描与判定**,
|
|
7
|
+
* ⛔ 不另写一份解析链、不另写一份声明校验(全部调读取器的 `validate()`)。
|
|
8
|
+
*
|
|
9
|
+
* ## 四道门禁(逐条都可单独跑:`--gate 1|2|3|4`)
|
|
10
|
+
*
|
|
11
|
+
* | # | 名称 | 判据 | 假绿防线(本门禁自己也要能被反证) |
|
|
12
|
+
* |---|---|---|---|
|
|
13
|
+
* | ① | **双向对账** | (a) 代码里 `cfg.get('k')` / `require('k')` 一类**字面量读**的 key 必须已声明;(b) **每个非 server 声明**都必须有"被读"证据(字面量读 **或** `binding` 指向的 env 名/文件/字段在代码里**真被引用**);(c) 声明形状合法、key 与环境变量名**一一对应**(不得两个 key 归一成同名 env,否则进程 env 覆盖会串项) | 扫描面必须 >0 文件;`--selftest` 注入"读了没声明 / 声明了没人读 / 两个 key 归一撞名"三种假象,**必须逐条变红** |
|
|
14
|
+
* | ② | **敏感不出门** | `secret: true` 的项:`describe()` 载荷里**没有值**(也没有长度/默认值);`require()` 报错文本里没有值;目录文件里没有 `example/sample/value` 这类真实样例字段、没有非空 `default`;**用诱饵值**(`DECOY-<随机>`)真写进各种来源后,载荷 + 报错 + 全仓库文本里都搜不到它 | 诱饵值必须**先证明能被读到**(`get()` 拿得到)⇒ 才说明"搜不到"不是因为它根本没生效 |
|
|
15
|
+
* | ③ | **必须项可解析** | `required: true` 的项在**空环境**(`env={}` + 空数据目录 + 无 default)下调用 `require()` 必须抛 `ConfigMissingError`,且消息里**点名 key** + **给出可执行落点** + **列出已试来源**;同时:真机上所有 required 项必须能解析(否则装机就是断的) | required 项数必须 >0(否则这条门禁是空跑);夹具里放一个"故意配不齐"的 required 项,断言报错**真的指路** |
|
|
16
|
+
* | ④ | **新增即生效** | 在夹具目录里**加一条新声明**(不改读取器、不改任何 UI 代码)⇒ 它必须出现在 `describe()` 载荷里,且 `get()/sourceOf()` 立刻可用;删掉文件 ⇒ 立刻消失(缓存不得留残影) | 断言"夹具新项"与"真仓库原项"**同时**在载荷里(证明是增量而不是替换) |
|
|
17
|
+
*
|
|
18
|
+
* ## 用法
|
|
19
|
+
* node scripts/check-config-catalog.mjs # 只报告,**恒 exit 0**
|
|
20
|
+
* node scripts/check-config-catalog.mjs --strict # 任一门禁红 ⇒ exit 1(CI / 手动)
|
|
21
|
+
* node scripts/check-config-catalog.mjs --json # 机器可读(含树/载荷摘要)
|
|
22
|
+
* node scripts/check-config-catalog.mjs --list # 按分类打印所有可配项(人读;secret 只显示已配置态)
|
|
23
|
+
* node scripts/check-config-catalog.mjs --describe # 打**脱敏载荷**(= 设置页拿到的形状)
|
|
24
|
+
* node scripts/check-config-catalog.mjs --gate 3 # 只跑第 3 道
|
|
25
|
+
* node scripts/check-config-catalog.mjs --selftest # 只跑"门禁自证"(注入假象必须变红)
|
|
26
|
+
* node scripts/check-config-catalog.mjs --root <dir> --data-dir <dir> # 夹具(只读)
|
|
27
|
+
*
|
|
28
|
+
* ## 纪律
|
|
29
|
+
* · **只读**:不改任何配置、不写数据目录、不联网;
|
|
30
|
+
* · 门禁脚本自身**不含业务词**(`vertical-lint` 会扫 `scripts/`):分类树、key、归属
|
|
31
|
+
* 一律从目录文件**读**出来,⛔ 不在这里硬编码任何业务线/能力名;
|
|
32
|
+
* · secret 项在**任何**输出路径上都只呈现"已配置/未配置 + 来源"。
|
|
33
|
+
*/
|
|
34
|
+
import { execFileSync } from 'node:child_process'
|
|
35
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
36
|
+
import { tmpdir } from 'node:os'
|
|
37
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
38
|
+
import { fileURLToPath } from 'node:url'
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
LAYERS, RESOLUTION_ORDER, SOURCES, createReader, envNameOf, validateCatalog,
|
|
42
|
+
} from '../core/lib/config-catalog.mjs'
|
|
43
|
+
import { isDirectRun } from './lib/is-direct-run.mjs'
|
|
44
|
+
|
|
45
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
46
|
+
const REPO = resolve(HERE, '..')
|
|
47
|
+
|
|
48
|
+
const argv = process.argv.slice(2)
|
|
49
|
+
const has = (f) => argv.includes(f)
|
|
50
|
+
const argOf = (name, dflt = null) => {
|
|
51
|
+
const i = argv.indexOf(name)
|
|
52
|
+
return i >= 0 ? argv[i + 1] ?? dflt : dflt
|
|
53
|
+
}
|
|
54
|
+
const STRICT = has('--strict')
|
|
55
|
+
const JSON_OUT = has('--json')
|
|
56
|
+
const root = resolve(argOf('--root', REPO))
|
|
57
|
+
const dataDir = argOf('--data-dir', null)
|
|
58
|
+
const onlyGate = argOf('--gate', null)
|
|
59
|
+
|
|
60
|
+
const problems = []
|
|
61
|
+
const notes = []
|
|
62
|
+
const red = (gate, msg) => problems.push({ gate, msg })
|
|
63
|
+
|
|
64
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
65
|
+
// 扫描面:代码里"真被读"的证据
|
|
66
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
67
|
+
|
|
68
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '__pycache__', 'dist', 'attic', 'vendor', 'archive', 'backups'])
|
|
69
|
+
const CODE_RE = /\.(mjs|js|cjs|py|sh|rb)$/
|
|
70
|
+
|
|
71
|
+
function walk(dir, out = [], depth = 0) {
|
|
72
|
+
if (depth > 8) return out
|
|
73
|
+
let entries = []
|
|
74
|
+
try { entries = readdirSync(dir) } catch { return out }
|
|
75
|
+
for (const e of entries) {
|
|
76
|
+
if (SKIP_DIRS.has(e) || e.startsWith('.')) continue
|
|
77
|
+
const p = join(dir, e)
|
|
78
|
+
let st
|
|
79
|
+
try { st = statSync(p) } catch { continue }
|
|
80
|
+
if (st.isDirectory()) walk(p, out, depth + 1)
|
|
81
|
+
else out.push(p)
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 剥注释(JS 行/块、Python/Shell `#`)——防"注释里提了一句"被当成读取证据。 */
|
|
87
|
+
function stripComments(text, file) {
|
|
88
|
+
if (/\.(py|sh|rb)$/.test(file)) return text.replace(/^[ \t]*#.*$/gm, '')
|
|
89
|
+
let out = ''
|
|
90
|
+
let i = 0
|
|
91
|
+
let state = 'code'
|
|
92
|
+
const n = text.length
|
|
93
|
+
while (i < n) {
|
|
94
|
+
const c = text[i]
|
|
95
|
+
const c2 = text[i + 1]
|
|
96
|
+
const esc = i > 0 && text[i - 1] === '\\'
|
|
97
|
+
if (state === 'code') {
|
|
98
|
+
if (c === '/' && c2 === '/' && !esc) { state = 'line'; i += 2; continue }
|
|
99
|
+
if (c === '/' && c2 === '*' && !esc) { state = 'block'; i += 2; continue }
|
|
100
|
+
if (c === "'" || c === '"' || c === '`') { state = c; out += c; i++; continue }
|
|
101
|
+
out += c; i++; continue
|
|
102
|
+
}
|
|
103
|
+
if (state === 'line') { if (c === '\n') { state = 'code'; out += c } i++; continue }
|
|
104
|
+
if (state === 'block') { if (c === '*' && c2 === '/') { state = 'code'; i += 2; continue } if (c === '\n') out += c; i++; continue }
|
|
105
|
+
// 字符串**保留**(env 名/字段名正是字符串字面量)
|
|
106
|
+
if (c === state && !esc) { state = 'code' }
|
|
107
|
+
out += c; i++
|
|
108
|
+
}
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 扫描面:仓库里所有代码文件(剥注释后拼接),并统计"每行"。 */
|
|
113
|
+
export function scanSources() {
|
|
114
|
+
const hits = new Map() // token → [{file,line}]
|
|
115
|
+
const files = []
|
|
116
|
+
for (const top of ['core', 'scripts', 'plugins', 'verticals', 'configs']) {
|
|
117
|
+
const p = join(root, top)
|
|
118
|
+
if (existsSync(p)) walk(p, files)
|
|
119
|
+
}
|
|
120
|
+
if (existsSync(join(root, 'install-argent.mjs'))) files.push(join(root, 'install-argent.mjs'))
|
|
121
|
+
let scanned = 0
|
|
122
|
+
for (const f of files) {
|
|
123
|
+
if (!CODE_RE.test(f)) continue
|
|
124
|
+
const rel = relative(root, f)
|
|
125
|
+
// configs/ 自己是声明面,不是"读它的代码";tests 允许写夹具
|
|
126
|
+
if (rel.startsWith('configs/')) continue
|
|
127
|
+
let text = ''
|
|
128
|
+
try { text = readFileSync(f, 'utf8') } catch { continue }
|
|
129
|
+
scanned++
|
|
130
|
+
const stripped = stripComments(text, f)
|
|
131
|
+
stripped.split('\n').forEach((line, idx) => {
|
|
132
|
+
for (const m of line.matchAll(/[A-Za-z_][A-Za-z0-9_.$-]*/g)) {
|
|
133
|
+
const tok = m[0]
|
|
134
|
+
if (tok.length < 3) continue
|
|
135
|
+
if (!hits.has(tok)) hits.set(tok, [])
|
|
136
|
+
const arr = hits.get(tok)
|
|
137
|
+
if (arr.length < 5) arr.push({ file: rel, line: idx + 1 })
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
return { hits, scanned }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 读取器 API 的**字面量调用**扫描(`cfg.get('k')` 一类;`--reader-alias` 可扩展别名)。 */
|
|
145
|
+
export const READER_CALL_RE = /\b(?:config|cfg|catalog|reader)\s*\.\s*(?:get|require|sourceOf|isConfigured)\s*\(\s*(['"])([a-z][a-z0-9_]*(?:\.[a-z0-9_]+)+)\1/g
|
|
146
|
+
export const READER_FN_RE = /\b(?:requireKey|configGet)\s*\(\s*(['"])([a-z][a-z0-9_]*(?:\.[a-z0-9_]+)+)\1/g
|
|
147
|
+
|
|
148
|
+
function literalReads() {
|
|
149
|
+
const out = []
|
|
150
|
+
for (const top of ['core', 'scripts', 'plugins', 'verticals']) {
|
|
151
|
+
const p = join(root, top)
|
|
152
|
+
if (!existsSync(p)) continue
|
|
153
|
+
for (const f of walk(p)) {
|
|
154
|
+
if (!CODE_RE.test(f)) continue
|
|
155
|
+
const rel = relative(root, f)
|
|
156
|
+
let text = ''
|
|
157
|
+
try { text = readFileSync(f, 'utf8') } catch { continue }
|
|
158
|
+
const stripped = stripComments(text, f)
|
|
159
|
+
for (const re of [READER_CALL_RE, READER_FN_RE]) {
|
|
160
|
+
re.lastIndex = 0
|
|
161
|
+
let m
|
|
162
|
+
while ((m = re.exec(stripped)) !== null) {
|
|
163
|
+
out.push({ key: m[2], file: rel, line: stripped.slice(0, m.index).split('\n').length })
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 一项声明的"被读证据"候选 token(**一个都不命中 = 孤儿声明**)。 */
|
|
172
|
+
export function evidenceTokens(item) {
|
|
173
|
+
const b = item.binding || {}
|
|
174
|
+
const toks = []
|
|
175
|
+
if (b.env) toks.push(String(b.env))
|
|
176
|
+
if (b.file) toks.push(String(b.file))
|
|
177
|
+
if (b.field) {
|
|
178
|
+
toks.push(String(b.field))
|
|
179
|
+
const leaf = String(b.field).split('.').pop()
|
|
180
|
+
if (leaf) toks.push(leaf)
|
|
181
|
+
}
|
|
182
|
+
if (b.special) toks.push({ dataDir: 'ARGENT_DATA_DIR', dshHome: 'DSH_HOME', dataDirSource: 'ARGENT_DATA_DIR' }[b.special] || '')
|
|
183
|
+
if (b.kind === 'env') toks.push(envNameOf(item.key))
|
|
184
|
+
return [...new Set(toks.filter(Boolean))]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
188
|
+
// 门禁 ① —— 双向对账
|
|
189
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
190
|
+
|
|
191
|
+
export function gateReconcile(reader, scan, opts = {}) {
|
|
192
|
+
const out = []
|
|
193
|
+
const cat = reader.catalog()
|
|
194
|
+
// ①-0 声明形状(读取器实现,⛔ 门禁不另写一份)
|
|
195
|
+
for (const p of validateCatalog(cat)) out.push({ kind: 'declaration', msg: p })
|
|
196
|
+
|
|
197
|
+
const items = cat.items
|
|
198
|
+
const keys = new Set(items.map((it) => it.key))
|
|
199
|
+
|
|
200
|
+
// ①-a 代码读了但没声明
|
|
201
|
+
const reads = opts.reads || literalReads()
|
|
202
|
+
for (const r of reads) {
|
|
203
|
+
if (!keys.has(r.key)) out.push({ kind: 'read-undeclared', msg: `${r.file}:${r.line} 读了未声明的 key \`${r.key}\` ⇒ 先在 configs/ 或 verticals/<id>/config.json 里声明它` })
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ①-b 声明了但没人读(每个非 server 项必须有证据)
|
|
207
|
+
const hits = scan.hits
|
|
208
|
+
let evidenced = 0
|
|
209
|
+
let serverPending = 0
|
|
210
|
+
for (const it of items) {
|
|
211
|
+
if (String(it.source) === 'server') {
|
|
212
|
+
serverPending++
|
|
213
|
+
if (it.binding) out.push({ kind: 'server-binding', msg: `${it.key} source=server(预留)却声明了 binding ⇒ 本批不读服务端,声明会让人以为能读到` })
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
const literal = reads.filter((r) => r.key === it.key)
|
|
217
|
+
const toks = evidenceTokens(it)
|
|
218
|
+
const found = toks.filter((t) => hits.has(t))
|
|
219
|
+
if (literal.length) { evidenced++; continue }
|
|
220
|
+
if (found.length) { evidenced++; continue }
|
|
221
|
+
out.push({
|
|
222
|
+
kind: 'orphan-declaration',
|
|
223
|
+
msg: `${it.key} 声明了但**没有任何读取证据**(候选 token:${toks.join(' / ') || '(空)'})`
|
|
224
|
+
+ ` —— 要么让调用方改用读取器(\`cfg.get('${it.key}')\`),要么把 binding 改对;孤儿声明会骗页面`,
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
if (!items.length) out.push({ kind: 'vacuous', msg: '配置目录里一项都没有 ⇒ 这道门禁等于空跑' })
|
|
228
|
+
if (!evidenced) out.push({ kind: 'vacuous', msg: '没有任何声明带读取证据 ⇒ 这道门禁等于空跑' })
|
|
229
|
+
if (!reads.length) {
|
|
230
|
+
out.push({
|
|
231
|
+
kind: 'vacuity-note',
|
|
232
|
+
msg: '还没有调用方改用读取器(字面量读 = 0)⇒ ①-a「读了必须已声明」这半目前是**空跑**;本批以 ①-b'
|
|
233
|
+
+ '「声明必须有读取证据(binding 指向的 env 名 / 文件 / 字段在代码里真被引用)」为准。'
|
|
234
|
+
+ '迁移期把调用方逐个换到读取器后这半自动生效(门禁自证里已证明它**能**判红)。',
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
if (!serverPending) out.push({ kind: 'vacuity-note', msg: '没有任何 source=server 的预留声明(`server` 来源没被用上;至少留一条演示"上云后接什么")' })
|
|
238
|
+
|
|
239
|
+
// ①-c key ↔ 环境变量名一一对应
|
|
240
|
+
const env = new Map()
|
|
241
|
+
for (const it of items) {
|
|
242
|
+
const n = String((it.binding && it.binding.env) || envNameOf(it.key))
|
|
243
|
+
if (env.has(n) && env.get(n) !== it.key) {
|
|
244
|
+
out.push({ kind: 'env-collision', msg: `\`${it.key}\` 与 \`${env.get(n)}\` 归一成同一个环境变量名 ${n} ⇒ 进程 env 覆盖会串项` })
|
|
245
|
+
} else env.set(n, it.key)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ①-d 层级枚举必须自洽(防止有人悄悄改了链)
|
|
249
|
+
if (RESOLUTION_ORDER[RESOLUTION_ORDER.length - 1] !== 'default') out.push({ kind: 'chain', msg: '解析链最后一级必须是 default' })
|
|
250
|
+
for (const l of RESOLUTION_ORDER) if (!LAYERS.includes(l)) out.push({ kind: 'chain', msg: `解析链里的 ${l} 不是合法层级` })
|
|
251
|
+
for (const s of SOURCES) if (!LAYERS.includes(s)) out.push({ kind: 'chain', msg: `source 枚举里的 ${s} 不是合法层级` })
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
problems: out,
|
|
255
|
+
stats: {
|
|
256
|
+
items: items.length,
|
|
257
|
+
evidenced,
|
|
258
|
+
serverPending,
|
|
259
|
+
literalReads: reads.length,
|
|
260
|
+
scannedFiles: scan.scanned,
|
|
261
|
+
catalogFiles: cat.files.length,
|
|
262
|
+
envNames: env.size,
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
268
|
+
// 门禁 ② —— 敏感不出门
|
|
269
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
270
|
+
|
|
271
|
+
const luhnish = () => `DECOY-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* @param {object} reader 读取器
|
|
275
|
+
* @param {{ decoy?: string, probeEnv?: object, repoText?: string }} [opts]
|
|
276
|
+
* `probeEnv` 用来把诱饵值"真喂进去"(默认用每项自己的进程 env 名)。
|
|
277
|
+
*/
|
|
278
|
+
export function gateSecret(reader, opts = {}) {
|
|
279
|
+
const out = []
|
|
280
|
+
const decoy = opts.decoy || luhnish()
|
|
281
|
+
const secrets = reader.all({ secret: true })
|
|
282
|
+
if (!secrets.length) out.push({ kind: 'vacuous', msg: '目录里没有任何 secret 项 ⇒ 这道门禁等于空跑' })
|
|
283
|
+
|
|
284
|
+
// ②-a 载荷里不得有 secret 的值(连长度/默认值都不给)
|
|
285
|
+
const payload = JSON.stringify(reader.describe())
|
|
286
|
+
if (payload.includes(decoy)) out.push({ kind: 'payload-leak', msg: `describe() 载荷里出现了诱饵值` })
|
|
287
|
+
for (const it of secrets) {
|
|
288
|
+
const view = reader.describe().items.find((x) => x.key === it.key)
|
|
289
|
+
if (!view) { out.push({ kind: 'missing-in-payload', msg: `${it.key} 是 secret 却没有出现在载荷里(页面会漏掉它)` }); continue }
|
|
290
|
+
if (Object.prototype.hasOwnProperty.call(view, 'value') && view.value !== undefined && view.value !== null && view.value !== '') {
|
|
291
|
+
out.push({ kind: 'secret-value-in-payload', msg: `${it.key} 的载荷里带了 value ⇒ 必须删掉(secret 只回已配置/未配置)` })
|
|
292
|
+
}
|
|
293
|
+
if (Object.prototype.hasOwnProperty.call(view, 'default') && view.default) {
|
|
294
|
+
out.push({ kind: 'secret-default-in-payload', msg: `${it.key} 的载荷里带了非空 default` })
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ②-b 声明文件里不得有真实样例/非空 default
|
|
299
|
+
for (const it of secrets) {
|
|
300
|
+
const raw = JSON.stringify(it)
|
|
301
|
+
for (const bad of ['"example"', '"sample"', '"value"', '"secretValue"']) {
|
|
302
|
+
if (raw.includes(bad)) out.push({ kind: 'declaration-leak', msg: `${it._file}:${it.key} 声明里出现 ${bad}` })
|
|
303
|
+
}
|
|
304
|
+
if (it.default !== undefined && it.default !== null && String(it.default) !== '') {
|
|
305
|
+
out.push({ kind: 'declaration-leak', msg: `${it._file}:${it.key} secret 项带了非空 default` })
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ②-c 诱饵值:先证明"能被读到",再断言"搜不到"
|
|
310
|
+
const probeEnv = opts.probeEnv || Object.fromEntries(
|
|
311
|
+
secrets.map((it) => [String((it.binding && it.binding.env) || envNameOf(it.key)), decoy]),
|
|
312
|
+
)
|
|
313
|
+
const probeReader = opts.probeReader || createReader({
|
|
314
|
+
root: reader.catalog().root, env: probeEnv, dataDir: opts.dataDir || reader.dataDir(),
|
|
315
|
+
})
|
|
316
|
+
let readable = 0
|
|
317
|
+
for (const it of secrets) {
|
|
318
|
+
const got = probeReader.get(it.key)
|
|
319
|
+
if (got === decoy) readable++
|
|
320
|
+
else if (String(it.source) === 'server') continue
|
|
321
|
+
else if (it.binding && it.binding.kind !== 'env') continue // 非 env 层:诱饵喂不进去(如实跳过)
|
|
322
|
+
else out.push({ kind: 'decoy-not-readable', msg: `${it.key} 的诱饵值没被读到 ⇒ 后面的"搜不到"证明不了任何事` })
|
|
323
|
+
}
|
|
324
|
+
if (readable === 0) out.push({ kind: 'vacuous', msg: '诱饵值一个都没被读到 ⇒ 这道门禁的"搜不到"是假绿' })
|
|
325
|
+
const probePayload = JSON.stringify(probeReader.describe())
|
|
326
|
+
if (probePayload.includes(decoy)) out.push({ kind: 'payload-leak', msg: '诱饵值经进程 env 生效后,仍然出现在 describe() 载荷里' })
|
|
327
|
+
|
|
328
|
+
// ②-d 报错文本里不得带值
|
|
329
|
+
for (const it of secrets) {
|
|
330
|
+
try {
|
|
331
|
+
probeReader.require(it.key === secrets[0].key ? it.key : it.key)
|
|
332
|
+
} catch (e) {
|
|
333
|
+
if (String(e.message).includes(decoy)) out.push({ kind: 'error-leak', msg: `${it.key} 的缺配置报错里带了值` })
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ②-e 全仓库文本里不得出现诱饵值(目录/文档/测试输出都不许)
|
|
338
|
+
const repoText = opts.repoText !== undefined ? opts.repoText : readRepoText(reader.catalog().root)
|
|
339
|
+
if (repoText.includes(decoy)) out.push({ kind: 'repo-leak', msg: `仓库文本里出现了诱饵值(谁把密钥写进仓库了?)` })
|
|
340
|
+
|
|
341
|
+
return { problems: out, stats: { secrets: secrets.length, decoyReadable: readable, decoy } }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function readRepoText(root) {
|
|
345
|
+
const parts = []
|
|
346
|
+
for (const top of ['configs', 'verticals', 'scripts', 'core', 'docs']) {
|
|
347
|
+
const p = join(root, top)
|
|
348
|
+
if (!existsSync(p)) continue
|
|
349
|
+
for (const f of walk(p)) {
|
|
350
|
+
if (!/\.(json|md|mjs|js|py|sh|yml|yaml|txt)$/.test(f)) continue
|
|
351
|
+
try { parts.push(readFileSync(f, 'utf8')) } catch { /* 忽略不可读 */ }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return parts.join('\n')
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
358
|
+
// 门禁 ③ —— 必须项可解析
|
|
359
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
360
|
+
|
|
361
|
+
/** 空环境读取器:`env={}` + 空数据目录(无 env 文件、无 config.json)。 */
|
|
362
|
+
export function emptyReader(rootDir, tmpBase) {
|
|
363
|
+
const dir = join(tmpBase, 'empty-data-dir')
|
|
364
|
+
mkdirSync(dir, { recursive: true })
|
|
365
|
+
return createReader({ root: rootDir, env: {}, dataDir: dir, home: dir })
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function gateRequired(reader, opts = {}) {
|
|
369
|
+
const out = []
|
|
370
|
+
const required = reader.all().filter((it) => it.required === true)
|
|
371
|
+
if (!required.length) out.push({ kind: 'vacuous', msg: '目录里没有任何 required 项 ⇒ 这道门禁等于空跑' })
|
|
372
|
+
|
|
373
|
+
// ③-a 真机上必须能解析(否则装机就是断的)
|
|
374
|
+
for (const it of reader.all().filter((x) => x.required === true)) {
|
|
375
|
+
const s = reader.sourceOf(it.key)
|
|
376
|
+
if (s.layer === 'missing') {
|
|
377
|
+
out.push({ kind: 'required-unresolved', msg: `${it.key}(必须项)**在真机上解析不到**:${(s.tried || []).join(';') || '五级链全未命中'}` })
|
|
378
|
+
} else if (s.layer === 'default') {
|
|
379
|
+
out.push({ kind: 'required-from-default', msg: `${it.key} 是必须项却只靠 default 兜底(等于没配也能跑 ⇒ 要么去掉 required,要么去掉 default)` })
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ③-b 空环境下必须给出**可执行**报错
|
|
384
|
+
const tmpBase = opts.tmpBase || mkdtempSync(join(tmpdir(), 'cfg-gate3-'))
|
|
385
|
+
const empty = emptyReader(reader.catalog().root, tmpBase)
|
|
386
|
+
for (const it of empty.all().filter((x) => x.required === true)) {
|
|
387
|
+
let err = null
|
|
388
|
+
try { empty.require(it.key) } catch (e) { err = e }
|
|
389
|
+
if (!err) { out.push({ kind: 'required-no-error', msg: `${it.key} 在空环境下没报错(必须项缺失必须响亮失败)` }); continue }
|
|
390
|
+
if (err.code !== 'CONFIG_MISSING') out.push({ kind: 'required-wrong-error', msg: `${it.key} 的报错 code=${err.code}(期望 CONFIG_MISSING)` })
|
|
391
|
+
const msg = String(err.message || '')
|
|
392
|
+
if (!msg.includes(it.key)) out.push({ kind: 'required-msg-nokey', msg: `${it.key} 的报错里没点名 key` })
|
|
393
|
+
if (!/去哪里配/.test(msg)) out.push({ kind: 'required-msg-nowhere', msg: `${it.key} 的报错里没有"去哪里配"一节` })
|
|
394
|
+
if (!/已尝试的来源/.test(msg)) out.push({ kind: 'required-msg-notried', msg: `${it.key} 的报错里没有列出已试来源` })
|
|
395
|
+
// "可执行"的最低标准:至少给一条具体的落点(域名/环境变量/路径)
|
|
396
|
+
if (!/[\w.-]+\.env|config\.json|进程环境变量\s+[A-Z]|[A-Z]{3,}_[A-Z0-9_]+/.test(msg)) {
|
|
397
|
+
out.push({ kind: 'required-msg-unactionable', msg: `${it.key} 的报错里没有一条可照做的落点(env 名 / 文件 / 字段)` })
|
|
398
|
+
}
|
|
399
|
+
if (it.secret === true && msg.includes(String(empty.get(it.key) ?? '\u0000'))) {
|
|
400
|
+
out.push({ kind: 'error-leak', msg: `${it.key} 的报错里带了值` })
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return { problems: out, stats: { required: required.length } }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
407
|
+
// 门禁 ④ —— 新增即生效(加一条假配置,不改任何代码)
|
|
408
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
409
|
+
|
|
410
|
+
export const FAKE_KEY = 'fixture.extensibility.probe'
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 在夹具根里造一份"真仓库目录 + 一条新声明",断言新声明**不改读取器/不改 UI 代码**就进载荷。
|
|
414
|
+
* @returns {{problems: Array, evidence: object}}
|
|
415
|
+
*/
|
|
416
|
+
export function gateExtensible(reader, opts = {}) {
|
|
417
|
+
const out = []
|
|
418
|
+
const base = opts.tmpBase || mkdtempSync(join(tmpdir(), 'cfg-gate4-'))
|
|
419
|
+
const fixRoot = join(base, 'fixture-root')
|
|
420
|
+
mkdirSync(join(fixRoot, 'configs'), { recursive: true })
|
|
421
|
+
// 夹具 = 真目录文件(**逐字节复制**)+ 一条新声明 ⇒ 证明是"增量"而不是"另起一套"
|
|
422
|
+
const cat = reader.catalog()
|
|
423
|
+
for (const rel of cat.files) {
|
|
424
|
+
const src = join(cat.root, rel)
|
|
425
|
+
const dst = join(fixRoot, rel)
|
|
426
|
+
mkdirSync(dirname(dst), { recursive: true })
|
|
427
|
+
writeFileSync(dst, readFileSync(src))
|
|
428
|
+
}
|
|
429
|
+
const before = reader.describe().items.length
|
|
430
|
+
const newItem = {
|
|
431
|
+
key: FAKE_KEY,
|
|
432
|
+
label: '夹具新增项',
|
|
433
|
+
category: '夹具分类',
|
|
434
|
+
type: 'int',
|
|
435
|
+
source: 'json',
|
|
436
|
+
secret: false,
|
|
437
|
+
required: false,
|
|
438
|
+
default: 7,
|
|
439
|
+
capability: null,
|
|
440
|
+
vertical: null,
|
|
441
|
+
editable: true,
|
|
442
|
+
help: '门禁 ④ 的夹具:加一条声明就必须出现在载荷里,不改读取器、不改 UI。',
|
|
443
|
+
binding: { kind: 'json', file: 'config.json', field: 'fixtureProbe' },
|
|
444
|
+
}
|
|
445
|
+
const fixCatalog = join(fixRoot, 'configs', 'zz-fixture.json')
|
|
446
|
+
writeFileSync(fixCatalog, JSON.stringify({ domain: 'fixture', order: 999, items: [newItem], entries: [{ id: 'fixture.entry', label: '夹具入口', category: '夹具分类', kind: 'action', implemented: false }] }, null, 2))
|
|
447
|
+
|
|
448
|
+
const fixReader = createReader({ root: fixRoot, env: {}, dataDir: join(base, 'fix-data') })
|
|
449
|
+
mkdirSync(join(base, 'fix-data'), { recursive: true })
|
|
450
|
+
const payload = fixReader.describe()
|
|
451
|
+
const mine = payload.items.find((x) => x.key === FAKE_KEY)
|
|
452
|
+
const stillThere = payload.items.find((x) => x.key === cat.items[0].key)
|
|
453
|
+
if (!mine) out.push({ kind: 'not-in-payload', msg: `夹具新增的 ${FAKE_KEY} 没有出现在 describe() 载荷里 ⇒ "加声明即生效"不成立` })
|
|
454
|
+
else {
|
|
455
|
+
if (mine.category !== '夹具分类') out.push({ kind: 'category-lost', msg: `${FAKE_KEY} 的分类没进载荷(页面无法归类)` })
|
|
456
|
+
if (mine.value !== 7) out.push({ kind: 'value-wrong', msg: `${FAKE_KEY} 的默认值没被解析(得到 ${JSON.stringify(mine.value)})` })
|
|
457
|
+
if (mine.resolvedFrom.layer !== 'default') out.push({ kind: 'layer-wrong', msg: `${FAKE_KEY} 的来源层应是 default(得到 ${mine.resolvedFrom.layer})` })
|
|
458
|
+
}
|
|
459
|
+
if (!stillThere) out.push({ kind: 'replaced-not-added', msg: `夹具把原仓库的项挤掉了(${cat.items[0].key} 不在载荷里)⇒ 证明的不是"增量生效"` })
|
|
460
|
+
if (!payload.entries.some((e) => e.id === 'fixture.entry')) out.push({ kind: 'entry-not-in-payload', msg: '夹具新增的**入口**(检查更新那类)没有出现在载荷 entries 里' })
|
|
461
|
+
if (payload.items.length !== before + 1) out.push({ kind: 'count-wrong', msg: `载荷条数应为 ${before + 1},实际 ${payload.items.length}` })
|
|
462
|
+
|
|
463
|
+
const afterAdd = payload.items.length
|
|
464
|
+
// 缓存失效:删掉夹具文件后必须立刻消失(页面不会看到残影)
|
|
465
|
+
rmSync(fixCatalog)
|
|
466
|
+
const after = fixReader.describe()
|
|
467
|
+
if (after.items.some((x) => x.key === FAKE_KEY)) out.push({ kind: 'stale-cache', msg: '删掉目录文件后该项仍在载荷里 ⇒ 缓存没失效' })
|
|
468
|
+
|
|
469
|
+
if (!opts.keep) rmSync(base, { recursive: true, force: true })
|
|
470
|
+
return { problems: out, evidence: { before, afterAdd, afterDelete: after.items.length, fixtureFile: 'configs/zz-fixture.json', key: FAKE_KEY } }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
474
|
+
// 门禁自证 —— 注入假象必须变红(一个永远返回 [] 的判定器也能"全绿")
|
|
475
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
476
|
+
|
|
477
|
+
export function selfTest(reader, opts = {}) {
|
|
478
|
+
const out = []
|
|
479
|
+
const tmpBase = opts.tmpBase || mkdtempSync(join(tmpdir(), 'cfg-selftest-'))
|
|
480
|
+
const cat = reader.catalog()
|
|
481
|
+
const fixRoot = join(tmpBase, 'selftest-root')
|
|
482
|
+
mkdirSync(join(fixRoot, 'configs'), { recursive: true })
|
|
483
|
+
for (const rel of cat.files) {
|
|
484
|
+
const dst = join(fixRoot, rel)
|
|
485
|
+
mkdirSync(dirname(dst), { recursive: true })
|
|
486
|
+
writeFileSync(dst, readFileSync(join(cat.root, rel)))
|
|
487
|
+
}
|
|
488
|
+
// 夹具里放一个 required 且**故意配不齐**的项 + 一个 normal 项(供各门禁注入用)
|
|
489
|
+
const broken = {
|
|
490
|
+
key: 'fixture.selftest.broken',
|
|
491
|
+
label: '夹具缺配置项',
|
|
492
|
+
category: '夹具分类',
|
|
493
|
+
type: 'string',
|
|
494
|
+
source: 'envfile',
|
|
495
|
+
secret: false,
|
|
496
|
+
required: true,
|
|
497
|
+
capability: null,
|
|
498
|
+
vertical: null,
|
|
499
|
+
editable: false,
|
|
500
|
+
help: '门禁自证夹具:空环境下必须给出可执行报错。',
|
|
501
|
+
binding: { kind: 'envfile', file: 'fixture-selftest.env', env: 'FIXTURE_SELFTEST_BROKEN', vertical: null },
|
|
502
|
+
}
|
|
503
|
+
const dupA = { ...broken, key: 'fixture.selftest.a', required: false, binding: { kind: 'env', env: 'FIXTURE_SAME_NAME' } }
|
|
504
|
+
const dupB = { ...broken, key: 'fixture.selftest.b', required: false, binding: { kind: 'env', env: 'FIXTURE_SAME_NAME' } }
|
|
505
|
+
const fixFile = join(fixRoot, 'configs', 'zz-selftest.json')
|
|
506
|
+
const write = (items) => writeFileSync(fixFile, JSON.stringify({ domain: 'fixture', items }, null, 2))
|
|
507
|
+
write([broken])
|
|
508
|
+
|
|
509
|
+
const scanFor = (root, reads) => {
|
|
510
|
+
const hits = new Map()
|
|
511
|
+
for (const tok of (reads || []).map((r) => r.token).filter(Boolean)) {
|
|
512
|
+
if (!hits.has(tok)) hits.set(tok, [{ file: 'fixture.mjs', line: 1 }])
|
|
513
|
+
}
|
|
514
|
+
return { hits, scanned: 1 }
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const r1 = createReader({ root: fixRoot, env: {}, dataDir: join(tmpBase, 'd') })
|
|
518
|
+
mkdirSync(join(tmpBase, 'd'), { recursive: true })
|
|
519
|
+
|
|
520
|
+
// ①-a 读了没声明
|
|
521
|
+
const g1a = gateReconcile(r1, scanFor(fixRoot), { reads: [{ key: 'fixture.selftest.read_but_undeclared', file: 'x.mjs', line: 3 }] })
|
|
522
|
+
if (!g1a.problems.some((p) => p.kind === 'read-undeclared')) out.push({ kind: 'selftest', msg: '①-a 注入"读了未声明的 key"却没有变红' })
|
|
523
|
+
|
|
524
|
+
// ①-b 声明了没人读(把 binding 指到一个代码里根本不存在的 env 名)
|
|
525
|
+
write([{ ...broken, required: false, binding: { kind: 'env', env: 'FIXTURE_NOBODY_READS_THIS_9f3a' } }])
|
|
526
|
+
const g1b = gateReconcile(createReader({ root: fixRoot, env: {}, dataDir: join(tmpBase, 'd') }), scanFor(fixRoot))
|
|
527
|
+
if (!g1b.problems.some((p) => p.kind === 'orphan-declaration')) out.push({ kind: 'selftest', msg: '①-b 注入"声明了没人读"却没有变红' })
|
|
528
|
+
|
|
529
|
+
// ①-c 两个 key 归一撞名
|
|
530
|
+
write([dupA, dupB])
|
|
531
|
+
const g1c = gateReconcile(createReader({ root: fixRoot, env: {}, dataDir: join(tmpBase, 'd') }), scanFor(fixRoot))
|
|
532
|
+
if (!g1c.problems.some((p) => p.kind === 'env-collision')) out.push({ kind: 'selftest', msg: '①-c 注入"两个 key 归一成同名 env"却没有变红' })
|
|
533
|
+
|
|
534
|
+
// ①-d server 项带 binding
|
|
535
|
+
write([{ ...broken, required: false, source: 'server', binding: { kind: 'json', file: 'config.json', field: 'x' } }])
|
|
536
|
+
const g1d = gateReconcile(createReader({ root: fixRoot, env: {}, dataDir: join(tmpBase, 'd') }), scanFor(fixRoot))
|
|
537
|
+
if (!g1d.problems.some((p) => p.kind === 'server-binding')) out.push({ kind: 'selftest', msg: '①-d 注入"server 项带 binding"却没有变红' })
|
|
538
|
+
|
|
539
|
+
// ② 诱饵值真的会漏(故意让 secret 项带上 value)
|
|
540
|
+
const leaky = { ...broken, required: false, secret: true, type: 'string', source: 'env', binding: { kind: 'env', env: 'FIXTURE_SELFTEST_SECRET' } }
|
|
541
|
+
write([leaky])
|
|
542
|
+
const r2 = createReader({ root: fixRoot, env: { FIXTURE_SELFTEST_SECRET: 'DECOY-LEAK-CHECK' }, dataDir: join(tmpBase, 'd') })
|
|
543
|
+
const payload2 = r2.describe()
|
|
544
|
+
const view2 = payload2.items.find((x) => x.key === leaky.key)
|
|
545
|
+
if (Object.prototype.hasOwnProperty.call(view2, 'value')) out.push({ kind: 'selftest', msg: '② secret 项的载荷里居然有 value 字段(说明"不回值"不是读取器保证的)' })
|
|
546
|
+
const g2 = gateSecret(r2, { decoy: 'DECOY-LEAK-CHECK', repoText: 'DECOY-LEAK-CHECK' })
|
|
547
|
+
if (!g2.problems.some((p) => p.kind === 'repo-leak')) out.push({ kind: 'selftest', msg: '② 注入"仓库里出现诱饵值"却没有变红' })
|
|
548
|
+
|
|
549
|
+
// ③ 必须项在空环境下没报错(把 required 项改成有 default ⇒ 报"靠 default 兜底")
|
|
550
|
+
write([{ ...broken, default: 'x' }])
|
|
551
|
+
const g3 = gateRequired(createReader({ root: fixRoot, env: {}, dataDir: join(tmpBase, 'd') }), { tmpBase })
|
|
552
|
+
if (!g3.problems.some((p) => p.kind === 'required-from-default')) out.push({ kind: 'selftest', msg: '③ 注入"必须项靠 default 兜底"却没有变红' })
|
|
553
|
+
// ③ 报错不可执行(binding 与 source 都指不出落点)
|
|
554
|
+
write([{ ...broken, binding: null, source: 'machine' }])
|
|
555
|
+
const g3b = gateRequired(createReader({ root: fixRoot, env: {}, dataDir: join(tmpBase, 'd') }), { tmpBase })
|
|
556
|
+
if (!g3b.problems.length) out.push({ kind: 'selftest', msg: '③ 注入"缺 binding 的必须项"却没有变红' })
|
|
557
|
+
|
|
558
|
+
// ④ 新增项不进载荷(把声明写成非法形状,读出来就该报错或缺席)
|
|
559
|
+
write([{ ...broken, required: false, key: 'BadKey' }])
|
|
560
|
+
const g4 = gateReconcile(createReader({ root: fixRoot, env: {}, dataDir: join(tmpBase, 'd') }), scanFor(fixRoot))
|
|
561
|
+
if (!g4.problems.some((p) => p.kind === 'declaration')) out.push({ kind: 'selftest', msg: '④ 注入非法 key 形状却没有变红' })
|
|
562
|
+
|
|
563
|
+
if (!opts.keep) rmSync(tmpBase, { recursive: true, force: true })
|
|
564
|
+
return { problems: out }
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
568
|
+
// 主流程
|
|
569
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
570
|
+
|
|
571
|
+
const wantGate = (n) => !onlyGate || String(onlyGate).split(',').map((x) => x.trim()).includes(String(n))
|
|
572
|
+
|
|
573
|
+
function main() {
|
|
574
|
+
if (has('--selftest')) {
|
|
575
|
+
const r = createReader({ root, env: process.env, ...(dataDir ? { dataDir } : {}) })
|
|
576
|
+
const st = selfTest(r)
|
|
577
|
+
const okAll = st.problems.length === 0
|
|
578
|
+
console.log(`门禁自证:${okAll ? '✅ 注入的六类假象全部被判红(门禁不是空壳)' : `❌ ${st.problems.length} 条自证失败`}`)
|
|
579
|
+
for (const p of st.problems) console.log(` ❌ [${p.kind}] ${p.msg}`)
|
|
580
|
+
process.exit(okAll ? 0 : 1)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const reader = createReader({ root, env: process.env, ...(dataDir ? { dataDir } : {}) })
|
|
584
|
+
const scan = scanSources()
|
|
585
|
+
const results = {}
|
|
586
|
+
|
|
587
|
+
if (wantGate(1)) {
|
|
588
|
+
const r = gateReconcile(reader, scan)
|
|
589
|
+
results['①双向对账'] = r
|
|
590
|
+
for (const p of r.problems) {
|
|
591
|
+
if (p.kind === 'vacuity-note') { notes.push(`①提示:${p.msg}`); continue }
|
|
592
|
+
red(1, `[${p.kind}] ${p.msg}`)
|
|
593
|
+
}
|
|
594
|
+
notes.push(`①扫描面:${r.stats.scannedFiles} 个代码文件、${r.stats.items} 条声明(有证据 ${r.stats.evidenced}、预留 server ${r.stats.serverPending})、字面量读 ${r.stats.literalReads} 处`)
|
|
595
|
+
}
|
|
596
|
+
if (wantGate(2)) {
|
|
597
|
+
const r = gateSecret(reader, dataDir ? { dataDir } : {})
|
|
598
|
+
results['②敏感不出门'] = r
|
|
599
|
+
for (const p of r.problems) red(2, `[${p.kind}] ${p.msg}`)
|
|
600
|
+
notes.push(`②诱饵值:${r.stats.secrets} 个 secret 项,其中 ${r.stats.decoyReadable} 个真被诱饵值读到("搜不到"才有意义)`)
|
|
601
|
+
}
|
|
602
|
+
if (wantGate(3)) {
|
|
603
|
+
const r = gateRequired(reader)
|
|
604
|
+
results['③必须项可解析'] = r
|
|
605
|
+
for (const p of r.problems) red(3, `[${p.kind}] ${p.msg}`)
|
|
606
|
+
notes.push(`③必须项:${r.stats.required} 条 required 声明`)
|
|
607
|
+
}
|
|
608
|
+
if (wantGate(4)) {
|
|
609
|
+
const r = gateExtensible(reader)
|
|
610
|
+
results['④新增即生效'] = r
|
|
611
|
+
for (const p of r.problems) red(4, `[${p.kind}] ${p.msg}`)
|
|
612
|
+
notes.push(`④夹具:先 ${r.evidence.before} 项 → 加一条声明后 ${r.evidence.afterAdd} 项 → 删掉后 ${r.evidence.afterDelete} 项(全程未改读取器/UI 代码)`)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const payload = reader.describe()
|
|
616
|
+
const tree = payload.categories.map((c) => ({ 分类: c.id, 项数: c.items, 密钥项: c.secrets, 已配置: c.configured, 必须项: c.required, 能力: c.capabilities }))
|
|
617
|
+
const out = {
|
|
618
|
+
ok: problems.length === 0,
|
|
619
|
+
gates: Object.fromEntries(Object.entries(results).map(([k, v]) => [k, { problems: v.problems, stats: v.stats }])),
|
|
620
|
+
problems,
|
|
621
|
+
notes,
|
|
622
|
+
tree,
|
|
623
|
+
entries: payload.entries,
|
|
624
|
+
items: payload.items.length,
|
|
625
|
+
catalogFiles: payload.catalogFiles,
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ⚠️ 这里一律用 `process.exitCode`,**不用** `process.exit()`(2026-10-09 实测事故):
|
|
629
|
+
// stdout 是管道时写入是异步的,`process.exit()` 会在冲刷前终止进程 ⇒ 输出被**截断在 64 KiB**
|
|
630
|
+
// (管道缓冲区大小)。载荷在设置页批次里长到 80 KB 就当场命中:`--describe | JSON.parse` 报
|
|
631
|
+
// "Expected double-quoted property name",而重定向到文件却正常(文件写入是同步的)——
|
|
632
|
+
// 这正是"看着是绿的、消费方拿到半截"的静默降级形态。
|
|
633
|
+
// `process.exitCode` + 自然退出 = 先冲刷再退出,退出码语义**逐字不变**。
|
|
634
|
+
if (JSON_OUT) { console.log(JSON.stringify(out, null, 2)); process.exitCode = STRICT && problems.length ? 1 : 0; return }
|
|
635
|
+
|
|
636
|
+
if (has('--list')) {
|
|
637
|
+
console.log(`配置目录:${payload.catalogFiles.length} 个文件 / ${payload.items.length} 项(分类 ${tree.length} 个)`)
|
|
638
|
+
for (const c of payload.categories) {
|
|
639
|
+
console.log(`\n【${c.id}】 ${c.items} 项(密钥 ${c.secrets} / 已配置 ${c.configured}${c.capabilities.length ? ` / 能力 ${c.capabilities.join(',')}` : ''})`)
|
|
640
|
+
for (const it of payload.items.filter((x) => x.category === c.id)) {
|
|
641
|
+
const state = it.secret
|
|
642
|
+
? (it.configured ? '已配置' : '未配置')
|
|
643
|
+
: (it.configured ? JSON.stringify(it.value) : (it.resolvable ? `默认 ${JSON.stringify(it.value)}` : '未配置'))
|
|
644
|
+
console.log(` ${it.key.padEnd(34)} ${it.label.padEnd(20)} [${it.type}] ${String(it.declaredSource).padEnd(8)} ${state} ← ${it.resolvedFrom.layer}`)
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (payload.entries.length) {
|
|
648
|
+
console.log('\n【入口声明】(本批**只声明不实现**)')
|
|
649
|
+
for (const e of payload.entries) console.log(` ${e.id.padEnd(28)} ${e.label.padEnd(16)} implemented=${e.implemented} — ${e.help}`)
|
|
650
|
+
}
|
|
651
|
+
process.exitCode = 0
|
|
652
|
+
return
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (has('--describe')) {
|
|
656
|
+
console.log(JSON.stringify(payload, null, 2))
|
|
657
|
+
process.exitCode = 0 // 同上:载荷可达数十 KB,必须让 stdout 冲刷完再退
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
console.log(`配置目录门禁(root=${root}|数据目录=${payload.dataDir})`)
|
|
662
|
+
console.log(` 目录文件 ${payload.catalogFiles.length} 个、声明 ${payload.items.length} 项、入口 ${payload.entries.length} 条`)
|
|
663
|
+
for (const n of notes) console.log(` · ${n}`)
|
|
664
|
+
console.log('')
|
|
665
|
+
console.log(' 分类树(页面按它分节):')
|
|
666
|
+
for (const c of tree) console.log(` ${c.分类.padEnd(14)} ${String(c.项数).padStart(3)} 项 密钥 ${c.密钥项} 已配置 ${c.已配置} 必须 ${c.必须项}`)
|
|
667
|
+
console.log('')
|
|
668
|
+
if (problems.length) {
|
|
669
|
+
console.log(`结果:❌ ${problems.length} 条问题`)
|
|
670
|
+
for (const p of problems) console.log(` ❌ [门禁 ${p.gate}] ${p.msg}`)
|
|
671
|
+
} else {
|
|
672
|
+
console.log('结果:✅ 四道门禁全绿(双向对账 / 敏感不出门 / 必须项可解析 / 新增即生效)')
|
|
673
|
+
}
|
|
674
|
+
process.exitCode = STRICT && problems.length ? 1 : 0
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (isDirectRun(import.meta.url)) main()
|