@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.
- package/MANIFEST.json +341 -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 +576 -0
- package/engine/scripts/billing-mode-daily.sh +13 -0
- package/engine/scripts/build-engine-package.mjs +126 -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 +378 -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/collect-evidence.mjs +42 -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 +135 -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 +47 -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 +261 -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/final-check.mjs +43 -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 +735 -0
- package/engine/scripts/flow-scaffold.py +275 -0
- package/engine/scripts/flow-timers-plist.sh +87 -0
- package/engine/scripts/flow-timers.sh +184 -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 +1025 -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 +2278 -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 +195 -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.sh +18 -0
- package/engine/scripts/lib/argent_datadir.py +285 -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 +295 -0
- package/engine/scripts/poll-invoice.sh +76 -0
- package/engine/scripts/profile-doctor.mjs +686 -0
- package/engine/scripts/provider-select.sh +71 -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/retire-inventory.mjs +40 -0
- package/engine/scripts/run-tests.sh +257 -0
- package/engine/scripts/s1-audit.mjs +85 -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 +137 -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 +74 -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 +35 -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 +217 -0
- package/engine/scripts/ziniao-post-upgrade-check.sh +121 -0
- package/engine/scripts/ziniao-windows.sh +154 -0
- package/engine/skills/README.md +86 -0
- package/engine/skills/argent-dev/SKILL.md +41 -0
- package/engine/skills/internal.json +37 -0
- package/engine/skills/lktop-pm/SKILL.md +35 -0
- package/engine/skills/lktopapp-dev/SKILL.md +34 -0
- package/engine/skills/ml-config-guide/SKILL.md +50 -0
- package/engine/skills/ml-fulfillment-sop/SKILL.md +178 -0
- package/engine/skills/ml-fulfillment-sop/references/andes-components.md +69 -0
- package/engine/skills/ml-fulfillment-sop/references/cdp-port-mapping.md +35 -0
- package/engine/skills/ml-fulfillment-sop/references/feishu-base-fulfillment-schema.md +136 -0
- package/engine/skills/ml-fulfillment-sop/references/multi-window-concurrency.md +69 -0
- package/engine/skills/ml-fulfillment-sop/references/pitfalls.md +77 -0
- package/engine/skills/ml-fulfillment-sop/references/selectors.md +243 -0
- package/engine/skills/ml-fulfillment-sop/scripts/fulfillment.js +586 -0
- package/engine/skills/ml-fulfillment-sop/scripts/poll-and-execute.sh +271 -0
- package/engine/skills/ml-fulfillment-sop/templates/report.md +55 -0
- package/engine/skills/ml-invoice/SKILL.md +506 -0
- package/engine/skills/ml-invoice/references/pitfalls.md +268 -0
- package/engine/skills/ml-invoice/references/portal-flow.md +360 -0
- package/engine/skills/ml-invoice/references/template-fill-contract.md +133 -0
- package/engine/skills/ml-invoice-xzh/SKILL.md +133 -0
- package/engine/skills/ml-invoice-xzh/references/pitfalls.md +230 -0
- package/engine/skills/ml-store-daily-report/SKILL.md +224 -0
- package/engine/skills/ml-store-daily-report/scripts/README.md +25 -0
- package/engine/skills/ml-store-daily-report/scripts/extract.js +691 -0
- package/engine/skills/ml-store-daily-report/scripts/generate.js +299 -0
- package/engine/skills/pet-dev/SKILL.md +42 -0
- package/engine/skills/whyshu-pm/SKILL.md +35 -0
- package/engine/skills/whyshu-web-dev/SKILL.md +50 -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 +738 -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 +22 -4
- package/requirements.txt +5 -0
- package/README.md +0 -3
|
@@ -0,0 +1,1172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置目录(catalog)+ **通用读取器** —— 配置面的唯一真源(2026-10-09,配置地基批次)
|
|
3
|
+
*
|
|
4
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* 一、它解决什么
|
|
6
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
7
|
+
* 此前"这台机器有哪些可配项"没有一份清单:有的项只活在 env 文件里(`fulfillment.env`)、
|
|
8
|
+
* 有的只活在 `config.json` 的一个键里、有的只活在某个 `*.json` 的深层字段里、有的干脆是
|
|
9
|
+
* 代码里的 `process.env.X || '默认值'`。后果有三笔:
|
|
10
|
+
* ① **设置页无法生成** —— 页面只能靠人一份份手写名单,加一项就要改 UI 代码(本批要终结它);
|
|
11
|
+
* ② **"缺配置"报错五花八门** —— 同一个缺失,有的静默用默认值、有的抛一句 `KeyError`,
|
|
12
|
+
* 用户不知道"该去哪配";
|
|
13
|
+
* ③ **秘密没有统一口径** —— 哪个字段是密钥、能不能进日志/页面,全靠各文件自觉。
|
|
14
|
+
*
|
|
15
|
+
* 本文件给两样东西(**声明**与**读取**分离,各自只有一处实现):
|
|
16
|
+
* · **目录**:`configs/<域>.json`(引擎级)+ `verticals/<id>/config.json`(业务线级),
|
|
17
|
+
* 逐项声明 key/label/category/type/source/secret/default/required/validation/归属/editable/help;
|
|
18
|
+
* · **读取器**:本文件导出的 {@link createReader}(`get`/`require`/`sourceOf`/`isConfigured`/
|
|
19
|
+
* `all`/`describe`/`invalidate`)——**全仓库唯一一份解析链**。
|
|
20
|
+
*
|
|
21
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
22
|
+
* 二、解析顺序(**固定五级**,声明只描述"主来源",不改变链)
|
|
23
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
24
|
+
* ① 进程环境变量 由 key 归一化的名字(`ml.daily.hour` → `ML_DAILY_HOUR`)
|
|
25
|
+
* ② 域 env 文件 `<数据目录>/verticals/<vid>/env/<域>.env` → `<数据目录>/<域>.env`
|
|
26
|
+
* (规范路径优先、旧平铺兜底 —— 与 `scripts/flow_config.py` 同口径;
|
|
27
|
+
* `~/.dsh/argent/*.env` 的**文件路径一个字节都不动**,见契约 §7)
|
|
28
|
+
* ③ `config.json` `<数据目录>/config.json#<字段>`(控制台/设置页写的那一份)
|
|
29
|
+
* ④ 业务线/机器本地文件 `<数据目录>/<文件>#<字段>`(如 `ml-api-stores.json` / `instance.json`)
|
|
30
|
+
* ⑤ 声明里的 default
|
|
31
|
+
*
|
|
32
|
+
* **`source`(声明)与 `sourceOf()`(事实)是两件事**,别混读:
|
|
33
|
+
* · `item.source` = 这一项的**主来源**(给人看"它该在哪",`server` 为**预留**:本批不读、不写);
|
|
34
|
+
* · `sourceOf(key).layer` = 这一次**实际**是从哪一级取到的 —— 设置页要显示的就是它
|
|
35
|
+
* ("已配置(来自 env 文件 fulfillment.env)")。
|
|
36
|
+
*
|
|
37
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
38
|
+
* 三、秘密纪律(**唯一红线,由门禁 ② 守着**)
|
|
39
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
40
|
+
* · `secret: true` 的项:{@link createReader} 的 `describe()` **永不**回值(连长度都不回),
|
|
41
|
+
* 只回 `{ configured, resolvedFrom }`;`require()` 的报错**永不**带值;
|
|
42
|
+
* · 目录文件里 secret 项**不得**写 `default` 的非空值、**不得**写 `example`;
|
|
43
|
+
* · `get()` 仍会返回值 —— 它是给**进程内**用的,谁调用谁负责不落日志(门禁 ② 用"诱饵值"
|
|
44
|
+
* 断言 `describe()` / 报错 / 目录文件里都找不到它)。
|
|
45
|
+
*
|
|
46
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
47
|
+
* 四、为什么在 L1(`core/lib/`)
|
|
48
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
49
|
+
* · 契约 §1:**确定性逻辑只能落在 L1**("这一项现在是配置好了还是没配"是判断,不是 UI);
|
|
50
|
+
* · 零宿主依赖:只 import `node:` 内建与同层的 `./argent-datadir.mjs`(数据目录唯一判据,
|
|
51
|
+
* **不自己拼三级链**)⇒ `kernel-purity` 对 `core/**` 的扫描面天然干净;
|
|
52
|
+
* · 页面(L2)与 CLI/MCP(L1)**消费同一份** `describe()`,不允许壳里再写一套"这值配没配"。
|
|
53
|
+
*
|
|
54
|
+
* 跑法(回归):`node scripts/tests/config-gates.test.mjs`
|
|
55
|
+
* 门禁 CLI:`node scripts/check-config-catalog.mjs --strict`
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
59
|
+
import { dirname, isAbsolute, join, resolve as resolvePath } from 'node:path'
|
|
60
|
+
import { fileURLToPath } from 'node:url'
|
|
61
|
+
|
|
62
|
+
import { resolveDataDir } from './argent-datadir.mjs'
|
|
63
|
+
|
|
64
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
65
|
+
// 一、契约枚举(**唯一一份**;门禁、读取器、页面都从这里取,不各写一套)
|
|
66
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
67
|
+
|
|
68
|
+
/** 载荷 schema 名(页面按它做版本判断)。 */
|
|
69
|
+
export const CATALOG_SCHEMA = 'argent-config-catalog/1'
|
|
70
|
+
|
|
71
|
+
/** 引擎级目录所在目录(仓库根或安装根的相对路径)。 */
|
|
72
|
+
export const CATALOG_DIR = 'configs'
|
|
73
|
+
/** 业务线级目录文件名(落在 `verticals/<id>/` 下)。 */
|
|
74
|
+
export const VERTICAL_CATALOG_FILE = 'config.json'
|
|
75
|
+
/** 允许的目录文件形状:`<域>.json`(域名字母数字/下划线/连字符,不带路径分隔符)。 */
|
|
76
|
+
export const CATALOG_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*\.json$/
|
|
77
|
+
|
|
78
|
+
/** 项的 `source`(**主来源**;`server` = 预留,本批不读不写)。 */
|
|
79
|
+
export const SOURCES = ['env', 'envfile', 'json', 'machine', 'server', 'default']
|
|
80
|
+
/** 实际取值层(`sourceOf()` 的 `layer`);`missing` 只在"没取到"时出现。 */
|
|
81
|
+
export const LAYERS = ['env', 'envfile', 'json', 'machine', 'server', 'default', 'missing']
|
|
82
|
+
/** 解析顺序(固定;`default` 恒最后)。 */
|
|
83
|
+
export const RESOLUTION_ORDER = ['env', 'envfile', 'json', 'machine', 'server', 'default']
|
|
84
|
+
/** 项的 `type`。`secret` 是**历史形态**:等价于 `type: string` + `secret: true`(门禁 ① 强制一致)。 */
|
|
85
|
+
export const ITEM_TYPES = ['string', 'int', 'bool', 'enum', 'secret', 'path', 'list']
|
|
86
|
+
/** `binding.kind`("今天实际怎么读")。 */
|
|
87
|
+
export const BINDING_KINDS = ['env', 'envfile', 'json', 'machine', 'server']
|
|
88
|
+
/** 列表派生方式(只给只读事实项用)。 */
|
|
89
|
+
export const TRANSFORMS = ['length']
|
|
90
|
+
/** 内核判据派生(不自己拼路径,走 `argent-datadir.mjs`)。 */
|
|
91
|
+
export const SPECIALS = ['dataDir', 'dshHome', 'dataDirSource']
|
|
92
|
+
/**
|
|
93
|
+
* key 必须点分命名(`ml.daily.hour`)。
|
|
94
|
+
* **只允许小写/数字/下划线** ⇒ {@link envNameOf} 是**无损**映射(大写 + `.`→`_`),
|
|
95
|
+
* 于是"进程环境变量"这一级不需要任何人工登记表;门禁 ① 另查"两个 key 不得归一成同一个
|
|
96
|
+
* 环境变量名"(否则进程 env 覆盖会串项)。
|
|
97
|
+
*/
|
|
98
|
+
export const KEY_RE = /^[a-z][a-z0-9_]*(?:\.[a-z0-9_]+)+$/
|
|
99
|
+
/** 域 env 文件名形状(`daily-report.env`);写死形状 = 防目录里塞任意路径。 */
|
|
100
|
+
export const ENV_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.env$/
|
|
101
|
+
/** 目录文件里**不得**出现的键(secret 项尤其):值是"真实样例"的字段一律不许进仓库。 */
|
|
102
|
+
export const FORBIDDEN_ITEM_KEYS = ['example', 'sample', 'value', 'secretValue']
|
|
103
|
+
|
|
104
|
+
const ITEM_REQUIRED_FIELDS = ['key', 'label', 'category', 'type', 'source', 'help']
|
|
105
|
+
|
|
106
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
107
|
+
// 二、纯函数小件(门禁与读取器共用)
|
|
108
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* key → 进程环境变量名:点分 → 下划线、大写、非字母数字下划线一律换成 `_`。
|
|
112
|
+
* `ml.daily.hour` → `ML_DAILY_HOUR`;`account.ziniao.apiKey` → `ACCOUNT_ZINIAO_APIKEY`。
|
|
113
|
+
*/
|
|
114
|
+
export function envNameOf(key) {
|
|
115
|
+
return String(key || '').trim().toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** `a.b.c` 取值(对象;缺失 → undefined;数组下标不支持 —— 目录只声明定点字段)。 */
|
|
119
|
+
export function pickPath(doc, path) {
|
|
120
|
+
if (!path) return doc
|
|
121
|
+
let cur = doc
|
|
122
|
+
for (const seg of String(path).split('.')) {
|
|
123
|
+
if (cur === null || cur === undefined) return undefined
|
|
124
|
+
if (typeof cur !== 'object') return undefined
|
|
125
|
+
cur = cur[seg]
|
|
126
|
+
}
|
|
127
|
+
return cur
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** `KEY=VALUE` 行解析(注释/空行/`export ` 前缀/成对引号;与 shell 同口径的保守子集)。 */
|
|
131
|
+
export function parseEnvText(text) {
|
|
132
|
+
const out = {}
|
|
133
|
+
for (const raw of String(text || '').split('\n')) {
|
|
134
|
+
const line = raw.trim()
|
|
135
|
+
if (!line || line.startsWith('#')) continue
|
|
136
|
+
const m = /^(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t]*=(.*)$/.exec(line)
|
|
137
|
+
if (!m) continue
|
|
138
|
+
let v = m[2].trim()
|
|
139
|
+
if ((v.startsWith('"') && v.endsWith('"') && v.length > 1) || (v.startsWith("'") && v.endsWith("'") && v.length > 1)) {
|
|
140
|
+
v = v.slice(1, -1)
|
|
141
|
+
}
|
|
142
|
+
out[m[1]] = v
|
|
143
|
+
}
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 值的字面类型名(报错/校验用;`null`/`undefined` 单独归 `empty`)。 */
|
|
148
|
+
export function literalTypeOf(v) {
|
|
149
|
+
if (v === null || v === undefined || v === '') return 'empty'
|
|
150
|
+
if (Array.isArray(v)) return 'list'
|
|
151
|
+
if (typeof v === 'boolean') return 'bool'
|
|
152
|
+
if (typeof v === 'number') return 'int'
|
|
153
|
+
return 'string'
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 按声明把字符串投成目标类型;投不动 → `{ ok:false, why }`(**不抛**,调用方决定)。 */
|
|
157
|
+
export function coerce(value, type) {
|
|
158
|
+
if (value === undefined || value === null) return { ok: false, why: 'empty' }
|
|
159
|
+
const t = String(type)
|
|
160
|
+
// 标量型一律拒绝对象/数组:binding 写错时**响亮失败**,绝不吐出 "[object Object]"
|
|
161
|
+
if (t !== 'list' && typeof value === 'object') {
|
|
162
|
+
return { ok: false, why: `期望标量,实际拿到 ${Array.isArray(value) ? 'list' : 'object'}(binding 的 field 写错了?)` }
|
|
163
|
+
}
|
|
164
|
+
if (t === 'int') {
|
|
165
|
+
const s = String(value).trim()
|
|
166
|
+
if (!/^-?\d+$/.test(s)) return { ok: false, why: `不是整数:${JSON.stringify(s)}` }
|
|
167
|
+
return { ok: true, value: Number(s) }
|
|
168
|
+
}
|
|
169
|
+
if (t === 'bool') {
|
|
170
|
+
const s = String(value).trim().toLowerCase()
|
|
171
|
+
if (['1', 'true', 'yes', 'on'].includes(s)) return { ok: true, value: true }
|
|
172
|
+
if (['0', 'false', 'no', 'off', ''].includes(s)) return { ok: true, value: false }
|
|
173
|
+
return { ok: false, why: `不是布尔字面量:${JSON.stringify(String(value))}` }
|
|
174
|
+
}
|
|
175
|
+
if (t === 'list') {
|
|
176
|
+
if (Array.isArray(value)) return { ok: true, value }
|
|
177
|
+
const s = String(value).trim()
|
|
178
|
+
if (!s) return { ok: true, value: [] }
|
|
179
|
+
return { ok: true, value: s.split(',').map((x) => x.trim()).filter(Boolean) }
|
|
180
|
+
}
|
|
181
|
+
if (t === 'path') {
|
|
182
|
+
const s = String(value).trim()
|
|
183
|
+
if (!s) return { ok: false, why: 'empty' }
|
|
184
|
+
return { ok: true, value: s }
|
|
185
|
+
}
|
|
186
|
+
if (t === 'enum') {
|
|
187
|
+
const s = String(value).trim()
|
|
188
|
+
return s ? { ok: true, value: s } : { ok: false, why: 'empty' }
|
|
189
|
+
}
|
|
190
|
+
// string / secret
|
|
191
|
+
return { ok: true, value: typeof value === 'string' ? value : String(value) }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* **写入落点**(本页能不能改、改了写去哪)—— L1 的判定,页面**只消费不复制**。
|
|
196
|
+
*
|
|
197
|
+
* 为什么必须有它(2026-10-09,设置页批次):"这一项可不可以从页面改"是**判断**(契约 §1:判断只能落 L1),
|
|
198
|
+
* 而且答案取决于 `editable` + `binding.kind` + 落点是否已有既有写入口三件事的合取。若让页面各自
|
|
199
|
+
* 推一遍,就会出现"页面显示能点保存、后端其实不受理"(或反之)——**两处判据必然漂移**。
|
|
200
|
+
*
|
|
201
|
+
* 返回值:`{ kind, label, file, field, env, vertical }` 或 `null`
|
|
202
|
+
* · `kind: 'config'` = 写 `<数据目录>/config.json#<field>`(既有落点:host 的 POST /api/argent-state/config)
|
|
203
|
+
* · `kind: 'envfile'` = 写域 env 文件(既有落点:scripts/argent-env-write.mjs,受 ARGENT_ENV_WRITER 开关管)
|
|
204
|
+
* · `null` = 本页不可写(原因见 {@link writeBlockedReason})
|
|
205
|
+
* ⛔ **不新增第三套存储**:只有上面两条既有落点,别的 binding 一律 `null`。
|
|
206
|
+
*/
|
|
207
|
+
export function writeTargetOf(item) {
|
|
208
|
+
if (!item || typeof item !== 'object') return null
|
|
209
|
+
if (item.editable !== true) return null
|
|
210
|
+
if (String(item.source) === 'server') return null // 预留来源:本批不读不写
|
|
211
|
+
const b = item.binding || {}
|
|
212
|
+
if (b.special) return null // 内核判据派生(数据目录/home/来源)
|
|
213
|
+
if (b.presence) return null // 存在性标记(暂停开关这类,由系统维护)
|
|
214
|
+
if (b.kind === 'json') {
|
|
215
|
+
const file = String(b.file || 'config.json')
|
|
216
|
+
if (file !== 'config.json' || !b.field) return null // 只认既有那个 config.json 写入口
|
|
217
|
+
return { kind: 'config', label: `config.json#${b.field}`, file, field: String(b.field) }
|
|
218
|
+
}
|
|
219
|
+
if (b.kind === 'envfile') {
|
|
220
|
+
const file = String(b.file || '')
|
|
221
|
+
if (!file) return null
|
|
222
|
+
const vertical = b.vertical || item.vertical || null
|
|
223
|
+
return {
|
|
224
|
+
kind: 'envfile',
|
|
225
|
+
label: vertical ? `verticals/${vertical}/env/${file}` : file,
|
|
226
|
+
file, field: null,
|
|
227
|
+
env: String(b.env || envNameOf(item.key)),
|
|
228
|
+
vertical,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 本页**不可写**的原因(`editable:true` 却拿不到落点时,页面要能说清"为什么改不了")。 */
|
|
235
|
+
export function writeBlockedReason(item) {
|
|
236
|
+
if (!item) return '未声明'
|
|
237
|
+
if (item.editable !== true) return '声明为只读(editable:false)'
|
|
238
|
+
if (String(item.source) === 'server') return '来源 server 为本批预留(当前无读取/写入实现)'
|
|
239
|
+
const b = item.binding || {}
|
|
240
|
+
if (b.special) return '内核判据派生(数据目录 / 宿主 home 等机器事实)'
|
|
241
|
+
if (b.presence) return '存在性标记(由系统/流程维护,不在页面改)'
|
|
242
|
+
if (b.kind === 'env') return '进程环境变量:由宿主启动环境提供,页面不写'
|
|
243
|
+
if (b.kind === 'machine') return '机器本地文件:由安装/体检生成,页面不写'
|
|
244
|
+
if (b.kind === 'json') return '声明指向的不是 config.json(脚本维护的部署事实,页面不写)'
|
|
245
|
+
return '声明里没有可写落点(binding 不指向既有写入口)'
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* **旧优先级提示**(`todayChain` 与规范链**首级不同** ⇒ 系统仍按旧优先级读)。
|
|
250
|
+
*
|
|
251
|
+
* 契约 §7.2 的 `todayChain` 如实登记"今天代码里的优先级";读取器恒按规范链解析
|
|
252
|
+
* (`env → envfile → json → machine → server → default`)。两者首级不同时:页面写的是**该旧链的第一落点**
|
|
253
|
+
* (写对了),但**同名环境变量/env 文件一旦有值,实际生效的仍是旧链那一级** ⇒ 必须当面讲清,
|
|
254
|
+
* ⛔ 不能"看起来改了其实没改"。
|
|
255
|
+
*/
|
|
256
|
+
export function chainNoteOf(item) {
|
|
257
|
+
const t = item && Array.isArray(item.todayChain) ? item.todayChain : null
|
|
258
|
+
if (!t || !t.length) return null
|
|
259
|
+
if (String(t[0]) === RESOLUTION_ORDER[0]) return null
|
|
260
|
+
return `当前系统仍按**旧优先级**读取:${t.join(' → ')}(规范链是 ${RESOLUTION_ORDER.join(' → ')})`
|
|
261
|
+
+ ' —— 本页写入的是旧链的第一落点,写对了;但若同名环境变量 / env 文件同时有值,'
|
|
262
|
+
+ '实际生效的仍是旧链里更靠前的那一级。'
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
266
|
+
// 二·五、**生效来源**(谁赢了)+ 密钥脱敏(2026-10-09,配置地基第三批次)
|
|
267
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
268
|
+
//
|
|
269
|
+
// 要消灭的误会:**「我在设置页改了,但更靠前的那一级还在生效」**。
|
|
270
|
+
// 既有提示({@link chainNoteOf})是**静态**的(只说"两套链不同序"),缺**逐项动态**的
|
|
271
|
+
// "这一项此刻到底谁赢了、谁被压住了" —— 本节的纯函数补的就是它。
|
|
272
|
+
//
|
|
273
|
+
// 三条纪律(本批的行为契约):
|
|
274
|
+
// ① **零行为变化**:本节的函数**不读文件、不写任何东西、不改变解析顺序**。它们只把
|
|
275
|
+
// "调用方已经取到的各级现值" 按**规范链**({@link RESOLUTION_ORDER})排一次序。
|
|
276
|
+
// 读取器真正取值的那条路径(`resolveOne`)一个字节都没动。
|
|
277
|
+
// ② **可纯夹具回归**:`resolveEffective(item, levels)` 的输入全是普通对象 ⇒ 不碰磁盘也能测;
|
|
278
|
+
// 真机取值的活儿由 {@link createReader} 的 `levelsOf()` 干(它复用同一份文件缓存)。
|
|
279
|
+
// ③ **密钥只在门外**:`secret: true` 的项,输出里的**任何**值位(含 `shadowed[].value`、
|
|
280
|
+
// `invalid[].why`)一律是 {@link redactSecret} 的产物,**永不含明文**(门禁 ② 另用诱饵值钉住)。
|
|
281
|
+
|
|
282
|
+
/** 密钥占位形态:**只给"已设置 + 长度"**,不给任何前缀(前缀也能被撞库/比对)。 */
|
|
283
|
+
export function redactSecret(value) {
|
|
284
|
+
const s = value === undefined || value === null ? '' : String(value)
|
|
285
|
+
return `❰已设置(长度${s.length})❱`
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 值位脱敏:`secret: true` 的项走占位,其余原样(含 `false` / `0` / `''` 这类合法值)。 */
|
|
289
|
+
export function maskIfSecret(value, secret) {
|
|
290
|
+
return secret ? redactSecret(value) : value
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 这一项**读取器真会去看**哪几级(按规范链次序)。
|
|
295
|
+
*
|
|
296
|
+
* 判据与 `resolveOne` **逐条对齐**(两边必须同序同名,否则"生效来源"会与真实取值分家):
|
|
297
|
+
* · `env` —— **恒**参与(任何 binding 都先试进程环境变量,名字 = `binding.env || envNameOf(key)`);
|
|
298
|
+
* · `envfile` —— 仅当 `binding.kind === 'envfile'` 或 `source === 'envfile'`;
|
|
299
|
+
* · `json` / `machine` —— 仅当 `binding.special`(内核判据派生 ⇒ `machine`)
|
|
300
|
+
* 或 `binding.kind` 是 `json` / `machine`(两者互斥);
|
|
301
|
+
* · `default` —— 恒参与(恒最后一级);
|
|
302
|
+
* · `server` —— **本批无读取实现**(预留来源)⇒ 不进这张表,但 CLI 会如实标"预留"。
|
|
303
|
+
*/
|
|
304
|
+
export function levelsApplicableTo(item) {
|
|
305
|
+
const b = (item && item.binding) || {}
|
|
306
|
+
const out = ['env']
|
|
307
|
+
if (b.kind === 'envfile' || (item && item.source === 'envfile')) out.push('envfile')
|
|
308
|
+
if (b.special) out.push('machine')
|
|
309
|
+
else if (b.kind === 'json') out.push('json')
|
|
310
|
+
else if (b.kind === 'machine') out.push('machine')
|
|
311
|
+
out.push('default')
|
|
312
|
+
return out
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* **纯函数**:给定一项声明 + "各级现值视图",算出生效级与被压住的级。
|
|
317
|
+
*
|
|
318
|
+
* @param {object} item 目录里的一项声明(只读 `key` / `type` / `secret` / `binding` /
|
|
319
|
+
* `source` / `default` / `todayChain`)
|
|
320
|
+
* @param {Record<string, any>} [levels] 各级现值(**调用方已取到**;本函数不读文件):
|
|
321
|
+
* `{ env, envfile, json, machine, server, default }`。缺席/`null` = 该级无值。
|
|
322
|
+
* `levels.default` 省略时取 `item.default` —— 那是**声明里的最后一级**,不是隐式默认值。
|
|
323
|
+
* @param {{ chain?: string[] }} [opts] `chain` 覆盖规范链(默认 {@link RESOLUTION_ORDER};夹具用)
|
|
324
|
+
* @returns {{ key: string, from: string|null, value: any, chain: string[], applicable: string[],
|
|
325
|
+
* present: string[], shadowed: Array<{level: string, value: any, why: string}>,
|
|
326
|
+
* conflict: boolean, invalid: Array<{level: string, why: string}>, secret: boolean,
|
|
327
|
+
* todayChain: string[]|null, todayFirst: string|null, todayFirstPresent: boolean,
|
|
328
|
+
* divergent: boolean, note: string }}
|
|
329
|
+
*
|
|
330
|
+
* 判据(**"谁赢"的顺序依据 = 规范链 `RESOLUTION_ORDER`,不是 `source`、也不是 `todayChain`**):
|
|
331
|
+
* ① `from` = 规范链里**第一个有值**的级(值还要能被声明的 `type` 投出来 —— 与 `resolveOne` 同口径;
|
|
332
|
+
* 投不动的级进 `invalid`,**不算有值**,正是"我设了但它其实没生效"的那种情形);
|
|
333
|
+
* ② `present` = **真实来源级**里有值且投得动的级名(按规范链次序,**不含 `default`**);
|
|
334
|
+
* ③ `shadowed` = `present` 里除 `from` 之外的级(**有值但被压住**),逐条给出被压住的原因;
|
|
335
|
+
* ④ `conflict` = `shadowed.length > 0`(**≥2 个真实级同时有值**);
|
|
336
|
+
* ⑤ 空值判据:非 `default` 级缺席 = `undefined | null`(`false` / `0` / `''` 都**算有值**,
|
|
337
|
+
* 与 `resolveOne` 一致);`default` 级另把 `''` 视为缺席;
|
|
338
|
+
* ⑥ **`default` 是兜底级**:只有"其余级全都没值"时它才成为 `from`(那时 `present` 为空)——
|
|
339
|
+
* 声明里写了个默认值**不是冲突**(否则"有 default 的项"会全被标红);
|
|
340
|
+
* ⑦ `divergent` = **旧链第一落点此刻真有值**、且规范链从别的真实级取到值 ⇒ 两套口径答案不同;
|
|
341
|
+
* ⑧ **不做任何写入、不做隐式默认**:本函数没有副作用,也不必知道数据目录在哪。
|
|
342
|
+
*/
|
|
343
|
+
export function resolveEffective(item, levels = {}, opts = {}) {
|
|
344
|
+
const it = item && typeof item === 'object' ? item : {}
|
|
345
|
+
const secret = it.secret === true
|
|
346
|
+
const chain = Array.isArray(opts.chain) && opts.chain.length ? [...opts.chain] : [...RESOLUTION_ORDER]
|
|
347
|
+
const view = levels && typeof levels === 'object' ? levels : {}
|
|
348
|
+
const wantType = it.type === 'secret' ? 'string' : String(it.type || 'string')
|
|
349
|
+
const applicable = levelsApplicableTo(it)
|
|
350
|
+
|
|
351
|
+
const present = []
|
|
352
|
+
const values = {}
|
|
353
|
+
const invalid = []
|
|
354
|
+
for (const level of chain) {
|
|
355
|
+
if (!applicable.includes(level)) continue
|
|
356
|
+
const raw = level === 'default'
|
|
357
|
+
? (view.default !== undefined ? view.default : it.default) // 声明里的 default = 最后一级
|
|
358
|
+
: view[level]
|
|
359
|
+
if (raw === undefined || raw === null) continue
|
|
360
|
+
if (level === 'default' && raw === '') continue // 与 resolveOne 同口径:空 default 不算
|
|
361
|
+
const c = coerce(raw, wantType)
|
|
362
|
+
if (!c.ok) {
|
|
363
|
+
// ⛔ 密钥项的失败原因也可能带值(`coerce` 的 int 报错会回显字面量)⇒ 一律换成通用措辞
|
|
364
|
+
invalid.push({ level, why: secret ? '取值失败(密钥项不显示细节)' : c.why })
|
|
365
|
+
continue
|
|
366
|
+
}
|
|
367
|
+
values[level] = c.value
|
|
368
|
+
// `default` 是**兜底级**,不进 `present`/`shadowed`:声明里写了个默认值**不是冲突**
|
|
369
|
+
// (否则"有 default 的项"全都会被标成冲突,`--all` 当场失去意义)。
|
|
370
|
+
if (level !== 'default') present.push(level)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const from = present.length ? present[0] : (values.default !== undefined ? 'default' : null)
|
|
374
|
+
const shadowed = present.slice(1).map((level) => ({
|
|
375
|
+
level,
|
|
376
|
+
value: maskIfSecret(values[level], secret),
|
|
377
|
+
why: `规范链里 ${from} 比它靠前且已有值 ⇒ 它当前不生效`,
|
|
378
|
+
}))
|
|
379
|
+
|
|
380
|
+
// 今日链(`todayChain` 是**登记**的"今天代码里的优先级",本批**不按它解析**):
|
|
381
|
+
// 只做"两套口径是否分歧 + 旧链第一落点此刻有没有值"的如实呈现 —— ⛔ 不改读取顺序。
|
|
382
|
+
const todayChain = Array.isArray(it.todayChain) && it.todayChain.length ? it.todayChain.map(String) : null
|
|
383
|
+
const todayFirst = todayChain ? todayChain[0] : null
|
|
384
|
+
const todayFirstPresent = Boolean(todayFirst)
|
|
385
|
+
&& (present.includes(todayFirst) || (todayFirst === 'default' && values.default !== undefined))
|
|
386
|
+
// "两套口径不一致" = **旧链第一落点此刻真有值**,且规范链从**别的真实级**取到值
|
|
387
|
+
// ⇒ 两个答案不一样(这才是"我在页面上改了、它却读别处"的那种情形)。
|
|
388
|
+
// 旧链第一落点没值时不算分歧(旧代码会自己往下走,最终落到同一处)。
|
|
389
|
+
const divergent = Boolean(from && from !== 'default' && todayFirst && todayFirst !== from && todayFirstPresent)
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
key: String(it.key || ''),
|
|
393
|
+
from,
|
|
394
|
+
value: from ? maskIfSecret(values[from], secret) : null,
|
|
395
|
+
chain,
|
|
396
|
+
applicable,
|
|
397
|
+
present,
|
|
398
|
+
shadowed,
|
|
399
|
+
conflict: shadowed.length > 0,
|
|
400
|
+
invalid,
|
|
401
|
+
secret,
|
|
402
|
+
todayChain,
|
|
403
|
+
todayFirst,
|
|
404
|
+
todayFirstPresent,
|
|
405
|
+
divergent,
|
|
406
|
+
note: effectiveNote({ from, shadowed, present, invalid, todayFirst, todayFirstPresent, divergent }),
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** 生效来源的**人话**(L1 出文案,页面只渲染 —— 两处各写一份必然漂移)。 */
|
|
411
|
+
export function effectiveNote({ from, shadowed, present, invalid, todayFirst, todayFirstPresent, divergent }) {
|
|
412
|
+
const parts = []
|
|
413
|
+
if (from === 'default') {
|
|
414
|
+
parts.push(`生效级:default(${present.length ? present.join(' / ') : '真实来源'}都没有值 ⇒ 用的是声明里的默认值)`)
|
|
415
|
+
} else if (from) {
|
|
416
|
+
parts.push(`生效级:${from}(按规范链 ${RESOLUTION_ORDER.join(' → ')} 取第一个有值的级)`)
|
|
417
|
+
parts.push(shadowed.length
|
|
418
|
+
? `被压住:${shadowed.map((s) => `${s.level}(改了不生效:${from} 比它靠前且已有值)`).join(';')}`
|
|
419
|
+
: '无覆盖(其余级当前都没有值)')
|
|
420
|
+
} else {
|
|
421
|
+
parts.push('五级链全无值(也没有可用的声明 default)')
|
|
422
|
+
}
|
|
423
|
+
if (invalid && invalid.length) parts.push(`有值但取不动:${invalid.map((x) => `${x.level}(${x.why})`).join(';')}`)
|
|
424
|
+
if (todayFirst) {
|
|
425
|
+
parts.push(`今天代码按登记的 todayChain 先读 ${todayFirst}(当前${todayFirstPresent ? '有值' : '无值'})`)
|
|
426
|
+
if (divergent) parts.push(`⚠️ 与规范链生效级 ${from} 不是同一级 ⇒ 两套口径不一致(迁移未完,见「旧优先级」提示)`)
|
|
427
|
+
}
|
|
428
|
+
return parts.join(';')
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** 声明里的 validation 校验(pattern / enum / min / max / minLength)。→ `[]` 通过。 */
|
|
432
|
+
export function validateValue(value, item) {
|
|
433
|
+
const v = item && item.validation
|
|
434
|
+
if (!v || typeof v !== 'object') return []
|
|
435
|
+
const problems = []
|
|
436
|
+
const isList = Array.isArray(value)
|
|
437
|
+
const text = isList ? value.join(',') : String(value ?? '')
|
|
438
|
+
if (v.pattern) {
|
|
439
|
+
let re = null
|
|
440
|
+
try { re = new RegExp(String(v.pattern)) } catch (e) { return [`validation.pattern 不是合法正则:${e.message}`] }
|
|
441
|
+
if (text && !re.test(text)) problems.push(`不匹配 pattern ${v.pattern}`)
|
|
442
|
+
}
|
|
443
|
+
if (Array.isArray(v.enum) && v.enum.length) {
|
|
444
|
+
const allowed = v.enum.map(String)
|
|
445
|
+
if (isList) {
|
|
446
|
+
for (const x of value) if (!allowed.includes(String(x))) problems.push(`值 ${JSON.stringify(String(x))} 不在 enum 内`)
|
|
447
|
+
} else if (text && !allowed.includes(text)) problems.push(`值不在 enum 内(允许:${allowed.join(' / ')})`)
|
|
448
|
+
}
|
|
449
|
+
if (typeof v.min === 'number' || typeof v.max === 'number') {
|
|
450
|
+
const n = Number(value)
|
|
451
|
+
if (Number.isFinite(n)) {
|
|
452
|
+
if (typeof v.min === 'number' && n < v.min) problems.push(`小于下界 ${v.min}`)
|
|
453
|
+
if (typeof v.max === 'number' && n > v.max) problems.push(`大于上界 ${v.max}`)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (typeof v.minLength === 'number' && !isList && text && text.length < v.minLength) {
|
|
457
|
+
problems.push(`长度小于 ${v.minLength}`)
|
|
458
|
+
}
|
|
459
|
+
return problems
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
463
|
+
// 三、目录加载(`configs/<域>.json` + `verticals/<id>/config.json`)
|
|
464
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
465
|
+
|
|
466
|
+
/** 仓库/安装根:本文件在 `<根>/core/lib/` ⇒ 上跳两级。可用 `ARGENT_CATALOG_ROOT` 覆盖(夹具用)。 */
|
|
467
|
+
export function catalogRoot(opts = {}) {
|
|
468
|
+
const env = opts.env || process.env
|
|
469
|
+
const explicit = (env && env.ARGENT_CATALOG_ROOT) || opts.root
|
|
470
|
+
if (explicit) return resolvePath(String(explicit))
|
|
471
|
+
return resolvePath(join(dirname(fileURLToPath(import.meta.url)), '..', '..'))
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const readJson = (p) => {
|
|
475
|
+
try { return JSON.parse(readFileSync(p, 'utf8')) } catch { return null }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** 目录文件清单(引擎级 + 业务线级,按路径排序;只读,缺失即空列表)。 */
|
|
479
|
+
export function listCatalogFiles(opts = {}) {
|
|
480
|
+
const root = catalogRoot(opts)
|
|
481
|
+
const out = []
|
|
482
|
+
const cdir = join(root, CATALOG_DIR)
|
|
483
|
+
if (existsSync(cdir)) {
|
|
484
|
+
let names = []
|
|
485
|
+
try { names = readdirSync(cdir) } catch { names = [] }
|
|
486
|
+
for (const n of names.sort()) {
|
|
487
|
+
if (!CATALOG_FILE_RE.test(n)) continue
|
|
488
|
+
out.push(join(cdir, n))
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const vdir = join(root, 'verticals')
|
|
492
|
+
if (existsSync(vdir)) {
|
|
493
|
+
let vids = []
|
|
494
|
+
try { vids = readdirSync(vdir) } catch { vids = [] }
|
|
495
|
+
for (const vid of vids.sort()) {
|
|
496
|
+
if (vid.startsWith('.') || vid.startsWith('_')) continue
|
|
497
|
+
const p = join(vdir, vid, VERTICAL_CATALOG_FILE)
|
|
498
|
+
if (existsSync(p)) out.push(p)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return out
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* 读全部目录文件并展平成 `{ schema, root, files, items, entries, domains }`。
|
|
506
|
+
* `items` 保序(文件顺序 → 文件内顺序),**不**去重(重复 key 由 {@link validateCatalog} 判红)。
|
|
507
|
+
*/
|
|
508
|
+
export function loadCatalog(opts = {}) {
|
|
509
|
+
const root = catalogRoot(opts)
|
|
510
|
+
const env = opts.env || process.env
|
|
511
|
+
const files = opts.files || listCatalogFiles({ ...opts, root, env })
|
|
512
|
+
const items = []
|
|
513
|
+
const entries = []
|
|
514
|
+
const problems = []
|
|
515
|
+
const usedFiles = []
|
|
516
|
+
for (const abs of files) {
|
|
517
|
+
const doc = readJson(abs)
|
|
518
|
+
const rel = abs.startsWith(root) ? abs.slice(root.length + 1) : abs
|
|
519
|
+
if (!doc || typeof doc !== 'object') {
|
|
520
|
+
problems.push(`${rel}:不是合法 JSON 对象`)
|
|
521
|
+
continue
|
|
522
|
+
}
|
|
523
|
+
usedFiles.push(rel)
|
|
524
|
+
const domain = String(doc.domain || rel.split('/').pop().replace(/\.json$/, ''))
|
|
525
|
+
const vertical = doc.vertical ? String(doc.vertical) : (/^verticals\/([^/]+)\//.exec(rel) || [])[1] || null
|
|
526
|
+
// 域级 `order`(页面分区次序的**唯一真源**,契约 §7.2):只在目录文件里写一次,随每一项/入口带下去
|
|
527
|
+
// ⇒ `describe()` 的分类树按它排序,页面**不写任何次序名单**(新增域 = 加文件,不改页面)。
|
|
528
|
+
const order = Number.isFinite(Number(doc.order)) ? Number(doc.order) : null
|
|
529
|
+
const fileEntries = Array.isArray(doc.entries) ? doc.entries : []
|
|
530
|
+
for (const e of fileEntries) {
|
|
531
|
+
entries.push({ ...e, domain, vertical: e.vertical || vertical, _file: rel, _order: order })
|
|
532
|
+
}
|
|
533
|
+
const list = Array.isArray(doc.items) ? doc.items : []
|
|
534
|
+
if (!Array.isArray(doc.items)) problems.push(`${rel}:缺 items 数组(目录文件必须显式声明,即使是空)`)
|
|
535
|
+
list.forEach((raw, i) => {
|
|
536
|
+
items.push({ ...raw, domain, vertical: raw.vertical || vertical, _file: rel, _index: i, _order: order })
|
|
537
|
+
})
|
|
538
|
+
}
|
|
539
|
+
return { schema: CATALOG_SCHEMA, root, files: usedFiles, items, entries, problems }
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
543
|
+
// 四、目录自检(**门禁 ① 的一半**:声明本身合法)
|
|
544
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* 声明形状校验(唯一实现;门禁与测试都调它,不另写"测试版判据")。
|
|
548
|
+
* @returns {string[]} 问题清单(空 = 合法);每条形如 `<文件>:<key> 说明`。
|
|
549
|
+
*/
|
|
550
|
+
export function validateCatalog(cat) {
|
|
551
|
+
const problems = [...(cat.problems || [])]
|
|
552
|
+
const seen = new Map()
|
|
553
|
+
const entryIds = new Set()
|
|
554
|
+
for (const it of cat.items) {
|
|
555
|
+
const at = `${it._file}${it.key ? `:${it.key}` : `[${it._index}]`}`
|
|
556
|
+
for (const f of ITEM_REQUIRED_FIELDS) {
|
|
557
|
+
if (it[f] === undefined || it[f] === null || String(it[f]).trim() === '') problems.push(`${at} 缺必需字段 \`${f}\``)
|
|
558
|
+
}
|
|
559
|
+
if (it.key && !KEY_RE.test(String(it.key))) {
|
|
560
|
+
problems.push(`${at} key 必须是点分命名(如 \`ml.daily.hour\`)`)
|
|
561
|
+
}
|
|
562
|
+
if (it.key) {
|
|
563
|
+
if (seen.has(it.key)) problems.push(`${at} key 与 ${seen.get(it.key)} 重复(key 必须全局唯一)`)
|
|
564
|
+
else seen.set(it.key, at)
|
|
565
|
+
}
|
|
566
|
+
if (it.type && !ITEM_TYPES.includes(String(it.type))) {
|
|
567
|
+
problems.push(`${at} type=\`${it.type}\` 不在 ${ITEM_TYPES.join(' / ')} 内`)
|
|
568
|
+
}
|
|
569
|
+
if (it.source && !SOURCES.includes(String(it.source))) {
|
|
570
|
+
problems.push(`${at} source=\`${it.source}\` 不在 ${SOURCES.join(' / ')} 内(\`server\` 是预留来源)`)
|
|
571
|
+
}
|
|
572
|
+
if (it.type === 'secret' && it.secret !== true) {
|
|
573
|
+
problems.push(`${at} type=secret 必须同时写 \`secret: true\`(两处口径必须一致,否则页面会漏掩)`)
|
|
574
|
+
}
|
|
575
|
+
if (it.type === 'enum' && !(Array.isArray(it.validation && it.validation.enum) && it.validation.enum.length)) {
|
|
576
|
+
problems.push(`${at} type=enum 必须在 validation.enum 里列出允许值`)
|
|
577
|
+
}
|
|
578
|
+
for (const k of FORBIDDEN_ITEM_KEYS) {
|
|
579
|
+
if (it[k] !== undefined) problems.push(`${at} 不允许出现 \`${k}\` 字段(真实样例值不得进仓库;secret 项尤其)`)
|
|
580
|
+
}
|
|
581
|
+
if (it.secret === true) {
|
|
582
|
+
if (it.default !== undefined && it.default !== null && String(it.default) !== '') {
|
|
583
|
+
problems.push(`${at} secret 项不得写非空 default(默认值会进仓库、进日志)`)
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (it.validation && typeof it.validation !== 'object') problems.push(`${at} validation 必须是对象`)
|
|
587
|
+
if (it.todayChain !== undefined) {
|
|
588
|
+
if (!Array.isArray(it.todayChain) || !it.todayChain.length) problems.push(`${at} todayChain 必须是非空数组(层级名)`)
|
|
589
|
+
else for (const l of it.todayChain) {
|
|
590
|
+
if (!LAYERS.includes(String(l))) problems.push(`${at} todayChain 里的 \`${l}\` 不是合法层级(${LAYERS.join(' / ')})`)
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (it.validation && it.validation.pattern) {
|
|
594
|
+
try { new RegExp(String(it.validation.pattern)) } catch (e) { problems.push(`${at} validation.pattern 不是合法正则:${e.message}`) }
|
|
595
|
+
}
|
|
596
|
+
if (it.editable !== undefined && typeof it.editable !== 'boolean') problems.push(`${at} editable 必须是布尔`)
|
|
597
|
+
if (it.required !== undefined && typeof it.required !== 'boolean') problems.push(`${at} required 必须是布尔`)
|
|
598
|
+
if (it.secret !== undefined && typeof it.secret !== 'boolean') problems.push(`${at} secret 必须是布尔`)
|
|
599
|
+
// 归属:至少要有 capability 或 vertical(否则"谁的东西"没人知道)
|
|
600
|
+
if (!it.capability && !it.vertical && !it.domain) problems.push(`${at} 缺归属(capability / vertical / domain 至少要有一个)`)
|
|
601
|
+
// binding:`source: server` 是预留 ⇒ 允许无 binding;其余必须有可执行的读法
|
|
602
|
+
const b = it.binding
|
|
603
|
+
if (String(it.source) === 'server') {
|
|
604
|
+
if (b) problems.push(`${at} source=server(预留来源)不得声明 binding —— 本批不读服务端`)
|
|
605
|
+
} else if (!b || typeof b !== 'object') {
|
|
606
|
+
problems.push(`${at} 缺 binding(非 server 来源必须有可执行的读法)`)
|
|
607
|
+
} else {
|
|
608
|
+
if (!BINDING_KINDS.includes(String(b.kind))) problems.push(`${at} binding.kind=\`${b.kind}\` 不在 ${BINDING_KINDS.join(' / ')} 内`)
|
|
609
|
+
if (b.kind === 'envfile' && !ENV_FILE_RE.test(String(b.file || ''))) {
|
|
610
|
+
problems.push(`${at} binding.file 必须是 \`<域>.env\` 形状(得到 ${JSON.stringify(b.file)})`)
|
|
611
|
+
}
|
|
612
|
+
if (b.kind !== 'env' && b.kind !== 'server') {
|
|
613
|
+
const f = String(b.file || '')
|
|
614
|
+
if (f && (isAbsolute(f) || f.includes('..'))) problems.push(`${at} binding.file 必须是数据目录下的相对路径(不得绝对路径/上跳)`)
|
|
615
|
+
}
|
|
616
|
+
if (b.transform && !TRANSFORMS.includes(String(b.transform))) problems.push(`${at} binding.transform=\`${b.transform}\` 不在 ${TRANSFORMS.join(' / ')} 内`)
|
|
617
|
+
if (b.special && !SPECIALS.includes(String(b.special))) problems.push(`${at} binding.special=\`${b.special}\` 不在 ${SPECIALS.join(' / ')} 内`)
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
for (const e of cat.entries) {
|
|
621
|
+
const at = `${e._file}:${e.id || '(缺 id)'}`
|
|
622
|
+
if (!e.id || !String(e.id).trim()) problems.push(`${at} 入口缺 id`)
|
|
623
|
+
else if (entryIds.has(e.id)) problems.push(`${at} 入口 id 重复`)
|
|
624
|
+
else entryIds.add(e.id)
|
|
625
|
+
if (!e.label) problems.push(`${at} 入口缺 label`)
|
|
626
|
+
if (!e.category) problems.push(`${at} 入口缺 category`)
|
|
627
|
+
if (e.implemented !== undefined && typeof e.implemented !== 'boolean') problems.push(`${at} implemented 必须是布尔`)
|
|
628
|
+
}
|
|
629
|
+
return problems
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
633
|
+
// 五、读取器
|
|
634
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
635
|
+
|
|
636
|
+
/** 缺配置错误:消息必须**可执行**(点名 key + 该去哪配 + 现有哪些来源)。 */
|
|
637
|
+
export class ConfigMissingError extends Error {
|
|
638
|
+
constructor(message, { key, item, tried } = {}) {
|
|
639
|
+
super(message)
|
|
640
|
+
this.name = 'ConfigMissingError'
|
|
641
|
+
this.code = 'CONFIG_MISSING'
|
|
642
|
+
this.key = key
|
|
643
|
+
this.label = (item && item.label) || ''
|
|
644
|
+
this.tried = tried || []
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** 带 mtime/size 签名的文件缓存(配置一改就失效 ⇒ 页面立刻看到新值)。 */
|
|
649
|
+
function fileCache() {
|
|
650
|
+
const map = new Map()
|
|
651
|
+
const sig = (p) => {
|
|
652
|
+
try { const st = statSync(p); return `${st.mtimeMs}:${st.size}` } catch { return 'absent' }
|
|
653
|
+
}
|
|
654
|
+
return {
|
|
655
|
+
text(p) {
|
|
656
|
+
const s = sig(p)
|
|
657
|
+
const hit = map.get(p)
|
|
658
|
+
if (hit && hit.sig === s) return hit.value
|
|
659
|
+
let value = null
|
|
660
|
+
if (s !== 'absent') { try { value = readFileSync(p, 'utf8') } catch { value = null } }
|
|
661
|
+
map.set(p, { sig: s, value })
|
|
662
|
+
return value
|
|
663
|
+
},
|
|
664
|
+
json(p) {
|
|
665
|
+
const t = this.text(p)
|
|
666
|
+
if (t === null) return null
|
|
667
|
+
try { return JSON.parse(t) } catch { return null }
|
|
668
|
+
},
|
|
669
|
+
env(p) {
|
|
670
|
+
const t = this.text(p)
|
|
671
|
+
return t === null ? null : parseEnvText(t)
|
|
672
|
+
},
|
|
673
|
+
clear() { map.clear() },
|
|
674
|
+
/** 缓存里有几个文件是"当前存在"的(诊断用)。 */
|
|
675
|
+
size() { return map.size },
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* 造一个读取器。**默认单例**见文件末尾的同名导出。
|
|
681
|
+
* @param {{ root?: string, env?: Record<string,string|undefined>, home?: string,
|
|
682
|
+
* dataDir?: string, files?: string[] }} [opts]
|
|
683
|
+
*/
|
|
684
|
+
export function createReader(opts = {}) {
|
|
685
|
+
const env = opts.env || process.env
|
|
686
|
+
const cache = fileCache()
|
|
687
|
+
let catCache = null
|
|
688
|
+
|
|
689
|
+
const catalog = () => {
|
|
690
|
+
if (catCache && !opts.files) {
|
|
691
|
+
// 目录文件本身的改动也要立刻可见:签名(路径+mtime+size)不变才复用
|
|
692
|
+
const sig = listCatalogFiles({ ...opts, env }).map((p) => {
|
|
693
|
+
try { const st = statSync(p); return `${p}:${st.mtimeMs}:${st.size}` } catch { return `${p}:absent` }
|
|
694
|
+
}).join('|')
|
|
695
|
+
if (sig === catCache.sig) return catCache.cat
|
|
696
|
+
}
|
|
697
|
+
const cat = loadCatalog({ ...opts, env })
|
|
698
|
+
const sig = opts.files
|
|
699
|
+
? 'fixed'
|
|
700
|
+
: cat.files.map((rel) => {
|
|
701
|
+
const p = join(cat.root, rel)
|
|
702
|
+
try { const st = statSync(p); return `${p}:${st.mtimeMs}:${st.size}` } catch { return `${p}:absent` }
|
|
703
|
+
}).join('|')
|
|
704
|
+
catCache = { cat, sig }
|
|
705
|
+
return cat
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const byKey = () => {
|
|
709
|
+
const m = new Map()
|
|
710
|
+
for (const it of catalog().items) if (!m.has(it.key)) m.set(it.key, it)
|
|
711
|
+
return m
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const dataDir = () => (opts.dataDir ? resolvePath(String(opts.dataDir)) : resolveDataDir({ env, home: opts.home }).dir)
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* 解析一项的**当前取值**。
|
|
718
|
+
* @returns {{ key, found: boolean, layer: string, detail: string, value?: any,
|
|
719
|
+
* skipped?: string[], coerced?: boolean, problems?: string[] }}
|
|
720
|
+
*/
|
|
721
|
+
function resolveOne(item) {
|
|
722
|
+
const tried = []
|
|
723
|
+
if (!item) return { key: '', found: false, layer: 'missing', detail: '未声明', tried }
|
|
724
|
+
const b = item.binding || {}
|
|
725
|
+
const name = String(b.env || envNameOf(item.key))
|
|
726
|
+
const wantType = item.type === 'secret' ? 'string' : String(item.type || 'string')
|
|
727
|
+
|
|
728
|
+
/** 逐层尝试:命中即投类型;投不动记一条 tried 继续往下(不静默吞成默认值)。 */
|
|
729
|
+
const attempt = (layer, detail, raw) => {
|
|
730
|
+
if (raw === undefined || raw === null) { tried.push(`${layer}:${detail}=未设置`); return null }
|
|
731
|
+
const c = coerce(raw, wantType)
|
|
732
|
+
if (!c.ok) { tried.push(`${layer}:${detail}=${c.why}`); return null }
|
|
733
|
+
return { key: item.key, found: true, layer, detail, value: c.value, tried, problems: validateValue(c.value, item) }
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ① 进程环境变量
|
|
737
|
+
const fromEnv = attempt('env', name, env ? env[name] : undefined)
|
|
738
|
+
if (fromEnv) return fromEnv
|
|
739
|
+
|
|
740
|
+
// ② 域 env 文件(规范路径优先、旧平铺兜底 —— 与 flow_config._resolve_env_path 同口径)
|
|
741
|
+
if (b.kind === 'envfile' || item.source === 'envfile') {
|
|
742
|
+
const file = String(b.file || '')
|
|
743
|
+
// 规范路径的**命名空间**以 `binding.vertical` 为准(引擎级目录里的项也可能指向某条业务线的
|
|
744
|
+
// env 目录);`item.vertical` 是"这项归谁"的归属,两者可以不同(如 `host.path.stores_json`)。
|
|
745
|
+
const vid = b.vertical || item.vertical
|
|
746
|
+
const candidates = []
|
|
747
|
+
if (vid) candidates.push(join(dataDir(), 'verticals', vid, 'env', file))
|
|
748
|
+
candidates.push(join(dataDir(), file))
|
|
749
|
+
for (const p of candidates) {
|
|
750
|
+
const doc = cache.env(p)
|
|
751
|
+
if (doc === null) { tried.push(`envfile:${p}=文件不存在`); continue }
|
|
752
|
+
const hit = attempt('envfile', p, doc[name])
|
|
753
|
+
if (hit) return hit
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// ④ json 与机器本地文件(同一套读法,只差 layer 标签)
|
|
758
|
+
// ⚠️ 顺序:`binding.special`(内核判据派生)**先于**通用文件读 —— 否则"没写 file 的
|
|
759
|
+
// special 项"会被通用分支当成"读 config.json 整个文档",静默吐一个对象(实测踩过)。
|
|
760
|
+
if (b.special) {
|
|
761
|
+
const r = resolveDataDir({ env, home: opts.home })
|
|
762
|
+
const v = b.special === 'dataDir' ? r.dir : b.special === 'dshHome' ? r.dshHome : r.source
|
|
763
|
+
const hit = attempt('machine', `内核判据(${b.special})`, v)
|
|
764
|
+
if (hit) return hit
|
|
765
|
+
} else if (b.kind === 'json' || b.kind === 'machine') {
|
|
766
|
+
const file = String(b.file || 'config.json')
|
|
767
|
+
const p = join(dataDir(), file)
|
|
768
|
+
const doc = cache.json(p)
|
|
769
|
+
const layer = b.kind
|
|
770
|
+
if (doc === null) {
|
|
771
|
+
tried.push(`${layer}:${p}=文件不存在或不是 JSON`)
|
|
772
|
+
} else if (b.presence) {
|
|
773
|
+
return { key: item.key, found: true, layer, detail: p, value: true, tried, problems: [] }
|
|
774
|
+
} else {
|
|
775
|
+
let raw = b.field ? pickPath(doc, b.field) : doc
|
|
776
|
+
if (b.transform === 'length' && raw !== undefined && raw !== null) {
|
|
777
|
+
raw = Array.isArray(raw) ? raw.length : Object.keys(raw || {}).length
|
|
778
|
+
}
|
|
779
|
+
const hit = attempt(layer, `${p}${b.field ? `#${b.field}` : ''}`, raw)
|
|
780
|
+
if (hit) return hit
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// ⑤ 声明里的 default
|
|
785
|
+
if (item.default !== undefined && item.default !== null && item.default !== '') {
|
|
786
|
+
const c = coerce(item.default, wantType)
|
|
787
|
+
if (c.ok) {
|
|
788
|
+
return { key: item.key, found: true, layer: 'default', detail: '声明 default', value: c.value, tried, problems: validateValue(c.value, item), fromDefault: true }
|
|
789
|
+
}
|
|
790
|
+
tried.push(`default:${c.why}`)
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return { key: item.key, found: false, layer: 'missing', detail: '五级链全部未命中', tried, problems: [] }
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** 给报错用的"该去哪配"(声明的所有来源 → 可照做的落点)。 */
|
|
797
|
+
function whereToConfigure(item) {
|
|
798
|
+
const b = item.binding || {}
|
|
799
|
+
const out = []
|
|
800
|
+
const name = String(b.env || envNameOf(item.key))
|
|
801
|
+
if (b.kind === 'env' || item.source === 'env') out.push(`进程环境变量 ${name}`)
|
|
802
|
+
if (b.kind === 'envfile' || item.source === 'envfile') {
|
|
803
|
+
const file = String(b.file || `${item.domain || 'config'}.env`)
|
|
804
|
+
const vid = b.vertical || item.vertical
|
|
805
|
+
out.push(vid ? `${dataDir()}/verticals/${vid}/env/${file}(或旧平铺 ${dataDir()}/${file})` : `${dataDir()}/${file}`)
|
|
806
|
+
}
|
|
807
|
+
if (b.kind === 'json') out.push(`${dataDir()}/${b.file || 'config.json'}${b.field ? ` 的 \`${b.field}\` 字段` : ''}(设置页「${item.category}」或 /api/argent-state/config)`)
|
|
808
|
+
if (b.kind === 'machine') out.push(`${dataDir()}/${b.file || 'config.json'}${b.field ? ` 的 \`${b.field}\` 字段` : ''}(机器本地文件,由安装/体检生成)`)
|
|
809
|
+
if (item.source === 'server') out.push('服务端(来源 server 为本批**预留**,当前无读取实现)')
|
|
810
|
+
if (item.default !== undefined && item.default !== null && item.default !== '') out.push(`声明 default=${JSON.stringify(item.default)}`)
|
|
811
|
+
return out.length ? out : ['(目录里没写任何可配落点 —— 请先补 binding)']
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* **逐级现值视图**(给 {@link resolveEffective} 用):把"这一项在**每一级**上现在有没有值"
|
|
816
|
+
* 都取出来 —— 而 `resolveOne()` 只回**第一个命中**的级。两者的读法与缓存**完全共用**
|
|
817
|
+
* (同一个 `cache`、同一个 `dataDir()`),所以"生效来源"不会与真实取值分家。
|
|
818
|
+
*
|
|
819
|
+
* ⛔ **只取不改**:不写任何文件、不改变 `resolveOne` 的顺序;本函数只是把同样的几处
|
|
820
|
+
* 读一遍并把**每一级**的结果都留下(含落点路径)。`server` 级本批无读取实现 ⇒ 恒缺席。
|
|
821
|
+
*
|
|
822
|
+
* @returns {{ values: Record<string, any>, detail: Record<string, string> }}
|
|
823
|
+
*/
|
|
824
|
+
function levelsOf(item) {
|
|
825
|
+
const b = (item && item.binding) || {}
|
|
826
|
+
const values = {}
|
|
827
|
+
const detail = {}
|
|
828
|
+
const applicable = levelsApplicableTo(item)
|
|
829
|
+
const secret = item && item.secret === true
|
|
830
|
+
|
|
831
|
+
// ① 进程环境变量(恒参与;名字与 resolveOne 同一处判据)
|
|
832
|
+
const name = String(b.env || envNameOf(item.key))
|
|
833
|
+
detail.env = `进程环境变量 ${name}`
|
|
834
|
+
if (env && env[name] !== undefined) values.env = env[name]
|
|
835
|
+
|
|
836
|
+
// ② 域 env 文件(规范路径优先、旧平铺兜底 —— 与 resolveOne 同一候选顺序)
|
|
837
|
+
if (applicable.includes('envfile')) {
|
|
838
|
+
const file = String(b.file || '')
|
|
839
|
+
const vid = b.vertical || item.vertical
|
|
840
|
+
const cands = []
|
|
841
|
+
if (vid) cands.push(join(dataDir(), 'verticals', vid, 'env', file))
|
|
842
|
+
cands.push(join(dataDir(), file))
|
|
843
|
+
const seen = []
|
|
844
|
+
for (const p of cands) {
|
|
845
|
+
const doc = cache.env(p)
|
|
846
|
+
if (doc === null) { seen.push(`${p}(文件不存在)`); continue }
|
|
847
|
+
if (doc[name] === undefined) { seen.push(`${p}(文件在,但没有 ${name})`); continue }
|
|
848
|
+
seen.push(`${p}(**有** ${name})`)
|
|
849
|
+
if (values.envfile === undefined) {
|
|
850
|
+
values.envfile = doc[name]
|
|
851
|
+
detail.envfile = p
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (detail.envfile === undefined) detail.envfile = `候选文件都没有 ${name}:${seen.join(';')}`
|
|
855
|
+
else if (seen.length > 1) detail.envfile = `${detail.envfile}(候选:${seen.join(';')})`
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ④ json / 机器本地文件(与 resolveOne 同分支:`special` 优先,其次 binding.kind)
|
|
859
|
+
if (b.special) {
|
|
860
|
+
const r = resolveDataDir({ env, home: opts.home })
|
|
861
|
+
const v = b.special === 'dataDir' ? r.dir : b.special === 'dshHome' ? r.dshHome : r.source
|
|
862
|
+
values.machine = v
|
|
863
|
+
detail.machine = `内核判据(binding.special=${b.special})`
|
|
864
|
+
} else if (b.kind === 'json' || b.kind === 'machine') {
|
|
865
|
+
const layer = b.kind
|
|
866
|
+
const file = String(b.file || 'config.json')
|
|
867
|
+
const p = join(dataDir(), file)
|
|
868
|
+
detail[layer] = `${p}${b.field ? `#${b.field}` : ''}`
|
|
869
|
+
const doc = cache.json(p)
|
|
870
|
+
if (doc === null) {
|
|
871
|
+
detail[layer] = `${detail[layer]}(文件不存在或不是 JSON)`
|
|
872
|
+
} else if (b.presence) {
|
|
873
|
+
values[layer] = true // 存在性标记(暂停开关这类):文件在 = 有值
|
|
874
|
+
detail[layer] = `${detail[layer]}(存在性标记:文件在即有值)`
|
|
875
|
+
} else {
|
|
876
|
+
let raw = b.field ? pickPath(doc, b.field) : doc
|
|
877
|
+
if (b.transform === 'length' && raw !== undefined && raw !== null) {
|
|
878
|
+
raw = Array.isArray(raw) ? raw.length : Object.keys(raw || {}).length
|
|
879
|
+
}
|
|
880
|
+
if (raw !== undefined && raw !== null) values[layer] = raw
|
|
881
|
+
else detail[layer] = `${detail[layer]}(字段不存在)`
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// ⑤ 声明里的 default(最后一级;空值不算 —— 与 resolveOne 同口径)
|
|
886
|
+
const hasDefault = item && item.default !== undefined && item.default !== null && item.default !== ''
|
|
887
|
+
values.default = hasDefault ? item.default : undefined
|
|
888
|
+
detail.default = hasDefault
|
|
889
|
+
? `声明 default=${secret ? redactSecret(item.default) : JSON.stringify(item.default)}`
|
|
890
|
+
: '没有声明 default'
|
|
891
|
+
|
|
892
|
+
return { values, detail }
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* **这一项此刻的"生效来源"**(页面/CLI 要显示的就是它):纯函数 {@link resolveEffective}
|
|
897
|
+
* 的取值封装 —— 值从 {@link levelsOf} 来,落点路径一并带上。
|
|
898
|
+
*
|
|
899
|
+
* ⛔ 输出里**永不含明文密钥**(`secret: true` 的项,值位全是 `❰已设置(长度N)❱`)。
|
|
900
|
+
*/
|
|
901
|
+
function effectiveOf(key) {
|
|
902
|
+
const item = byKey().get(String(key))
|
|
903
|
+
if (!item) {
|
|
904
|
+
return {
|
|
905
|
+
key: String(key), from: null, value: null, chain: [...RESOLUTION_ORDER], applicable: [],
|
|
906
|
+
present: [], shadowed: [], conflict: false, invalid: [], secret: false,
|
|
907
|
+
todayChain: null, todayFirst: null, todayFirstPresent: false, divergent: false,
|
|
908
|
+
detail: '', levelDetail: {}, declared: false, note: `未声明的配置 key:${key}(目录里没有它)`,
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
const { values, detail } = levelsOf(item)
|
|
912
|
+
const eff = resolveEffective(item, values)
|
|
913
|
+
return {
|
|
914
|
+
...eff,
|
|
915
|
+
detail: eff.from ? String(detail[eff.from] || '') : '',
|
|
916
|
+
levelDetail: detail,
|
|
917
|
+
declared: true,
|
|
918
|
+
declaredSource: item.source,
|
|
919
|
+
source: String(item.source) === 'server' ? '服务端(来源 server 为本批预留,当前无读取实现)' : undefined,
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function missingMessage(item, res) {
|
|
924
|
+
const name = String((item.binding && item.binding.env) || envNameOf(item.key))
|
|
925
|
+
const lines = [
|
|
926
|
+
`缺配置:${item.key}(${item.label})—— 该能力/业务线需要它才能继续。`,
|
|
927
|
+
...(item.help ? [`这一项是什么:${item.help}`] : []),
|
|
928
|
+
`去哪里配:`,
|
|
929
|
+
...whereToConfigure(item).map((x) => ` · ${x}`),
|
|
930
|
+
`已尝试的来源:`,
|
|
931
|
+
...(res.tried || []).map((x) => ` · ${x}`),
|
|
932
|
+
`同名环境变量:${name};查全部可配项:node scripts/check-config-catalog.mjs --list`,
|
|
933
|
+
]
|
|
934
|
+
if (item.secret === true) lines.push('(这是密钥项:报错与日志都不会显示它的值)')
|
|
935
|
+
return lines.join('\n')
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const reader = {
|
|
939
|
+
/** 当前目录(含 problems;门禁消费)。 */
|
|
940
|
+
catalog,
|
|
941
|
+
/** 数据目录(绝对路径;唯一判据)。 */
|
|
942
|
+
dataDir,
|
|
943
|
+
/** 目录自检(形状校验;门禁 ① 的一半)。 */
|
|
944
|
+
validate: () => validateCatalog(catalog()),
|
|
945
|
+
|
|
946
|
+
/** 取值(缺失或投类型失败 → 返回 fallback,不抛)。**secret 项也回值**(进程内用)。 */
|
|
947
|
+
get(key, fallback = undefined) {
|
|
948
|
+
const item = byKey().get(String(key))
|
|
949
|
+
if (!item) return fallback
|
|
950
|
+
const r = resolveOne(item)
|
|
951
|
+
return r.found ? r.value : fallback
|
|
952
|
+
},
|
|
953
|
+
|
|
954
|
+
/** 取值,缺失即**可执行报错**(点名 key + 该去哪配 + 已试来源)。 */
|
|
955
|
+
require(key) {
|
|
956
|
+
const item = byKey().get(String(key))
|
|
957
|
+
if (!item) {
|
|
958
|
+
const err = new ConfigMissingError(`未声明的配置 key:${key}(配置目录里没有它 ⇒ 先声明再读)`, { key: String(key), tried: [] })
|
|
959
|
+
err.code = 'CONFIG_UNDECLARED'
|
|
960
|
+
throw err
|
|
961
|
+
}
|
|
962
|
+
const r = resolveOne(item)
|
|
963
|
+
if (!r.found) throw new ConfigMissingError(missingMessage(item, r), { key: item.key, item, tried: r.tried })
|
|
964
|
+
return r.value
|
|
965
|
+
},
|
|
966
|
+
|
|
967
|
+
/** 这一项**此刻**的取值来自哪一级(页面要显示的就是它)。 */
|
|
968
|
+
sourceOf(key) {
|
|
969
|
+
const item = byKey().get(String(key))
|
|
970
|
+
if (!item) return { key: String(key), layer: 'missing', detail: '未声明', configured: false, declaredSource: null }
|
|
971
|
+
const r = resolveOne(item)
|
|
972
|
+
return {
|
|
973
|
+
key: item.key,
|
|
974
|
+
layer: r.layer,
|
|
975
|
+
detail: r.detail,
|
|
976
|
+
configured: r.found && r.layer !== 'default',
|
|
977
|
+
fromDefault: Boolean(r.fromDefault),
|
|
978
|
+
declaredSource: item.source,
|
|
979
|
+
tried: r.tried,
|
|
980
|
+
}
|
|
981
|
+
},
|
|
982
|
+
|
|
983
|
+
/** 已配置 = 从**任一真实来源**取到(`default` 不算"配好了")。 */
|
|
984
|
+
isConfigured(key) { return reader.sourceOf(key).configured },
|
|
985
|
+
|
|
986
|
+
/** 某一项是否"此刻可解析"(含 default)。 */
|
|
987
|
+
isResolvable(key) { return reader.sourceOf(key).layer !== 'missing' },
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* **逐级现值视图**(含每级的落点路径):`{ values, detail }`。
|
|
991
|
+
* ⛔ 返回值里**可能含明文密钥**(它等价于"把每一级都读一遍")⇒ **只给进程内/CLI 判据用**,
|
|
992
|
+
* 任何出门的载荷(页面/端点)都走 {@link createReader}#effectiveOf(已脱敏)。
|
|
993
|
+
*/
|
|
994
|
+
levelsOf(key) { return levelsOf(byKey().get(String(key))) },
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* **生效来源**(脱敏、可直接进载荷):谁赢了、谁被压住、两套链是否分歧。
|
|
998
|
+
* 见纯函数 {@link resolveEffective};本方法只负责"把各级现值取出来"。
|
|
999
|
+
*/
|
|
1000
|
+
effectiveOf(key) { return effectiveOf(key) },
|
|
1001
|
+
|
|
1002
|
+
/** 有冲突的项(≥2 级同时有值 ⇒ 有值被压住):`--all` 与页首计数用。 */
|
|
1003
|
+
conflicts() { return catalog().items.map((it) => effectiveOf(it.key)).filter((e) => e.conflict) },
|
|
1004
|
+
|
|
1005
|
+
/** 按条件列项(`category` / `capability` / `vertical` / `secret`)。 */
|
|
1006
|
+
all(filter = {}) {
|
|
1007
|
+
return catalog().items.filter((it) => {
|
|
1008
|
+
if (filter.category && it.category !== filter.category) return false
|
|
1009
|
+
if (filter.capability && it.capability !== filter.capability) return false
|
|
1010
|
+
if (filter.vertical && it.vertical !== filter.vertical) return false
|
|
1011
|
+
if (filter.secret !== undefined && Boolean(it.secret) !== Boolean(filter.secret)) return false
|
|
1012
|
+
return true
|
|
1013
|
+
})
|
|
1014
|
+
},
|
|
1015
|
+
|
|
1016
|
+
/** 未解析的 `required` 项(装机体检/门禁 ③ 用)。 */
|
|
1017
|
+
missingRequired(filter = {}) {
|
|
1018
|
+
return reader.all(filter)
|
|
1019
|
+
.filter((it) => it.required === true)
|
|
1020
|
+
.map((it) => ({ item: it, res: resolveOne(it) }))
|
|
1021
|
+
.filter((x) => !x.res.found)
|
|
1022
|
+
},
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* **给设置页的载荷**(脱敏)。
|
|
1026
|
+
* `secret: true` 的项**永不**出现在 `value` 里 —— 只回 `configured` + `resolvedFrom`。
|
|
1027
|
+
*/
|
|
1028
|
+
describe(filter = {}) {
|
|
1029
|
+
const cat = catalog()
|
|
1030
|
+
const items = reader.all(filter).map((it) => {
|
|
1031
|
+
const r = resolveOne(it)
|
|
1032
|
+
const target = writeTargetOf(it)
|
|
1033
|
+
const base = {
|
|
1034
|
+
key: it.key,
|
|
1035
|
+
label: it.label,
|
|
1036
|
+
category: it.category,
|
|
1037
|
+
domain: it.domain,
|
|
1038
|
+
type: it.type,
|
|
1039
|
+
declaredSource: it.source,
|
|
1040
|
+
resolvedFrom: { layer: r.layer, detail: r.found || r.tried.length ? r.detail : '' },
|
|
1041
|
+
configured: r.found && r.layer !== 'default',
|
|
1042
|
+
resolvable: r.found,
|
|
1043
|
+
fromDefault: Boolean(r.fromDefault),
|
|
1044
|
+
required: it.required === true,
|
|
1045
|
+
secret: it.secret === true,
|
|
1046
|
+
editable: it.editable === true,
|
|
1047
|
+
capability: it.capability || null,
|
|
1048
|
+
vertical: it.vertical || null,
|
|
1049
|
+
help: it.help || '',
|
|
1050
|
+
validation: it.validation || null,
|
|
1051
|
+
pendingServer: it.source === 'server',
|
|
1052
|
+
// ── 页面消费面(2026-10-09,设置页批次)——**判断全在 L1**,页面只渲染 ──────────
|
|
1053
|
+
// `writable` = 能写 + 写去哪(既有落点:config.json / 域 env 文件;⛔ 不新增第三套存储);
|
|
1054
|
+
// 为空且 `editable:true` ⇒ `writeBlocked` 说清为什么改不了(页面不留空白让人猜)。
|
|
1055
|
+
writable: target,
|
|
1056
|
+
writeBlocked: target ? null : writeBlockedReason(it),
|
|
1057
|
+
// 旧优先级提示(`todayChain` 首级 ≠ 规范链首级;全仓库 18 项登记在案)
|
|
1058
|
+
chainNote: chainNoteOf(it),
|
|
1059
|
+
// **今天代码里的优先级**(迁移用;读取器**不**按它解析 —— 解析链恒为 RESOLUTION_ORDER)。
|
|
1060
|
+
// 为空 = 与规范链一致;非空 = 把调用方迁到读取器前必须先核对这条差异。
|
|
1061
|
+
todayChain: Array.isArray(it.todayChain) ? [...it.todayChain] : null,
|
|
1062
|
+
// ── 生效来源(2026-10-09,第三批次)—— **动态**回答"这一项此刻谁赢了" ──────────────
|
|
1063
|
+
// 与上面的 `chainNote`(静态说明"两套链不同序")互补:`chainNote` 说"链不一样",
|
|
1064
|
+
// `effective` 说"**这一次**是谁赢了、谁被压住了、旧链第一落点此刻有没有值"。
|
|
1065
|
+
// 判据 = 纯函数 `resolveEffective()`(按规范链取第一有值的级);⛔ 不含明文密钥。
|
|
1066
|
+
effective: (() => {
|
|
1067
|
+
const e = effectiveOf(it.key)
|
|
1068
|
+
return {
|
|
1069
|
+
from: e.from,
|
|
1070
|
+
present: e.present,
|
|
1071
|
+
shadowed: e.shadowed,
|
|
1072
|
+
conflict: e.conflict,
|
|
1073
|
+
note: e.note,
|
|
1074
|
+
detail: e.detail,
|
|
1075
|
+
todayChain: e.todayChain,
|
|
1076
|
+
todayFirst: e.todayFirst,
|
|
1077
|
+
todayFirstPresent: e.todayFirstPresent,
|
|
1078
|
+
divergent: e.divergent,
|
|
1079
|
+
...(e.invalid.length ? { invalid: e.invalid } : {}),
|
|
1080
|
+
}
|
|
1081
|
+
})(),
|
|
1082
|
+
}
|
|
1083
|
+
if (it.secret === true) return base // ⛔ 不回值、不回长度、不回 default
|
|
1084
|
+
base.value = r.found ? r.value : null
|
|
1085
|
+
base.default = it.default !== undefined ? it.default : null
|
|
1086
|
+
if (r.problems && r.problems.length) base.problems = r.problems
|
|
1087
|
+
return base
|
|
1088
|
+
})
|
|
1089
|
+
// ── 分类树:**次序的唯一真源 = 目录文件的 `order` + 文件内 items 顺序**(契约 §7.2/§7.6)──
|
|
1090
|
+
// 页面按数组顺序分节渲染 ⇒ 加一个域文件(带 order)就多一个分区,⛔ 不改页面代码。
|
|
1091
|
+
// `order`/`domains` 取自**声明**(不受 filter 影响);计数只统计 filter 后的 items(与改动前一致)。
|
|
1092
|
+
const orderByCat = new Map()
|
|
1093
|
+
const domainsByCat = new Map()
|
|
1094
|
+
for (const it of cat.items) {
|
|
1095
|
+
const k = String(it.category)
|
|
1096
|
+
const o = Number(it._order)
|
|
1097
|
+
if (Number.isFinite(o)) orderByCat.set(k, o)
|
|
1098
|
+
else if (!orderByCat.has(k)) orderByCat.set(k, null)
|
|
1099
|
+
if (!domainsByCat.has(k)) domainsByCat.set(k, new Set())
|
|
1100
|
+
if (it.domain) domainsByCat.get(k).add(String(it.domain))
|
|
1101
|
+
}
|
|
1102
|
+
const byCat = new Map()
|
|
1103
|
+
for (const it of items) {
|
|
1104
|
+
const k = String(it.category)
|
|
1105
|
+
if (!byCat.has(k)) {
|
|
1106
|
+
byCat.set(k, { id: k, label: k, items: 0, secrets: 0, configured: 0, required: 0, capabilities: new Set() })
|
|
1107
|
+
if (!orderByCat.has(k)) orderByCat.set(k, null)
|
|
1108
|
+
}
|
|
1109
|
+
const c = byCat.get(k)
|
|
1110
|
+
c.items++
|
|
1111
|
+
if (it.secret) c.secrets++
|
|
1112
|
+
if (it.configured) c.configured++
|
|
1113
|
+
if (it.required) c.required++
|
|
1114
|
+
if (it.capability) c.capabilities.add(it.capability)
|
|
1115
|
+
}
|
|
1116
|
+
const firstSeen = new Map([...byCat.keys()].map((k, i) => [k, i]))
|
|
1117
|
+
const categories = [...byCat.values()]
|
|
1118
|
+
.map((c) => ({
|
|
1119
|
+
...c,
|
|
1120
|
+
order: orderByCat.get(c.id) === undefined ? null : orderByCat.get(c.id),
|
|
1121
|
+
domains: [...(domainsByCat.get(c.id) || [])].sort(),
|
|
1122
|
+
capabilities: [...c.capabilities].sort(),
|
|
1123
|
+
}))
|
|
1124
|
+
.sort((a, b) => {
|
|
1125
|
+
const ao = a.order === null || a.order === undefined ? 100 : a.order
|
|
1126
|
+
const bo = b.order === null || b.order === undefined ? 100 : b.order
|
|
1127
|
+
if (ao !== bo) return ao - bo
|
|
1128
|
+
return firstSeen.get(a.id) - firstSeen.get(b.id)
|
|
1129
|
+
})
|
|
1130
|
+
return {
|
|
1131
|
+
schema: CATALOG_SCHEMA,
|
|
1132
|
+
catalogFiles: cat.files,
|
|
1133
|
+
resolutionOrder: [...RESOLUTION_ORDER],
|
|
1134
|
+
dataDir: dataDir(),
|
|
1135
|
+
categories,
|
|
1136
|
+
items,
|
|
1137
|
+
entries: cat.entries.map((e) => ({
|
|
1138
|
+
id: e.id,
|
|
1139
|
+
label: e.label,
|
|
1140
|
+
category: e.category,
|
|
1141
|
+
kind: e.kind || 'action',
|
|
1142
|
+
action: e.action || null,
|
|
1143
|
+
implemented: e.implemented === true,
|
|
1144
|
+
capability: e.capability || null,
|
|
1145
|
+
vertical: e.vertical || null,
|
|
1146
|
+
help: e.help || '',
|
|
1147
|
+
order: Number.isFinite(Number(e._order)) ? Number(e._order) : null,
|
|
1148
|
+
domain: e.domain || null,
|
|
1149
|
+
})),
|
|
1150
|
+
missingRequired: items.filter((i) => i.required && !i.resolvable).map((i) => i.key),
|
|
1151
|
+
// 有冲突的项(≥2 级同时有值 ⇒ 有值被压住):页面页首计数 + CLI `--all` 的同一判据。
|
|
1152
|
+
conflictKeys: items.filter((i) => i.effective && i.effective.conflict).map((i) => i.key),
|
|
1153
|
+
}
|
|
1154
|
+
},
|
|
1155
|
+
|
|
1156
|
+
/** 显式失效(配置改完页面要立刻看到;也有 mtime 签名兜底)。 */
|
|
1157
|
+
invalidate() { cache.clear(); catCache = null },
|
|
1158
|
+
}
|
|
1159
|
+
return reader
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/** **默认读取器**(进程内单例;`ARGENT_CATALOG_ROOT` 可覆盖根,夹具用)。 */
|
|
1163
|
+
export const config = createReader()
|
|
1164
|
+
export const get = (...a) => config.get(...a)
|
|
1165
|
+
export const requireKey = (...a) => config.require(...a)
|
|
1166
|
+
export const sourceOf = (...a) => config.sourceOf(...a)
|
|
1167
|
+
export const isConfigured = (...a) => config.isConfigured(...a)
|
|
1168
|
+
export const all = (...a) => config.all(...a)
|
|
1169
|
+
export const describe = (...a) => config.describe(...a)
|
|
1170
|
+
export const invalidate = (...a) => config.invalidate(...a)
|
|
1171
|
+
|
|
1172
|
+
export default config
|