dsh-crwu-workbench 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1516 -0
- package/LICENSE +26 -0
- package/README.en.md +204 -0
- package/README.md +521 -0
- package/SECURITY.md +39 -0
- package/bin/darwin-arm64/crwu +0 -0
- package/bin/darwin-arm64/dws +0 -0
- package/bin/darwin-arm64/ossutil +0 -0
- package/bin/manifest.json +82 -0
- package/bin/win32-x64/crwu.exe +0 -0
- package/bin/win32-x64/dws.exe +4 -0
- package/bin/win32-x64/ossutil.exe +0 -0
- package/common/skills/.synced.json +8 -0
- package/common/skills/crwu-dws/SKILL.md +208 -0
- package/common/skills/crwu-dws/references/00-/347/233/256/345/275/225/345/277/253/347/205/247schema.md +113 -0
- package/common/skills/crwu-dws/references/01-/345/256/241/346/240/270/344/270/213/350/275/275/344/270/216manifest/350/247/204/350/214/203.md +115 -0
- package/common/skills/crwu-dws/references/02-/347/274/223/345/255/230/344/270/216/345/205/234/345/272/225/346/237/245/346/211/276/350/247/204/350/214/203.md +119 -0
- package/common/skills/crwu-dws/scripts/test_dws_source_contract.py +493 -0
- package/common/skills/crwu-h3yun-login/SKILL.md +130 -0
- package/common/skills/crwu-h3yun-query/SKILL.md +192 -0
- package/config/crwu-workbench.yml +26 -0
- package/cordis.patch.yml +46 -0
- package/lib/client.js +10346 -0
- package/lib/index.js +11584 -0
- package/package.json +119 -0
- package/scripts/prepare.mjs +59 -0
- package/skills/README.md +26 -0
- package/skills/crwu/crwu-audit/SKILL.md +152 -0
- package/skills/crwu/crwu-audit/references/00-input-and-route-profile.md +232 -0
- package/skills/crwu/crwu-audit/references/01-report-id-resolution.md +13 -0
- package/skills/crwu/crwu-audit/references/02-scope-classification.md +25 -0
- package/skills/crwu/crwu-audit/references/03-asset-classification.md +27 -0
- package/skills/crwu/crwu-audit/references/04-business-classification.md +63 -0
- package/skills/crwu/crwu-audit/references/05-method-classification.md +66 -0
- package/skills/crwu/crwu-audit/references/06-overlay-classification.md +42 -0
- package/skills/crwu/crwu-audit/references/07-skill-registry.md +57 -0
- package/skills/crwu/crwu-audit/references/08-union-dispatch-rules.md +142 -0
- package/skills/crwu/crwu-audit/references/09-review-risk-classification.md +85 -0
- package/skills/crwu/crwu-audit/references/10-capability-gap-proposal.md +68 -0
- package/skills/crwu/crwu-audit/references/11-html-delivery-spec.md +881 -0
- package/skills/crwu/crwu-audit/references/12-leaf-common-contract.md +202 -0
- package/skills/crwu/crwu-audit/references/13-dingtalk-result-publish.md +88 -0
- package/skills/crwu/crwu-audit/references/14-orchestration-workflow.md +52 -0
- package/skills/crwu/crwu-audit/references/99-maintenance.md +62 -0
- package/skills/crwu/crwu-audit/scripts/README.md +157 -0
- package/skills/crwu/crwu-audit/scripts/audit_delivery.py +2736 -0
- package/skills/crwu/crwu-audit/scripts/audit_result.schema.json +1659 -0
- package/skills/crwu/crwu-audit/scripts/examples/audit-result.sample.json +739 -0
- package/skills/crwu/crwu-audit/scripts/fetch_review_records.py +85 -0
- package/skills/crwu/crwu-audit/scripts/media_extract.py +513 -0
- package/skills/crwu/crwu-audit/scripts/prepare_materials.py +1252 -0
- package/skills/crwu/crwu-audit/scripts/test_audit_delivery.py +1678 -0
- package/skills/crwu/crwu-audit/scripts/test_audit_multiaxis_router.py +1339 -0
- package/skills/crwu/crwu-audit/scripts/test_fetch_review_records.py +77 -0
- package/skills/crwu/crwu-audit/scripts/test_media_extract.py +332 -0
- package/skills/crwu/crwu-audit/scripts/test_prepare_materials.py +1081 -0
- package/skills/crwu/crwu-audit/template/audit-report.html +435 -0
- package/skills/crwu/crwu-audit-asset-asset-group-goodwill/SKILL.md +38 -0
- package/skills/crwu/crwu-audit-asset-asset-group-goodwill/references/00-applicability.md +28 -0
- package/skills/crwu/crwu-audit-asset-asset-group-goodwill/references/01-kb-assembly.md +31 -0
- package/skills/crwu/crwu-audit-asset-asset-group-goodwill/references/02-review-focus.md +21 -0
- package/skills/crwu/crwu-audit-asset-asset-group-goodwill/references/03-common-contract.md +202 -0
- package/skills/crwu/crwu-audit-asset-debt/SKILL.md +38 -0
- package/skills/crwu/crwu-audit-asset-debt/references/00-applicability.md +40 -0
- package/skills/crwu/crwu-audit-asset-debt/references/01-kb-assembly.md +41 -0
- package/skills/crwu/crwu-audit-asset-debt/references/02-review-focus.md +54 -0
- package/skills/crwu/crwu-audit-asset-debt/references/03-common-contract.md +202 -0
- package/skills/crwu/crwu-audit-asset-enterprise-value/SKILL.md +38 -0
- package/skills/crwu/crwu-audit-asset-enterprise-value/references/00-applicability.md +31 -0
- package/skills/crwu/crwu-audit-asset-enterprise-value/references/01-kb-assembly.md +35 -0
- package/skills/crwu/crwu-audit-asset-enterprise-value/references/02-review-focus.md +33 -0
- package/skills/crwu/crwu-audit-asset-enterprise-value/references/03-common-contract.md +202 -0
- package/skills/crwu/crwu-audit-asset-equipment/SKILL.md +38 -0
- package/skills/crwu/crwu-audit-asset-equipment/references/00-applicability.md +40 -0
- package/skills/crwu/crwu-audit-asset-equipment/references/01-kb-assembly.md +39 -0
- package/skills/crwu/crwu-audit-asset-equipment/references/02-review-focus.md +52 -0
- package/skills/crwu/crwu-audit-asset-equipment/references/03-common-contract.md +202 -0
- package/skills/crwu/crwu-audit-asset-intangible/SKILL.md +38 -0
- package/skills/crwu/crwu-audit-asset-intangible/references/00-applicability.md +36 -0
- package/skills/crwu/crwu-audit-asset-intangible/references/01-kb-assembly.md +40 -0
- package/skills/crwu/crwu-audit-asset-intangible/references/02-review-focus.md +68 -0
- package/skills/crwu/crwu-audit-asset-intangible/references/03-common-contract.md +202 -0
- package/skills/crwu/crwu-audit-asset-inventory/SKILL.md +38 -0
- package/skills/crwu/crwu-audit-asset-inventory/references/00-applicability.md +28 -0
- package/skills/crwu/crwu-audit-asset-inventory/references/01-kb-assembly.md +31 -0
- package/skills/crwu/crwu-audit-asset-inventory/references/02-review-focus.md +21 -0
- package/skills/crwu/crwu-audit-asset-inventory/references/03-common-contract.md +202 -0
- package/skills/crwu/crwu-audit-asset-mining-right/SKILL.md +38 -0
- package/skills/crwu/crwu-audit-asset-mining-right/references/00-applicability.md +31 -0
- package/skills/crwu/crwu-audit-asset-mining-right/references/01-kb-assembly.md +35 -0
- package/skills/crwu/crwu-audit-asset-mining-right/references/02-review-focus.md +33 -0
- package/skills/crwu/crwu-audit-asset-mining-right/references/03-common-contract.md +202 -0
- package/skills/crwu/crwu-audit-asset-other/SKILL.md +38 -0
- package/skills/crwu/crwu-audit-asset-other/references/00-applicability.md +28 -0
- package/skills/crwu/crwu-audit-asset-other/references/01-kb-assembly.md +31 -0
- package/skills/crwu/crwu-audit-asset-other/references/02-review-focus.md +21 -0
- package/skills/crwu/crwu-audit-asset-other/references/03-common-contract.md +202 -0
- package/skills/crwu/crwu-audit-asset-portfolio/SKILL.md +38 -0
- package/skills/crwu/crwu-audit-asset-portfolio/references/00-applicability.md +28 -0
- package/skills/crwu/crwu-audit-asset-portfolio/references/01-kb-assembly.md +31 -0
- package/skills/crwu/crwu-audit-asset-portfolio/references/02-review-focus.md +21 -0
- package/skills/crwu/crwu-audit-asset-portfolio/references/03-common-contract.md +202 -0
- package/skills/crwu/crwu-audit-asset-realestate/SKILL.md +38 -0
- package/skills/crwu/crwu-audit-asset-realestate/references/00-applicability.md +43 -0
- package/skills/crwu/crwu-audit-asset-realestate/references/01-kb-assembly.md +75 -0
- package/skills/crwu/crwu-audit-asset-realestate/references/02-review-focus.md +93 -0
- package/skills/crwu/crwu-audit-asset-realestate/references/03-common-contract.md +202 -0
- package/skills/crwu/crwu-audit-asset-scrap-materials/SKILL.md +38 -0
- package/skills/crwu/crwu-audit-asset-scrap-materials/references/00-applicability.md +28 -0
- package/skills/crwu/crwu-audit-asset-scrap-materials/references/01-kb-assembly.md +31 -0
- package/skills/crwu/crwu-audit-asset-scrap-materials/references/02-review-focus.md +21 -0
- package/skills/crwu/crwu-audit-asset-scrap-materials/references/03-common-contract.md +202 -0
- package/skills/crwu/crwu-audit-asset-transport-equipment/SKILL.md +38 -0
- package/skills/crwu/crwu-audit-asset-transport-equipment/references/00-applicability.md +31 -0
- package/skills/crwu/crwu-audit-asset-transport-equipment/references/01-kb-assembly.md +35 -0
- package/skills/crwu/crwu-audit-asset-transport-equipment/references/02-review-focus.md +33 -0
- package/skills/crwu/crwu-audit-asset-transport-equipment/references/03-common-contract.md +202 -0
- package/skills/crwu/crwu-audit-biz-asset-operation/SKILL.md +38 -0
- package/skills/crwu/crwu-audit-biz-asset-operation/references/00-applicability.md +30 -0
- package/skills/crwu/crwu-audit-biz-asset-operation/references/01-kb-assembly.md +32 -0
- package/skills/crwu/crwu-audit-biz-asset-operation/references/02-review-focus.md +42 -0
- package/skills/crwu/crwu-audit-biz-asset-operation/references/03-common-contract.md +202 -0
- package/skills/crwu/crwu-audit-biz-consulting-review/SKILL.md +38 -0
- package/skills/crwu/crwu-audit-biz-consulting-review/references/00-applicability.md +30 -0
- package/skills/crwu/crwu-audit-biz-consulting-review/references/01-kb-assembly.md +32 -0
- package/skills/crwu/crwu-audit-biz-consulting-review/references/02-review-focus.md +40 -0
- package/skills/crwu/crwu-audit-biz-consulting-review/references/03-common-contract.md +202 -0
- package/skills/crwu/crwu-audit-biz-financial-reporting/SKILL.md +38 -0
- package/skills/crwu/crwu-audit-biz-financial-reporting/references/00-applicability.md +41 -0
- package/skills/crwu/crwu-audit-biz-financial-reporting/references/01-kb-assembly.md +40 -0
- package/skills/crwu/crwu-audit-biz-financial-reporting/references/02-review-focus.md +64 -0
- package/skills/crwu/crwu-audit-biz-financial-reporting/references/03-common-contract.md +202 -0
- package/skills/crwu/crwu-audit-biz-financing-debt/SKILL.md +38 -0
- package/skills/crwu/crwu-audit-biz-financing-debt/references/00-applicability.md +31 -0
- package/skills/crwu/crwu-audit-biz-financing-debt/references/01-kb-assembly.md +34 -0
- package/skills/crwu/crwu-audit-biz-financing-debt/references/02-review-focus.md +47 -0
- package/skills/crwu/crwu-audit-biz-financing-debt/references/03-common-contract.md +202 -0
- package/skills/crwu/crwu-audit-biz-investment-capital/SKILL.md +38 -0
- package/skills/crwu/crwu-audit-biz-investment-capital/references/00-applicability.md +31 -0
- package/skills/crwu/crwu-audit-biz-investment-capital/references/01-kb-assembly.md +34 -0
- package/skills/crwu/crwu-audit-biz-investment-capital/references/02-review-focus.md +47 -0
- package/skills/crwu/crwu-audit-biz-investment-capital/references/03-common-contract.md +202 -0
- package/skills/crwu/crwu-audit-biz-judicial-liquidation-compensation/SKILL.md +38 -0
- package/skills/crwu/crwu-audit-biz-judicial-liquidation-compensation/references/00-applicability.md +30 -0
- package/skills/crwu/crwu-audit-biz-judicial-liquidation-compensation/references/01-kb-assembly.md +32 -0
- package/skills/crwu/crwu-audit-biz-judicial-liquidation-compensation/references/02-review-focus.md +40 -0
- package/skills/crwu/crwu-audit-biz-judicial-liquidation-compensation/references/03-common-contract.md +202 -0
- package/skills/crwu/crwu-audit-biz-tax-history/SKILL.md +38 -0
- package/skills/crwu/crwu-audit-biz-tax-history/references/00-applicability.md +29 -0
- package/skills/crwu/crwu-audit-biz-tax-history/references/01-kb-assembly.md +30 -0
- package/skills/crwu/crwu-audit-biz-tax-history/references/02-review-focus.md +33 -0
- package/skills/crwu/crwu-audit-biz-tax-history/references/03-common-contract.md +202 -0
- package/skills/crwu/crwu-audit-biz-transaction-disposal/SKILL.md +38 -0
- package/skills/crwu/crwu-audit-biz-transaction-disposal/references/00-applicability.md +31 -0
- package/skills/crwu/crwu-audit-biz-transaction-disposal/references/01-kb-assembly.md +34 -0
- package/skills/crwu/crwu-audit-biz-transaction-disposal/references/02-review-focus.md +47 -0
- package/skills/crwu/crwu-audit-biz-transaction-disposal/references/03-common-contract.md +202 -0
- package/skills/crwu/crwu-audit-datacheck/SKILL.md +59 -0
- package/skills/crwu/crwu-audit-datacheck/references/00-KB/350/243/205/351/205/215/350/241/250.md +25 -0
- package/skills/crwu/crwu-audit-datacheck/references/01-runtime-and-check-catalog.md +139 -0
- package/skills/crwu/crwu-audit-datacheck/scripts/recalc_check.py +521 -0
- package/skills/crwu/crwu-audit-datacheck/scripts/test_recalc_check.py +419 -0
- package/skills/crwu/crwu-audit-datacheck/scripts/test_skill_self_containment.py +44 -0
- package/skills/crwu/crwu-audit-external-data/SKILL.md +73 -0
- package/skills/crwu/crwu-audit-external-data/references/00-KB/350/243/205/351/205/215/350/241/250.md +38 -0
- package/skills/crwu/crwu-audit-external-data/references/01-connector-access.md +116 -0
- package/skills/crwu/crwu-audit-external-data/references/02-verification-workflow.md +78 -0
- package/skills/crwu/crwu-audit-external-data/scripts/connector_probe.py +374 -0
- package/skills/crwu/crwu-audit-external-data/scripts/test_connector_probe.py +264 -0
- package/skills/crwu/crwu-audit-output-filter/SKILL.md +34 -0
- package/skills/crwu/crwu-audit-output-filter/references/00-applicability.md +7 -0
- package/skills/crwu/crwu-audit-output-filter/references/01-kb-assembly.md +11 -0
- package/skills/crwu/crwu-audit-output-filter/references/02-review-focus.md +25 -0
- package/skills/crwu/crwu-audit-public-general-standards/SKILL.md +46 -0
- package/skills/crwu/crwu-audit-public-general-standards/references/00-applicability.md +41 -0
- package/skills/crwu/crwu-audit-public-general-standards/references/01-kb-assembly.md +66 -0
- package/skills/crwu/crwu-audit-public-general-standards/references/02-review-focus.md +111 -0
- package/skills/crwu/crwu-audit-public-general-standards/references/03-execution-contract.md +89 -0
- package/skills/crwu/crwu-dev-audit-optimize/SKILL.md +98 -0
- package/skills/crwu/crwu-dev-audit-optimize/references/00-/344/274/230/345/214/226/350/247/204/350/214/203/344/270/216/346/226/207/344/273/266/350/220/275/347/202/271.md +132 -0
- package/skills/crwu/crwu-dev-audit-optimize/references/01-/345/217/215/351/246/210/345/256/232/344/275/215/344/270/216/347/224/273/345/203/217/346/265/201/347/250/213.md +108 -0
- package/skills/crwu/crwu-dev-audit-optimize/references/02-/346/226/271/346/241/210/346/250/241/346/235/277/344/270/216/347/241/256/350/256/244/351/227/250/347/246/201.md +92 -0
- package/skills/crwu/crwu-dev-audit-optimize/references/03-AI/344/272/272/345/267/245/345/267/256/350/267/235/345/210/206/346/236/220/346/265/201/347/250/213.md +95 -0
- package/skills/crwu/crwu-dev-audit-optimize/scripts/examples/gap-analysis.sample.json +116 -0
- package/skills/crwu/crwu-dev-audit-optimize/scripts/gap_analysis.schema.json +33 -0
- package/skills/crwu/crwu-dev-audit-optimize/scripts/gap_analysis_delivery.py +475 -0
- package/skills/crwu/crwu-dev-audit-optimize/scripts/test_gap_analysis_delivery.py +855 -0
- package/skills/crwu/crwu-dev-audit-optimize/template/NOTICE.echarts.txt +7 -0
- package/skills/crwu/crwu-dev-audit-optimize/template/echarts.min.js +45 -0
- package/skills/crwu/crwu-dev-audit-optimize/template/gap-analysis-report.html +71 -0
- package/skills/crwu/crwu-dev-audit-skill-maintainer/SKILL.md +74 -0
- package/skills/crwu/crwu-dev-audit-skill-maintainer/references/00-responsibility-and-modes.md +62 -0
- package/skills/crwu/crwu-dev-audit-skill-maintainer/references/01-kb-source-discovery.md +89 -0
- package/skills/crwu/crwu-dev-audit-skill-maintainer/references/02-child-skill-contract.md +99 -0
- package/skills/crwu/crwu-dev-audit-skill-maintainer/references/03-review-item-execution-contract.md +51 -0
- package/skills/crwu/crwu-dev-audit-skill-maintainer/references/04-registry-and-mapping-update.md +73 -0
- package/skills/crwu/crwu-dev-audit-skill-maintainer/references/05-validation-and-delivery.md +127 -0
- package/skills/crwu/crwu-dev-audit-skill-maintainer/references/06-live-routing-reconciliation.md +136 -0
- package/skills/crwu/crwu-dev-audit-skill-maintainer/references/07-kb-skill-map.md +411 -0
- package/skills/crwu/crwu-dev-audit-skill-maintainer/scripts/README.md +200 -0
- package/skills/crwu/crwu-dev-audit-skill-maintainer/scripts/check_audit_skill_mappings.py +2477 -0
- package/skills/crwu/crwu-dev-audit-skill-maintainer/scripts/kb_tool.py +577 -0
- package/skills/crwu/crwu-dev-audit-skill-maintainer/scripts/test_audit_skill_maintainer.py +5097 -0
- package/skills/dws/LICENSE +178 -0
- package/skills/dws/NOTICE +17 -0
- package/skills/dws/dingtalk-aisearch/SKILL.md +83 -0
- package/skills/dws/dingtalk-aisearch/references/aisearch.md +249 -0
- package/skills/dws/dingtalk-aisearch/references/intent-guide.md +15 -0
- package/skills/dws/dingtalk-aisearch/references/lite-recipes.md +31 -0
- package/skills/dws/dingtalk-aitable/SKILL.md +139 -0
- package/skills/dws/dingtalk-aitable/references/06-data-analytics.md +11 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-advperm.md +251 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-attachment.md +49 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-best-practices.md +48 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-cell-value.md +346 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-dashboard-chart.md +46 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-data-analysis-sop.md +131 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-datasource.md +272 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-error-recovery.md +128 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-export-import.md +113 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-field-properties.md +238 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-field.md +186 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-filter-sort.md +135 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-form.md +130 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-formula-guide.md +224 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-primary-doc.md +58 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-record-create.md +52 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-record-delete.md +20 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-record-history.md +97 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-record-name-key.md +47 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-record-query.md +120 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-record-share.md +54 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-record-stats.md +59 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-record-update.md +66 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-record-upsert.md +89 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-view-config.md +140 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-view-extras.md +195 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-view-types.md +95 -0
- package/skills/dws/dingtalk-aitable/references/aitable/aitable-workflow.md +384 -0
- package/skills/dws/dingtalk-aitable/references/aitable-record-ops.md +80 -0
- package/skills/dws/dingtalk-aitable/references/aitable-section.md +30 -0
- package/skills/dws/dingtalk-aitable/references/aitable.md +59 -0
- package/skills/dws/dingtalk-aitable/references/field-rules.md +17 -0
- package/skills/dws/dingtalk-aitable/references/intent-guide.md +17 -0
- package/skills/dws/dingtalk-aitable/references/recipes/conventions.md +44 -0
- package/skills/dws/dingtalk-aitable/references/url-patterns.md +144 -0
- package/skills/dws/dingtalk-aitable/scripts/aitable_export_via_task.py +210 -0
- package/skills/dws/dingtalk-aitable/scripts/aitable_import_via_task.py +173 -0
- package/skills/dws/dingtalk-aitable/scripts/bulk_add_fields.py +273 -0
- package/skills/dws/dingtalk-aitable/scripts/import_records.py +333 -0
- package/skills/dws/dingtalk-aitable/scripts/upload_attachment.py +190 -0
- package/skills/dws/dingtalk-calendar/SKILL.md +137 -0
- package/skills/dws/dingtalk-calendar/references/03-meeting.md +56 -0
- package/skills/dws/dingtalk-calendar/references/calendar.md +760 -0
- package/skills/dws/dingtalk-calendar/references/intent-guide.md +9 -0
- package/skills/dws/dingtalk-calendar/references/lite-recipes.md +25 -0
- package/skills/dws/dingtalk-calendar/scripts/calendar_free_slot_finder.py +199 -0
- package/skills/dws/dingtalk-calendar/scripts/calendar_schedule_meeting.py +233 -0
- package/skills/dws/dingtalk-calendar/scripts/calendar_today_agenda.py +139 -0
- package/skills/dws/dingtalk-chat/SKILL.md +127 -0
- package/skills/dws/dingtalk-chat/references/01-messaging.md +103 -0
- package/skills/dws/dingtalk-chat/references/card/callback.md +10 -0
- package/skills/dws/dingtalk-chat/references/card/create.md +27 -0
- package/skills/dws/dingtalk-chat/references/card/schema.md +14 -0
- package/skills/dws/dingtalk-chat/references/card/update.md +13 -0
- package/skills/dws/dingtalk-chat/references/chat/chat-bot.md +187 -0
- package/skills/dws/dingtalk-chat/references/chat/chat-conversation.md +165 -0
- package/skills/dws/dingtalk-chat/references/chat/group-admin.md +144 -0
- package/skills/dws/dingtalk-chat/references/chat/group-discovery.md +121 -0
- package/skills/dws/dingtalk-chat/references/chat/message-actions.md +132 -0
- package/skills/dws/dingtalk-chat/references/chat/message-media.md +76 -0
- package/skills/dws/dingtalk-chat/references/chat/message-query.md +135 -0
- package/skills/dws/dingtalk-chat/references/chat/thread.md +46 -0
- package/skills/dws/dingtalk-chat/references/chat-emoji-list.md +216 -0
- package/skills/dws/dingtalk-chat/references/chat.md +140 -0
- package/skills/dws/dingtalk-chat/references/contracts.md +70 -0
- package/skills/dws/dingtalk-chat/references/intent-guide.md +56 -0
- package/skills/dws/dingtalk-contact/SKILL.md +122 -0
- package/skills/dws/dingtalk-contact/references/08-directory.md +51 -0
- package/skills/dws/dingtalk-contact/references/contact.md +595 -0
- package/skills/dws/dingtalk-contact/references/intent-guide.md +14 -0
- package/skills/dws/dingtalk-contact/references/lite-recipes.md +30 -0
- package/skills/dws/dingtalk-contact/references/recipes/conventions.md +44 -0
- package/skills/dws/dingtalk-contact/scripts/contact_dept_members.py +130 -0
- package/skills/dws/dingtalk-doc/SKILL.md +112 -0
- package/skills/dws/dingtalk-doc/references/04-document.md +57 -0
- package/skills/dws/dingtalk-doc/references/contracts.md +40 -0
- package/skills/dws/dingtalk-doc/references/doc/doc-block.md +39 -0
- package/skills/dws/dingtalk-doc/references/doc/doc-comment.md +29 -0
- package/skills/dws/dingtalk-doc/references/doc/doc-create.md +54 -0
- package/skills/dws/dingtalk-doc/references/doc/doc-export.md +30 -0
- package/skills/dws/dingtalk-doc/references/doc/doc-import.md +28 -0
- package/skills/dws/dingtalk-doc/references/doc/doc-info.md +12 -0
- package/skills/dws/dingtalk-doc/references/doc/doc-media.md +46 -0
- package/skills/dws/dingtalk-doc/references/doc/doc-read.md +56 -0
- package/skills/dws/dingtalk-doc/references/doc/doc-update.md +87 -0
- package/skills/dws/dingtalk-doc/references/doc/format/doc-jsonml-cookbook.md +469 -0
- package/skills/dws/dingtalk-doc/references/doc/format/doc-jsonml-schema.json +526 -0
- package/skills/dws/dingtalk-doc/references/doc/format/doc-jsonml-schema.md +711 -0
- package/skills/dws/dingtalk-doc/references/doc/style/doc-create-workflow.md +414 -0
- package/skills/dws/dingtalk-doc/references/doc/style/doc-style-guideline.md +404 -0
- package/skills/dws/dingtalk-doc/references/doc/style/doc-update-workflow.md +316 -0
- package/skills/dws/dingtalk-doc/references/doc.md +82 -0
- package/skills/dws/dingtalk-doc/references/intent-guide.md +20 -0
- package/skills/dws/dingtalk-drive/SKILL.md +128 -0
- package/skills/dws/dingtalk-drive/references/drive.md +126 -0
- package/skills/dws/dingtalk-drive/references/folder-sync.md +40 -0
- package/skills/dws/dingtalk-drive/references/intent-guide.md +22 -0
- package/skills/dws/dingtalk-drive/references/lite-recipes.md +39 -0
- package/skills/dws/dingtalk-event/SKILL.md +106 -0
- package/skills/dws/dingtalk-event/references/event-im-keys.md +54 -0
- package/skills/dws/dingtalk-event/references/event-im-lifecycle.md +35 -0
- package/skills/dws/dingtalk-event/references/event-im-operations.md +52 -0
- package/skills/dws/dingtalk-event/references/event-im-output.md +39 -0
- package/skills/dws/dingtalk-event/references/event-im.md +58 -0
- package/skills/dws/dingtalk-event/references/event-oa.md +155 -0
- package/skills/dws/dingtalk-event/references/event-todo.md +50 -0
- package/skills/dws/dingtalk-event/references/event-voip.md +69 -0
- package/skills/dws/dingtalk-mail/SKILL.md +113 -0
- package/skills/dws/dingtalk-mail/references/09-mail.md +45 -0
- package/skills/dws/dingtalk-mail/references/intent-guide.md +8 -0
- package/skills/dws/dingtalk-mail/references/lite-recipes.md +45 -0
- package/skills/dws/dingtalk-mail/references/mail.md +1889 -0
- package/skills/dws/dingtalk-mail/references/recipes/conventions.md +44 -0
- package/skills/dws/dingtalk-mail/scripts/mail_send_with_cc.py +122 -0
- package/skills/dws/dingtalk-mail/scripts/mail_unread_summary.py +114 -0
- package/skills/dws/dingtalk-minutes/SKILL.md +116 -0
- package/skills/dws/dingtalk-minutes/references/07-minutes.md +149 -0
- package/skills/dws/dingtalk-minutes/references/10-minutes-speaker-match.md +32 -0
- package/skills/dws/dingtalk-minutes/references/11-minutes-speaker-correct.md +40 -0
- package/skills/dws/dingtalk-minutes/references/intent-guide.md +79 -0
- package/skills/dws/dingtalk-minutes/references/lite-recipes.md +25 -0
- package/skills/dws/dingtalk-minutes/references/minutes.md +210 -0
- package/skills/dws/dingtalk-minutes/references/recipes/conventions.md +33 -0
- package/skills/dws/dingtalk-minutes/scripts/minutes_recent_summary.py +196 -0
- package/skills/dws/dingtalk-minutes/scripts/test_minutes_scripts.py +222 -0
- package/skills/dws/dingtalk-misc/SKILL.md +55 -0
- package/skills/dws/dingtalk-misc/references/05-reporting.md +29 -0
- package/skills/dws/dingtalk-misc/references/agoal.md +302 -0
- package/skills/dws/dingtalk-misc/references/attendance-intent-guide.md +7 -0
- package/skills/dws/dingtalk-misc/references/attendance-report.md +109 -0
- package/skills/dws/dingtalk-misc/references/attendance-schedule.md +115 -0
- package/skills/dws/dingtalk-misc/references/attendance-vacation.md +129 -0
- package/skills/dws/dingtalk-misc/references/attendance.md +149 -0
- package/skills/dws/dingtalk-misc/references/dev/app.md +42 -0
- package/skills/dws/dingtalk-misc/references/dev/connect.md +108 -0
- package/skills/dws/dingtalk-misc/references/dev/credentials.md +26 -0
- package/skills/dws/dingtalk-misc/references/dev/dev-index.md +13 -0
- package/skills/dws/dingtalk-misc/references/dev/event.md +47 -0
- package/skills/dws/dingtalk-misc/references/dev/mcp.md +106 -0
- package/skills/dws/dingtalk-misc/references/dev/member.md +19 -0
- package/skills/dws/dingtalk-misc/references/dev/permission.md +56 -0
- package/skills/dws/dingtalk-misc/references/dev/recipes.md +58 -0
- package/skills/dws/dingtalk-misc/references/dev/robot.md +74 -0
- package/skills/dws/dingtalk-misc/references/dev/security.md +21 -0
- package/skills/dws/dingtalk-misc/references/dev/version.md +99 -0
- package/skills/dws/dingtalk-misc/references/dev/webapp.md +22 -0
- package/skills/dws/dingtalk-misc/references/devapp.md +148 -0
- package/skills/dws/dingtalk-misc/references/devdoc-intent-guide.md +7 -0
- package/skills/dws/dingtalk-misc/references/devdoc.md +42 -0
- package/skills/dws/dingtalk-misc/references/ding.md +184 -0
- package/skills/dws/dingtalk-misc/references/hrbrain.md +67 -0
- package/skills/dws/dingtalk-misc/references/live.md +23 -0
- package/skills/dws/dingtalk-misc/references/markdown.md +167 -0
- package/skills/dws/dingtalk-misc/references/oa/oa-form-components.md +345 -0
- package/skills/dws/dingtalk-misc/references/oa/oa-process-nodes.md +374 -0
- package/skills/dws/dingtalk-misc/references/oa.md +1014 -0
- package/skills/dws/dingtalk-misc/references/openapi-explorer.md +47 -0
- package/skills/dws/dingtalk-misc/references/pat.md +52 -0
- package/skills/dws/dingtalk-misc/references/profile.md +41 -0
- package/skills/dws/dingtalk-misc/references/recruit.md +89 -0
- package/skills/dws/dingtalk-misc/references/report-conventions.md +44 -0
- package/skills/dws/dingtalk-misc/references/report-intent-guide.md +11 -0
- package/skills/dws/dingtalk-misc/references/report-lite-recipes.md +25 -0
- package/skills/dws/dingtalk-misc/references/report.md +109 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-batch-operations.md +104 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-chart.md +69 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-comment.md +166 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-conditional-format.md +59 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-dimension-operations.md +60 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-dropdown.md +103 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-export.md +135 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-filter-view.md +51 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-filter.md +181 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-formula.md +192 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-import.md +76 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-media-image.md +263 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-pivot-table.md +292 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-range-operations.md +161 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-read-data.md +60 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-revision-changeset.md +46 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-search-replace.md +120 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-style-format.md +71 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-version.md +84 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-workbook.md +59 -0
- package/skills/dws/dingtalk-misc/references/sheet/sheet-write-data.md +93 -0
- package/skills/dws/dingtalk-misc/references/sheet-conventions.md +44 -0
- package/skills/dws/dingtalk-misc/references/sheet-intent-guide.md +12 -0
- package/skills/dws/dingtalk-misc/references/sheet.md +166 -0
- package/skills/dws/dingtalk-misc/references/skill.md +98 -0
- package/skills/dws/dingtalk-misc/references/unsupported-scripts.md +49 -0
- package/skills/dws/dingtalk-misc/references/url-patterns.md +144 -0
- package/skills/dws/dingtalk-misc/references/whiteboard/compose.md +200 -0
- package/skills/dws/dingtalk-misc/references/whiteboard/open-nodes-v1/01-overview.md +57 -0
- package/skills/dws/dingtalk-misc/references/whiteboard/open-nodes-v1/02-query.md +288 -0
- package/skills/dws/dingtalk-misc/references/whiteboard/open-nodes-v1/03-update.md +249 -0
- package/skills/dws/dingtalk-misc/references/whiteboard/open-nodes-v1/04-text-style.md +395 -0
- package/skills/dws/dingtalk-misc/references/whiteboard/open-nodes-v1/05-shape-frame-group-connector.md +186 -0
- package/skills/dws/dingtalk-misc/references/whiteboard/open-nodes-v1/06-vector-icon-path.md +313 -0
- package/skills/dws/dingtalk-misc/references/whiteboard/open-nodes-v1/07-examples-errors-write-support.md +270 -0
- package/skills/dws/dingtalk-misc/references/whiteboard/open-nodes-v1/08-catalogs.md +61 -0
- package/skills/dws/dingtalk-misc/references/whiteboard/open-nodes-v1.md +54 -0
- package/skills/dws/dingtalk-misc/references/whiteboard/replace.md +41 -0
- package/skills/dws/dingtalk-misc/references/whiteboard/vector.md +51 -0
- package/skills/dws/dingtalk-misc/references/whiteboard.md +115 -0
- package/skills/dws/dingtalk-misc/scripts/aiapp_create_and_poll.py +138 -0
- package/skills/dws/dingtalk-misc/scripts/attendance_my_record.py +91 -0
- package/skills/dws/dingtalk-misc/scripts/attendance_report_checkin.py +440 -0
- package/skills/dws/dingtalk-misc/scripts/attendance_report_common.py +1698 -0
- package/skills/dws/dingtalk-misc/scripts/attendance_report_daily.py +558 -0
- package/skills/dws/dingtalk-misc/scripts/attendance_report_detail.py +809 -0
- package/skills/dws/dingtalk-misc/scripts/attendance_report_monthly.py +758 -0
- package/skills/dws/dingtalk-misc/scripts/attendance_report_record.py +947 -0
- package/skills/dws/dingtalk-misc/scripts/attendance_schedule_export.py +344 -0
- package/skills/dws/dingtalk-misc/scripts/attendance_schedule_import.py +498 -0
- package/skills/dws/dingtalk-misc/scripts/attendance_team_shift.py +89 -0
- package/skills/dws/dingtalk-misc/scripts/attendance_vacation_balance.py +617 -0
- package/skills/dws/dingtalk-misc/scripts/finance_daily_cashflow.py +70 -0
- package/skills/dws/dingtalk-misc/scripts/finance_expense_flow.py +138 -0
- package/skills/dws/dingtalk-misc/scripts/oa_batch_approve.py +158 -0
- package/skills/dws/dingtalk-misc/scripts/oa_pending_review.py +132 -0
- package/skills/dws/dingtalk-misc/scripts/report_received_today.py +315 -0
- package/skills/dws/dingtalk-misc/scripts/yida_custom_page_update.py +401 -0
- package/skills/dws/dingtalk-misc/scripts/yida_form_builder.py +526 -0
- package/skills/dws/dingtalk-misc/scripts/yida_form_fields.py +315 -0
- package/skills/dws/dingtalk-misc/scripts/yida_form_inspector.py +311 -0
- package/skills/dws/dingtalk-misc/scripts/yida_form_update.py +185 -0
- package/skills/dws/dingtalk-misc/scripts/yida_jsx_pipeline.py +1588 -0
- package/skills/dws/dingtalk-misc/scripts/yida_page_compiler.py +1441 -0
- package/skills/dws/dingtalk-misc/scripts/yida_page_generate.py +514 -0
- package/skills/dws/dingtalk-misc/scripts/yida_page_schema.py +519 -0
- package/skills/dws/dingtalk-misc/scripts/yida_page_self_check.py +398 -0
- package/skills/dws/dingtalk-misc/scripts/yida_process_flow.py +1478 -0
- package/skills/dws/dingtalk-misc/scripts/yida_process_update.py +589 -0
- package/skills/dws/dingtalk-misc/scripts/yida_report_builder.py +494 -0
- package/skills/dws/dingtalk-misc/scripts/yida_report_charts.py +1434 -0
- package/skills/dws/dingtalk-misc/scripts/yida_report_update.py +244 -0
- package/skills/dws/dingtalk-misc/scripts/yida_schema_common.py +340 -0
- package/skills/dws/dingtalk-shared/SKILL.md +83 -0
- package/skills/dws/dingtalk-shared/references/capability-limits.md +31 -0
- package/skills/dws/dingtalk-shared/references/channel-login.md +26 -0
- package/skills/dws/dingtalk-shared/references/confirmation.md +59 -0
- package/skills/dws/dingtalk-shared/references/error-codes.md +155 -0
- package/skills/dws/dingtalk-shared/references/global-reference.md +122 -0
- package/skills/dws/dingtalk-shared/references/intent-guide.md +537 -0
- package/skills/dws/dingtalk-shared/references/recipes/conventions.md +45 -0
- package/skills/dws/dingtalk-shared/references/recipes/lite-catalog.md +275 -0
- package/skills/dws/dingtalk-shared/references/recipes/meta.md +67 -0
- package/skills/dws/dingtalk-shared/references/routing.md +52 -0
- package/skills/dws/dingtalk-shared/references/runtime-contract.md +11 -0
- package/skills/dws/dingtalk-shared/references/schema-usage.md +88 -0
- package/skills/dws/dingtalk-shared/references/url-patterns.md +144 -0
- package/skills/dws/dingtalk-shared/references/workflow-routing.md +45 -0
- package/skills/dws/dingtalk-todo/SKILL.md +119 -0
- package/skills/dws/dingtalk-todo/references/02-task.md +70 -0
- package/skills/dws/dingtalk-todo/references/intent-guide.md +15 -0
- package/skills/dws/dingtalk-todo/references/lite-recipes.md +68 -0
- package/skills/dws/dingtalk-todo/references/recipes/conventions.md +44 -0
- package/skills/dws/dingtalk-todo/references/todo.md +517 -0
- package/skills/dws/dingtalk-todo/scripts/todo_batch_create.py +354 -0
- package/skills/dws/dingtalk-todo/scripts/todo_daily_summary.py +153 -0
- package/skills/dws/dingtalk-todo/scripts/todo_overdue_check.py +101 -0
- package/skills/dws/dingtalk-wiki/SKILL.md +117 -0
- package/skills/dws/dingtalk-wiki/references/intent-guide.md +14 -0
- package/skills/dws/dingtalk-wiki/references/lite-recipes.md +44 -0
- package/skills/dws/dingtalk-wiki/references/wiki-members.md +32 -0
- package/skills/dws/dingtalk-wiki/references/wiki-node-ops.md +46 -0
- package/skills/dws/dingtalk-wiki/references/wiki.md +65 -0
- package/skills/dws/provenance.json +27 -0
|
@@ -0,0 +1,2477 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read-only inventory checker for crwu-audit asset and business skills.
|
|
3
|
+
|
|
4
|
+
源仓维护工具:以 `--repo-root <源仓>` 定位技能树(`skills/crwu/` 层里的 crwu-audit 族),
|
|
5
|
+
用于源仓一致性盘点与门禁,运行时不需要它。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
import datetime
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Iterable, Sequence
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
REPORT_SCHEMA = "crwu.audit-skill-maintainer.report.v1"
|
|
21
|
+
# Canonical crwu-dws artifacts (see crwu-dws cache: 目录快照.json / node-index.json).
|
|
22
|
+
DIR_SNAPSHOT_SCHEMA = "crwu.kb-dir-snapshot.v1"
|
|
23
|
+
KB_NODE_INDEX_SCHEMA = "crwu.kb-node-index.v1"
|
|
24
|
+
# Older nested/legacy shapes kept accepted so existing fixtures and snapshots still load.
|
|
25
|
+
SNAPSHOT_SCHEMA = "crwu.kb-catalog.snapshot.v1"
|
|
26
|
+
NODE_INDEX_SCHEMA = "crwu.kb-dir-cache.nodeindex.v1"
|
|
27
|
+
# 明确列出可接受集合:错误信息要能告诉人"该改什么",只报 schema 名不够
|
|
28
|
+
# (真实失效:实时缓存把 node-index 形态写进 目录快照.json,并把 schema 字面写成
|
|
29
|
+
# `crwu.kb-dir-cache.snapshot.v1`——与 .cache-meta.json 的 `crwu.kb-dir-cache.meta.v1`
|
|
30
|
+
# 混合而成,历史上从未存在过这个名字。不得为它开别名,否则等于把漂移固化成契约。
|
|
31
|
+
ACCEPTED_CATALOG_SCHEMAS = (
|
|
32
|
+
DIR_SNAPSHOT_SCHEMA, KB_NODE_INDEX_SCHEMA, SNAPSHOT_SCHEMA, NODE_INDEX_SCHEMA,
|
|
33
|
+
)
|
|
34
|
+
REQUIRED_REFERENCES = (
|
|
35
|
+
"00-applicability.md",
|
|
36
|
+
"01-kb-assembly.md",
|
|
37
|
+
"02-review-focus.md",
|
|
38
|
+
)
|
|
39
|
+
AXIS_ROOTS = {
|
|
40
|
+
"asset": "02-资产类型/",
|
|
41
|
+
"business": "01-业务路线/",
|
|
42
|
+
}
|
|
43
|
+
# Report-shape-independent public capabilities. They are deliberately outside the
|
|
44
|
+
# asset/business first-level-root model (their assembly keys live under `06-规则库/`
|
|
45
|
+
# and may be more than one path), so they must not be forced into AXIS_ROOTS.
|
|
46
|
+
PUBLIC_AXIS = "public"
|
|
47
|
+
# Public capabilities are cross-cutting, so they are not held to the asset/business leaf
|
|
48
|
+
# naming (`00-applicability/01-kb-assembly/02-review-focus`). What they must not lack is a
|
|
49
|
+
# declared KB assembly table: without it the skill has no addressable rule material at all.
|
|
50
|
+
PUBLIC_ASSEMBLY_REFERENCES = ("01-kb-assembly.md", "00-KB装配表.md")
|
|
51
|
+
AXIS_SKILL_PREFIX = {
|
|
52
|
+
"asset": "crwu-audit-asset-",
|
|
53
|
+
"business": "crwu-audit-biz-",
|
|
54
|
+
}
|
|
55
|
+
LEGACY_BUSINESS_PREFIX = "crwu-audit-business-"
|
|
56
|
+
# Optional shared review layer of a first-level business directory: when the knowledge
|
|
57
|
+
# base ships it, the business skill must download and reference it for every subroute.
|
|
58
|
+
BUSINESS_COMMON_REVIEW_DOC = "共同审核点"
|
|
59
|
+
|
|
60
|
+
# Knowledge-base modules whose current content is a design/prototype contract rather than
|
|
61
|
+
# executable audit rules. They stay visible in every inventory run but must not enter runtime
|
|
62
|
+
# assembly until a maintainer explicitly verifies the enablement conditions and changes this
|
|
63
|
+
# policy. Directory-only inspection cannot infer that a document's body has matured.
|
|
64
|
+
# ---- 非运行时 / 仅兼容模块(不得进入运行时装配)----
|
|
65
|
+
# `runtimePolicy` 决定被装配时报哪个码:
|
|
66
|
+
# - `prototype`:状态=原型、规则待编,满足启用条件并经人工复核前不得参与审核
|
|
67
|
+
# → `NON_RUNTIME_MODULE_ASSEMBLED`;
|
|
68
|
+
# - `compat-only`:库内保留的**兼容入口**,正文自身声明"正式内容一律指向迁移目标",
|
|
69
|
+
# 供外部旧引用使用,CRWU 运行时不再装配 → `COMPAT_MODULE_ASSEMBLED`。
|
|
70
|
+
# 两者都进 `nonRuntime` 校准桶(同一张表就能看出"谁被隔离、谁被误装")。
|
|
71
|
+
NON_RUNTIME_KB_MODULES = (
|
|
72
|
+
{
|
|
73
|
+
"prefix": "06-规则库/M-收益法/",
|
|
74
|
+
"runtimePolicy": "prototype",
|
|
75
|
+
"classification": "prototype/non-runtime",
|
|
76
|
+
"reason": "模块规程仍为原型,规则依据含待编占位,不能作为正式审核依据",
|
|
77
|
+
"enableWhen": "知识库状态改为正式、规则编号补齐且维护人明确后,经人工复核启用",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"prefix": "06-规则库/M-法律法规合规/",
|
|
81
|
+
"runtimePolicy": "prototype",
|
|
82
|
+
"classification": "prototype/non-runtime",
|
|
83
|
+
"reason": "模块规程仍为原型,A 级规则未发布且规则依据待编,不能作为正式合规依据",
|
|
84
|
+
"enableWhen": "知识库状态改为正式、规则编号补齐且维护人明确后,经人工复核启用",
|
|
85
|
+
},
|
|
86
|
+
# 2026-09-26(F-007):`05-模块-基准要素一致性` 自述「版本 v0.1 / 状态 原型」、
|
|
87
|
+
# 「A 级条目(报告准则/程序准则相关 RULE 待编)」、「严重度定级依据(A 级规则发布后启用)」,
|
|
88
|
+
# 却曾被 available 的 crwu-audit-datacheck 作为勾稽口径装入运行时。
|
|
89
|
+
{
|
|
90
|
+
"prefix": "06-规则库/M-数据对齐-勾稽与一致性/05-模块-基准要素一致性",
|
|
91
|
+
"runtimePolicy": "prototype",
|
|
92
|
+
"classification": "prototype/non-runtime",
|
|
93
|
+
"reason": "模块规程仍为原型(v0.1),A 级条目待编,严重度定级依据待 A 级规则发布",
|
|
94
|
+
"enableWhen": "知识库状态改为正式、规则编号补齐且维护人明确后,经人工复核启用",
|
|
95
|
+
},
|
|
96
|
+
# 2026-09-26(F-006 决策 2):兼容入口。库内 `M-计算表审核/00` §七与 `09` §六都写明
|
|
97
|
+
# "旧模块降为兼容入口:保留旧路径供既有引用使用,正式内容一律指向本模块"。
|
|
98
|
+
{
|
|
99
|
+
"prefix": "06-规则库/M-数据对齐-勾稽与一致性/03-模块-数据校对",
|
|
100
|
+
"runtimePolicy": "compat-only",
|
|
101
|
+
"classification": "compat-only/non-runtime",
|
|
102
|
+
"reason": "兼容入口,正式口径已迁移至 06-规则库/M-计算表审核/,仅供外部旧引用使用",
|
|
103
|
+
"enableWhen": "不需要启用:运行时口径以 06-规则库/M-计算表审核/ 为准;库侧删除该兼容入口后退出追踪",
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# 必须**递归**(带 `/` 的目录键)装配的权威根:只写单文件键虽然"覆盖"了前缀,
|
|
108
|
+
# 但库内新增正文时会静默掉出下载清单(与 CHK-MKT-001~014 缺装同一失效模式)。
|
|
109
|
+
RECURSIVE_ROOT_REQUIRED = {
|
|
110
|
+
"06-规则库/M-计算表审核/": (
|
|
111
|
+
"authoritative calculation-sheet root must be assembled as one recursive directory key; "
|
|
112
|
+
"per-file keys silently drop any document added to the library later"
|
|
113
|
+
),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# ---- 路由层(router ↔ 目录 ↔ 注册表 ↔ 真实 Skill)----
|
|
117
|
+
ROUTER_SKILL = "crwu-audit"
|
|
118
|
+
ROUTER_REFERENCES = (
|
|
119
|
+
"00-input-and-route-profile.md",
|
|
120
|
+
"02-scope-classification.md",
|
|
121
|
+
"03-asset-classification.md",
|
|
122
|
+
"04-business-classification.md",
|
|
123
|
+
"05-method-classification.md",
|
|
124
|
+
"06-overlay-classification.md",
|
|
125
|
+
"07-skill-registry.md",
|
|
126
|
+
"08-union-dispatch-rules.md",
|
|
127
|
+
"10-capability-gap-proposal.md",
|
|
128
|
+
"99-maintenance.md",
|
|
129
|
+
)
|
|
130
|
+
# Leaf-owned reference names: the router may name them in prose without owning them.
|
|
131
|
+
LEAF_OWNED_REFERENCES = ("00-applicability.md", "01-kb-assembly.md", "02-review-focus.md")
|
|
132
|
+
# Catalog axis -> the union-dispatch input variable that must exist for it.
|
|
133
|
+
AXIS_DISPATCH_INPUT = {"asset": "asset_skills", "business": "business_skills"}
|
|
134
|
+
# Concrete skill names only: excludes wildcards (crwu-audit-asset-*) and placeholders
|
|
135
|
+
# (crwu-audit-<axis>-<label>) and longer identifiers such as design-crwu-audit-skills.md.
|
|
136
|
+
# 两组前缀都认:审核族 `crwu-audit-*` 与开发/维护侧 `crwu-dev-audit-*`
|
|
137
|
+
# (2026-09-16 先把 optimize / public-general-standards 移至 crwu-dev-audit-,2026-09-19
|
|
138
|
+
# 归组调整:public-general-standards 回到 crwu-audit-、维护器移入 crwu-dev-audit-;
|
|
139
|
+
# 若只认前者,路由正文里的 dev 成员会连 token 都提不出来 → R4 静默漏检)。
|
|
140
|
+
_SKILL_TOKEN_RE = re.compile(r"(?<![a-z0-9-])crwu-(?:dev-)?audit-[a-z0-9][a-z0-9-]*(?![a-z0-9*<-])")
|
|
141
|
+
_REFERENCE_TOKEN_RE = re.compile(r"(?<![0-9a-z-])(\d\d-[a-z0-9-]+\.md)")
|
|
142
|
+
# Skills that are deliberately not axis leaves (router, maintainers, cross-axis
|
|
143
|
+
# capabilities), so they are exempt from the asset/biz leaf naming rule.
|
|
144
|
+
NON_LEAF_SKILLS = {
|
|
145
|
+
"crwu-audit",
|
|
146
|
+
"crwu-dev-audit-optimize",
|
|
147
|
+
"crwu-dev-audit-skill-maintainer",
|
|
148
|
+
"crwu-audit-datacheck",
|
|
149
|
+
"crwu-audit-output-filter",
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
KNOWN_SKILL_SUFFIXES = {
|
|
153
|
+
("asset", "房地产"): "realestate",
|
|
154
|
+
("asset", "机器设备"): "equipment",
|
|
155
|
+
("asset", "设备"): "equipment",
|
|
156
|
+
("asset", "无形资产"): "intangible",
|
|
157
|
+
("asset", "企业价值"): "enterprise-value",
|
|
158
|
+
("asset", "矿业权"): "mining-right",
|
|
159
|
+
("asset", "存货"): "inventory",
|
|
160
|
+
("asset", "债权"): "debt",
|
|
161
|
+
("asset", "资产组合"): "portfolio",
|
|
162
|
+
("asset", "交通运输设备"): "transport-equipment",
|
|
163
|
+
("asset", "资产组-含商誉"): "asset-group-goodwill",
|
|
164
|
+
("asset", "废旧物资"): "scrap-materials",
|
|
165
|
+
("asset", "其他"): "other",
|
|
166
|
+
("business", "资产经营"): "asset-operation",
|
|
167
|
+
("business", "交易与处置"): "transaction-disposal",
|
|
168
|
+
("business", "财务报告"): "financial-reporting",
|
|
169
|
+
("business", "融资与债务"): "financing-debt",
|
|
170
|
+
("business", "投资与资本运作"): "investment-capital",
|
|
171
|
+
("business", "税务与历史确认"): "tax-history",
|
|
172
|
+
("business", "司法清算与补偿"): "judicial-liquidation-compensation",
|
|
173
|
+
("business", "咨询复核与其他"): "consulting-review",
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True)
|
|
178
|
+
class Catalog:
|
|
179
|
+
source_schema: str
|
|
180
|
+
generated_at: str | None
|
|
181
|
+
complete: bool | None
|
|
182
|
+
paths: tuple[str, ...]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class RegistryRow:
|
|
187
|
+
axis: str
|
|
188
|
+
label: str
|
|
189
|
+
skill: str
|
|
190
|
+
status: str
|
|
191
|
+
load_behavior: str
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _normalize_path(raw: str, *, folder: bool | None = None) -> str:
|
|
195
|
+
path = raw.strip().replace("\\", "/")
|
|
196
|
+
path = re.sub(r"/+", "/", path).strip("/")
|
|
197
|
+
if not path:
|
|
198
|
+
return ""
|
|
199
|
+
if folder is True or (folder is None and raw.strip().endswith("/")):
|
|
200
|
+
return f"{path}/"
|
|
201
|
+
return path
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _label(name: str) -> str:
|
|
205
|
+
value = name.strip().strip("/")
|
|
206
|
+
value = re.sub(r"(?i)(?:\.md)+$", "", value)
|
|
207
|
+
return re.sub(r"^\d+[-_、..]\s*", "", value).strip()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _is_ignored_label(name: str) -> bool:
|
|
211
|
+
normalized = _label(name).casefold()
|
|
212
|
+
return normalized == "todo" or normalized.endswith("- todo")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# `crwu-dws` writes its `目录树.md` as `/ <folder>` and `- <doc>` with indentation as
|
|
216
|
+
# depth. Header lines (`- fetched_at: …`) precede the first folder and are metadata, not nodes.
|
|
217
|
+
SLASH_TREE_LINE = re.compile(r"^(?P<indent>[ \t]*)(?P<marker>[/-])\s+(?P<name>\S.*?)\s*$")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _parse_markdown_tree(text: str) -> tuple[str, ...]:
|
|
221
|
+
"""A pasted directory tree, in any published form.
|
|
222
|
+
|
|
223
|
+
Order: icon tree (`- 📁 name`) → slash tree (`/ <folder>` / `- <doc>`) → box tree
|
|
224
|
+
(`├─ name/ [F]`, the form `crwu-dws/references/00` §4 actually prescribes for
|
|
225
|
+
`目录树.md`). The box form used to be unparseable, so the mandated artifact could not
|
|
226
|
+
be used as a `--catalog` input at all.
|
|
227
|
+
"""
|
|
228
|
+
return _parse_icon_tree(text) or _parse_slash_tree(text) or _parse_box_tree(text)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
BOX_TREE_LINE = re.compile(r"^(?P<prefix>(?:[│ ]{3})*)(?:(?P<branch>├─|└─) )?(?P<rest>.+?)\s*$")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _parse_box_tree(text: str) -> tuple[str, ...]:
|
|
235
|
+
"""`crwu-dws/references/00` §4 规定的 `目录树.md`:`├─`/`└─`,每级缩进 3 字符
|
|
236
|
+
(`│ ` 或 ` `);folder 行尾 `[F]`,文档行尾为 `extension`(`ext:未提供` 表缺席)。
|
|
237
|
+
|
|
238
|
+
2026-09-16 修:规范示例中**根层条目不写分支标记**(`<顶层folder或文档>/` 为裸名),
|
|
239
|
+
旧实现要求每行都有 `├─`/`└─`,于是整体丢弃根层行、并因层级栈错位把子行挂到错误前缀上
|
|
240
|
+
(实测 327 节点只解析出 315)。现允许无分支的根层行;输出仍与 JSON 形态一致地排序去重。
|
|
241
|
+
"""
|
|
242
|
+
paths: set[str] = set()
|
|
243
|
+
folders: list[str] = []
|
|
244
|
+
for line in text.splitlines():
|
|
245
|
+
stripped = line.strip()
|
|
246
|
+
if not stripped or stripped.startswith(("#", ">")):
|
|
247
|
+
continue # 标题与 `> key: value` 元数据行不是节点
|
|
248
|
+
match = BOX_TREE_LINE.match(line)
|
|
249
|
+
if not match:
|
|
250
|
+
continue
|
|
251
|
+
level = len(match.group("prefix")) // 3
|
|
252
|
+
rest = match.group("rest").strip()
|
|
253
|
+
if match.group("branch") is None:
|
|
254
|
+
# 只有规范式根层裸名才是节点;其余无分支行(表头等)不认
|
|
255
|
+
if level != 0 or not re.search(r"\s{2,}\S+$", rest):
|
|
256
|
+
continue
|
|
257
|
+
is_folder = rest.endswith("[F]")
|
|
258
|
+
if is_folder:
|
|
259
|
+
name = rest[: -len("[F]")].strip().rstrip("/").strip()
|
|
260
|
+
else:
|
|
261
|
+
# 文档名与行尾 extension 之间由多个空格分隔;名称本身可含单空格
|
|
262
|
+
name = re.split(r"\s{2,}", rest)[0].strip().rstrip("/")
|
|
263
|
+
if not name:
|
|
264
|
+
continue
|
|
265
|
+
folders = folders[:level]
|
|
266
|
+
normalized = _normalize_path("/".join([*folders, name]), folder=is_folder)
|
|
267
|
+
if normalized:
|
|
268
|
+
paths.add(normalized)
|
|
269
|
+
if is_folder:
|
|
270
|
+
folders.append(name)
|
|
271
|
+
return tuple(sorted(paths))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _parse_icon_tree(text: str) -> tuple[str, ...]:
|
|
275
|
+
paths: set[str] = set()
|
|
276
|
+
folders: list[str] = []
|
|
277
|
+
bullet = re.compile(
|
|
278
|
+
r"^(?P<indent>[ \t]*)[-*]\s+(?P<icon>📁|📄)\s+(?P<name>.+?)\s*$"
|
|
279
|
+
)
|
|
280
|
+
for line in text.splitlines():
|
|
281
|
+
match = bullet.match(line)
|
|
282
|
+
if not match:
|
|
283
|
+
continue
|
|
284
|
+
indent = match.group("indent").replace("\t", " ")
|
|
285
|
+
level = len(indent) // 2
|
|
286
|
+
name = match.group("name").strip()
|
|
287
|
+
is_folder = match.group("icon") == "📁" or name.endswith("/")
|
|
288
|
+
clean_name = name.rstrip("/").strip()
|
|
289
|
+
folders = folders[:level]
|
|
290
|
+
full = "/".join([*folders, clean_name])
|
|
291
|
+
normalized = _normalize_path(full, folder=is_folder)
|
|
292
|
+
if normalized:
|
|
293
|
+
paths.add(normalized)
|
|
294
|
+
if is_folder:
|
|
295
|
+
folders.append(clean_name)
|
|
296
|
+
return tuple(sorted(paths))
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _parse_slash_tree(text: str) -> tuple[str, ...]:
|
|
300
|
+
"""`crwu-dws` `目录树.md`: `/ <folder>` / `- <doc>`, indentation = folder depth."""
|
|
301
|
+
paths: set[str] = set()
|
|
302
|
+
folders: list[str] = []
|
|
303
|
+
started = False
|
|
304
|
+
for line in text.splitlines():
|
|
305
|
+
match = SLASH_TREE_LINE.match(line)
|
|
306
|
+
if not match:
|
|
307
|
+
continue
|
|
308
|
+
is_folder = match.group("marker") == "/"
|
|
309
|
+
if not started:
|
|
310
|
+
if not is_folder:
|
|
311
|
+
continue # metadata block before the first folder line
|
|
312
|
+
started = True
|
|
313
|
+
indent = match.group("indent").replace("\t", " ")
|
|
314
|
+
level = len(indent) // 2
|
|
315
|
+
clean_name = match.group("name").strip().rstrip("/").strip()
|
|
316
|
+
if not clean_name:
|
|
317
|
+
continue
|
|
318
|
+
folders = folders[:level]
|
|
319
|
+
normalized = _normalize_path("/".join([*folders, clean_name]), folder=is_folder)
|
|
320
|
+
if normalized:
|
|
321
|
+
paths.add(normalized)
|
|
322
|
+
if is_folder:
|
|
323
|
+
folders.append(clean_name)
|
|
324
|
+
return tuple(sorted(paths))
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _walk_snapshot(nodes: Iterable[object], parents: tuple[str, ...] = ()) -> Iterable[str]:
|
|
328
|
+
for raw_node in nodes:
|
|
329
|
+
if not isinstance(raw_node, dict):
|
|
330
|
+
continue
|
|
331
|
+
name = raw_node.get("name")
|
|
332
|
+
if not isinstance(name, str) or not name.strip():
|
|
333
|
+
continue
|
|
334
|
+
node_type = raw_node.get("type")
|
|
335
|
+
is_folder = node_type == "folder"
|
|
336
|
+
parts = (*parents, name.strip().strip("/"))
|
|
337
|
+
yield _normalize_path("/".join(parts), folder=is_folder)
|
|
338
|
+
children = raw_node.get("children")
|
|
339
|
+
if isinstance(children, list):
|
|
340
|
+
yield from _walk_snapshot(children, parts if is_folder else parents)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _parent_link(node: dict) -> str | None:
|
|
344
|
+
"""Authoritative snapshot field is `parentFolderId`; `parentId` is a legacy alias."""
|
|
345
|
+
for field in ("parentFolderId", "parentId"):
|
|
346
|
+
value = node.get(field)
|
|
347
|
+
if isinstance(value, str) and value:
|
|
348
|
+
return value
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _paths_from_flat_nodes(nodes: Iterable[object]) -> Iterable[str]:
|
|
353
|
+
"""Flat snapshot form: rebuild each path by walking the parent link up to a root."""
|
|
354
|
+
by_id: dict[str, dict] = {}
|
|
355
|
+
for raw_node in nodes:
|
|
356
|
+
if not isinstance(raw_node, dict):
|
|
357
|
+
continue
|
|
358
|
+
node_id = raw_node.get("nodeId")
|
|
359
|
+
if isinstance(node_id, str) and node_id:
|
|
360
|
+
by_id[node_id] = raw_node
|
|
361
|
+
for node in by_id.values():
|
|
362
|
+
parts: list[str] = []
|
|
363
|
+
seen: set[str] = set()
|
|
364
|
+
cursor: dict | None = node
|
|
365
|
+
while isinstance(cursor, dict):
|
|
366
|
+
cursor_id = cursor.get("nodeId")
|
|
367
|
+
if cursor_id in seen:
|
|
368
|
+
parts = [] # broken parent chain; refuse to guess a path
|
|
369
|
+
break
|
|
370
|
+
if isinstance(cursor_id, str):
|
|
371
|
+
seen.add(cursor_id)
|
|
372
|
+
name = cursor.get("name")
|
|
373
|
+
if isinstance(name, str) and name.strip():
|
|
374
|
+
parts.append(name.strip().strip("/"))
|
|
375
|
+
cursor = by_id.get(_parent_link(cursor) or "")
|
|
376
|
+
if not parts:
|
|
377
|
+
continue
|
|
378
|
+
yield _normalize_path("/".join(reversed(parts)), folder=node.get("type") == "folder")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _paths_from_snapshot_nodes(nodes: Iterable[object]) -> Iterable[str]:
|
|
382
|
+
"""crwu-dws directory snapshot: authoritative form nests nodes via `children`
|
|
383
|
+
(`crwu-dws/references/00-目录快照schema.md`); a flat list linked by `parentFolderId`
|
|
384
|
+
(legacy alias `parentId`) is tolerated so older snapshots still load."""
|
|
385
|
+
node_list = [n for n in nodes if isinstance(n, dict)]
|
|
386
|
+
if any(isinstance(n.get("children"), list) and n["children"] for n in node_list):
|
|
387
|
+
return _walk_snapshot(node_list)
|
|
388
|
+
return _paths_from_flat_nodes(node_list)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# Capture-time field names vary by producer. `crwu-dws` writes snake_case
|
|
392
|
+
# (`fetched_at`, `built_from_snapshot_at`); the older flat probe wrote camelCase
|
|
393
|
+
# (`fetchedAt`); the catalog snapshot form uses `generated_at`. All are accepted so a
|
|
394
|
+
# real crwu-dws artifact is never rejected as "carries no readable capture time".
|
|
395
|
+
CAPTURE_TIME_FIELDS = (
|
|
396
|
+
"fetched_at",
|
|
397
|
+
"fetchedAt",
|
|
398
|
+
"generated_at",
|
|
399
|
+
"generatedAt",
|
|
400
|
+
"built_from_snapshot_at",
|
|
401
|
+
"builtFromSnapshotAt",
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _capture_time(data: dict, *fields: str) -> str | None:
|
|
406
|
+
"""First non-empty capture timestamp among `fields` (default: all known aliases)."""
|
|
407
|
+
for field in fields or CAPTURE_TIME_FIELDS:
|
|
408
|
+
value = data.get(field)
|
|
409
|
+
if isinstance(value, str) and value.strip():
|
|
410
|
+
return value.strip()
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _paths_from_node_index(data: dict, nodes: object = None) -> Iterable[str]:
|
|
415
|
+
"""Node index in either naming convention.
|
|
416
|
+
|
|
417
|
+
`crwu-dws` writes camelCase maps (`byNodeId` / `byPath` / `byName`) keyed by nodeId;
|
|
418
|
+
`crwu-dws/references/02` documents the snake_case form (`by_node_id` / `by_path`).
|
|
419
|
+
`byPath` keys already are full library paths, so they win when present. Folder-ness comes
|
|
420
|
+
from the entry's `type` (the camelCase form carries no trailing `/` on folder keys).
|
|
421
|
+
"""
|
|
422
|
+
for key in ("byPath", "by_path"):
|
|
423
|
+
by_path = data.get(key)
|
|
424
|
+
if isinstance(by_path, dict) and by_path:
|
|
425
|
+
for raw_key, value in by_path.items():
|
|
426
|
+
is_folder: bool | None = None
|
|
427
|
+
if isinstance(value, dict) and isinstance(value.get("type"), str):
|
|
428
|
+
is_folder = value["type"] == "folder"
|
|
429
|
+
elif str(raw_key).endswith("/"):
|
|
430
|
+
is_folder = True
|
|
431
|
+
normalized = _normalize_path(str(raw_key), folder=is_folder)
|
|
432
|
+
if normalized:
|
|
433
|
+
yield normalized
|
|
434
|
+
return
|
|
435
|
+
for key in ("byNodeId", "by_node_id"):
|
|
436
|
+
by_node = data.get(key)
|
|
437
|
+
if isinstance(by_node, dict) and by_node:
|
|
438
|
+
yield from _paths_from_node_values(by_node)
|
|
439
|
+
return
|
|
440
|
+
if isinstance(nodes, dict) and nodes:
|
|
441
|
+
yield from _paths_from_node_values(nodes)
|
|
442
|
+
return
|
|
443
|
+
raise ValueError("node index carries no readable byPath/byNodeId/nodes map")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _paths_from_node_values(by_node: dict) -> Iterable[str]:
|
|
447
|
+
for value in by_node.values():
|
|
448
|
+
if not isinstance(value, dict):
|
|
449
|
+
continue
|
|
450
|
+
raw_path = value.get("path")
|
|
451
|
+
if not isinstance(raw_path, str) or not raw_path.strip():
|
|
452
|
+
continue
|
|
453
|
+
yield _normalize_path(raw_path, folder=value.get("type") == "folder")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _snapshot_is_complete(data: dict) -> bool | None:
|
|
457
|
+
"""A snapshot is only usable as path truth when every page came back.
|
|
458
|
+
|
|
459
|
+
A non-empty `failures` list always wins. Then, in order: a top-level `complete`
|
|
460
|
+
boolean (`crwu-dws` writes it there, alongside `truncated`), `stats.complete`
|
|
461
|
+
(`crwu-dws/references/00` §3), and finally a legacy top-level `evidence[]` receipt list.
|
|
462
|
+
"""
|
|
463
|
+
failures = data.get("failures")
|
|
464
|
+
if isinstance(failures, list) and failures:
|
|
465
|
+
return False
|
|
466
|
+
if data.get("truncated") is True:
|
|
467
|
+
return False
|
|
468
|
+
if isinstance(data.get("complete"), bool):
|
|
469
|
+
return data["complete"]
|
|
470
|
+
stats = data.get("stats")
|
|
471
|
+
if isinstance(stats, dict) and isinstance(stats.get("complete"), bool):
|
|
472
|
+
return stats["complete"]
|
|
473
|
+
evidence = data.get("evidence")
|
|
474
|
+
if not isinstance(evidence, list):
|
|
475
|
+
return True if isinstance(failures, list) else None
|
|
476
|
+
for receipt in evidence:
|
|
477
|
+
if not isinstance(receipt, dict):
|
|
478
|
+
return None
|
|
479
|
+
if receipt.get("hasMore") is True or receipt.get("autoPageComplete") is False:
|
|
480
|
+
return False
|
|
481
|
+
return True
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def load_catalog(path: Path) -> Catalog:
|
|
485
|
+
text = path.read_text(encoding="utf-8")
|
|
486
|
+
try:
|
|
487
|
+
data = json.loads(text)
|
|
488
|
+
except json.JSONDecodeError:
|
|
489
|
+
paths = _parse_markdown_tree(text)
|
|
490
|
+
if not paths:
|
|
491
|
+
raise ValueError("catalog is neither supported JSON nor a pasted Markdown directory tree")
|
|
492
|
+
captured = re.search(r"(?:抓取时间|fetched_at|fetchedAt)\s*[::]\s*([^\s]+)", text)
|
|
493
|
+
generated_at = captured.group(1) if captured else None
|
|
494
|
+
return Catalog("markdown-tree", generated_at, None, paths)
|
|
495
|
+
|
|
496
|
+
if not isinstance(data, dict):
|
|
497
|
+
raise ValueError("catalog JSON root must be an object")
|
|
498
|
+
schema = data.get("schema")
|
|
499
|
+
if schema == DIR_SNAPSHOT_SCHEMA:
|
|
500
|
+
nodes = data.get("nodes")
|
|
501
|
+
if not isinstance(nodes, list):
|
|
502
|
+
raise ValueError("directory snapshot nodes must be a list")
|
|
503
|
+
return Catalog(
|
|
504
|
+
DIR_SNAPSHOT_SCHEMA,
|
|
505
|
+
_capture_time(data),
|
|
506
|
+
_snapshot_is_complete(data),
|
|
507
|
+
tuple(sorted(set(_paths_from_snapshot_nodes(nodes)))),
|
|
508
|
+
)
|
|
509
|
+
if schema == KB_NODE_INDEX_SCHEMA:
|
|
510
|
+
return Catalog(
|
|
511
|
+
KB_NODE_INDEX_SCHEMA,
|
|
512
|
+
_capture_time(data),
|
|
513
|
+
_snapshot_is_complete(data),
|
|
514
|
+
tuple(sorted(set(_paths_from_node_index(data, data.get("nodes"))))),
|
|
515
|
+
)
|
|
516
|
+
if schema == SNAPSHOT_SCHEMA:
|
|
517
|
+
nodes = data.get("nodes")
|
|
518
|
+
if not isinstance(nodes, list):
|
|
519
|
+
raise ValueError("snapshot nodes must be a list")
|
|
520
|
+
stats = data.get("stats") if isinstance(data.get("stats"), dict) else {}
|
|
521
|
+
return Catalog(
|
|
522
|
+
SNAPSHOT_SCHEMA,
|
|
523
|
+
_capture_time(data, "generated_at", "generatedAt"),
|
|
524
|
+
stats.get("complete") if isinstance(stats.get("complete"), bool) else None,
|
|
525
|
+
tuple(sorted(set(_walk_snapshot(nodes)))),
|
|
526
|
+
)
|
|
527
|
+
if schema == NODE_INDEX_SCHEMA:
|
|
528
|
+
return Catalog(
|
|
529
|
+
NODE_INDEX_SCHEMA,
|
|
530
|
+
_capture_time(data, "built_from_snapshot_at", "builtFromSnapshotAt", "fetched_at", "fetchedAt"),
|
|
531
|
+
_snapshot_is_complete(data),
|
|
532
|
+
tuple(sorted(set(_paths_from_node_index(data)))),
|
|
533
|
+
)
|
|
534
|
+
raise ValueError(
|
|
535
|
+
f"unsupported catalog schema: {schema!r}; accepted: "
|
|
536
|
+
+ ", ".join(repr(s) for s in ACCEPTED_CATALOG_SCHEMAS)
|
|
537
|
+
+ f". If this file is a crwu-dws directory cache, rebuild it with crwu-dws M1: "
|
|
538
|
+
f"the snapshot schema literal must be exactly {SNAPSHOT_SCHEMA!r}, carry "
|
|
539
|
+
f"generated_at/profile/mode and stats.complete, and use **nested `children`** nodes "
|
|
540
|
+
f"(a flat node list carrying its own `path` is the node-index shape, not the snapshot)."
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _table_cells(line: str) -> tuple[str, ...] | None:
|
|
545
|
+
if "|" not in line:
|
|
546
|
+
return None
|
|
547
|
+
cells = []
|
|
548
|
+
for cell in line.strip().strip("|").split("|"):
|
|
549
|
+
value = cell.strip()
|
|
550
|
+
inline = re.fullmatch(r"`([^`]*)`", value)
|
|
551
|
+
cells.append((inline.group(1) if inline else value).strip())
|
|
552
|
+
return tuple(cells)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _is_separator(cells: tuple[str, ...]) -> bool:
|
|
556
|
+
return bool(cells) and all(re.fullmatch(r":?-{3,}:?", cell.replace(" ", "")) for cell in cells)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _read_registry(path: Path) -> list[RegistryRow]:
|
|
560
|
+
if not path.is_file():
|
|
561
|
+
return []
|
|
562
|
+
rows: list[RegistryRow] = []
|
|
563
|
+
in_table = False
|
|
564
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
565
|
+
cells = _table_cells(line)
|
|
566
|
+
if cells is None:
|
|
567
|
+
if in_table and rows:
|
|
568
|
+
break
|
|
569
|
+
continue
|
|
570
|
+
lowered = tuple(cell.casefold() for cell in cells)
|
|
571
|
+
if lowered == ("axis", "label", "skill", "status", "load behavior"):
|
|
572
|
+
in_table = True
|
|
573
|
+
continue
|
|
574
|
+
if not in_table or len(cells) != 5 or _is_separator(cells):
|
|
575
|
+
continue
|
|
576
|
+
rows.append(RegistryRow(*cells))
|
|
577
|
+
return rows
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _immediate_folder_children(paths: set[str], parent: str) -> list[str]:
|
|
581
|
+
prefix = _normalize_path(parent, folder=True)
|
|
582
|
+
children = []
|
|
583
|
+
for path in paths:
|
|
584
|
+
if not path.endswith("/") or not path.startswith(prefix) or path == prefix:
|
|
585
|
+
continue
|
|
586
|
+
remainder = path[len(prefix) :].rstrip("/")
|
|
587
|
+
if remainder and "/" not in remainder:
|
|
588
|
+
children.append(remainder)
|
|
589
|
+
return sorted(children)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _direct_docs(paths: set[str], parent: str) -> list[str]:
|
|
593
|
+
prefix = _normalize_path(parent, folder=True)
|
|
594
|
+
docs = []
|
|
595
|
+
for path in paths:
|
|
596
|
+
if path.endswith("/") or not path.startswith(prefix):
|
|
597
|
+
continue
|
|
598
|
+
remainder = path[len(prefix) :]
|
|
599
|
+
if remainder and "/" not in remainder:
|
|
600
|
+
docs.append(remainder)
|
|
601
|
+
return sorted(docs)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _find_named_folder(paths: set[str], parent: str, label: str) -> str | None:
|
|
605
|
+
for child in _immediate_folder_children(paths, parent):
|
|
606
|
+
if _label(child) == label:
|
|
607
|
+
return f"{_normalize_path(parent, folder=True)}{child}/"
|
|
608
|
+
return None
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _named_doc(paths: set[str], parent: str, label: str) -> str | None:
|
|
612
|
+
"""Exact library node name of a direct child document carrying `label`, or None."""
|
|
613
|
+
for name in _direct_docs(paths, parent):
|
|
614
|
+
if _label(name) == label:
|
|
615
|
+
return name
|
|
616
|
+
return None
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _has_named_doc(paths: set[str], parent: str, label: str) -> bool:
|
|
620
|
+
return _named_doc(paths, parent, label) is not None
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _references_common_review(text: str, kb_root: str, doc_name: str) -> bool:
|
|
624
|
+
"""Does the skill positively address the shared review layer by its library path key?
|
|
625
|
+
|
|
626
|
+
Naming the document is not enough: a leaf that says "一级根**未提供** `共同审核点`" mentions it
|
|
627
|
+
too, and a plain `in` test would let that stale claim pass (the exact field bug this fixes).
|
|
628
|
+
The reference must therefore carry the first-level root (`<kb_root><节点名>`). Both the catalog
|
|
629
|
+
node name and the bare label are accepted, because older libraries prefix/suffix node names
|
|
630
|
+
(`01-共同审核点.md`) while the current one ships `共同审核点` verbatim.
|
|
631
|
+
"""
|
|
632
|
+
prefix = _normalize_path(kb_root, folder=True)
|
|
633
|
+
candidates = {BUSINESS_COMMON_REVIEW_DOC, doc_name, _label(doc_name)}
|
|
634
|
+
return any(f"{prefix}{candidate}" in text for candidate in candidates if candidate)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _frontmatter_name(path: Path) -> str | None:
|
|
638
|
+
if not path.is_file():
|
|
639
|
+
return None
|
|
640
|
+
text = path.read_text(encoding="utf-8")
|
|
641
|
+
match = re.search(r"(?m)^name:\s*([^\s#]+)\s*$", text)
|
|
642
|
+
return match.group(1).strip() if match else None
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _extract_kb_paths(text: str) -> list[str]:
|
|
646
|
+
matches = re.findall(r"(?:01-业务路线|02-资产类型)/[^`\n|<]+", text)
|
|
647
|
+
return sorted({_normalize_path(match.strip()) for match in matches if match.strip()})
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _first_level_root(path: str) -> str | None:
|
|
651
|
+
normalized = _normalize_path(path, folder=path.endswith("/"))
|
|
652
|
+
parts = normalized.rstrip("/").split("/")
|
|
653
|
+
if len(parts) < 2 or parts[0] not in {"01-业务路线", "02-资产类型"}:
|
|
654
|
+
return None
|
|
655
|
+
return f"{parts[0]}/{parts[1]}/"
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _declared_first_level_roots(text: str) -> set[str]:
|
|
659
|
+
"""Every first-level knowledge root a skill's assembly references, directly or deeper."""
|
|
660
|
+
roots = (_first_level_root(path) for path in _extract_kb_paths(text))
|
|
661
|
+
return {root for root in roots if root}
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
# ---- 库内路径键存在性(公共轴也查)----
|
|
665
|
+
# Backticked spans are the skill convention for addressing keys; plain prose is not scanned.
|
|
666
|
+
_BACKTICKED_RE = re.compile(r"`([^`\n]+)`")
|
|
667
|
+
# Placeholders / globs / ellipses are examples, not addressing keys.
|
|
668
|
+
_PATH_PLACEHOLDER_RE = re.compile(r"[…*<>]|某|示例|待建|不存在|省略")
|
|
669
|
+
# 审核族目录前缀(含开发/维护侧 `crwu-dev-audit-*`):装配路径键、frontmatter 名与
|
|
670
|
+
# "真实存在的技能目录"解析都按这两组前缀收集——只认 `crwu-audit` 会让改到 dev 前缀的
|
|
671
|
+
# 技能静默掉出扫描范围(2026-09-16 改名时同步修正)。
|
|
672
|
+
AUDIT_FAMILY_PREFIX = ("crwu-audit", "crwu-dev-audit")
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _catalog_top_levels(paths: set[str]) -> dict[str, str]:
|
|
676
|
+
"""Top-level library containers actually captured by this catalog."""
|
|
677
|
+
top: dict[str, str] = {}
|
|
678
|
+
for path in paths:
|
|
679
|
+
if path.endswith("/"):
|
|
680
|
+
name = path.rstrip("/")
|
|
681
|
+
if "/" not in name:
|
|
682
|
+
top[name] = "folder"
|
|
683
|
+
elif "/" not in path:
|
|
684
|
+
top[path] = "file"
|
|
685
|
+
return top
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _addressing_keys(text: str, top_levels: dict[str, str]) -> list[str]:
|
|
689
|
+
"""Backticked library paths that address a captured top-level container."""
|
|
690
|
+
keys: set[str] = set()
|
|
691
|
+
for raw in _BACKTICKED_RE.findall(text):
|
|
692
|
+
token = raw.strip()
|
|
693
|
+
if not token or _PATH_PLACEHOLDER_RE.search(token):
|
|
694
|
+
continue
|
|
695
|
+
head = token.split("/")[0]
|
|
696
|
+
if head not in top_levels:
|
|
697
|
+
continue
|
|
698
|
+
if "/" not in token and top_levels[head] == "folder":
|
|
699
|
+
token = token + "/" # bare container name = the folder itself
|
|
700
|
+
keys.add(_normalize_path(token, folder=token.endswith("/")))
|
|
701
|
+
return sorted(keys)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
# ---- 技能树位置(按层组织,一层一个根)----
|
|
706
|
+
# 本仓技能按**层**组织,DSH 运行时把每个层都注册成一个技能根:
|
|
707
|
+
# - 插件层 `plugins/<插件>/skills/<层>/`(审核能力族在 `.../skills/crwu/`;`.../skills/dws/`
|
|
708
|
+
# 是随包 vendored 的上游钉钉技能,不参与审核能力盘点,所以**不列为候选根**);
|
|
709
|
+
# - 公共层 `plugins/common/skills/`。
|
|
710
|
+
# 旧布局(技能直接放在仓根 `skills/` 或其下一级)继续支持:源仓契约测试会在临时仓库里
|
|
711
|
+
# 造 `skills/<技能>/` 作为夹具,所以按候选顺序取存在的那些。
|
|
712
|
+
_SKILLS_ROOT_CANDIDATES = (
|
|
713
|
+
"skills",
|
|
714
|
+
"plugins/dsh-crwu-workbench/skills/crwu",
|
|
715
|
+
"plugins/common/skills",
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def skills_roots(repo_root: Path) -> list[Path]:
|
|
720
|
+
"""存在的技能根,按候选顺序;一个都没有时回落到 `skills/`(由调用方报缺失)。"""
|
|
721
|
+
found = [repo_root / rel for rel in _SKILLS_ROOT_CANDIDATES if (repo_root / rel).is_dir()]
|
|
722
|
+
return found or [repo_root / "skills"]
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def resolve_skill_dir(repo_root: Path, name: str) -> Path:
|
|
726
|
+
"""按技能名在任一技能根里定位技能目录;都没有时回落到第一个根下的路径(供报错展示)。"""
|
|
727
|
+
for root in skills_roots(repo_root):
|
|
728
|
+
if (root / name).is_dir():
|
|
729
|
+
return root / name
|
|
730
|
+
return skills_roots(repo_root)[0] / name
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def inspect_path_keys(
|
|
734
|
+
repo_root: Path,
|
|
735
|
+
paths: set[str],
|
|
736
|
+
add_finding,
|
|
737
|
+
) -> dict[str, object]:
|
|
738
|
+
"""Every library path key written in the audit family must exist in the latest catalog.
|
|
739
|
+
|
|
740
|
+
Guards against the two drift classes seen in practice: an address key that still carries
|
|
741
|
+
the export suffix (`.md`), and keys copied from a different/older library.
|
|
742
|
+
"""
|
|
743
|
+
top_levels = _catalog_top_levels(paths)
|
|
744
|
+
if not top_levels:
|
|
745
|
+
return {"checked": 0, "issues": []}
|
|
746
|
+
roots = [root for root in skills_roots(repo_root) if root.is_dir()]
|
|
747
|
+
if not roots:
|
|
748
|
+
return {"checked": 0, "issues": []}
|
|
749
|
+
|
|
750
|
+
checked = 0
|
|
751
|
+
issues: list[dict[str, object]] = []
|
|
752
|
+
for skill_dir in sorted(entry for root in roots for entry in root.iterdir()):
|
|
753
|
+
if not skill_dir.is_dir() or not skill_dir.name.startswith(AUDIT_FAMILY_PREFIX):
|
|
754
|
+
continue
|
|
755
|
+
for document in sorted(skill_dir.rglob("*.md")):
|
|
756
|
+
text = document.read_text(encoding="utf-8")
|
|
757
|
+
source = str(document.relative_to(repo_root))
|
|
758
|
+
for key in _addressing_keys(text, top_levels):
|
|
759
|
+
# Only judge containers this catalog actually captured.
|
|
760
|
+
if key.rstrip("/").split("/")[0] not in top_levels:
|
|
761
|
+
continue
|
|
762
|
+
checked += 1
|
|
763
|
+
if key in paths:
|
|
764
|
+
continue
|
|
765
|
+
stripped = key[:-3] if key.endswith(".md") else None
|
|
766
|
+
if stripped and stripped in paths:
|
|
767
|
+
add_finding(
|
|
768
|
+
"KB_PATH_KEY_HAS_EXPORT_SUFFIX",
|
|
769
|
+
"error",
|
|
770
|
+
(
|
|
771
|
+
"library path key carries the .md export suffix; addressing keys must "
|
|
772
|
+
"match the library node name verbatim (.md is only the local export name)"
|
|
773
|
+
),
|
|
774
|
+
path=key,
|
|
775
|
+
skill=skill_dir.name,
|
|
776
|
+
source=source,
|
|
777
|
+
)
|
|
778
|
+
elif not key.endswith("/") and f"{key}/" in paths:
|
|
779
|
+
add_finding(
|
|
780
|
+
"KB_PATH_KEY_FOLDER_NEEDS_SLASH",
|
|
781
|
+
"error",
|
|
782
|
+
"library path key addresses a folder but does not end with '/'",
|
|
783
|
+
path=key,
|
|
784
|
+
skill=skill_dir.name,
|
|
785
|
+
source=source,
|
|
786
|
+
)
|
|
787
|
+
elif key.endswith("/") and key.rstrip("/") in paths:
|
|
788
|
+
add_finding(
|
|
789
|
+
"KB_PATH_KEY_HAS_EXPORT_SUFFIX",
|
|
790
|
+
"error",
|
|
791
|
+
"library path key addresses a file but is written as a directory",
|
|
792
|
+
path=key,
|
|
793
|
+
skill=skill_dir.name,
|
|
794
|
+
source=source,
|
|
795
|
+
)
|
|
796
|
+
else:
|
|
797
|
+
add_finding(
|
|
798
|
+
"KB_PATH_KEY_NOT_IN_CATALOG",
|
|
799
|
+
"error",
|
|
800
|
+
"library path key does not exist in the latest catalog",
|
|
801
|
+
path=key,
|
|
802
|
+
skill=skill_dir.name,
|
|
803
|
+
source=source,
|
|
804
|
+
)
|
|
805
|
+
issues.append({"skill": skill_dir.name, "key": key, "source": source})
|
|
806
|
+
return {"checked": checked, "issues": issues}
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _suggested_skill(axis: str, label: str) -> str | None:
|
|
810
|
+
suffix = KNOWN_SKILL_SUFFIXES.get((axis, label))
|
|
811
|
+
if not suffix:
|
|
812
|
+
return None
|
|
813
|
+
prefix = "crwu-audit-asset" if axis == "asset" else "crwu-audit-biz"
|
|
814
|
+
return f"{prefix}-{suffix}"
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
# ---- 路径覆盖语义(按分段,不做裸字符串前缀)----
|
|
818
|
+
# 2026-09-26(OPT-004-R1 复核)实测三个反例,全部来自 `key.startswith(prefix)` 这种普通字符串前缀比较:
|
|
819
|
+
# · 只装父目录 `06-规则库/M-数据对齐-勾稽与一致性/` → 两个禁用模块被递归下进本次审核,却**漏报**;
|
|
820
|
+
# · 装相似名兄弟文件 `…/05-模块-基准要素一致性-扩展` → **误报** 该模块被装配;
|
|
821
|
+
# · 只装子文件 `…/M-计算表审核/00-模块边界与证据规则` → 子文件被当成覆盖了**整个**受监视目录。
|
|
822
|
+
# 正确语义:目录键(以 `/` 结尾)递归包含其下全部节点;文件键只精确匹配同名文件。
|
|
823
|
+
# 目录键天然分段安全(`06-规则库/清单-M-成本法/` 不会命中 `清单-M-成本法-旧/`),因为斜杠就在末尾。
|
|
824
|
+
def _path_covers(key: str, target: str) -> bool:
|
|
825
|
+
"""装配键 `key` 是否把 `target` 递归纳入本次下载清单。"""
|
|
826
|
+
if key == target:
|
|
827
|
+
return True
|
|
828
|
+
if not key.endswith("/"):
|
|
829
|
+
return False # 文件键不递归:`…-扩展` 与 `…` 是两个不同节点
|
|
830
|
+
return target.startswith(key)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _path_present(paths: set[str], target: str) -> bool:
|
|
834
|
+
"""`target` 是否存在于最新目录:`target` 本身,或是它(作为目录)内部的任一节点。"""
|
|
835
|
+
return any(path == target or _path_covers(target, path) for path in paths)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _path_overlaps(key: str, target: str) -> bool:
|
|
839
|
+
"""键是否把目标模块的**任意部分**带进本次下载清单(非运行时 / 仅兼容模块隔离专用)。
|
|
840
|
+
|
|
841
|
+
与 `_path_covers` 的区别正是 2026-09-26(R2)复核指出的第二条:**目标是目录**时,其**内部文件**键
|
|
842
|
+
也算部分泄漏 —— `06-规则库/M-收益法/04-模块-收益法` 进入运行时,等于该原型模块泄漏了一半,
|
|
843
|
+
旧实现(只认"键==目录或键在目录之下以 / 结尾")对此**完全静默**。
|
|
844
|
+
目标为**文件**时仍只认"它自己"或"祖先目录",否则相似名兄弟(`…-扩展`)会假阳性。
|
|
845
|
+
"""
|
|
846
|
+
if key == target:
|
|
847
|
+
return True
|
|
848
|
+
if key.endswith("/") and target.startswith(key):
|
|
849
|
+
return True # 键是目标的祖先目录:递归包含
|
|
850
|
+
if target.endswith("/") and key.startswith(target):
|
|
851
|
+
return True # 目标是目录而键在其内部:部分泄漏
|
|
852
|
+
return False
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _path_within(key: str, folder: str) -> bool:
|
|
856
|
+
"""`key` 是否就是 `folder`,或位于 `folder` 之内。
|
|
857
|
+
|
|
858
|
+
与 `_path_covers` **方向相反**,用于"某个子目录是否被**显式**登记"这类判定:
|
|
859
|
+
父目录(一级根)永远包含全部二级条目,用 `_path_covers` 判定会让二级维度恒真 ——
|
|
860
|
+
每个可用叶子都必然声明了一级根,于是"库里有、叶子没索引"重新变成不可见。
|
|
861
|
+
"""
|
|
862
|
+
return key == folder or (folder.endswith("/") and key.startswith(folder))
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def inspect_method_layer_assembly(
|
|
867
|
+
repo_root: Path,
|
|
868
|
+
paths: set[str],
|
|
869
|
+
add_finding,
|
|
870
|
+
) -> dict[str, object]:
|
|
871
|
+
"""方法层 / 清单类内容必须有人装配(`ASSEMBLY_GAP_METHOD_LAYER`)。
|
|
872
|
+
|
|
873
|
+
背景(2026-09-15 实测):方法轴技能 `crwu-audit-method-*` 为 pending 期间,方法层与清单类内容
|
|
874
|
+
由命中叶子的装配键承载。此前无此检查,`03-评估方法/**`、`06-规则库/清单-M-*`、`06-规则库/易错点库`
|
|
875
|
+
可长期无人装配而不报错,导致整套市场法审核清单(CHK-MKT-001~014)在真实审核中缺装。
|
|
876
|
+
|
|
877
|
+
判定只看**装配键集合**(不看正文):被监视目录若存在于最新目录,但**三类合法装配来源**都没有覆盖它,
|
|
878
|
+
即记装配缺口 —— 命中叶子的 `01-kb-assembly.md`、横切技能的 `00-KB装配表.md`、
|
|
879
|
+
以及 router 的 `08-union-dispatch-rules.md`(方法层/覆盖层/财务报告条件映射,2026-09-16 起纳入)。
|
|
880
|
+
「方法轴技能 pending」不构成免检理由。
|
|
881
|
+
另检查通道可导性:覆盖了方法层目录时,下载规范必须声明原生文本通道(`drive +download` 或 `markdown fetch`)。
|
|
882
|
+
"""
|
|
883
|
+
watched_prefixes = (
|
|
884
|
+
"03-评估方法/00-评估方法准则2019-精编/",
|
|
885
|
+
"03-评估方法/01-市场法/",
|
|
886
|
+
"03-评估方法/02-收益法/",
|
|
887
|
+
"03-评估方法/03-资产基础法/",
|
|
888
|
+
"03-评估方法/04-方法选择/",
|
|
889
|
+
"03-评估方法/05-审核要点/",
|
|
890
|
+
"06-规则库/清单-M-市场法/",
|
|
891
|
+
"06-规则库/清单-M-成本法/",
|
|
892
|
+
# 2026-09-16:清单-M-* 不止市场法/成本法两份;漏列会让新清单无人装配而不报错
|
|
893
|
+
# (与当年 CHK-MKT-001~014 缺装同一失效模式)。
|
|
894
|
+
"06-规则库/清单-M-收益法/",
|
|
895
|
+
"06-规则库/清单-M-资产基础法/",
|
|
896
|
+
"06-规则库/易错点库/",
|
|
897
|
+
# 2026-09-26(F-006):`06-规则库/M-计算表审核/` 11 份正文此前**零引用**——
|
|
898
|
+
# 库内有正式计算表口径、datacheck 却装的是旧兼容入口,无人装配也不报错。
|
|
899
|
+
"06-规则库/M-计算表审核/",
|
|
900
|
+
# 2026-09-16:这 5 个目录此前完全不在监视范围。四类监管 overlay 在技能 pending
|
|
901
|
+
# 期间由 router 按 overlays[] 装配;财务报告目录按 business_types[]=财务报告装配
|
|
902
|
+
# (见 crwu-audit references/08)。无装载方即为真实缺口,必须在门禁暴露。
|
|
903
|
+
"04-监管覆盖/国资/",
|
|
904
|
+
"04-监管覆盖/证券/",
|
|
905
|
+
"04-监管覆盖/金融国资/",
|
|
906
|
+
"04-监管覆盖/司法/",
|
|
907
|
+
"04-监管覆盖/财务报告/",
|
|
908
|
+
)
|
|
909
|
+
roots = [root for root in skills_roots(repo_root) if root.is_dir()]
|
|
910
|
+
top_levels = _catalog_top_levels(paths)
|
|
911
|
+
if not roots or not top_levels:
|
|
912
|
+
return {"watched": 0, "covered": 0, "gaps": [], "channelDeclared": False}
|
|
913
|
+
|
|
914
|
+
assembly_keys: dict[str, str] = {}
|
|
915
|
+
for skill_dir in sorted(entry for root in roots for entry in root.iterdir()):
|
|
916
|
+
if not skill_dir.is_dir() or not skill_dir.name.startswith(AUDIT_FAMILY_PREFIX):
|
|
917
|
+
continue
|
|
918
|
+
for name in ("01-kb-assembly.md", "00-KB装配表.md"):
|
|
919
|
+
for document in sorted(skill_dir.rglob(name)):
|
|
920
|
+
text = document.read_text(encoding="utf-8")
|
|
921
|
+
for key in _addressing_keys(text, top_levels):
|
|
922
|
+
assembly_keys.setdefault(key, str(document.relative_to(repo_root)))
|
|
923
|
+
|
|
924
|
+
# 2026-09-16:router 的分发规则文件也是**合法装配来源**——方法轴与覆盖层技能 pending 期间,
|
|
925
|
+
# 其知识库目录由 router 按本次 methods[]/overlays[] 命中项追加(见该文件 §「方法层与覆盖层的
|
|
926
|
+
# 库内装配映射」)。只扫叶子装配表会把这类真实覆盖误报成缺口。
|
|
927
|
+
router_dispatch = resolve_skill_dir(repo_root, ROUTER_SKILL) / "references" / "08-union-dispatch-rules.md"
|
|
928
|
+
if router_dispatch.is_file():
|
|
929
|
+
text = router_dispatch.read_text(encoding="utf-8")
|
|
930
|
+
for key in _addressing_keys(text, top_levels):
|
|
931
|
+
assembly_keys.setdefault(key, str(router_dispatch.relative_to(repo_root)))
|
|
932
|
+
|
|
933
|
+
covered: list[dict[str, object]] = []
|
|
934
|
+
gaps: list[dict[str, object]] = []
|
|
935
|
+
non_runtime: list[dict[str, object]] = []
|
|
936
|
+
watched = 0
|
|
937
|
+
for prefix in watched_prefixes:
|
|
938
|
+
if not _path_present(paths, prefix):
|
|
939
|
+
continue # 目录不在最新目录 → 不是装配问题(由目录漂移类检查负责)
|
|
940
|
+
watched += 1
|
|
941
|
+
holders = sorted({source for key, source in assembly_keys.items()
|
|
942
|
+
if _path_covers(key, prefix)})
|
|
943
|
+
if holders:
|
|
944
|
+
covered.append({"prefix": prefix, "coveredBy": holders})
|
|
945
|
+
else:
|
|
946
|
+
gaps.append({"prefix": prefix})
|
|
947
|
+
add_finding(
|
|
948
|
+
"ASSEMBLY_GAP_METHOD_LAYER",
|
|
949
|
+
"error",
|
|
950
|
+
(
|
|
951
|
+
"method-layer/checklist content exists in the library but no legal assembly source "
|
|
952
|
+
"(leaf assembly table `01-kb-assembly.md`, cross-cutting `00-KB装配表.md`, or the router "
|
|
953
|
+
"dispatch mapping `08-union-dispatch-rules.md`) covers it; method-axis skills being "
|
|
954
|
+
"pending does NOT mean the content may stay unassembled"
|
|
955
|
+
),
|
|
956
|
+
path=prefix,
|
|
957
|
+
)
|
|
958
|
+
# 覆盖 ≠ 递归覆盖:权威根必须有**它自己**的目录键。过粗祖先(如 `06-规则库/`)虽然把内容
|
|
959
|
+
# 带进了清单(因此不报装配缺口),却会把整个规则库连禁用模块一起拖进来,不能替代本根的
|
|
960
|
+
# 显式递归装配(2026-09-26 · R2 复核)。
|
|
961
|
+
if prefix in RECURSIVE_ROOT_REQUIRED and prefix not in assembly_keys:
|
|
962
|
+
add_finding(
|
|
963
|
+
"AUTHORITATIVE_ROOT_NOT_RECURSIVE",
|
|
964
|
+
"error",
|
|
965
|
+
str(RECURSIVE_ROOT_REQUIRED[prefix]),
|
|
966
|
+
path=prefix,
|
|
967
|
+
source=", ".join(holders) or None,
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
for module in NON_RUNTIME_KB_MODULES:
|
|
971
|
+
prefix = str(module["prefix"])
|
|
972
|
+
if not _path_present(paths, prefix):
|
|
973
|
+
continue
|
|
974
|
+
holders = sorted(
|
|
975
|
+
{
|
|
976
|
+
source
|
|
977
|
+
for key, source in assembly_keys.items()
|
|
978
|
+
if _path_overlaps(key, prefix)
|
|
979
|
+
}
|
|
980
|
+
)
|
|
981
|
+
tracked = dict(module)
|
|
982
|
+
tracked["runtimeAssembled"] = bool(holders)
|
|
983
|
+
tracked["holders"] = holders
|
|
984
|
+
non_runtime.append(tracked)
|
|
985
|
+
if holders:
|
|
986
|
+
policy = str(module.get("runtimePolicy", "prototype"))
|
|
987
|
+
add_finding(
|
|
988
|
+
"COMPAT_MODULE_ASSEMBLED" if policy == "compat-only" else "NON_RUNTIME_MODULE_ASSEMBLED",
|
|
989
|
+
"error",
|
|
990
|
+
(
|
|
991
|
+
"compatibility-only knowledge module is present in runtime assembly; the formal "
|
|
992
|
+
"authority lives elsewhere and this shim must stay out of the download manifest"
|
|
993
|
+
if policy == "compat-only"
|
|
994
|
+
else (
|
|
995
|
+
"prototype/non-runtime knowledge module is present in runtime assembly; "
|
|
996
|
+
"remove the assembly path until its enablement conditions are verified"
|
|
997
|
+
)
|
|
998
|
+
),
|
|
999
|
+
path=prefix,
|
|
1000
|
+
source=", ".join(holders),
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
channel_declared = False
|
|
1004
|
+
dws_root = resolve_skill_dir(repo_root, "crwu-dws")
|
|
1005
|
+
if dws_root.is_dir():
|
|
1006
|
+
for document in sorted(dws_root.rglob("*.md")):
|
|
1007
|
+
lowered = document.read_text(encoding="utf-8").lower()
|
|
1008
|
+
if ("drive" in lowered and "+download" in lowered) or ("markdown" in lowered and "fetch" in lowered):
|
|
1009
|
+
channel_declared = True
|
|
1010
|
+
break
|
|
1011
|
+
if covered and not channel_declared:
|
|
1012
|
+
add_finding(
|
|
1013
|
+
"EXPORT_CHANNEL_UNDECLARED",
|
|
1014
|
+
"error",
|
|
1015
|
+
"native (non-adoc) nodes need an explicit download channel; the downloading spec declares none",
|
|
1016
|
+
path=str(dws_root.relative_to(repo_root)),
|
|
1017
|
+
)
|
|
1018
|
+
return {
|
|
1019
|
+
"watched": watched,
|
|
1020
|
+
"covered": len(covered),
|
|
1021
|
+
"gaps": gaps,
|
|
1022
|
+
"nonRuntime": non_runtime,
|
|
1023
|
+
"channelDeclared": channel_declared,
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _catalog_age_hours(catalog: "Catalog") -> float | None:
|
|
1028
|
+
"""Age of the catalog snapshot, or None when it carries no readable timestamp."""
|
|
1029
|
+
if not catalog.generated_at:
|
|
1030
|
+
return None
|
|
1031
|
+
raw = catalog.generated_at.strip()
|
|
1032
|
+
try:
|
|
1033
|
+
stamp = datetime.datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
1034
|
+
except ValueError:
|
|
1035
|
+
return None
|
|
1036
|
+
if stamp.tzinfo is None:
|
|
1037
|
+
stamp = stamp.replace(tzinfo=datetime.timezone.utc)
|
|
1038
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
1039
|
+
return (now - stamp).total_seconds() / 3600.0
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def _available_row_for(
|
|
1043
|
+
rows_by_skill: dict[str, list["RegistryRow"]], axis: str, label: str
|
|
1044
|
+
) -> "RegistryRow | None":
|
|
1045
|
+
for rows in rows_by_skill.values():
|
|
1046
|
+
for row in rows:
|
|
1047
|
+
if row.axis == axis and row.label == label and row.status == "available":
|
|
1048
|
+
return row
|
|
1049
|
+
return None
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _second_level_children(
|
|
1053
|
+
paths: set[str], axis: str, first_level_root: str
|
|
1054
|
+
) -> list[tuple[str, str]]:
|
|
1055
|
+
"""`[(二级目录节点名, 该目录的完整库内路径)]`,按 `SECOND_LEVEL_SPECS` 的容器规则取。"""
|
|
1056
|
+
spec = SECOND_LEVEL_SPECS[axis]
|
|
1057
|
+
if spec["container"]:
|
|
1058
|
+
container_root = _find_named_folder(paths, first_level_root, spec["container"])
|
|
1059
|
+
if container_root is None:
|
|
1060
|
+
return []
|
|
1061
|
+
parent = container_root
|
|
1062
|
+
else:
|
|
1063
|
+
parent = first_level_root
|
|
1064
|
+
return [
|
|
1065
|
+
(child, f"{parent}{child}/")
|
|
1066
|
+
for child in _immediate_folder_children(paths, parent)
|
|
1067
|
+
if not _is_ignored_label(child)
|
|
1068
|
+
]
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
_BACKTICKED_SPAN_RE = re.compile(r"`[^`\n]*`")
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _mentions_on_index_line(text: str, names: tuple[str, ...]) -> bool:
|
|
1075
|
+
"""`names` 是否作为**展示名**出现在索引行(表格行 / 列表项)里。
|
|
1076
|
+
|
|
1077
|
+
两个刻意收紧的地方,都是为了不让"分类"维度和"装配"维度变成同一个判断:
|
|
1078
|
+
|
|
1079
|
+
- 只认索引行:散文里提到一次某个词,不足以证明它被登记进了取值域;
|
|
1080
|
+
- **反引号内容先剥掉再匹配**:库内路径写作 `` `02-细分对象/01-通用设备/` ``,去掉数字前缀的
|
|
1081
|
+
标签 `通用设备` 是它的子串 —— 不剥的话,任何列了路径的装配行都会顺带满足分类维度。
|
|
1082
|
+
分类表把标签写在单元格里(反引号外),路径才进反引号,所以剥掉反引号正好区分两者。
|
|
1083
|
+
"""
|
|
1084
|
+
for line in text.splitlines():
|
|
1085
|
+
stripped = line.strip()
|
|
1086
|
+
if not stripped:
|
|
1087
|
+
continue
|
|
1088
|
+
if not (stripped.startswith(("|", "-", "*", "+")) or re.match(r"^\d+[.)、]\s", stripped)):
|
|
1089
|
+
continue
|
|
1090
|
+
visible = _BACKTICKED_SPAN_RE.sub(" ", line)
|
|
1091
|
+
if any(name and name in visible for name in names):
|
|
1092
|
+
return True
|
|
1093
|
+
return False
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
SUBROUTE_COLUMN_HEADER = "子业务"
|
|
1097
|
+
SUBROUTE_SEPARATORS = "、,,"
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _subroute_value_set(text: str, label: str) -> set[str]:
|
|
1101
|
+
"""`04-business-classification.md` 里 `label` 的**子业务取值域**,返回精确标签集合。
|
|
1102
|
+
|
|
1103
|
+
三条纪律,缺一条都会产生真实假阴性:
|
|
1104
|
+
|
|
1105
|
+
- **锚在表头**:只认表头里真的带 `子业务` 列的表(约定位置是「子业务识别」小节)。一级业务表
|
|
1106
|
+
那一行的第二格是**命中信号**(含"商誉减值测试"这类历史行为词),拿它判"子业务是否登记"会把
|
|
1107
|
+
信号词当成登记项。
|
|
1108
|
+
- **只取 `子业务` 那一格**:把整行其余单元格拼起来,第三列(目标技能)或自定义列里恰好写了
|
|
1109
|
+
`商誉减值` 就会冒充登记项。
|
|
1110
|
+
- **按分隔符切成集合再做成员判断**(`second in value_set`),不做子串比较:否则
|
|
1111
|
+
`商誉减值测试` 会满足 `商誉减值`。
|
|
1112
|
+
|
|
1113
|
+
目标一级业务在「子业务」表里**整行缺失**时返回**空集合**(不是 `None`):调用方据此为每个实际
|
|
1114
|
+
存在的二级子业务各报一次,不得静默跳过。
|
|
1115
|
+
"""
|
|
1116
|
+
header: list[str] | None = None
|
|
1117
|
+
column: int | None = None
|
|
1118
|
+
values: set[str] = set()
|
|
1119
|
+
for line in text.splitlines():
|
|
1120
|
+
stripped = line.strip()
|
|
1121
|
+
if not stripped.startswith("|"):
|
|
1122
|
+
header = None
|
|
1123
|
+
column = None
|
|
1124
|
+
continue
|
|
1125
|
+
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
|
|
1126
|
+
if _is_separator(cells):
|
|
1127
|
+
continue
|
|
1128
|
+
if header is None:
|
|
1129
|
+
header = [_label(cell) for cell in cells]
|
|
1130
|
+
column = header.index(SUBROUTE_COLUMN_HEADER) if SUBROUTE_COLUMN_HEADER in header else None
|
|
1131
|
+
continue
|
|
1132
|
+
if column is None or column >= len(cells) or _label(cells[0]) != label:
|
|
1133
|
+
continue
|
|
1134
|
+
# 同一一级业务意外出现多行时合并各行的精确值,不取最后一行覆盖前面内容。
|
|
1135
|
+
for raw in re.split("[" + re.escape(SUBROUTE_SEPARATORS) + "]", cells[column]):
|
|
1136
|
+
token = raw.replace("`", "").strip()
|
|
1137
|
+
if token:
|
|
1138
|
+
values.add(token)
|
|
1139
|
+
return values
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def inspect_second_level_index(
|
|
1143
|
+
repo_root: Path,
|
|
1144
|
+
paths: set[str],
|
|
1145
|
+
first_level: dict[str, list[tuple[str, str]]],
|
|
1146
|
+
rows_by_skill: dict[str, list["RegistryRow"]],
|
|
1147
|
+
add_finding,
|
|
1148
|
+
) -> None:
|
|
1149
|
+
"""知识库里的每个二级对象/子业务都必须在父叶子里被**分维度**索引。
|
|
1150
|
+
|
|
1151
|
+
背景(2026-09-26 快照实测):机器设备与债权各自已有 4 个 `02-细分对象/…/评估审核条目`,
|
|
1152
|
+
两个父叶子却仍声明"本一级根未提供 `02-细分对象/`"并完全没有二级索引;财务报告已有 5 个子业务,
|
|
1153
|
+
父叶子只索引 4 个(漏"商誉减值")。旧门禁只检查二级目录**是否缺审核文件**,
|
|
1154
|
+
从不检查父叶子有没有索引它 —— 于是"知识库有、审核读不到"这一整类漏装完全不可见。
|
|
1155
|
+
|
|
1156
|
+
三个维度分别落在三个文件上(各有职责,互不替代):
|
|
1157
|
+
- `00-applicability.md`:二级标签进入取值域(表格行 / 列表项);
|
|
1158
|
+
- `01-kb-assembly.md`:该二级目录被装进装配键(完整库内路径或它的前缀);
|
|
1159
|
+
- `02-review-focus.md`:写出该二级目录的完整库内路径,作为审核关注点入口。
|
|
1160
|
+
|
|
1161
|
+
只对**已注册且 `available`** 的父 Skill 强制;二级对象/子业务**不**要求独立 Skill 或 registry 行。
|
|
1162
|
+
"""
|
|
1163
|
+
audit_root = resolve_skill_dir(repo_root, ROUTER_SKILL)
|
|
1164
|
+
classification_path = audit_root / "references" / "04-business-classification.md"
|
|
1165
|
+
classification_text = (
|
|
1166
|
+
classification_path.read_text(encoding="utf-8") if classification_path.is_file() else ""
|
|
1167
|
+
)
|
|
1168
|
+
top_levels = _catalog_top_levels(paths)
|
|
1169
|
+
|
|
1170
|
+
for axis, items in first_level.items():
|
|
1171
|
+
spec = SECOND_LEVEL_SPECS[axis]
|
|
1172
|
+
class_code, assemble_code, review_code = spec["codes"]
|
|
1173
|
+
for label, first_level_root in items:
|
|
1174
|
+
row = _available_row_for(rows_by_skill, axis, label)
|
|
1175
|
+
if row is None:
|
|
1176
|
+
continue
|
|
1177
|
+
skill_dir = resolve_skill_dir(repo_root, row.skill)
|
|
1178
|
+
texts: dict[str, str] = {}
|
|
1179
|
+
for name in LEAF_OWNED_REFERENCES:
|
|
1180
|
+
reference_path = skill_dir / "references" / name
|
|
1181
|
+
if not reference_path.is_file():
|
|
1182
|
+
break
|
|
1183
|
+
texts[name] = reference_path.read_text(encoding="utf-8")
|
|
1184
|
+
if len(texts) != len(LEAF_OWNED_REFERENCES):
|
|
1185
|
+
continue # 缺失的 reference 由 SKILL_REFERENCE_MISSING 负责,不在这里重复报
|
|
1186
|
+
children = _second_level_children(paths, axis, first_level_root)
|
|
1187
|
+
|
|
1188
|
+
# 知识库确实有二级目录,父叶子却仍声称"本一级根未提供" —— 陈旧且主动误导。
|
|
1189
|
+
absent_marker = spec["absent_marker"]
|
|
1190
|
+
if children and absent_marker:
|
|
1191
|
+
for name, text in texts.items():
|
|
1192
|
+
if absent_marker in text:
|
|
1193
|
+
add_finding(
|
|
1194
|
+
spec["absent_code"],
|
|
1195
|
+
"error",
|
|
1196
|
+
f"{name} still claims the first-level root provides no "
|
|
1197
|
+
f"{spec['container'] or 'subroutes'} while the catalog has "
|
|
1198
|
+
f"{len(children)} of them",
|
|
1199
|
+
axis=axis,
|
|
1200
|
+
label=label,
|
|
1201
|
+
skill=row.skill,
|
|
1202
|
+
path=first_level_root,
|
|
1203
|
+
)
|
|
1204
|
+
break
|
|
1205
|
+
|
|
1206
|
+
if not children:
|
|
1207
|
+
continue
|
|
1208
|
+
keys = set(_addressing_keys(texts["01-kb-assembly.md"], top_levels))
|
|
1209
|
+
for child, child_root in children:
|
|
1210
|
+
second = _label(child)
|
|
1211
|
+
# 二级目录的写法有两套、都被接受:相对本一级根的写法(`02-细分对象/<对象>/`)
|
|
1212
|
+
# 与完整库内路径写法(`02-资产类型/<一级>/02-细分对象/<对象>/`)。
|
|
1213
|
+
container = str(spec["container"] or "")
|
|
1214
|
+
relative_folder = f"{container}/{child}/" if container else f"{child}/"
|
|
1215
|
+
relative_doc = f"{relative_folder}{spec['doc']}"
|
|
1216
|
+
forms = (child_root, f"{child_root}{spec['doc']}", relative_folder, relative_doc)
|
|
1217
|
+
# (1)取值域登记:二级**标签**出现为 `00-applicability.md` 的表格行 / 列表项。
|
|
1218
|
+
# **只认 00**:装配表(01)不得替适用性分类说话 —— 否则三个维度重新退化成两个。
|
|
1219
|
+
# 只认去掉数字前缀的标签,不认节点名 `01-通用设备`:后者是库内路径的组成部分,
|
|
1220
|
+
# 任何列出路径的装配行都会命中它。反引号内容先剥掉再匹配(同上理由)。
|
|
1221
|
+
if not _mentions_on_index_line(texts["00-applicability.md"], (second,)):
|
|
1222
|
+
add_finding(
|
|
1223
|
+
class_code,
|
|
1224
|
+
"error",
|
|
1225
|
+
f"second-level {'object' if axis == 'asset' else 'subroute'} is not in the "
|
|
1226
|
+
"applicability value set (table row / list item expected in "
|
|
1227
|
+
"00-applicability.md)",
|
|
1228
|
+
axis=axis,
|
|
1229
|
+
label=label,
|
|
1230
|
+
skill=row.skill,
|
|
1231
|
+
path=child_root,
|
|
1232
|
+
second_level=second,
|
|
1233
|
+
)
|
|
1234
|
+
# (2)装配:该二级目录必须以相对目录或完整库内路径出现在装配表里。
|
|
1235
|
+
if not (
|
|
1236
|
+
any(form in texts["01-kb-assembly.md"] for form in forms)
|
|
1237
|
+
or any(_path_within(key, child_root) for key in keys)
|
|
1238
|
+
):
|
|
1239
|
+
add_finding(
|
|
1240
|
+
assemble_code,
|
|
1241
|
+
"error",
|
|
1242
|
+
"second-level folder is not assembled: list its relative directory or full "
|
|
1243
|
+
"library path in 01-kb-assembly.md",
|
|
1244
|
+
axis=axis,
|
|
1245
|
+
label=label,
|
|
1246
|
+
skill=row.skill,
|
|
1247
|
+
path=child_root,
|
|
1248
|
+
second_level=second,
|
|
1249
|
+
)
|
|
1250
|
+
# (3)审核关注点:写出该二级目录的路径(相对或完整),作为审核入口。
|
|
1251
|
+
if not any(form in texts["02-review-focus.md"] for form in forms):
|
|
1252
|
+
add_finding(
|
|
1253
|
+
review_code,
|
|
1254
|
+
"error",
|
|
1255
|
+
"second-level folder has no review-focus entry carrying its library path "
|
|
1256
|
+
"(relative or full)",
|
|
1257
|
+
axis=axis,
|
|
1258
|
+
label=label,
|
|
1259
|
+
skill=row.skill,
|
|
1260
|
+
path=child_root,
|
|
1261
|
+
second_level=second,
|
|
1262
|
+
)
|
|
1263
|
+
# 业务子业务还必须在 router 的业务分类取值域里登记一次(子业务不占 registry 行)。
|
|
1264
|
+
# 精确成员判断;`_subroute_value_set` 在"整行缺失"时返回**空集合**(不是 None),
|
|
1265
|
+
# 所以父业务在「子业务」表里没有行时,它的每个实际子业务都会各自报错,不静默跳过。
|
|
1266
|
+
if axis == "business" and classification_text:
|
|
1267
|
+
if second not in _subroute_value_set(classification_text, label):
|
|
1268
|
+
add_finding(
|
|
1269
|
+
"BUSINESS_SUBROUTE_NOT_IN_CLASSIFICATION",
|
|
1270
|
+
"error",
|
|
1271
|
+
"business subroute is missing from the router business classification "
|
|
1272
|
+
"subroute value set",
|
|
1273
|
+
axis=axis,
|
|
1274
|
+
label=label,
|
|
1275
|
+
skill=row.skill,
|
|
1276
|
+
path=child_root,
|
|
1277
|
+
second_level=second,
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
# ---- 二级目录一致性(2026-09-26 立规)----
|
|
1282
|
+
# 一级根内部还有一层结构:资产轴的 `02-细分对象/<对象>/` 与业务轴一级根直接下挂的
|
|
1283
|
+
# `<子业务>/`。两者都**不**建 Skill、不占 registry 行,必须由父叶子在三个文件里各自索引。
|
|
1284
|
+
# 容器名、审核文件名、三类错误码与"陈旧缺省声明"话术集中在这里,避免多处各写一份。
|
|
1285
|
+
SECOND_LEVEL_SPECS: dict[str, dict[str, object]] = {
|
|
1286
|
+
"asset": {
|
|
1287
|
+
"container": "细分对象",
|
|
1288
|
+
"doc": "评估审核条目",
|
|
1289
|
+
"codes": (
|
|
1290
|
+
"ASSET_SUBOBJECT_NOT_CLASSIFIED",
|
|
1291
|
+
"ASSET_SUBOBJECT_NOT_ASSEMBLED",
|
|
1292
|
+
"ASSET_SUBOBJECT_NOT_REVIEWED",
|
|
1293
|
+
),
|
|
1294
|
+
"absent_code": "ASSET_SUBOBJECT_ABSENT_CLAIM",
|
|
1295
|
+
"absent_marker": "未提供 `02-细分对象",
|
|
1296
|
+
},
|
|
1297
|
+
"business": {
|
|
1298
|
+
"container": "",
|
|
1299
|
+
"doc": "业务通用审核要点",
|
|
1300
|
+
"codes": (
|
|
1301
|
+
"BUSINESS_SUBROUTE_NOT_CLASSIFIED",
|
|
1302
|
+
"BUSINESS_SUBROUTE_NOT_ASSEMBLED",
|
|
1303
|
+
"BUSINESS_SUBROUTE_NOT_REVIEWED",
|
|
1304
|
+
),
|
|
1305
|
+
"absent_code": "BUSINESS_SUBROUTE_ABSENT_CLAIM",
|
|
1306
|
+
"absent_marker": "",
|
|
1307
|
+
},
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def inspect_routing_layer(
|
|
1312
|
+
repo_root: Path,
|
|
1313
|
+
first_level: dict[str, list[tuple[str, str]]],
|
|
1314
|
+
rows_by_skill: dict[str, list["RegistryRow"]],
|
|
1315
|
+
add_finding,
|
|
1316
|
+
) -> None:
|
|
1317
|
+
"""Compare the knowledge base against the router, its references, the registry
|
|
1318
|
+
and the real skill directories: routing mechanism, reference registration,
|
|
1319
|
+
skill existence and whether a skill still needs to be created."""
|
|
1320
|
+
audit_root = resolve_skill_dir(repo_root, ROUTER_SKILL)
|
|
1321
|
+
router_skill = audit_root / "SKILL.md"
|
|
1322
|
+
references_dir = audit_root / "references"
|
|
1323
|
+
|
|
1324
|
+
if not router_skill.is_file():
|
|
1325
|
+
add_finding(
|
|
1326
|
+
"ROUTER_FILE_MISSING",
|
|
1327
|
+
"error",
|
|
1328
|
+
"the crwu-audit router entry point does not exist",
|
|
1329
|
+
path=str(router_skill),
|
|
1330
|
+
)
|
|
1331
|
+
return
|
|
1332
|
+
|
|
1333
|
+
missing_references = [
|
|
1334
|
+
name for name in ROUTER_REFERENCES if not (references_dir / name).is_file()
|
|
1335
|
+
]
|
|
1336
|
+
for name in missing_references:
|
|
1337
|
+
add_finding(
|
|
1338
|
+
"ROUTER_REFERENCE_MISSING",
|
|
1339
|
+
"error",
|
|
1340
|
+
"a routing reference the router depends on does not exist",
|
|
1341
|
+
path=str(references_dir / name),
|
|
1342
|
+
)
|
|
1343
|
+
|
|
1344
|
+
router_text = router_skill.read_text(encoding="utf-8")
|
|
1345
|
+
dispatch_path = references_dir / "08-union-dispatch-rules.md"
|
|
1346
|
+
dispatch_text = dispatch_path.read_text(encoding="utf-8") if dispatch_path.is_file() else ""
|
|
1347
|
+
|
|
1348
|
+
# R2: every NN-*.md the router names must resolve to a router-owned reference.
|
|
1349
|
+
named = set()
|
|
1350
|
+
for text in (router_text, dispatch_text):
|
|
1351
|
+
named.update(_REFERENCE_TOKEN_RE.findall(text))
|
|
1352
|
+
for name in sorted(named):
|
|
1353
|
+
if name in LEAF_OWNED_REFERENCES:
|
|
1354
|
+
continue
|
|
1355
|
+
if not (references_dir / name).is_file():
|
|
1356
|
+
add_finding(
|
|
1357
|
+
"ROUTER_REFERENCE_UNRESOLVED",
|
|
1358
|
+
"error",
|
|
1359
|
+
"the router names a reference file that does not exist under crwu-audit/references/",
|
|
1360
|
+
path=str(references_dir / name),
|
|
1361
|
+
)
|
|
1362
|
+
|
|
1363
|
+
# R3: an axis present in the knowledge base must be dispatched by the union rules.
|
|
1364
|
+
for axis, key in AXIS_DISPATCH_INPUT.items():
|
|
1365
|
+
if not first_level[axis]:
|
|
1366
|
+
continue
|
|
1367
|
+
if not dispatch_text:
|
|
1368
|
+
continue # missing dispatch rules already reported above
|
|
1369
|
+
if key not in dispatch_text:
|
|
1370
|
+
add_finding(
|
|
1371
|
+
"ROUTER_AXIS_UNDISPATCHED",
|
|
1372
|
+
"error",
|
|
1373
|
+
f"the knowledge base has {axis} first-level folders but the union rules never load {key}",
|
|
1374
|
+
axis=axis,
|
|
1375
|
+
)
|
|
1376
|
+
|
|
1377
|
+
# R4: every concrete skill name the routing layer names must exist or be registered.
|
|
1378
|
+
# Resolution covers any real audit-family directory (`crwu-audit*` leaves *and* non-leaf
|
|
1379
|
+
# skills such as the optimizer or datacheck, plus `crwu-dev-audit*` maintenance-side
|
|
1380
|
+
# members), plus every name the registry claims.
|
|
1381
|
+
on_disk = {
|
|
1382
|
+
child.name
|
|
1383
|
+
for root in skills_roots(repo_root)
|
|
1384
|
+
if root.is_dir()
|
|
1385
|
+
for child in root.iterdir()
|
|
1386
|
+
if child.is_dir() and child.name.startswith(AUDIT_FAMILY_PREFIX)
|
|
1387
|
+
}
|
|
1388
|
+
known = on_disk | set(rows_by_skill) | {ROUTER_SKILL}
|
|
1389
|
+
for source, text in (
|
|
1390
|
+
(str((router_skill / "SKILL.md").relative_to(repo_root)), router_text),
|
|
1391
|
+
(str(dispatch_path.relative_to(repo_root)), dispatch_text),
|
|
1392
|
+
):
|
|
1393
|
+
for token in sorted(set(_SKILL_TOKEN_RE.findall(text))):
|
|
1394
|
+
if token in known:
|
|
1395
|
+
continue
|
|
1396
|
+
add_finding(
|
|
1397
|
+
"ROUTER_SKILL_REFERENCE_UNRESOLVED",
|
|
1398
|
+
"error",
|
|
1399
|
+
"the routing layer names a skill that is neither a real directory nor a registry row",
|
|
1400
|
+
skill=token,
|
|
1401
|
+
path=source,
|
|
1402
|
+
)
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def inspect_repository(
|
|
1406
|
+
repo_root: Path,
|
|
1407
|
+
catalog: Catalog,
|
|
1408
|
+
max_age_hours: float | None = None,
|
|
1409
|
+
) -> dict[str, object]:
|
|
1410
|
+
paths = set(catalog.paths)
|
|
1411
|
+
findings: list[dict[str, object]] = []
|
|
1412
|
+
proposals: list[dict[str, object]] = []
|
|
1413
|
+
|
|
1414
|
+
def add_finding(
|
|
1415
|
+
code: str,
|
|
1416
|
+
severity: str,
|
|
1417
|
+
message: str,
|
|
1418
|
+
*,
|
|
1419
|
+
axis: str | None = None,
|
|
1420
|
+
label: str | None = None,
|
|
1421
|
+
skill: str | None = None,
|
|
1422
|
+
path: str | None = None,
|
|
1423
|
+
source: str | None = None,
|
|
1424
|
+
second_level: str | None = None,
|
|
1425
|
+
) -> None:
|
|
1426
|
+
findings.append(
|
|
1427
|
+
{
|
|
1428
|
+
"code": code,
|
|
1429
|
+
"severity": severity,
|
|
1430
|
+
"axis": axis,
|
|
1431
|
+
"label": label,
|
|
1432
|
+
"skill": skill,
|
|
1433
|
+
"path": path,
|
|
1434
|
+
"source": source,
|
|
1435
|
+
"second_level": second_level,
|
|
1436
|
+
"message": message,
|
|
1437
|
+
}
|
|
1438
|
+
)
|
|
1439
|
+
|
|
1440
|
+
def add_proposal(
|
|
1441
|
+
action: str,
|
|
1442
|
+
axis: str,
|
|
1443
|
+
label: str,
|
|
1444
|
+
*,
|
|
1445
|
+
skill_name: str | None,
|
|
1446
|
+
kb_root: str,
|
|
1447
|
+
name_review_required: bool | None = None,
|
|
1448
|
+
) -> None:
|
|
1449
|
+
if name_review_required is None:
|
|
1450
|
+
name_review_required = action == "create_skill"
|
|
1451
|
+
proposals.append(
|
|
1452
|
+
{
|
|
1453
|
+
"action": action,
|
|
1454
|
+
"axis": axis,
|
|
1455
|
+
"label": label,
|
|
1456
|
+
"skill_name": skill_name,
|
|
1457
|
+
"kb_root": kb_root,
|
|
1458
|
+
"name_review_required": name_review_required,
|
|
1459
|
+
}
|
|
1460
|
+
)
|
|
1461
|
+
|
|
1462
|
+
first_level: dict[str, list[tuple[str, str]]] = {"asset": [], "business": []}
|
|
1463
|
+
for axis, root in AXIS_ROOTS.items():
|
|
1464
|
+
for child in _immediate_folder_children(paths, root):
|
|
1465
|
+
if _is_ignored_label(child):
|
|
1466
|
+
continue
|
|
1467
|
+
first_level[axis].append((_label(child), f"{root}{child}/"))
|
|
1468
|
+
|
|
1469
|
+
registry_path = resolve_skill_dir(repo_root, ROUTER_SKILL) / "references" / "07-skill-registry.md"
|
|
1470
|
+
registry_rows = _read_registry(registry_path)
|
|
1471
|
+
if not registry_path.is_file():
|
|
1472
|
+
add_finding(
|
|
1473
|
+
"REGISTRY_FILE_MISSING",
|
|
1474
|
+
"error",
|
|
1475
|
+
"crwu-audit skill registry does not exist",
|
|
1476
|
+
path=str(registry_path),
|
|
1477
|
+
)
|
|
1478
|
+
|
|
1479
|
+
rows_by_key: dict[tuple[str, str], list[RegistryRow]] = {}
|
|
1480
|
+
rows_by_skill: dict[str, list[RegistryRow]] = {}
|
|
1481
|
+
for row in registry_rows:
|
|
1482
|
+
rows_by_key.setdefault((row.axis, row.label), []).append(row)
|
|
1483
|
+
if row.skill and row.skill != "—":
|
|
1484
|
+
rows_by_skill.setdefault(row.skill, []).append(row)
|
|
1485
|
+
|
|
1486
|
+
# Every asset/business registry row is validated, including labels the supplied
|
|
1487
|
+
# catalog does not cover: a registry claim must hold on its own.
|
|
1488
|
+
audit_rows = [row for row in registry_rows if row.axis in AXIS_ROOTS]
|
|
1489
|
+
public_rows = [row for row in registry_rows if row.axis == PUBLIC_AXIS]
|
|
1490
|
+
for (axis, label), rows in sorted(rows_by_key.items()):
|
|
1491
|
+
if axis not in AXIS_ROOTS or len(rows) < 2:
|
|
1492
|
+
continue
|
|
1493
|
+
add_finding(
|
|
1494
|
+
"REGISTRY_LABEL_DUPLICATE",
|
|
1495
|
+
"error",
|
|
1496
|
+
f"first-level label is registered {len(rows)} times; keep exactly one registry row",
|
|
1497
|
+
axis=axis,
|
|
1498
|
+
label=label,
|
|
1499
|
+
)
|
|
1500
|
+
|
|
1501
|
+
legacy_registry_skills = {
|
|
1502
|
+
row.skill
|
|
1503
|
+
for row in registry_rows
|
|
1504
|
+
if row.skill.startswith(LEGACY_BUSINESS_PREFIX)
|
|
1505
|
+
}
|
|
1506
|
+
for skill_name in sorted(legacy_registry_skills):
|
|
1507
|
+
add_finding(
|
|
1508
|
+
"LEGACY_BUSINESS_PREFIX",
|
|
1509
|
+
"error",
|
|
1510
|
+
"business registry entry must migrate to the crwu-audit-biz-* prefix",
|
|
1511
|
+
axis="business",
|
|
1512
|
+
skill=skill_name,
|
|
1513
|
+
path=str(registry_path),
|
|
1514
|
+
)
|
|
1515
|
+
|
|
1516
|
+
skill_dirs: dict[str, Path] = {}
|
|
1517
|
+
for root in skills_roots(repo_root):
|
|
1518
|
+
if not root.is_dir():
|
|
1519
|
+
continue
|
|
1520
|
+
for child in root.iterdir():
|
|
1521
|
+
if not child.is_dir():
|
|
1522
|
+
continue
|
|
1523
|
+
if child.name.startswith(
|
|
1524
|
+
(AXIS_SKILL_PREFIX["asset"], AXIS_SKILL_PREFIX["business"], LEGACY_BUSINESS_PREFIX)
|
|
1525
|
+
):
|
|
1526
|
+
skill_dirs[child.name] = child
|
|
1527
|
+
|
|
1528
|
+
method_layer = inspect_method_layer_assembly(repo_root, paths, add_finding)
|
|
1529
|
+
|
|
1530
|
+
assembly_roots_by_skill: dict[str, set[str]] = {}
|
|
1531
|
+
frontmatter_names: dict[str, str] = {}
|
|
1532
|
+
for skill_name, skill_dir in sorted(skill_dirs.items()):
|
|
1533
|
+
if skill_name.startswith(LEGACY_BUSINESS_PREFIX) and skill_name not in legacy_registry_skills:
|
|
1534
|
+
add_finding(
|
|
1535
|
+
"LEGACY_BUSINESS_PREFIX",
|
|
1536
|
+
"error",
|
|
1537
|
+
"business skill must use the crwu-audit-biz-* prefix",
|
|
1538
|
+
axis="business",
|
|
1539
|
+
skill=skill_name,
|
|
1540
|
+
path=str(skill_dir),
|
|
1541
|
+
)
|
|
1542
|
+
frontmatter_name = _frontmatter_name(skill_dir / "SKILL.md")
|
|
1543
|
+
frontmatter_names[skill_name] = frontmatter_name or ""
|
|
1544
|
+
if frontmatter_name != skill_name:
|
|
1545
|
+
add_finding(
|
|
1546
|
+
"SKILL_NAME_MISMATCH",
|
|
1547
|
+
"error",
|
|
1548
|
+
"skill directory and frontmatter name do not match",
|
|
1549
|
+
skill=skill_name,
|
|
1550
|
+
path=str(skill_dir / "SKILL.md"),
|
|
1551
|
+
)
|
|
1552
|
+
for reference in REQUIRED_REFERENCES:
|
|
1553
|
+
reference_path = skill_dir / "references" / reference
|
|
1554
|
+
if not reference_path.is_file():
|
|
1555
|
+
add_finding(
|
|
1556
|
+
"SKILL_REFERENCE_MISSING",
|
|
1557
|
+
"error",
|
|
1558
|
+
f"required reference is missing: {reference}",
|
|
1559
|
+
skill=skill_name,
|
|
1560
|
+
path=str(reference_path),
|
|
1561
|
+
)
|
|
1562
|
+
assembly_path = skill_dir / "references" / "01-kb-assembly.md"
|
|
1563
|
+
if not assembly_path.is_file():
|
|
1564
|
+
continue
|
|
1565
|
+
assembly_text = assembly_path.read_text(encoding="utf-8")
|
|
1566
|
+
kb_paths = _extract_kb_paths(assembly_text)
|
|
1567
|
+
roots = {_first_level_root(path) for path in kb_paths}
|
|
1568
|
+
assembly_roots_by_skill[skill_name] = {root for root in roots if root}
|
|
1569
|
+
has_file_mapping = (
|
|
1570
|
+
re.search(r"(?i)\bfile\b", assembly_text) is not None
|
|
1571
|
+
or any(not path.endswith("/") for path in kb_paths)
|
|
1572
|
+
)
|
|
1573
|
+
if has_file_mapping:
|
|
1574
|
+
add_finding(
|
|
1575
|
+
"ASSEMBLY_FILE_MAPPING",
|
|
1576
|
+
"error",
|
|
1577
|
+
"assembly must use one recursive first-level directory root instead of file entries",
|
|
1578
|
+
skill=skill_name,
|
|
1579
|
+
path=str(assembly_path),
|
|
1580
|
+
)
|
|
1581
|
+
|
|
1582
|
+
known_roots_to_skills: dict[str, list[str]] = {}
|
|
1583
|
+
for skill, roots in assembly_roots_by_skill.items():
|
|
1584
|
+
for root in roots:
|
|
1585
|
+
known_roots_to_skills.setdefault(root, []).append(skill)
|
|
1586
|
+
|
|
1587
|
+
classification_files = {
|
|
1588
|
+
"asset": resolve_skill_dir(repo_root, ROUTER_SKILL) / "references" / "03-asset-classification.md",
|
|
1589
|
+
"business": resolve_skill_dir(repo_root, ROUTER_SKILL) / "references" / "04-business-classification.md",
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
catalog_labels: dict[str, dict[str, str]] = {"asset": {}, "business": {}}
|
|
1593
|
+
for axis, items in first_level.items():
|
|
1594
|
+
for label, kb_root in items:
|
|
1595
|
+
catalog_labels[axis].setdefault(label, kb_root)
|
|
1596
|
+
# A partial catalog must not be read as "this root was deleted": only judge root
|
|
1597
|
+
# existence for an axis whose own first-level container was actually captured.
|
|
1598
|
+
axis_captured = {axis: root in paths for axis, root in AXIS_ROOTS.items()}
|
|
1599
|
+
dir_by_frontmatter = {
|
|
1600
|
+
frontmatter: directory
|
|
1601
|
+
for directory, frontmatter in frontmatter_names.items()
|
|
1602
|
+
if frontmatter
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
# A duplicated registry row is already reported once by REGISTRY_LABEL_DUPLICATE,
|
|
1606
|
+
# so each distinct claim is validated exactly once here.
|
|
1607
|
+
unique_rows: dict[tuple[str, str, str, str], RegistryRow] = {}
|
|
1608
|
+
for row in audit_rows:
|
|
1609
|
+
unique_rows.setdefault((row.axis, row.label, row.skill, row.status), row)
|
|
1610
|
+
for row in [unique_rows[key] for key in sorted(unique_rows)]:
|
|
1611
|
+
skill_name = row.skill
|
|
1612
|
+
if not skill_name or skill_name == "—":
|
|
1613
|
+
continue
|
|
1614
|
+
# Legacy business names are already reported as LEGACY_BUSINESS_PREFIX.
|
|
1615
|
+
if (
|
|
1616
|
+
not skill_name.startswith(LEGACY_BUSINESS_PREFIX)
|
|
1617
|
+
and not skill_name.startswith(AXIS_SKILL_PREFIX[row.axis])
|
|
1618
|
+
):
|
|
1619
|
+
add_finding(
|
|
1620
|
+
"AXIS_PREFIX_MISMATCH",
|
|
1621
|
+
"error",
|
|
1622
|
+
f"{row.axis} row must map a {AXIS_SKILL_PREFIX[row.axis]}* skill",
|
|
1623
|
+
axis=row.axis,
|
|
1624
|
+
label=row.label,
|
|
1625
|
+
skill=skill_name,
|
|
1626
|
+
)
|
|
1627
|
+
|
|
1628
|
+
expected_root = catalog_labels[row.axis].get(row.label)
|
|
1629
|
+
if row.status != "available":
|
|
1630
|
+
continue
|
|
1631
|
+
|
|
1632
|
+
if expected_root is None and axis_captured[row.axis]:
|
|
1633
|
+
add_finding(
|
|
1634
|
+
"REGISTRY_LABEL_NOT_IN_CATALOG",
|
|
1635
|
+
"warning",
|
|
1636
|
+
"available registry label has no first-level folder in the latest catalog",
|
|
1637
|
+
axis=row.axis,
|
|
1638
|
+
label=row.label,
|
|
1639
|
+
skill=skill_name,
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
skill_dir = skill_dirs.get(skill_name)
|
|
1643
|
+
if skill_dir is None:
|
|
1644
|
+
add_finding(
|
|
1645
|
+
"AVAILABLE_SKILL_DIRECTORY_MISSING",
|
|
1646
|
+
"error",
|
|
1647
|
+
"available registry skill directory does not exist",
|
|
1648
|
+
axis=row.axis,
|
|
1649
|
+
label=row.label,
|
|
1650
|
+
skill=skill_name,
|
|
1651
|
+
)
|
|
1652
|
+
drifted_dir = dir_by_frontmatter.get(skill_name)
|
|
1653
|
+
if drifted_dir is not None:
|
|
1654
|
+
add_finding(
|
|
1655
|
+
"SKILL_NAME_MISMATCH",
|
|
1656
|
+
"error",
|
|
1657
|
+
"registry name matches a directory frontmatter name, but the directory itself is named differently",
|
|
1658
|
+
axis=row.axis,
|
|
1659
|
+
label=row.label,
|
|
1660
|
+
skill=skill_name,
|
|
1661
|
+
path=str(resolve_skill_dir(repo_root, drifted_dir)),
|
|
1662
|
+
)
|
|
1663
|
+
add_proposal(
|
|
1664
|
+
"repair_skill",
|
|
1665
|
+
row.axis,
|
|
1666
|
+
row.label,
|
|
1667
|
+
skill_name=skill_name,
|
|
1668
|
+
kb_root=expected_root or f"{AXIS_ROOTS[row.axis]}<{row.label}>/",
|
|
1669
|
+
)
|
|
1670
|
+
continue
|
|
1671
|
+
|
|
1672
|
+
assembly = skill_dir / "references" / "01-kb-assembly.md"
|
|
1673
|
+
assembly_text = assembly.read_text(encoding="utf-8") if assembly.is_file() else ""
|
|
1674
|
+
extracted = _extract_kb_paths(assembly_text)
|
|
1675
|
+
directory_contract = (
|
|
1676
|
+
re.search(r"(?i)\bdirectory\b", assembly_text) is not None
|
|
1677
|
+
and re.search(r"(?i)\btrue\b", assembly_text) is not None
|
|
1678
|
+
)
|
|
1679
|
+
if expected_root is not None:
|
|
1680
|
+
exact_root_present = f"`{expected_root}`" in assembly_text or expected_root in extracted
|
|
1681
|
+
else:
|
|
1682
|
+
# The catalog does not cover this label, so its root cannot be confirmed;
|
|
1683
|
+
# require only that a single well-formed recursive root is declared.
|
|
1684
|
+
exact_root_present = len(_declared_first_level_roots(assembly_text)) == 1
|
|
1685
|
+
if not exact_root_present or not directory_contract:
|
|
1686
|
+
add_finding(
|
|
1687
|
+
"ASSEMBLY_FIRST_LEVEL_ROOT_MISSING",
|
|
1688
|
+
"error",
|
|
1689
|
+
"available skill lacks its exact recursive first-level directory root",
|
|
1690
|
+
axis=row.axis,
|
|
1691
|
+
label=row.label,
|
|
1692
|
+
skill=skill_name,
|
|
1693
|
+
path=expected_root or str(assembly),
|
|
1694
|
+
)
|
|
1695
|
+
if expected_root is not None:
|
|
1696
|
+
add_proposal(
|
|
1697
|
+
"remap_skill",
|
|
1698
|
+
row.axis,
|
|
1699
|
+
row.label,
|
|
1700
|
+
skill_name=skill_name,
|
|
1701
|
+
kb_root=expected_root,
|
|
1702
|
+
)
|
|
1703
|
+
|
|
1704
|
+
if axis_captured[row.axis]:
|
|
1705
|
+
for declared_root in sorted(_declared_first_level_roots(assembly_text)):
|
|
1706
|
+
if not declared_root.startswith(AXIS_ROOTS[row.axis]):
|
|
1707
|
+
add_finding(
|
|
1708
|
+
"AXIS_ROOT_MISMATCH",
|
|
1709
|
+
"error",
|
|
1710
|
+
f"{row.axis} skill declares a root owned by another axis",
|
|
1711
|
+
axis=row.axis,
|
|
1712
|
+
label=row.label,
|
|
1713
|
+
skill=skill_name,
|
|
1714
|
+
path=declared_root,
|
|
1715
|
+
)
|
|
1716
|
+
elif declared_root not in paths:
|
|
1717
|
+
add_finding(
|
|
1718
|
+
"MAPPING_ROOT_NOT_IN_CATALOG",
|
|
1719
|
+
"error",
|
|
1720
|
+
"declared first-level root no longer exists in the latest catalog",
|
|
1721
|
+
axis=row.axis,
|
|
1722
|
+
label=row.label,
|
|
1723
|
+
skill=skill_name,
|
|
1724
|
+
path=declared_root,
|
|
1725
|
+
)
|
|
1726
|
+
|
|
1727
|
+
if row.axis == "business" and expected_root is not None:
|
|
1728
|
+
common_review_doc = _named_doc(paths, expected_root, BUSINESS_COMMON_REVIEW_DOC)
|
|
1729
|
+
if common_review_doc is not None:
|
|
1730
|
+
focus = skill_dir / "references" / "02-review-focus.md"
|
|
1731
|
+
focus_text = focus.read_text(encoding="utf-8") if focus.is_file() else ""
|
|
1732
|
+
if not _references_common_review(
|
|
1733
|
+
assembly_text + focus_text, expected_root, common_review_doc
|
|
1734
|
+
):
|
|
1735
|
+
add_finding(
|
|
1736
|
+
"BUSINESS_COMMON_REVIEW_NOT_REFERENCED",
|
|
1737
|
+
"warning",
|
|
1738
|
+
(
|
|
1739
|
+
f"the first-level business folder ships {common_review_doc}; "
|
|
1740
|
+
"the business skill must address it by its library path key "
|
|
1741
|
+
f"({expected_root}{common_review_doc}) as the shared review layer "
|
|
1742
|
+
"— merely naming the document (e.g. claiming it is absent) does not count"
|
|
1743
|
+
),
|
|
1744
|
+
axis=row.axis,
|
|
1745
|
+
label=row.label,
|
|
1746
|
+
skill=skill_name,
|
|
1747
|
+
path=expected_root,
|
|
1748
|
+
)
|
|
1749
|
+
|
|
1750
|
+
for axis, items in first_level.items():
|
|
1751
|
+
classification_text = (
|
|
1752
|
+
classification_files[axis].read_text(encoding="utf-8")
|
|
1753
|
+
if classification_files[axis].is_file()
|
|
1754
|
+
else None
|
|
1755
|
+
)
|
|
1756
|
+
for label, kb_root in items:
|
|
1757
|
+
row = rows_by_key.get((axis, label), [None])[0]
|
|
1758
|
+
mapped_skills = sorted(known_roots_to_skills.get(kb_root, []))
|
|
1759
|
+
if row is None:
|
|
1760
|
+
action = "register_skill" if mapped_skills else "create_skill"
|
|
1761
|
+
skill_name = mapped_skills[0] if mapped_skills else _suggested_skill(axis, label)
|
|
1762
|
+
add_finding(
|
|
1763
|
+
"FIRST_LEVEL_SKILL_MISSING",
|
|
1764
|
+
"error",
|
|
1765
|
+
"knowledge-base first-level folder has no registry skill",
|
|
1766
|
+
axis=axis,
|
|
1767
|
+
label=label,
|
|
1768
|
+
skill=skill_name,
|
|
1769
|
+
path=kb_root,
|
|
1770
|
+
)
|
|
1771
|
+
add_proposal(action, axis, label, skill_name=skill_name, kb_root=kb_root)
|
|
1772
|
+
elif row.status == "pending":
|
|
1773
|
+
add_finding(
|
|
1774
|
+
"FIRST_LEVEL_SKILL_PENDING",
|
|
1775
|
+
"warning",
|
|
1776
|
+
"knowledge-base first-level folder is registered but its skill is pending",
|
|
1777
|
+
axis=axis,
|
|
1778
|
+
label=label,
|
|
1779
|
+
skill=row.skill,
|
|
1780
|
+
path=kb_root,
|
|
1781
|
+
)
|
|
1782
|
+
proposal_skill = (
|
|
1783
|
+
_suggested_skill(axis, label)
|
|
1784
|
+
if row.skill.startswith(LEGACY_BUSINESS_PREFIX)
|
|
1785
|
+
else row.skill
|
|
1786
|
+
)
|
|
1787
|
+
add_proposal("create_skill", axis, label, skill_name=proposal_skill, kb_root=kb_root)
|
|
1788
|
+
if classification_text is not None and label not in classification_text:
|
|
1789
|
+
add_finding(
|
|
1790
|
+
"CLASSIFICATION_LABEL_MISSING",
|
|
1791
|
+
"warning",
|
|
1792
|
+
"first-level knowledge label is absent from its classification reference",
|
|
1793
|
+
axis=axis,
|
|
1794
|
+
label=label,
|
|
1795
|
+
path=str(classification_files[axis]),
|
|
1796
|
+
)
|
|
1797
|
+
|
|
1798
|
+
# Public-axis rows carry report-shape-independent capabilities (report disclosure,
|
|
1799
|
+
# procedure/quality control, tabular reconciliation). They sit outside the asset/business
|
|
1800
|
+
# first-level-root model, so they are validated here instead of in the loop above:
|
|
1801
|
+
# the declared skill directory must exist, match its frontmatter name, and declare a KB
|
|
1802
|
+
# assembly table (either the standard `01-kb-assembly.md` or the cross-cutting
|
|
1803
|
+
# `00-KB装配表.md` used by `crwu-audit-datacheck`). Their assembly path keys are already
|
|
1804
|
+
# covered globally by inspect_path_keys(), which walks every audit-family
|
|
1805
|
+
# (crwu-audit*/crwu-dev-audit-*) directory.
|
|
1806
|
+
for row in sorted(
|
|
1807
|
+
{(r.label, r.skill, r.status): r for r in public_rows}.values(),
|
|
1808
|
+
key=lambda r: (r.label, r.skill),
|
|
1809
|
+
):
|
|
1810
|
+
if not row.skill or row.skill == "—" or row.status != "available":
|
|
1811
|
+
continue
|
|
1812
|
+
public_dir = resolve_skill_dir(repo_root, row.skill)
|
|
1813
|
+
if not public_dir.is_dir():
|
|
1814
|
+
add_finding(
|
|
1815
|
+
"AVAILABLE_SKILL_DIRECTORY_MISSING",
|
|
1816
|
+
"error",
|
|
1817
|
+
"available public-axis registry skill directory does not exist",
|
|
1818
|
+
axis=row.axis,
|
|
1819
|
+
label=row.label,
|
|
1820
|
+
skill=row.skill,
|
|
1821
|
+
)
|
|
1822
|
+
add_proposal(
|
|
1823
|
+
"repair_skill",
|
|
1824
|
+
row.axis,
|
|
1825
|
+
row.label,
|
|
1826
|
+
skill_name=row.skill,
|
|
1827
|
+
kb_root="",
|
|
1828
|
+
)
|
|
1829
|
+
continue
|
|
1830
|
+
public_frontmatter = _frontmatter_name(public_dir / "SKILL.md")
|
|
1831
|
+
if public_frontmatter != row.skill:
|
|
1832
|
+
add_finding(
|
|
1833
|
+
"SKILL_NAME_MISMATCH",
|
|
1834
|
+
"error",
|
|
1835
|
+
"public-axis skill directory and frontmatter name do not match",
|
|
1836
|
+
axis=row.axis,
|
|
1837
|
+
label=row.label,
|
|
1838
|
+
skill=row.skill,
|
|
1839
|
+
path=str(public_dir / "SKILL.md"),
|
|
1840
|
+
)
|
|
1841
|
+
public_references = public_dir / "references"
|
|
1842
|
+
declared_assembly = [
|
|
1843
|
+
name
|
|
1844
|
+
for name in PUBLIC_ASSEMBLY_REFERENCES
|
|
1845
|
+
if (public_references / name).is_file()
|
|
1846
|
+
]
|
|
1847
|
+
if not declared_assembly:
|
|
1848
|
+
add_finding(
|
|
1849
|
+
"SKILL_REFERENCE_MISSING",
|
|
1850
|
+
"error",
|
|
1851
|
+
"public-axis skill declares no KB assembly table: "
|
|
1852
|
+
+ " or ".join(PUBLIC_ASSEMBLY_REFERENCES),
|
|
1853
|
+
axis=row.axis,
|
|
1854
|
+
label=row.label,
|
|
1855
|
+
skill=row.skill,
|
|
1856
|
+
path=str(public_references),
|
|
1857
|
+
)
|
|
1858
|
+
|
|
1859
|
+
# An audit-family skill that is neither an axis leaf nor a known non-leaf skill is a
|
|
1860
|
+
# leftover combined/legacy skill; register it or migrate it onto a real axis prefix.
|
|
1861
|
+
registered_names = set(rows_by_skill)
|
|
1862
|
+
for child in sorted(
|
|
1863
|
+
entry
|
|
1864
|
+
for root in skills_roots(repo_root)
|
|
1865
|
+
if root.is_dir()
|
|
1866
|
+
for entry in root.iterdir()
|
|
1867
|
+
):
|
|
1868
|
+
name = child.name
|
|
1869
|
+
if not child.is_dir() or not name.startswith(AUDIT_FAMILY_PREFIX):
|
|
1870
|
+
continue
|
|
1871
|
+
if name in NON_LEAF_SKILLS or name in registered_names:
|
|
1872
|
+
continue
|
|
1873
|
+
if name.startswith((AXIS_SKILL_PREFIX["asset"], AXIS_SKILL_PREFIX["business"], LEGACY_BUSINESS_PREFIX)):
|
|
1874
|
+
continue
|
|
1875
|
+
add_finding(
|
|
1876
|
+
"LEGACY_COMBINED_SKILL",
|
|
1877
|
+
"error",
|
|
1878
|
+
"crwu-audit skill is neither a registered axis skill nor a known non-leaf skill; register it or migrate it to crwu-audit-asset-* / crwu-audit-biz-*",
|
|
1879
|
+
skill=name,
|
|
1880
|
+
path=str(child),
|
|
1881
|
+
)
|
|
1882
|
+
|
|
1883
|
+
for skill_name, skill_dir in sorted(skill_dirs.items()):
|
|
1884
|
+
if skill_name not in rows_by_skill:
|
|
1885
|
+
add_finding(
|
|
1886
|
+
"ORPHAN_SKILL",
|
|
1887
|
+
"warning",
|
|
1888
|
+
"asset/business skill is not present in the registry",
|
|
1889
|
+
skill=skill_name,
|
|
1890
|
+
path=str(skill_dir),
|
|
1891
|
+
)
|
|
1892
|
+
|
|
1893
|
+
inspect_routing_layer(repo_root, first_level, rows_by_skill, add_finding)
|
|
1894
|
+
|
|
1895
|
+
path_key_issues = inspect_path_keys(repo_root, paths, add_finding)
|
|
1896
|
+
|
|
1897
|
+
if max_age_hours is not None:
|
|
1898
|
+
age = _catalog_age_hours(catalog)
|
|
1899
|
+
if age is None:
|
|
1900
|
+
add_finding(
|
|
1901
|
+
"CATALOG_NOT_LIVE",
|
|
1902
|
+
"error",
|
|
1903
|
+
(
|
|
1904
|
+
"catalog carries no readable capture time; refresh the directory through "
|
|
1905
|
+
"crwu-dws before drawing routing conclusions"
|
|
1906
|
+
),
|
|
1907
|
+
)
|
|
1908
|
+
elif age > max_age_hours:
|
|
1909
|
+
add_finding(
|
|
1910
|
+
"CATALOG_STALE",
|
|
1911
|
+
"error",
|
|
1912
|
+
(
|
|
1913
|
+
f"catalog capture time is {age:.1f}h old (limit {max_age_hours}h); "
|
|
1914
|
+
"refresh the directory through crwu-dws before drawing routing conclusions"
|
|
1915
|
+
),
|
|
1916
|
+
)
|
|
1917
|
+
|
|
1918
|
+
for label, asset_root in first_level["asset"]:
|
|
1919
|
+
common_root = _find_named_folder(paths, asset_root, "共性参考")
|
|
1920
|
+
if common_root is None:
|
|
1921
|
+
add_finding(
|
|
1922
|
+
"ASSET_COMMON_REFERENCE_MISSING",
|
|
1923
|
+
"error",
|
|
1924
|
+
"asset first-level folder has no common-reference directory",
|
|
1925
|
+
axis="asset",
|
|
1926
|
+
label=label,
|
|
1927
|
+
path=asset_root,
|
|
1928
|
+
)
|
|
1929
|
+
subobjects_root = _find_named_folder(paths, asset_root, "细分对象")
|
|
1930
|
+
if subobjects_root:
|
|
1931
|
+
for subobject in _immediate_folder_children(paths, subobjects_root):
|
|
1932
|
+
if _is_ignored_label(subobject):
|
|
1933
|
+
continue
|
|
1934
|
+
subobject_root = f"{subobjects_root}{subobject}/"
|
|
1935
|
+
if not _has_named_doc(paths, subobject_root, "评估审核条目"):
|
|
1936
|
+
add_finding(
|
|
1937
|
+
"ASSET_SUBOBJECT_REVIEW_MISSING",
|
|
1938
|
+
"warning",
|
|
1939
|
+
"asset subobject has no 评估审核条目; keep it under the parent skill and record a content gap",
|
|
1940
|
+
axis="asset",
|
|
1941
|
+
label=_label(subobject),
|
|
1942
|
+
path=subobject_root,
|
|
1943
|
+
)
|
|
1944
|
+
|
|
1945
|
+
for label, business_root in first_level["business"]:
|
|
1946
|
+
for subroute in _immediate_folder_children(paths, business_root):
|
|
1947
|
+
if _is_ignored_label(subroute):
|
|
1948
|
+
continue
|
|
1949
|
+
subroute_root = f"{business_root}{subroute}/"
|
|
1950
|
+
if not _has_named_doc(paths, subroute_root, "业务通用审核要点"):
|
|
1951
|
+
add_finding(
|
|
1952
|
+
"BUSINESS_SUBROUTE_REVIEW_MISSING",
|
|
1953
|
+
"warning",
|
|
1954
|
+
"business subroute has no 01-业务通用审核要点; keep it under the parent skill and record a content gap",
|
|
1955
|
+
axis="business",
|
|
1956
|
+
label=_label(subroute),
|
|
1957
|
+
path=subroute_root,
|
|
1958
|
+
)
|
|
1959
|
+
|
|
1960
|
+
# 有内容不等于被索引:二级对象/子业务必须逐个在父叶子的三个文件里各自成立。
|
|
1961
|
+
inspect_second_level_index(repo_root, paths, first_level, rows_by_skill, add_finding)
|
|
1962
|
+
|
|
1963
|
+
findings.sort(
|
|
1964
|
+
key=lambda item: (
|
|
1965
|
+
str(item["severity"]),
|
|
1966
|
+
str(item["axis"] or ""),
|
|
1967
|
+
str(item["label"] or ""),
|
|
1968
|
+
str(item["skill"] or ""),
|
|
1969
|
+
str(item["code"]),
|
|
1970
|
+
str(item["path"] or ""),
|
|
1971
|
+
)
|
|
1972
|
+
)
|
|
1973
|
+
proposals.sort(
|
|
1974
|
+
key=lambda item: (
|
|
1975
|
+
str(item["axis"]),
|
|
1976
|
+
str(item["label"]),
|
|
1977
|
+
str(item["action"]),
|
|
1978
|
+
str(item["skill_name"] or ""),
|
|
1979
|
+
)
|
|
1980
|
+
)
|
|
1981
|
+
summary = {
|
|
1982
|
+
"errors": sum(item["severity"] == "error" for item in findings),
|
|
1983
|
+
"warnings": sum(item["severity"] == "warning" for item in findings),
|
|
1984
|
+
"proposals": len(proposals),
|
|
1985
|
+
"non_runtime_modules": len(method_layer.get("nonRuntime", [])),
|
|
1986
|
+
"first_level_assets": len(first_level["asset"]),
|
|
1987
|
+
"first_level_businesses": len(first_level["business"]),
|
|
1988
|
+
}
|
|
1989
|
+
calibration = _build_calibration(
|
|
1990
|
+
first_level, rows_by_key, skill_dirs, paths, findings, catalog
|
|
1991
|
+
)
|
|
1992
|
+
calibration["path_key_issues"] = path_key_issues
|
|
1993
|
+
calibration["method_layer_assembly"] = method_layer
|
|
1994
|
+
return {
|
|
1995
|
+
"schema": REPORT_SCHEMA,
|
|
1996
|
+
"catalog": {
|
|
1997
|
+
"source_schema": catalog.source_schema,
|
|
1998
|
+
"generated_at": catalog.generated_at,
|
|
1999
|
+
"complete": catalog.complete,
|
|
2000
|
+
"paths": list(catalog.paths),
|
|
2001
|
+
},
|
|
2002
|
+
"summary": summary,
|
|
2003
|
+
"findings": findings,
|
|
2004
|
+
"proposals": proposals,
|
|
2005
|
+
"calibration": calibration,
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
|
|
2009
|
+
def _build_calibration(
|
|
2010
|
+
first_level: dict[str, list[tuple[str, str]]],
|
|
2011
|
+
rows_by_key: dict[tuple[str, str], list["RegistryRow"]],
|
|
2012
|
+
skill_dirs: dict[str, Path],
|
|
2013
|
+
paths: set[str],
|
|
2014
|
+
findings: list[dict[str, object]],
|
|
2015
|
+
catalog: "Catalog",
|
|
2016
|
+
) -> dict[str, object]:
|
|
2017
|
+
"""Human-readable 知识库 ↔ Skill 映射快照, derived only from this run's inputs."""
|
|
2018
|
+
rows: list[dict[str, object]] = []
|
|
2019
|
+
for axis in ("asset", "business"):
|
|
2020
|
+
for label, kb_root in first_level[axis]:
|
|
2021
|
+
registry = rows_by_key.get((axis, label), [])
|
|
2022
|
+
row = registry[0] if registry else None
|
|
2023
|
+
skill = row.skill if row is not None and row.skill not in ("", "—") else None
|
|
2024
|
+
status = row.status if row is not None else "unregistered"
|
|
2025
|
+
|
|
2026
|
+
declared_root: str | None = None
|
|
2027
|
+
if skill is not None and skill in skill_dirs:
|
|
2028
|
+
assembly = skill_dirs[skill] / "references" / "01-kb-assembly.md"
|
|
2029
|
+
if assembly.is_file():
|
|
2030
|
+
roots = sorted(
|
|
2031
|
+
_declared_first_level_roots(assembly.read_text(encoding="utf-8"))
|
|
2032
|
+
)
|
|
2033
|
+
declared_root = roots[0] if len(roots) == 1 else (
|
|
2034
|
+
" / ".join(roots) if roots else None
|
|
2035
|
+
)
|
|
2036
|
+
|
|
2037
|
+
detail: dict[str, object] = {}
|
|
2038
|
+
if axis == "asset":
|
|
2039
|
+
detail["common_reference"] = (
|
|
2040
|
+
_find_named_folder(paths, kb_root, "共性参考") is not None
|
|
2041
|
+
)
|
|
2042
|
+
subobject_root = _find_named_folder(paths, kb_root, "细分对象")
|
|
2043
|
+
subobjects = (
|
|
2044
|
+
[
|
|
2045
|
+
child
|
|
2046
|
+
for child in _immediate_folder_children(paths, subobject_root)
|
|
2047
|
+
if not _is_ignored_label(child)
|
|
2048
|
+
]
|
|
2049
|
+
if subobject_root
|
|
2050
|
+
else []
|
|
2051
|
+
)
|
|
2052
|
+
with_review = [
|
|
2053
|
+
child
|
|
2054
|
+
for child in subobjects
|
|
2055
|
+
if _has_named_doc(paths, f"{subobject_root}{child}/", "评估审核条目")
|
|
2056
|
+
]
|
|
2057
|
+
detail["subobjects"] = len(subobjects)
|
|
2058
|
+
detail["subobjects_with_reference"] = len(with_review)
|
|
2059
|
+
else:
|
|
2060
|
+
detail["common_review"] = _has_named_doc(
|
|
2061
|
+
paths, kb_root, BUSINESS_COMMON_REVIEW_DOC
|
|
2062
|
+
)
|
|
2063
|
+
subroutes = [
|
|
2064
|
+
child
|
|
2065
|
+
for child in _immediate_folder_children(paths, kb_root)
|
|
2066
|
+
if not _is_ignored_label(child)
|
|
2067
|
+
]
|
|
2068
|
+
with_reference = [
|
|
2069
|
+
child
|
|
2070
|
+
for child in subroutes
|
|
2071
|
+
if _has_named_doc(paths, f"{kb_root}{child}/", "业务通用审核要点")
|
|
2072
|
+
]
|
|
2073
|
+
detail["subroutes"] = len(subroutes)
|
|
2074
|
+
detail["subroutes_with_reference"] = len(with_reference)
|
|
2075
|
+
detail["subroutes_without_reference"] = [
|
|
2076
|
+
_label(child) for child in subroutes if child not in with_reference
|
|
2077
|
+
]
|
|
2078
|
+
|
|
2079
|
+
row_findings = sorted(
|
|
2080
|
+
{
|
|
2081
|
+
str(item["code"])
|
|
2082
|
+
for item in findings
|
|
2083
|
+
if item.get("axis") == axis and item.get("label") == label
|
|
2084
|
+
}
|
|
2085
|
+
)
|
|
2086
|
+
rows.append(
|
|
2087
|
+
{
|
|
2088
|
+
"axis": axis,
|
|
2089
|
+
"label": label,
|
|
2090
|
+
"kb_root": kb_root,
|
|
2091
|
+
"skill": skill,
|
|
2092
|
+
"status": status,
|
|
2093
|
+
"declared_root": declared_root,
|
|
2094
|
+
"detail": detail,
|
|
2095
|
+
"finding_codes": row_findings,
|
|
2096
|
+
}
|
|
2097
|
+
)
|
|
2098
|
+
|
|
2099
|
+
unregistered_skills = sorted(
|
|
2100
|
+
{
|
|
2101
|
+
str(item["skill"])
|
|
2102
|
+
for item in findings
|
|
2103
|
+
if item["code"] in {"ORPHAN_SKILL", "LEGACY_COMBINED_SKILL"} and item.get("skill")
|
|
2104
|
+
}
|
|
2105
|
+
)
|
|
2106
|
+
return {
|
|
2107
|
+
"catalog": {
|
|
2108
|
+
"source_schema": catalog.source_schema,
|
|
2109
|
+
"fetched_at": catalog.generated_at,
|
|
2110
|
+
"complete": catalog.complete,
|
|
2111
|
+
"node_paths": len(catalog.paths),
|
|
2112
|
+
},
|
|
2113
|
+
"rows": rows,
|
|
2114
|
+
"unregistered_skills": unregistered_skills,
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
|
|
2118
|
+
CALIBRATION_NOTES_BEGIN = "<!-- CALIBRATION-NOTES:BEGIN -->"
|
|
2119
|
+
CALIBRATION_NOTES_END = "<!-- CALIBRATION-NOTES:END -->"
|
|
2120
|
+
CALIBRATION_HISTORY_BEGIN = "<!-- CALIBRATION-HISTORY:BEGIN -->"
|
|
2121
|
+
CALIBRATION_HISTORY_END = "<!-- CALIBRATION-HISTORY:END -->"
|
|
2122
|
+
|
|
2123
|
+
|
|
2124
|
+
def _preserved_block(text: str | None, begin: str, end: str) -> str:
|
|
2125
|
+
"""Keep a hand-maintained block (notes) or an append-only log (history) across runs."""
|
|
2126
|
+
if not text:
|
|
2127
|
+
return ""
|
|
2128
|
+
start = text.find(begin)
|
|
2129
|
+
stop = text.find(end)
|
|
2130
|
+
if start < 0 or stop < 0 or stop < start:
|
|
2131
|
+
return ""
|
|
2132
|
+
return text[start + len(begin) : stop].strip("\n")
|
|
2133
|
+
|
|
2134
|
+
|
|
2135
|
+
def _markdown_anchor(title: str) -> str:
|
|
2136
|
+
"""把 H2 标题转成可点击的内链锚点(去标点、空白折为一个连字符)。
|
|
2137
|
+
|
|
2138
|
+
与契约测试里的同名规则保持一致 —— 生成器与测试各写一套就会漂移。
|
|
2139
|
+
"""
|
|
2140
|
+
slug = re.sub(r"[^\w\u4e00-\u9fff\s-]", "", title.strip().lower())
|
|
2141
|
+
return re.sub(r"\s+", "-", slug.strip())
|
|
2142
|
+
|
|
2143
|
+
|
|
2144
|
+
def render_calibration(report: dict[str, object], previous: str | None = None) -> str:
|
|
2145
|
+
calibration = report.get("calibration")
|
|
2146
|
+
assert isinstance(calibration, dict)
|
|
2147
|
+
catalog = calibration["catalog"]
|
|
2148
|
+
assert isinstance(catalog, dict)
|
|
2149
|
+
summary = report["summary"]
|
|
2150
|
+
assert isinstance(summary, dict)
|
|
2151
|
+
rows = calibration["rows"]
|
|
2152
|
+
assert isinstance(rows, list)
|
|
2153
|
+
|
|
2154
|
+
stamp = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
|
|
2155
|
+
complete = catalog.get("complete")
|
|
2156
|
+
complete_text = {True: "complete=true", False: "**complete=false(不可作路径事实)**"}.get(
|
|
2157
|
+
complete, "complete 未知"
|
|
2158
|
+
)
|
|
2159
|
+
if catalog.get("fetched_at") is None:
|
|
2160
|
+
complete_text += " · **无抓取时间(未能证明是最新)**"
|
|
2161
|
+
|
|
2162
|
+
# 轴计数只算一次:正文 H2 与导航标题复用同一份 `axis_titles`,避免两处各自拼接后计数/文案漂移。
|
|
2163
|
+
axis_rows = {
|
|
2164
|
+
axis: [row for row in rows if row["axis"] == axis] for axis in ("asset", "business")
|
|
2165
|
+
}
|
|
2166
|
+
axis_titles = {
|
|
2167
|
+
"asset": f"资产轴({len(axis_rows['asset'])})",
|
|
2168
|
+
"business": f"业务轴({len(axis_rows['business'])})",
|
|
2169
|
+
}
|
|
2170
|
+
# 导航标题 = 本次正文 H2 序列(顺序即正文顺序);不另存一份易漂移的文案副本。
|
|
2171
|
+
nav_titles = [
|
|
2172
|
+
axis_titles["asset"],
|
|
2173
|
+
axis_titles["business"],
|
|
2174
|
+
"库内路径键健康(公共轴也查)",
|
|
2175
|
+
"原型 / 非运行时模块",
|
|
2176
|
+
"未登记 / 待处理 Skill",
|
|
2177
|
+
"内容级校准备注(人工维护,工具不覆盖)",
|
|
2178
|
+
"校准历史(新→旧,工具追加)",
|
|
2179
|
+
]
|
|
2180
|
+
|
|
2181
|
+
lines = [
|
|
2182
|
+
"# 知识库 ↔ Skill 映射校准表",
|
|
2183
|
+
"",
|
|
2184
|
+
"> 本表由 `crwu-dev-audit-skill-maintainer` 在每次 `audit` 后重新生成,供人工一眼查看映射现状。",
|
|
2185
|
+
"> **事实源**:知识库最新目录(经 `crwu-dws` 实时拉取)+ `crwu-audit/references/07-skill-registry.md` + 源仓技能层里的真实目录。",
|
|
2186
|
+
"> 本表是**只读派生视图**:不要手工改状态列;不一致时重新校准,不要手改。",
|
|
2187
|
+
"",
|
|
2188
|
+
"## 导航",
|
|
2189
|
+
"",
|
|
2190
|
+
*[f"- [{title}](#{_markdown_anchor(title)})" for title in nav_titles],
|
|
2191
|
+
"",
|
|
2192
|
+
"| 校准项 | 值 |",
|
|
2193
|
+
"| --- | --- |",
|
|
2194
|
+
f"| 校准时间 | {stamp} |",
|
|
2195
|
+
f"| 知识库目录抓取时间 | {catalog.get('fetched_at') or '(无)'} |",
|
|
2196
|
+
f"| 目录输入形态 | `{catalog.get('source_schema')}` |",
|
|
2197
|
+
f"| 抓取完整性 | {complete_text} |",
|
|
2198
|
+
f"| 目录节点数 | {catalog.get('node_paths')} |",
|
|
2199
|
+
f"| 一级资产 / 一级业务 | {summary['first_level_assets']} / {summary['first_level_businesses']} |",
|
|
2200
|
+
f"| 本次 error / warning / 建议 | {summary['errors']} / {summary['warnings']} / {summary['proposals']} |",
|
|
2201
|
+
"",
|
|
2202
|
+
]
|
|
2203
|
+
|
|
2204
|
+
for axis in ("asset", "business"):
|
|
2205
|
+
rows_for_axis = axis_rows[axis]
|
|
2206
|
+
lines += [f"## {axis_titles[axis]}", ""]
|
|
2207
|
+
if not rows_for_axis:
|
|
2208
|
+
lines += ["(知识库本次无该轴一级目录)", ""]
|
|
2209
|
+
continue
|
|
2210
|
+
if axis == "asset":
|
|
2211
|
+
lines += [
|
|
2212
|
+
"| 知识库一级目录 | 一级标签 | Skill | registry | 声明的一级根 | 共性参考 | 细分对象(有审核条目) | 本次问题 |",
|
|
2213
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
2214
|
+
]
|
|
2215
|
+
else:
|
|
2216
|
+
lines += [
|
|
2217
|
+
"| 知识库一级目录 | 一级标签 | Skill | registry | 声明的一级根 | 共同审核点 | 子业务(有要点) | 本次问题 |",
|
|
2218
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
2219
|
+
]
|
|
2220
|
+
for row in rows_for_axis:
|
|
2221
|
+
detail = row["detail"]
|
|
2222
|
+
assert isinstance(detail, dict)
|
|
2223
|
+
if axis == "asset":
|
|
2224
|
+
extra_a = "有" if detail.get("common_reference") else "**无(gap)**"
|
|
2225
|
+
total = int(detail.get("subobjects") or 0)
|
|
2226
|
+
have = int(detail.get("subobjects_with_reference") or 0)
|
|
2227
|
+
extra_b = f"{total}({have})" if total else "—"
|
|
2228
|
+
else:
|
|
2229
|
+
extra_a = "有" if detail.get("common_review") else "无(不记缺口)"
|
|
2230
|
+
total = int(detail.get("subroutes") or 0)
|
|
2231
|
+
have = int(detail.get("subroutes_with_reference") or 0)
|
|
2232
|
+
missing = detail.get("subroutes_without_reference") or []
|
|
2233
|
+
extra_b = f"{total}({have})" if total else "—"
|
|
2234
|
+
if missing:
|
|
2235
|
+
extra_b += " 缺:" + "、".join(str(m) for m in missing)
|
|
2236
|
+
codes = row["finding_codes"]
|
|
2237
|
+
codes_text = "、".join(f"`{code}`" for code in codes) if codes else "—"
|
|
2238
|
+
skill_text = f"`{row['skill']}`" if row["skill"] else "**(无)**"
|
|
2239
|
+
root_text = f"`{row['declared_root']}`" if row["declared_root"] else "—"
|
|
2240
|
+
lines.append(
|
|
2241
|
+
f"| `{row['kb_root']}` | {row['label']} | {skill_text} | {row['status']} | "
|
|
2242
|
+
f"{root_text} | {extra_a} | {extra_b} | {codes_text} |"
|
|
2243
|
+
)
|
|
2244
|
+
lines.append("")
|
|
2245
|
+
|
|
2246
|
+
path_keys = calibration.get("path_key_issues") or {}
|
|
2247
|
+
assert isinstance(path_keys, dict)
|
|
2248
|
+
key_issues = path_keys.get("issues") or []
|
|
2249
|
+
lines += [
|
|
2250
|
+
"## 库内路径键健康(公共轴也查)",
|
|
2251
|
+
"",
|
|
2252
|
+
(
|
|
2253
|
+
f"检查 {path_keys.get('checked', 0)} 个寻址键(扫描 audit 族技能目录内全部 .md 的反引号库内路径):"
|
|
2254
|
+
+ ("**全部命中本次目录**" if not key_issues else f"**{len(key_issues)} 个未命中**")
|
|
2255
|
+
),
|
|
2256
|
+
"",
|
|
2257
|
+
]
|
|
2258
|
+
if key_issues:
|
|
2259
|
+
lines += [
|
|
2260
|
+
"| Skill | 路径键 |",
|
|
2261
|
+
"| --- | --- |",
|
|
2262
|
+
# Deliberately NOT backticked: this calibration table is itself inside the scanned
|
|
2263
|
+
# audit-family skill tree, so backticking a missing key here would make the next
|
|
2264
|
+
# run report the diagnostic table as fresh drift of this very file (non-idempotent).
|
|
2265
|
+
*[f"| `{i['skill']}` | {i['key']} |" for i in key_issues],
|
|
2266
|
+
"",
|
|
2267
|
+
"> 未命中键在此**不加反引号**:本文件自身也在扫描范围内,写成寻址键会把这张诊断表当成本文件的新漂移。",
|
|
2268
|
+
"",
|
|
2269
|
+
]
|
|
2270
|
+
|
|
2271
|
+
method_layer = calibration.get("method_layer_assembly") or {}
|
|
2272
|
+
assert isinstance(method_layer, dict)
|
|
2273
|
+
non_runtime = method_layer.get("nonRuntime") or []
|
|
2274
|
+
assert isinstance(non_runtime, list)
|
|
2275
|
+
lines += ["## 原型 / 非运行时模块", ""]
|
|
2276
|
+
if non_runtime:
|
|
2277
|
+
lines += [
|
|
2278
|
+
"> 下列路径已被维护器显式追踪,但不是运行时装配键;满足启用条件并经人工复核前不得参与审核。",
|
|
2279
|
+
"",
|
|
2280
|
+
"| 库内路径 | 分类 | 隔离原因 | 运行时装配 | 启用条件 |",
|
|
2281
|
+
"| --- | --- | --- | --- | --- |",
|
|
2282
|
+
]
|
|
2283
|
+
for item in non_runtime:
|
|
2284
|
+
assert isinstance(item, dict)
|
|
2285
|
+
assembled = "**错误:已装配**" if item.get("runtimeAssembled") else "否(隔离)"
|
|
2286
|
+
lines.append(
|
|
2287
|
+
f"| {item['prefix']} | {item['classification']} | {item['reason']} | {assembled} | {item['enableWhen']} |"
|
|
2288
|
+
)
|
|
2289
|
+
else:
|
|
2290
|
+
lines.append("(无)")
|
|
2291
|
+
lines.append("")
|
|
2292
|
+
|
|
2293
|
+
unregistered = calibration["unregistered_skills"]
|
|
2294
|
+
assert isinstance(unregistered, list)
|
|
2295
|
+
lines += ["## 未登记 / 待处理 Skill", ""]
|
|
2296
|
+
if unregistered:
|
|
2297
|
+
lines += ["| Skill 目录 | 处置 |", "| --- | --- |"]
|
|
2298
|
+
lines += [
|
|
2299
|
+
f"| `{name}` | {'遗留组合 Skill,迁移到 `crwu-audit-asset-*` / `crwu-audit-biz-*`' if not name.startswith(('crwu-audit-asset-', 'crwu-audit-biz-')) else '未登记,补 registry 行或删除'} |"
|
|
2300
|
+
for name in unregistered
|
|
2301
|
+
]
|
|
2302
|
+
else:
|
|
2303
|
+
lines.append("(无)")
|
|
2304
|
+
lines.append("")
|
|
2305
|
+
|
|
2306
|
+
notes = _preserved_block(previous, CALIBRATION_NOTES_BEGIN, CALIBRATION_NOTES_END)
|
|
2307
|
+
lines += [
|
|
2308
|
+
"## 内容级校准备注(人工维护,工具不覆盖)",
|
|
2309
|
+
"",
|
|
2310
|
+
"> 目录级事实由上表自动同步;**内容级状态**(必检项是否「待补」、文档是否为空、是否占位)需在下载正文核对后写在这里。",
|
|
2311
|
+
"> 空表示本次未下载正文核对,不代表内容合格。",
|
|
2312
|
+
# 2026-09-27(F-029):备注区同时保留新旧结论,缺读取规则时读者会把较早结论误当现状。
|
|
2313
|
+
# 规则固定生成在人工备注块**之外**(块内是人工维护内容,工具不改写)。
|
|
2314
|
+
"> **现行 / 历史读取规则**:本区按新到旧记录;**同一事项出现冲突时,只采信位置更靠前的较新结论**。",
|
|
2315
|
+
"> 其后较早的冲突表述均为**历史记录**,**不得作为当前事实**。",
|
|
2316
|
+
"",
|
|
2317
|
+
CALIBRATION_NOTES_BEGIN,
|
|
2318
|
+
notes if notes else "",
|
|
2319
|
+
CALIBRATION_NOTES_END,
|
|
2320
|
+
"",
|
|
2321
|
+
]
|
|
2322
|
+
|
|
2323
|
+
history_prev = _preserved_block(previous, CALIBRATION_HISTORY_BEGIN, CALIBRATION_HISTORY_END)
|
|
2324
|
+
entry = (
|
|
2325
|
+
f"| {stamp} | {catalog.get('fetched_at') or '—'} | {catalog.get('node_paths')} | "
|
|
2326
|
+
f"{summary['errors']} | {summary['warnings']} | {summary['proposals']} |"
|
|
2327
|
+
)
|
|
2328
|
+
history_lines = [line for line in history_prev.splitlines() if line.strip()]
|
|
2329
|
+
if not history_lines:
|
|
2330
|
+
history_lines = [
|
|
2331
|
+
"| 校准时间 | 目录抓取时间 | 节点数 | error | warning | 建议 |",
|
|
2332
|
+
"| --- | --- | --- | --- | --- | --- |",
|
|
2333
|
+
]
|
|
2334
|
+
# 2026-09-26(OPT-004-R1):旧实现把新行 append 到"按展示顺序解析出来"的列表上再整体反转,
|
|
2335
|
+
# 于是**每次 emit 都把已有行的顺序翻一次**(实测同一快照 4 行被搅成 17:57:50 / 17:36:32 /
|
|
2336
|
+
# 16:10:15 / 17:56:59),而且顺序一旦错乱无法自愈。改为**按校准时间排序**:输出确定、可自愈,
|
|
2337
|
+
# 不再依赖文件里的既有顺序。注意这**不是**字节幂等:每行第一格是本次运行时间,跨秒的两次 emit
|
|
2338
|
+
# 会各追加一条(append-only 设计);只有同一秒内整行相同才不重复追加。
|
|
2339
|
+
data_rows = [line for line in history_lines[2:] if line.strip()]
|
|
2340
|
+
if entry not in data_rows:
|
|
2341
|
+
data_rows.append(entry)
|
|
2342
|
+
data_rows = sorted(set(data_rows), key=lambda line: line.split("|")[1].strip())
|
|
2343
|
+
lines += [
|
|
2344
|
+
"## 校准历史(新→旧,工具追加)",
|
|
2345
|
+
"",
|
|
2346
|
+
CALIBRATION_HISTORY_BEGIN,
|
|
2347
|
+
# 表头在顶;数据行按时间升序保存、展示时反向(新→旧)
|
|
2348
|
+
"\n".join(history_lines[:2] + list(reversed(data_rows))),
|
|
2349
|
+
CALIBRATION_HISTORY_END,
|
|
2350
|
+
"",
|
|
2351
|
+
]
|
|
2352
|
+
return "\n".join(lines)
|
|
2353
|
+
|
|
2354
|
+
|
|
2355
|
+
def render_text(report: dict[str, object]) -> str:
|
|
2356
|
+
summary = report["summary"]
|
|
2357
|
+
assert isinstance(summary, dict)
|
|
2358
|
+
lines = [
|
|
2359
|
+
"crwu-audit Skill 映射盘点",
|
|
2360
|
+
(
|
|
2361
|
+
f"一级资产 {summary['first_level_assets']} / 一级业务 "
|
|
2362
|
+
f"{summary['first_level_businesses']} / 错误 {summary['errors']} / "
|
|
2363
|
+
f"警告 {summary['warnings']} / 建议 {summary['proposals']}"
|
|
2364
|
+
),
|
|
2365
|
+
f"原型/非运行时模块:{summary['non_runtime_modules']}",
|
|
2366
|
+
"",
|
|
2367
|
+
"发现:",
|
|
2368
|
+
]
|
|
2369
|
+
findings = report["findings"]
|
|
2370
|
+
assert isinstance(findings, list)
|
|
2371
|
+
if not findings:
|
|
2372
|
+
lines.append("- 无")
|
|
2373
|
+
for item in findings:
|
|
2374
|
+
assert isinstance(item, dict)
|
|
2375
|
+
target = item.get("label") or item.get("skill") or item.get("path") or "全局"
|
|
2376
|
+
lines.append(f"- [{item['severity']}] {item['code']} · {target}:{item['message']}")
|
|
2377
|
+
calibration = report["calibration"]
|
|
2378
|
+
assert isinstance(calibration, dict)
|
|
2379
|
+
method_layer = calibration.get("method_layer_assembly") or {}
|
|
2380
|
+
assert isinstance(method_layer, dict)
|
|
2381
|
+
non_runtime = method_layer.get("nonRuntime") or []
|
|
2382
|
+
assert isinstance(non_runtime, list)
|
|
2383
|
+
lines.extend(["", "原型/非运行时模块:"])
|
|
2384
|
+
if not non_runtime:
|
|
2385
|
+
lines.append("- 无")
|
|
2386
|
+
for item in non_runtime:
|
|
2387
|
+
assert isinstance(item, dict)
|
|
2388
|
+
state = "错误:已进入运行时" if item.get("runtimeAssembled") else "已隔离"
|
|
2389
|
+
lines.append(
|
|
2390
|
+
f"- {item['classification']} · {item['prefix']} · {state};原因:{item['reason']};启用条件:{item['enableWhen']}"
|
|
2391
|
+
)
|
|
2392
|
+
lines.extend(["", "建议:"])
|
|
2393
|
+
proposals = report["proposals"]
|
|
2394
|
+
assert isinstance(proposals, list)
|
|
2395
|
+
if not proposals:
|
|
2396
|
+
lines.append("- 无")
|
|
2397
|
+
for item in proposals:
|
|
2398
|
+
assert isinstance(item, dict)
|
|
2399
|
+
skill = item.get("skill_name") or "名称待人工确认"
|
|
2400
|
+
lines.append(
|
|
2401
|
+
f"- {item['action']} · {item['axis']}:{item['label']} · {skill} · {item['kb_root']}"
|
|
2402
|
+
)
|
|
2403
|
+
return "\n".join(lines) + "\n"
|
|
2404
|
+
|
|
2405
|
+
|
|
2406
|
+
def _parser() -> argparse.ArgumentParser:
|
|
2407
|
+
parser = argparse.ArgumentParser(
|
|
2408
|
+
description="Inspect crwu-audit asset/business skill mappings without modifying files."
|
|
2409
|
+
)
|
|
2410
|
+
parser.add_argument("--repo-root", type=Path, required=True)
|
|
2411
|
+
parser.add_argument("--catalog", type=Path, required=True)
|
|
2412
|
+
parser.add_argument("--format", choices=("text", "json"), default="text")
|
|
2413
|
+
parser.add_argument(
|
|
2414
|
+
"--strict",
|
|
2415
|
+
action="store_true",
|
|
2416
|
+
help="exit 1 when error-severity findings are present",
|
|
2417
|
+
)
|
|
2418
|
+
parser.add_argument(
|
|
2419
|
+
"--emit-map",
|
|
2420
|
+
type=Path,
|
|
2421
|
+
default=None,
|
|
2422
|
+
metavar="PATH",
|
|
2423
|
+
help=(
|
|
2424
|
+
"write/refresh the human-readable knowledge-base <-> skill calibration table at PATH; "
|
|
2425
|
+
"the hand-maintained notes and the append-only history block are preserved"
|
|
2426
|
+
),
|
|
2427
|
+
)
|
|
2428
|
+
parser.add_argument(
|
|
2429
|
+
"--max-age-hours",
|
|
2430
|
+
type=float,
|
|
2431
|
+
default=None,
|
|
2432
|
+
metavar="H",
|
|
2433
|
+
help=(
|
|
2434
|
+
"require the catalog to come from a fresh crwu-dws refresh no older than H hours; "
|
|
2435
|
+
"a missing or older capture time is reported as CATALOG_NOT_LIVE / CATALOG_STALE"
|
|
2436
|
+
),
|
|
2437
|
+
)
|
|
2438
|
+
return parser
|
|
2439
|
+
|
|
2440
|
+
|
|
2441
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
2442
|
+
args = _parser().parse_args(argv)
|
|
2443
|
+
try:
|
|
2444
|
+
catalog = load_catalog(args.catalog)
|
|
2445
|
+
report = inspect_repository(
|
|
2446
|
+
args.repo_root.resolve(), catalog, max_age_hours=args.max_age_hours
|
|
2447
|
+
)
|
|
2448
|
+
except (OSError, ValueError) as exc:
|
|
2449
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
2450
|
+
return 2
|
|
2451
|
+
if args.emit_map is not None:
|
|
2452
|
+
try:
|
|
2453
|
+
previous = (
|
|
2454
|
+
args.emit_map.read_text(encoding="utf-8") if args.emit_map.is_file() else None
|
|
2455
|
+
)
|
|
2456
|
+
args.emit_map.parent.mkdir(parents=True, exist_ok=True)
|
|
2457
|
+
args.emit_map.write_text(render_calibration(report, previous), encoding="utf-8")
|
|
2458
|
+
except OSError as exc:
|
|
2459
|
+
print(f"error: cannot write calibration map: {exc}", file=sys.stderr)
|
|
2460
|
+
return 2
|
|
2461
|
+
# `--format json` must keep stdout parseable as the single report object, so the notice
|
|
2462
|
+
# goes to stderr in that mode (callers pipe stdout straight into a JSON parser).
|
|
2463
|
+
print(
|
|
2464
|
+
f"校准表已更新:{args.emit_map}",
|
|
2465
|
+
file=sys.stderr if args.format == "json" else sys.stdout,
|
|
2466
|
+
)
|
|
2467
|
+
if args.format == "json":
|
|
2468
|
+
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
|
2469
|
+
else:
|
|
2470
|
+
print(render_text(report), end="")
|
|
2471
|
+
summary = report["summary"]
|
|
2472
|
+
assert isinstance(summary, dict)
|
|
2473
|
+
return 1 if args.strict and int(summary["errors"]) > 0 else 0
|
|
2474
|
+
|
|
2475
|
+
|
|
2476
|
+
if __name__ == "__main__":
|
|
2477
|
+
raise SystemExit(main())
|