@1aboveio/skills 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -2
- package/bin/cli.mjs +14 -0
- package/bin/select-core.mjs +172 -0
- package/package.json +146 -4
- package/skills/app-dev/airflow/LICENSE +3 -0
- package/skills/app-dev/airflow/SKILL.md +199 -0
- package/skills/app-dev/airflow/release-check.mjs +93 -0
- package/skills/app-dev/android-foundation/LICENSE +3 -0
- package/skills/app-dev/android-foundation/SKILL.md +192 -0
- package/skills/app-dev/android-foundation/assets/android-ci-job.yml +217 -0
- package/skills/app-dev/android-foundation/references/ci.md +87 -0
- package/skills/app-dev/android-foundation/references/gotchas.md +221 -0
- package/skills/app-dev/android-foundation/references/setup.md +164 -0
- package/skills/app-dev/android-foundation/release-check.mjs +93 -0
- package/skills/app-dev/android-foundation/scripts/device-run.sh +176 -0
- package/skills/app-dev/android-foundation/scripts/device-run.test.mjs +172 -0
- package/skills/app-dev/app-debug/LICENSE +3 -0
- package/skills/app-dev/app-debug/SKILL.md +110 -0
- package/skills/app-dev/app-debug/references/common-errors.md +128 -0
- package/skills/app-dev/app-debug/release-check.mjs +93 -0
- package/skills/app-dev/better-auth/LICENSE +3 -0
- package/skills/app-dev/better-auth/SKILL.md +598 -0
- package/skills/app-dev/better-auth/references/feishu-api.md +270 -0
- package/skills/app-dev/better-auth/release-check.mjs +93 -0
- package/skills/app-dev/data-catalog/LICENSE +3 -0
- package/skills/app-dev/data-catalog/SKILL.md +204 -0
- package/skills/app-dev/data-catalog/agents/openai.yaml +3 -0
- package/skills/app-dev/data-catalog/references/data-catalog-conventions.md +75 -0
- package/skills/app-dev/data-catalog/references/field-definitions.md +116 -0
- package/skills/app-dev/data-catalog/references/format.md +121 -0
- package/skills/app-dev/data-catalog/release-check.mjs +93 -0
- package/skills/app-dev/data-catalog/scripts/data-catalog.test.mjs +36 -0
- package/skills/app-dev/data-catalog/scripts/data_catalog.py +1392 -0
- package/skills/app-dev/data-catalog/scripts/persist-conventions-core.mjs +117 -0
- package/skills/app-dev/data-catalog/scripts/persist-conventions.mjs +17 -0
- package/skills/app-dev/data-catalog/scripts/test_data_catalog.py +571 -0
- package/skills/app-dev/ios-foundation/LICENSE +3 -0
- package/skills/app-dev/ios-foundation/SKILL.md +184 -0
- package/skills/app-dev/ios-foundation/references/setup.md +243 -0
- package/skills/app-dev/ios-foundation/release-check.mjs +93 -0
- package/skills/app-dev/monorepo/LICENSE +3 -0
- package/skills/app-dev/monorepo/SKILL.md +462 -0
- package/skills/app-dev/monorepo/release-check.mjs +93 -0
- package/skills/app-dev/nextjs-fullstack/LICENSE +3 -0
- package/skills/app-dev/nextjs-fullstack/SKILL.md +211 -0
- package/skills/app-dev/nextjs-fullstack/conventions.md +318 -0
- package/skills/app-dev/nextjs-fullstack/frontend-conventions.md +61 -0
- package/skills/app-dev/nextjs-fullstack/nextjs16.md +287 -0
- package/skills/app-dev/nextjs-fullstack/release-check.mjs +93 -0
- package/skills/app-dev/nextjs-fullstack/server-actions.md +409 -0
- package/skills/app-dev/prisma-setup/LICENSE +3 -0
- package/skills/app-dev/prisma-setup/SKILL.md +181 -0
- package/skills/app-dev/prisma-setup/nextjs.md +258 -0
- package/skills/app-dev/prisma-setup/release-check.mjs +93 -0
- package/skills/app-dev/prisma-setup/turborepo.md +301 -0
- package/skills/app-dev/pyspark/LICENSE +3 -0
- package/skills/app-dev/pyspark/SKILL.md +127 -0
- package/skills/app-dev/pyspark/assets/templates/etl.py +748 -0
- package/skills/app-dev/pyspark/assets/templates/utils/__init__.py +1 -0
- package/skills/app-dev/pyspark/assets/templates/utils/hudi_metadata.py +14 -0
- package/skills/app-dev/pyspark/references/diagnosis-and-profiling.md +169 -0
- package/skills/app-dev/pyspark/references/etl-contract.md +129 -0
- package/skills/app-dev/pyspark/references/production-validation.md +131 -0
- package/skills/app-dev/pyspark/references/reconciliation.md +38 -0
- package/skills/app-dev/pyspark/references/transformation-design.md +190 -0
- package/skills/app-dev/pyspark/references/velocity-feature-calculation.md +193 -0
- package/skills/app-dev/pyspark/release-check.mjs +93 -0
- package/skills/app-dev/pyspark/scripts/spark_eventlog_summary.py +223 -0
- package/skills/app-dev/python-backend/LICENSE +3 -0
- package/skills/app-dev/python-backend/SKILL.md +325 -0
- package/skills/app-dev/python-backend/assets/uv-install.sh +48 -0
- package/skills/app-dev/python-backend/references/uv-installs.md +83 -0
- package/skills/app-dev/python-backend/release-check.mjs +93 -0
- package/skills/app-dev/shadcn/LICENSE +3 -0
- package/skills/app-dev/shadcn/SKILL.md +120 -0
- package/skills/app-dev/shadcn/assets/shadcn-small.png +0 -0
- package/skills/app-dev/shadcn/assets/shadcn.png +0 -0
- package/skills/app-dev/shadcn/cli.md +411 -0
- package/skills/app-dev/shadcn/customization.md +224 -0
- package/skills/app-dev/shadcn/mcp.md +101 -0
- package/skills/app-dev/shadcn/release-check.mjs +93 -0
- package/skills/app-dev/shadcn/rules/base-vs-radix.md +323 -0
- package/skills/app-dev/shadcn/rules/component-selection.md +67 -0
- package/skills/app-dev/shadcn/rules/composition.md +195 -0
- package/skills/app-dev/shadcn/rules/data-table.md +201 -0
- package/skills/app-dev/shadcn/rules/forms.md +255 -0
- package/skills/app-dev/shadcn/rules/icons.md +103 -0
- package/skills/app-dev/shadcn/rules/styling.md +167 -0
- package/skills/app-dev/zod-v4/LICENSE +3 -0
- package/skills/app-dev/zod-v4/SKILL.md +286 -0
- package/skills/app-dev/zod-v4/release-check.mjs +93 -0
- package/skills/cicd/cloud-build/LICENSE +3 -0
- package/skills/cicd/cloud-build/SKILL.md +707 -0
- package/skills/cicd/cloud-build/release-check.mjs +93 -0
- package/skills/cicd/cloud-debug/LICENSE +3 -0
- package/skills/cicd/cloud-debug/SKILL.md +315 -0
- package/skills/cicd/cloud-debug/references/build-failures.md +238 -0
- package/skills/cicd/cloud-debug/references/deploy-failures.md +376 -0
- package/skills/cicd/cloud-debug/references/pipeline-failures.md +378 -0
- package/skills/cicd/cloud-debug/release-check.mjs +93 -0
- package/skills/cicd/cloud-deploy/LICENSE +3 -0
- package/skills/cicd/cloud-deploy/SKILL.md +230 -0
- package/skills/cicd/cloud-deploy/references/config-templates.md +257 -0
- package/skills/cicd/cloud-deploy/release-check.mjs +93 -0
- package/skills/cicd/docker/LICENSE +3 -0
- package/skills/cicd/docker/SKILL.md +151 -0
- package/skills/cicd/docker/release-check.mjs +93 -0
- package/skills/cicd/github-actions/LICENSE +3 -0
- package/skills/cicd/github-actions/SKILL.md +111 -0
- package/skills/cicd/github-actions/references/audit-profile-optimize.md +150 -0
- package/skills/cicd/github-actions/references/ci-case-studies.md +141 -0
- package/skills/cicd/github-actions/references/conditional-ci.md +214 -0
- package/skills/cicd/github-actions/references/debugging.md +13 -0
- package/skills/cicd/github-actions/references/pipeline.md +87 -0
- package/skills/cicd/github-actions/references/post-deploy-and-publishing.md +33 -0
- package/skills/cicd/github-actions/references/runners.md +253 -0
- package/skills/cicd/github-actions/references/security.md +29 -0
- package/skills/cicd/github-actions/references/speed-and-audit.md +115 -0
- package/skills/cicd/github-actions/references/two-pass-reading.md +92 -0
- package/skills/cicd/github-actions/release-check.mjs +93 -0
- package/skills/cicd/github-actions/scripts/ci-run-audit-core.mjs +612 -0
- package/skills/cicd/github-actions/scripts/ci-run-audit.mjs +4 -0
- package/skills/cicd/github-actions/scripts/ci-run-audit.test.mjs +637 -0
- package/skills/cicd/github-flow/LICENSE +3 -0
- package/skills/cicd/github-flow/SKILL.md +123 -0
- package/skills/cicd/github-flow/references/ci-conventions.md +14 -0
- package/skills/cicd/github-flow/release-check.mjs +93 -0
- package/skills/cicd/github-flow/scripts/flow-core.mjs +105 -0
- package/skills/cicd/github-flow/scripts/flow.mjs +81 -0
- package/skills/cicd/github-flow/scripts/flow.test.mjs +114 -0
- package/skills/cicd/github-flow/scripts/persist-conventions-core.mjs +117 -0
- package/skills/cicd/github-flow/scripts/persist-conventions.mjs +49 -0
- package/skills/cicd/github-flow/scripts/persist-conventions.test.mjs +108 -0
- package/skills/cicd/google-cloud/LICENSE +3 -0
- package/skills/cicd/google-cloud/SKILL.md +119 -0
- package/skills/cicd/google-cloud/references/gcs.md +469 -0
- package/skills/cicd/google-cloud/references/iam.md +451 -0
- package/skills/cicd/google-cloud/references/project.md +349 -0
- package/skills/cicd/google-cloud/references/secrets.md +336 -0
- package/skills/cicd/google-cloud/references/vpc.md +312 -0
- package/skills/cicd/google-cloud/release-check.mjs +93 -0
- package/skills/cicd/google-cloud/scripts/create-sa.sh +36 -0
- package/skills/cicd/google-cloud/scripts/gcp-config.sh +31 -0
- package/skills/cicd/google-cloud/scripts/grant-iap.sh +41 -0
- package/skills/cicd/google-cloud/scripts/setup-secrets.sh +48 -0
- package/skills/cicd/mergify/LICENSE +3 -0
- package/skills/cicd/mergify/SKILL.md +102 -0
- package/skills/cicd/mergify/assets/templates/mergify.yml +215 -0
- package/skills/cicd/mergify/assets/templates/ruleset.json +46 -0
- package/skills/cicd/mergify/references/branch-protection.md +282 -0
- package/skills/cicd/mergify/references/configuration.md +274 -0
- package/skills/cicd/mergify/references/diagnosis.md +109 -0
- package/skills/cicd/mergify/references/stacks.md +84 -0
- package/skills/cicd/mergify/references/traps.md +119 -0
- package/skills/cicd/mergify/references/watch-contract.md +274 -0
- package/skills/cicd/mergify/release-check.mjs +93 -0
- package/skills/cicd/mergify/scripts/audit-core.mjs +208 -0
- package/skills/cicd/mergify/scripts/audit.mjs +4 -0
- package/skills/cicd/mergify/scripts/audit.test.mjs +201 -0
- package/skills/cicd/mergify/scripts/entrypoints.test.mjs +106 -0
- package/skills/cicd/mergify/scripts/pr-620-mergify-comments.fixture.json +30 -0
- package/skills/cicd/mergify/scripts/stack-push-core.mjs +258 -0
- package/skills/cicd/mergify/scripts/stack-push.mjs +4 -0
- package/skills/cicd/mergify/scripts/stack-push.test.mjs +233 -0
- package/skills/cicd/mergify/scripts/watch-pr-delivery-core.mjs +1065 -0
- package/skills/cicd/mergify/scripts/watch-pr-delivery.mjs +4 -0
- package/skills/cicd/mergify/scripts/watch-pr-delivery.test.mjs +1826 -0
- package/skills/cicd/podman/LICENSE +3 -0
- package/skills/cicd/podman/SKILL.md +71 -0
- package/skills/cicd/podman/agents/openai.yaml +4 -0
- package/skills/cicd/podman/assets/templates/podman-compose-socket-directory.yml +6 -0
- package/skills/cicd/podman/assets/templates/podman-service-override.conf +3 -0
- package/skills/cicd/podman/references/compose-compatibility.md +70 -0
- package/skills/cicd/podman/references/networking-and-ports.md +74 -0
- package/skills/cicd/podman/references/rootless-services-and-sockets.md +156 -0
- package/skills/cicd/podman/references/troubleshooting.md +98 -0
- package/skills/cicd/podman/release-check.mjs +93 -0
- package/skills/content/article-review/LICENSE +3 -0
- package/skills/content/article-review/SKILL.md +66 -0
- package/skills/content/article-review/references/report-format.md +68 -0
- package/skills/content/article-review/references/template-guide.md +79 -0
- package/skills/content/article-review/references/template-wechat-payments.md +125 -0
- package/skills/content/article-review/release-check.mjs +93 -0
- package/skills/content/article-to-image/LICENSE +3 -0
- package/skills/content/article-to-image/SKILL.md +91 -0
- package/skills/content/article-to-image/references/formats.md +53 -0
- package/skills/content/article-to-image/release-check.mjs +93 -0
- package/skills/content/flowchart-svg/LICENSE +3 -0
- package/skills/content/flowchart-svg/SKILL.md +43 -0
- package/skills/content/flowchart-svg/release-check.mjs +93 -0
- package/skills/content/flowchart-svg/scripts/export_png.sh +8 -0
- package/skills/content/image-gen/LICENSE +3 -0
- package/skills/content/image-gen/SKILL.md +192 -0
- package/skills/content/image-gen/release-check.mjs +93 -0
- package/skills/content/image-gen/scripts/credentials.mjs +217 -0
- package/skills/content/image-gen/scripts/credentials.test.mjs +212 -0
- package/skills/content/image-gen/scripts/generate.mjs +240 -0
- package/skills/content/image-gen/scripts/options.mjs +293 -0
- package/skills/content/image-gen/scripts/options.test.mjs +245 -0
- package/skills/content/mermaid/LICENSE +3 -0
- package/skills/content/mermaid/SKILL.md +635 -0
- package/skills/content/mermaid/release-check.mjs +93 -0
- package/skills/content/wechat-article-writer/LICENSE +3 -0
- package/skills/content/wechat-article-writer/SKILL.md +92 -0
- package/skills/content/wechat-article-writer/references/style-selector.md +133 -0
- package/skills/content/wechat-article-writer/references/writing-rules.md +152 -0
- package/skills/content/wechat-article-writer/release-check.mjs +93 -0
- package/skills/content/wechat-article-writer/templates/wechat-comparison.md +52 -0
- package/skills/content/wechat-article-writer/templates/wechat-howto.md +51 -0
- package/skills/content/wechat-article-writer/templates/wechat-insight.md +50 -0
- package/skills/content/wechat-article-writer/templates/wechat-pitfall.md +52 -0
- package/skills/content/wechat-publisher/LICENSE +3 -0
- package/skills/content/wechat-publisher/SKILL.md +171 -0
- package/skills/content/wechat-publisher/references/api.md +105 -0
- package/skills/content/wechat-publisher/references/layout-rules.md +57 -0
- package/skills/content/wechat-publisher/references/wechat-safe-template.html +86 -0
- package/skills/content/wechat-publisher/release-check.mjs +93 -0
- package/skills/content/wechat-publisher/scripts/md_to_wechat_html.py +194 -0
- package/skills/content/wechat-publisher/scripts/publish.py +239 -0
- package/skills/content/wechat-search/LICENSE +3 -0
- package/skills/content/wechat-search/SKILL.md +211 -0
- package/skills/content/wechat-search/release-check.mjs +93 -0
- package/skills/content/wechat-search/scripts/start-chrome.sh +83 -0
- package/skills/content/xai-search/LICENSE +3 -0
- package/skills/content/xai-search/SKILL.md +79 -0
- package/skills/content/xai-search/release-check.mjs +93 -0
- package/skills/content/xai-search/scripts/xai_search.sh +187 -0
- package/skills/content/xai-search/tests/smoke_test.sh +36 -0
- package/skills/core/distill-writing/LICENSE +3 -0
- package/skills/core/distill-writing/SKILL.md +74 -0
- package/skills/core/distill-writing/references/extraction.md +95 -0
- package/skills/core/distill-writing/references/updating.md +46 -0
- package/skills/core/distill-writing/release-check.mjs +93 -0
- package/skills/core/doc-to-md/LICENSE +3 -0
- package/skills/core/doc-to-md/SKILL.md +78 -0
- package/skills/core/doc-to-md/release-check.mjs +93 -0
- package/skills/core/doc-to-md/scripts/convert.py +157 -0
- package/skills/core/editorial-review/LICENSE +3 -0
- package/skills/core/editorial-review/SKILL.md +79 -0
- package/skills/core/editorial-review/references/checklist.md +94 -0
- package/skills/core/editorial-review/release-check.mjs +93 -0
- package/skills/core/house-style/LICENSE +3 -0
- package/skills/core/house-style/SKILL.md +33 -0
- package/skills/core/house-style/references/defaults.md +66 -0
- package/skills/core/house-style/references/rules.md +267 -0
- package/skills/core/house-style/references/sources.md +27 -0
- package/skills/core/house-style/references/style-en.md +131 -0
- package/skills/core/house-style/references/style-zh.md +141 -0
- package/skills/core/house-style/references/terminology-zh.md +223 -0
- package/skills/core/house-style/release-check.mjs +93 -0
- package/skills/core/house-style/templates/brd.md +103 -0
- package/skills/core/house-style/templates/commercial-proposal.md +93 -0
- package/skills/core/house-style/templates/compliance-policy.md +109 -0
- package/skills/core/house-style/templates/gap-analysis.md +105 -0
- package/skills/core/house-style/templates/general.md +63 -0
- package/skills/core/house-style/templates/integration-guide.md +98 -0
- package/skills/core/house-style/templates/prd.md +81 -0
- package/skills/core/house-style/templates/risk-operations-procedure.md +94 -0
- package/skills/core/house-style/templates/technical-spec.md +96 -0
- package/skills/core/polish-cn/LICENSE +3 -0
- package/skills/core/polish-cn/SKILL.md +102 -0
- package/skills/core/polish-cn/references/brief.md +93 -0
- package/skills/core/polish-cn/references/models.md +76 -0
- package/skills/core/polish-cn/release-check.mjs +93 -0
- package/skills/core/polish-cn/scripts/polish-cn.test.mjs +244 -0
- package/skills/core/polish-cn/scripts/polish_blocks.py +489 -0
- package/skills/core/reg-parser/LICENSE +3 -0
- package/skills/core/reg-parser/SKILL.md +212 -0
- package/skills/core/reg-parser/references/checklist-schema.md +223 -0
- package/skills/core/reg-parser/references/checklists/hk-amlo-checklist.json +1306 -0
- package/skills/core/reg-parser/references/checklists/hk-amlo-checklist.md +469 -0
- package/skills/core/reg-parser/references/checklists/hk-fi-corporate-onboarding-cdd-checklist.json +634 -0
- package/skills/core/reg-parser/references/checklists/hk-fi-corporate-onboarding-cdd-checklist.md +233 -0
- package/skills/core/reg-parser/references/checklists/hk-mso-guideline-checklist.json +2194 -0
- package/skills/core/reg-parser/references/checklists/hk-mso-guideline-checklist.md +771 -0
- package/skills/core/reg-parser/references/checklists/kyc-aml-checklist.md +258 -0
- package/skills/core/reg-parser/references/checklists/visa-vars-checklist.md +1058 -0
- package/skills/core/reg-parser/references/mandates/hk-amlo-cap615.md +389 -0
- package/skills/core/reg-parser/references/mandates/visa-vars.md +205 -0
- package/skills/core/reg-parser/release-check.mjs +93 -0
- package/skills/core/reg-parser/scripts/compare_checklist.py +230 -0
- package/skills/core/reg-parser/scripts/dump_checklist.py +361 -0
- package/skills/core/reg-parser/scripts/preprocess-mandate.sh +187 -0
- package/skills/core/reg-parser/scripts/reg-parser.test.mjs +165 -0
- package/skills/core/reg-parser/scripts/test_quarantine.sh +105 -0
- package/skills/core/reg-parser/scripts/validate_checklist.py +563 -0
- package/skills/core/reg-parser/scripts/validate_mandate.py +273 -0
- package/skills/core/terminology/LICENSE +3 -0
- package/skills/core/terminology/SKILL.md +62 -0
- package/skills/core/terminology/references/format.md +50 -0
- package/skills/core/terminology/references/init.md +32 -0
- package/skills/core/terminology/release-check.mjs +93 -0
- package/skills/core/write-doc/LICENSE +3 -0
- package/skills/core/write-doc/SKILL.md +141 -0
- package/skills/core/write-doc/references/bilingual.md +60 -0
- package/skills/core/write-doc/release-check.mjs +93 -0
- package/skills/engineering/better-review/LICENSE +3 -0
- package/skills/engineering/better-review/SKILL.md +249 -0
- package/skills/engineering/better-review/agents/openai.yaml +9 -0
- package/skills/engineering/better-review/references/domain-hazards.md +53 -0
- package/skills/engineering/better-review/references/passes.md +147 -0
- package/skills/engineering/better-review/references/result.md +151 -0
- package/skills/engineering/better-review/release-check.mjs +93 -0
- package/skills/engineering/better-review/scripts/pr-context.mjs +212 -0
- package/skills/engineering/better-review/scripts/pr-context.test.mjs +168 -0
- package/skills/engineering/better-review/scripts/rereview-scope.mjs +503 -0
- package/skills/engineering/better-review/scripts/rereview-scope.test.mjs +587 -0
- package/skills/engineering/better-review/scripts/scan-diff.mjs +365 -0
- package/skills/engineering/better-review/scripts/scan-diff.test.mjs +414 -0
- package/skills/engineering/better-review/scripts/vendor-sync.test.mjs +96 -0
- package/skills/engineering/e2e-test/LICENSE +3 -0
- package/skills/engineering/e2e-test/SKILL.md +98 -0
- package/skills/engineering/e2e-test/assets/ci-gates.cloudbuild.yaml +24 -0
- package/skills/engineering/e2e-test/references/authoring/auth-flows.md +159 -0
- package/skills/engineering/e2e-test/references/authoring/playwright-config.md +73 -0
- package/skills/engineering/e2e-test/references/authoring/playwright-patterns.md +144 -0
- package/skills/engineering/e2e-test/references/ci-integration.md +25 -0
- package/skills/engineering/e2e-test/references/ci-playwright-container.md +290 -0
- package/skills/engineering/e2e-test/references/presentation-sweep.md +104 -0
- package/skills/engineering/e2e-test/references/reviewing.md +41 -0
- package/skills/engineering/e2e-test/references/route-manifest.md +51 -0
- package/skills/engineering/e2e-test/release-check.mjs +93 -0
- package/skills/engineering/e2e-test/scripts/detect-routes-nextjs.mjs +200 -0
- package/skills/engineering/e2e-test/scripts/presentation-checks.mjs +171 -0
- package/skills/engineering/e2e-test/scripts/presentation-checks.test.mjs +106 -0
- package/skills/engineering/e2e-test/scripts/presentation-perceivability.mjs +179 -0
- package/skills/engineering/e2e-test/scripts/presentation-perceivability.test.mjs +117 -0
- package/skills/engineering/e2e-test/scripts/presentation-reachability.mjs +154 -0
- package/skills/engineering/e2e-test/scripts/presentation-reachability.test.mjs +69 -0
- package/skills/engineering/e2e-test/scripts/presentation-render-health.mjs +141 -0
- package/skills/engineering/e2e-test/scripts/presentation-render-health.test.mjs +98 -0
- package/skills/engineering/e2e-test/scripts/presentation-sweep.mjs +147 -0
- package/skills/engineering/e2e-test/scripts/presentation-sweep.test.mjs +71 -0
- package/skills/engineering/e2e-test/scripts/presentation-temporal.mjs +127 -0
- package/skills/engineering/e2e-test/scripts/presentation-temporal.test.mjs +59 -0
- package/skills/engineering/e2e-test/scripts/project-route-manifest.mjs +82 -0
- package/skills/engineering/e2e-test/scripts/project-route-manifest.test.mjs +76 -0
- package/skills/engineering/e2e-test/scripts/validate-manifest.mjs +106 -0
- package/skills/engineering/engineering-runtime/LICENSE +3 -0
- package/skills/engineering/engineering-runtime/SKILL.md +11 -0
- package/skills/engineering/engineering-runtime/agents/openai.yaml +6 -0
- package/skills/engineering/engineering-runtime/coherence/workflow.json +597 -0
- package/skills/engineering/engineering-runtime/engineering-runtime.test.mjs +68 -0
- package/skills/engineering/engineering-runtime/release-check.mjs +93 -0
- package/skills/engineering/engineering-runtime/scripts/exact-head-artifact.mjs +131 -0
- package/skills/engineering/engineering-runtime/scripts/head-check-set.mjs +398 -0
- package/skills/engineering/engineering-runtime/scripts/invocation-policy.mjs +187 -0
- package/skills/engineering/engineering-runtime/scripts/invocation-policy.test.mjs +95 -0
- package/skills/engineering/engineering-runtime/scripts/main-module.mjs +79 -0
- package/skills/engineering/engineering-runtime/scripts/main-module.test.mjs +288 -0
- package/skills/engineering/engineering-runtime/scripts/mergify-yaml.mjs +11 -0
- package/skills/engineering/engineering-runtime/scripts/package-lock.json +43 -0
- package/skills/engineering/engineering-runtime/scripts/package.json +10 -0
- package/skills/engineering/engineering-runtime/scripts/required-check-plan.mjs +223 -0
- package/skills/engineering/engineering-runtime/scripts/required-check-plan.test.mjs +96 -0
- package/skills/engineering/engineering-runtime/scripts/review-completion.mjs +56 -0
- package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +595 -0
- package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +189 -0
- package/skills/engineering/engineering-runtime/scripts/yaml-mapping.mjs +18 -0
- package/skills/engineering/ensure-coverage/LICENSE +3 -0
- package/skills/engineering/ensure-coverage/SKILL.md +129 -0
- package/skills/engineering/ensure-coverage/references/breadth/coverage-ledger.md +92 -0
- package/skills/engineering/ensure-coverage/references/breadth/inventory-contract.md +85 -0
- package/skills/engineering/ensure-coverage/references/breadth/surface-baseline.md +44 -0
- package/skills/engineering/ensure-coverage/references/breadth/surface-discovery.md +16 -0
- package/skills/engineering/ensure-coverage/references/depth/characterization.md +68 -0
- package/skills/engineering/ensure-coverage/references/depth/coverage.config.example.json +19 -0
- package/skills/engineering/ensure-coverage/references/depth/grading.md +35 -0
- package/skills/engineering/ensure-coverage/references/depth/mock-policy.md +57 -0
- package/skills/engineering/ensure-coverage/references/depth/test-smells.md +39 -0
- package/skills/engineering/ensure-coverage/references/enforcement/ci-contract.md +44 -0
- package/skills/engineering/ensure-coverage/references/examples/coverage-ledger.md +110 -0
- package/skills/engineering/ensure-coverage/references/examples/refund-flow.md +35 -0
- package/skills/engineering/ensure-coverage/references/presentation/axis.md +64 -0
- package/skills/engineering/ensure-coverage/references/process/audit-mode.md +27 -0
- package/skills/engineering/ensure-coverage/references/process/output-template.md +139 -0
- package/skills/engineering/ensure-coverage/references/process/test-strategy.md +56 -0
- package/skills/engineering/ensure-coverage/release-check.mjs +93 -0
- package/skills/engineering/ensure-coverage/schemas/coverage-config.schema.json +67 -0
- package/skills/engineering/ensure-coverage/schemas/coverage-file.schema.json +140 -0
- package/skills/engineering/ensure-coverage/scripts/README.md +99 -0
- package/skills/engineering/ensure-coverage/scripts/adapters/nextjs-inventory.mjs +182 -0
- package/skills/engineering/ensure-coverage/scripts/adapters/nextjs-inventory.test.mjs +96 -0
- package/skills/engineering/ensure-coverage/scripts/check-quarantine-expiry.mjs +105 -0
- package/skills/engineering/ensure-coverage/scripts/check-quarantine-expiry.test.mjs +47 -0
- package/skills/engineering/ensure-coverage/scripts/ci-audit.mjs +137 -0
- package/skills/engineering/ensure-coverage/scripts/ci-audit.test.mjs +86 -0
- package/skills/engineering/ensure-coverage/scripts/coverage-ledger.mjs +674 -0
- package/skills/engineering/ensure-coverage/scripts/coverage-ledger.test.mjs +447 -0
- package/skills/engineering/ensure-coverage/scripts/design-parity.mjs +591 -0
- package/skills/engineering/ensure-coverage/scripts/design-parity.test.mjs +552 -0
- package/skills/engineering/ensure-coverage/scripts/lint-tests.mjs +282 -0
- package/skills/engineering/ensure-coverage/scripts/lint-tests.test.mjs +289 -0
- package/skills/engineering/ensure-coverage/scripts/mock-policy-config.mjs +176 -0
- package/skills/engineering/ensure-coverage/scripts/mock-policy-config.test.mjs +119 -0
- package/skills/engineering/ensure-coverage/scripts/package.json +19 -0
- package/skills/engineering/golden-path/LICENSE +3 -0
- package/skills/engineering/golden-path/SKILL.md +230 -0
- package/skills/engineering/golden-path/agents/openai.yaml +3 -0
- package/skills/engineering/golden-path/assets/golden-paths.example.json +57 -0
- package/skills/engineering/golden-path/references/contract.md +239 -0
- package/skills/engineering/golden-path/references/migration.md +111 -0
- package/skills/engineering/golden-path/release-check.mjs +93 -0
- package/skills/engineering/golden-path/scripts/golden-path-core.mjs +1779 -0
- package/skills/engineering/golden-path/scripts/golden-path.mjs +6 -0
- package/skills/engineering/golden-path/scripts/golden-path.test.mjs +1457 -0
- package/skills/engineering/harness-runtime/LICENSE +3 -0
- package/skills/engineering/harness-runtime/SKILL.md +19 -0
- package/skills/engineering/harness-runtime/agents/openai.yaml +6 -0
- package/skills/engineering/harness-runtime/bin/discover-models.mjs +4 -0
- package/skills/engineering/harness-runtime/bin/model-catalog.mjs +4 -0
- package/skills/engineering/harness-runtime/contracts.md +15 -0
- package/skills/engineering/harness-runtime/discover-models.mjs +392 -0
- package/skills/engineering/harness-runtime/discover-models.test.mjs +363 -0
- package/skills/engineering/harness-runtime/fixtures/native-question-schemas.json +33 -0
- package/skills/engineering/harness-runtime/fixtures/question-responses.json +54 -0
- package/skills/engineering/harness-runtime/harness-runtime.test.mjs +967 -0
- package/skills/engineering/harness-runtime/index.mjs +767 -0
- package/skills/engineering/harness-runtime/model-catalog-seed.test.mjs +829 -0
- package/skills/engineering/harness-runtime/model-catalog.mjs +787 -0
- package/skills/engineering/harness-runtime/model-catalog.test.mjs +366 -0
- package/skills/engineering/harness-runtime/native-question-contracts.md +37 -0
- package/skills/engineering/harness-runtime/references/model-catalog-seed.json +408 -0
- package/skills/engineering/harness-runtime/references/model-catalog.md +57 -0
- package/skills/engineering/harness-runtime/release-check.mjs +93 -0
- package/skills/engineering/resolve-issues/LICENSE +3 -0
- package/skills/engineering/resolve-issues/SKILL.md +250 -0
- package/skills/engineering/resolve-issues/agents/openai.yaml +9 -0
- package/skills/engineering/resolve-issues/plugin-hooks.json +17 -0
- package/skills/engineering/resolve-issues/references/canary.md +28 -0
- package/skills/engineering/resolve-issues/references/ci.md +105 -0
- package/skills/engineering/resolve-issues/references/done.md +25 -0
- package/skills/engineering/resolve-issues/references/dropbox.md +90 -0
- package/skills/engineering/resolve-issues/references/expire.md +318 -0
- package/skills/engineering/resolve-issues/references/exploration.md +202 -0
- package/skills/engineering/resolve-issues/references/implementation.md +303 -0
- package/skills/engineering/resolve-issues/references/preflight.md +445 -0
- package/skills/engineering/resolve-issues/references/review.md +228 -0
- package/skills/engineering/resolve-issues/references/shared-modules.md +44 -0
- package/skills/engineering/resolve-issues/references/task-plan.md +264 -0
- package/skills/engineering/resolve-issues/release-check.mjs +93 -0
- package/skills/engineering/resolve-issues/scripts/cleanup-runs.mjs +364 -0
- package/skills/engineering/resolve-issues/scripts/cleanup-runs.test.mjs +280 -0
- package/skills/engineering/resolve-issues/scripts/discover-models.mjs +9 -0
- package/skills/engineering/resolve-issues/scripts/dropbox.mjs +473 -0
- package/skills/engineering/resolve-issues/scripts/dropbox.test.mjs +346 -0
- package/skills/engineering/resolve-issues/scripts/findings-reuse.mjs +98 -0
- package/skills/engineering/resolve-issues/scripts/findings-reuse.test.mjs +88 -0
- package/skills/engineering/resolve-issues/scripts/github-reviews.mjs +384 -0
- package/skills/engineering/resolve-issues/scripts/github-reviews.test.mjs +264 -0
- package/skills/engineering/resolve-issues/scripts/install-hooks.mjs +135 -0
- package/skills/engineering/resolve-issues/scripts/install-hooks.test.mjs +79 -0
- package/skills/engineering/resolve-issues/scripts/issue-tree.mjs +162 -0
- package/skills/engineering/resolve-issues/scripts/issue-tree.test.mjs +107 -0
- package/skills/engineering/resolve-issues/scripts/manual-invocation.test.mjs +14 -0
- package/skills/engineering/resolve-issues/scripts/model-catalog.mjs +9 -0
- package/skills/engineering/resolve-issues/scripts/next-actions-capacity.test.mjs +149 -0
- package/skills/engineering/resolve-issues/scripts/next-actions.mjs +856 -0
- package/skills/engineering/resolve-issues/scripts/next-actions.test.mjs +915 -0
- package/skills/engineering/resolve-issues/scripts/plan-sync.mjs +269 -0
- package/skills/engineering/resolve-issues/scripts/plan-sync.test.mjs +156 -0
- package/skills/engineering/resolve-issues/scripts/preflight-models.mjs +736 -0
- package/skills/engineering/resolve-issues/scripts/preflight-models.test.mjs +448 -0
- package/skills/engineering/resolve-issues/scripts/ready-check.mjs +185 -0
- package/skills/engineering/resolve-issues/scripts/ready-check.test.mjs +217 -0
- package/skills/engineering/resolve-issues/scripts/rehydrate.mjs +98 -0
- package/skills/engineering/resolve-issues/scripts/rehydrate.test.mjs +91 -0
- package/skills/engineering/resolve-issues/scripts/review-verdict.mjs +509 -0
- package/skills/engineering/resolve-issues/scripts/review-verdict.test.mjs +1060 -0
- package/skills/engineering/resolve-issues/scripts/size-check.mjs +492 -0
- package/skills/engineering/resolve-issues/scripts/size-check.test.mjs +458 -0
- package/skills/engineering/resolve-release/LICENSE +3 -0
- package/skills/engineering/resolve-release/SKILL.md +215 -0
- package/skills/engineering/resolve-release/agents/openai.yaml +8 -0
- package/skills/engineering/resolve-release/flows/cloud-run-canary.md +13 -0
- package/skills/engineering/resolve-release/flows/cloud-run.md +7 -0
- package/skills/engineering/resolve-release/flows/root-tag-npm.md +13 -0
- package/skills/engineering/resolve-release/flows/workspace-npm.md +22 -0
- package/skills/engineering/resolve-release/references/canary.md +51 -0
- package/skills/engineering/resolve-release/references/candidate.md +41 -0
- package/skills/engineering/resolve-release/references/cut.md +131 -0
- package/skills/engineering/resolve-release/references/dark-launch.md +66 -0
- package/skills/engineering/resolve-release/references/flows.md +150 -0
- package/skills/engineering/resolve-release/references/preflight.md +154 -0
- package/skills/engineering/resolve-release/references/prepared-metadata.md +260 -0
- package/skills/engineering/resolve-release/references/promotion.md +82 -0
- package/skills/engineering/resolve-release/references/publish.md +232 -0
- package/skills/engineering/resolve-release/references/workspace-npm.md +103 -0
- package/skills/engineering/resolve-release/release-check.mjs +93 -0
- package/skills/engineering/resolve-release/scripts/apply.mjs +212 -0
- package/skills/engineering/resolve-release/scripts/apply.test.mjs +99 -0
- package/skills/engineering/resolve-release/scripts/dark-flags.mjs +134 -0
- package/skills/engineering/resolve-release/scripts/dark-flags.test.mjs +100 -0
- package/skills/engineering/resolve-release/scripts/flow.mjs +500 -0
- package/skills/engineering/resolve-release/scripts/flow.test.mjs +340 -0
- package/skills/engineering/resolve-release/scripts/green-head.mjs +231 -0
- package/skills/engineering/resolve-release/scripts/green-head.test.mjs +101 -0
- package/skills/engineering/resolve-release/scripts/npm-version-absence.mjs +138 -0
- package/skills/engineering/resolve-release/scripts/npm-version-absence.test.mjs +426 -0
- package/skills/engineering/resolve-release/scripts/plan-npm.test.mjs +659 -0
- package/skills/engineering/resolve-release/scripts/plan-toml-boundary.test.mjs +317 -0
- package/skills/engineering/resolve-release/scripts/plan-uv.test.mjs +291 -0
- package/skills/engineering/resolve-release/scripts/plan.mjs +806 -0
- package/skills/engineering/resolve-release/scripts/plan.test.mjs +428 -0
- package/skills/engineering/rush-issues/LICENSE +3 -0
- package/skills/engineering/rush-issues/SKILL.md +58 -0
- package/skills/engineering/rush-issues/agents/openai.yaml +8 -0
- package/skills/engineering/rush-issues/rehydrate.mjs +10 -0
- package/skills/engineering/rush-issues/release-check.mjs +93 -0
- package/skills/engineering/rush-release/LICENSE +3 -0
- package/skills/engineering/rush-release/SKILL.md +35 -0
- package/skills/engineering/rush-release/agents/openai.yaml +8 -0
- package/skills/engineering/rush-release/release-check.mjs +93 -0
- package/skills/engineering/test-quality/LICENSE +3 -0
- package/skills/engineering/test-quality/SKILL.md +79 -0
- package/skills/engineering/test-quality/references/audit.md +80 -0
- package/skills/engineering/test-quality/references/lint.md +24 -0
- package/skills/engineering/test-quality/references/principles.md +143 -0
- package/skills/engineering/test-quality/references/testing-conventions.md +9 -0
- package/skills/engineering/test-quality/release-check.mjs +93 -0
- package/skills/engineering/test-quality/scripts/lint-tests.mjs +574 -0
- package/skills/engineering/test-quality/scripts/lint-tests.test.mjs +591 -0
- package/skills/engineering/test-quality/scripts/mock-policy-config.mjs +176 -0
- package/skills/engineering/test-quality/scripts/mock-policy-config.test.mjs +119 -0
- package/skills/engineering/test-quality/scripts/persist-conventions-core.mjs +117 -0
- package/skills/engineering/test-quality/scripts/persist-conventions.mjs +17 -0
- package/skills/engineering/test-quality/scripts/persist-conventions.test.mjs +104 -0
- package/skills/risk-compliance/compliance-audit/LICENSE +3 -0
- package/skills/risk-compliance/compliance-audit/SKILL.md +439 -0
- package/skills/risk-compliance/compliance-audit/references/findings-schema.md +293 -0
- package/skills/risk-compliance/compliance-audit/references/normalization-rules.md +45 -0
- package/skills/risk-compliance/compliance-audit/references/vars-checklist.md +1068 -0
- package/skills/risk-compliance/compliance-audit/references/vars-findings-schema.md +398 -0
- package/skills/risk-compliance/compliance-audit/release-check.mjs +93 -0
- package/skills/risk-compliance/compliance-audit/scripts/compliance-audit.test.mjs +14 -0
- package/skills/risk-compliance/compliance-audit/scripts/export_tracker_xlsx.py +221 -0
- package/skills/risk-compliance/compliance-audit/scripts/reconcile_tracker.py +458 -0
- package/skills/risk-compliance/compliance-audit/scripts/test_export_tracker_xlsx.py +97 -0
- package/skills/risk-compliance/compliance-audit/scripts/test_validate_tracker.py +157 -0
- package/skills/risk-compliance/compliance-audit/scripts/validate_tracker.py +829 -0
- package/skills/risk-compliance/contract-compare/LICENSE +3 -0
- package/skills/risk-compliance/contract-compare/SKILL.md +199 -0
- package/skills/risk-compliance/contract-compare/references/comparison-dimensions.md +209 -0
- package/skills/risk-compliance/contract-compare/release-check.mjs +93 -0
- package/skills/risk-compliance/contract-review/LICENSE +3 -0
- package/skills/risk-compliance/contract-review/SKILL.md +240 -0
- package/skills/risk-compliance/contract-review/references/review-criteria.md +223 -0
- package/skills/risk-compliance/contract-review/release-check.mjs +93 -0
- package/skills/risk-compliance/doc-authenticity/LICENSE +3 -0
- package/skills/risk-compliance/doc-authenticity/SKILL.md +160 -0
- package/skills/risk-compliance/doc-authenticity/agents/openai.yaml +8 -0
- package/skills/risk-compliance/doc-authenticity/manual-invocation.test.mjs +12 -0
- package/skills/risk-compliance/doc-authenticity/references/agreement.md +66 -0
- package/skills/risk-compliance/doc-authenticity/references/bank-statement.md +150 -0
- package/skills/risk-compliance/doc-authenticity/references/forensic-signals.md +157 -0
- package/skills/risk-compliance/doc-authenticity/references/image-signals.md +114 -0
- package/skills/risk-compliance/doc-authenticity/references/invoice.md +66 -0
- package/skills/risk-compliance/doc-authenticity/references/issuer-profiles.md +86 -0
- package/skills/risk-compliance/doc-authenticity/references/verdict-and-report.md +95 -0
- package/skills/risk-compliance/doc-authenticity/references/verdict.schema.json +81 -0
- package/skills/risk-compliance/doc-authenticity/release-check.mjs +93 -0
- package/skills/risk-compliance/doc-authenticity/scripts/image_forensics.py +294 -0
- package/skills/risk-compliance/doc-authenticity/scripts/pdf_forensics.py +401 -0
- package/skills/risk-compliance/doc-authenticity/scripts/validate_codes.py +224 -0
- package/skills/risk-compliance/doc-authenticity/scripts/validate_verdict.py +308 -0
- package/skills/risk-compliance/doc-authenticity/scripts/validate_verdict.test.mjs +212 -0
- package/skills/risk-compliance/fraud-analysis/LICENSE +3 -0
- package/skills/risk-compliance/fraud-analysis/SKILL.md +114 -0
- package/skills/risk-compliance/fraud-analysis/references/archetypes/authorized-payment-scam.md +41 -0
- package/skills/risk-compliance/fraud-analysis/references/archetypes/first-party-fraud.md +44 -0
- package/skills/risk-compliance/fraud-analysis/references/archetypes/third-party-fraud.md +27 -0
- package/skills/risk-compliance/fraud-analysis/references/contexts/bank-transfer.md +24 -0
- package/skills/risk-compliance/fraud-analysis/references/contexts/card-payment.md +30 -0
- package/skills/risk-compliance/fraud-analysis/references/contexts/payment-collection.md +20 -0
- package/skills/risk-compliance/fraud-analysis/references/contexts/payout.md +20 -0
- package/skills/risk-compliance/fraud-analysis/references/feature-engineering.md +158 -0
- package/skills/risk-compliance/fraud-analysis/references/mechanisms/account-takeover.md +36 -0
- package/skills/risk-compliance/fraud-analysis/references/report-rationale.md +45 -0
- package/skills/risk-compliance/fraud-analysis/references/report-template.md +190 -0
- package/skills/risk-compliance/fraud-analysis/references/review-checklist.md +175 -0
- package/skills/risk-compliance/fraud-analysis/references/taxonomy.md +79 -0
- package/skills/risk-compliance/fraud-analysis/references/terminology.md +108 -0
- package/skills/risk-compliance/fraud-analysis/references/workflow.md +175 -0
- package/skills/risk-compliance/fraud-analysis/release-check.mjs +93 -0
- package/skills/risk-compliance/payment-analysis/LICENSE +3 -0
- package/skills/risk-compliance/payment-analysis/SKILL.md +180 -0
- package/skills/risk-compliance/payment-analysis/references/auth-rate-actions.md +30 -0
- package/skills/risk-compliance/payment-analysis/references/chargebacks.md +208 -0
- package/skills/risk-compliance/payment-analysis/references/chinese-polish.md +51 -0
- package/skills/risk-compliance/payment-analysis/references/event-layers.md +127 -0
- package/skills/risk-compliance/payment-analysis/references/fx.md +59 -0
- package/skills/risk-compliance/payment-analysis/references/journey.md +79 -0
- package/skills/risk-compliance/payment-analysis/references/metrics.md +67 -0
- package/skills/risk-compliance/payment-analysis/references/report-template.md +119 -0
- package/skills/risk-compliance/payment-analysis/references/terminology.md +95 -0
- package/skills/risk-compliance/payment-analysis/references/visualization.md +56 -0
- package/skills/risk-compliance/payment-analysis/release-check.mjs +93 -0
- package/skills/risk-compliance/report-writing/LICENSE +3 -0
- package/skills/risk-compliance/report-writing/SKILL.md +220 -0
- package/skills/risk-compliance/report-writing/references/audit-report-template.md +121 -0
- package/skills/risk-compliance/report-writing/references/external-assessment-template.md +167 -0
- package/skills/risk-compliance/report-writing/references/management-report-template.md +145 -0
- package/skills/risk-compliance/report-writing/references/vars-report-template.md +224 -0
- package/skills/risk-compliance/report-writing/release-check.mjs +93 -0
- package/skills/risk-compliance/similar-domain/LICENSE +3 -0
- package/skills/risk-compliance/similar-domain/SKILL.md +203 -0
- package/skills/risk-compliance/similar-domain/references/entities.md +67 -0
- package/skills/risk-compliance/similar-domain/references/fingerprints.md +134 -0
- package/skills/risk-compliance/similar-domain/references/pivots.md +82 -0
- package/skills/risk-compliance/similar-domain/references/report-template.md +93 -0
- package/skills/risk-compliance/similar-domain/references/scoring.md +130 -0
- package/skills/risk-compliance/similar-domain/references/terminology.md +41 -0
- package/skills/risk-compliance/similar-domain/references/workflow.md +85 -0
- package/skills/risk-compliance/similar-domain/release-check.mjs +93 -0
- package/skills/risk-compliance/tld-detection/LICENSE +3 -0
- package/skills/risk-compliance/tld-detection/SKILL.md +245 -0
- package/skills/risk-compliance/tld-detection/agents/openai.yaml +9 -0
- package/skills/risk-compliance/tld-detection/references/chinese-polish.md +55 -0
- package/skills/risk-compliance/tld-detection/references/clean.md +51 -0
- package/skills/risk-compliance/tld-detection/references/clustering.md +235 -0
- package/skills/risk-compliance/tld-detection/references/decision.md +189 -0
- package/skills/risk-compliance/tld-detection/references/fx-conversion.md +232 -0
- package/skills/risk-compliance/tld-detection/references/report-template.md +272 -0
- package/skills/risk-compliance/tld-detection/references/streams.md +141 -0
- package/skills/risk-compliance/tld-detection/references/terminology.md +95 -0
- package/skills/risk-compliance/tld-detection/references/workflow.md +95 -0
- package/skills/risk-compliance/tld-detection/release-check.mjs +93 -0
- package/skills/risk-compliance/tld-detection/scripts/detect_ladders.py +390 -0
- package/skills/risk-compliance/tld-detection/scripts/detect_ladders.test.mjs +14 -0
- package/skills/risk-compliance/tld-detection/scripts/detect_streams.py +650 -0
- package/skills/risk-compliance/tld-detection/scripts/detect_streams.test.mjs +14 -0
- package/skills/risk-compliance/tld-detection/tests/test_detect_ladders.py +134 -0
- package/skills/risk-compliance/tld-detection/tests/test_detect_streams.py +520 -0
|
@@ -0,0 +1,1392 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Data catalog engine: read column metadata from table scripts, generate the catalog, audit scripts.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
init set a repo up: config, this engine, the Field/Table module, CI workflow
|
|
6
|
+
generate write docs/catalog/*.md from the scripts; --check fails when they are stale
|
|
7
|
+
audit report scripts that write tables without complete column definitions (never edits)
|
|
8
|
+
drift local check: code changed since a git base while its definitions did not
|
|
9
|
+
version print the engine identity
|
|
10
|
+
|
|
11
|
+
Metadata carriers:
|
|
12
|
+
Python module-level CATALOG_TABLE = Table(...) and CATALOG_FIELDS = (Field(...), ...),
|
|
13
|
+
read with ast (the script is never imported, so Spark is not needed)
|
|
14
|
+
SQL a comment block of `-- @table <name>` and `-- @field <name>` entries
|
|
15
|
+
|
|
16
|
+
Standard library only. Copied into a repo by `init`; refresh it the same way.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import ast
|
|
22
|
+
import hashlib
|
|
23
|
+
import io
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import tokenize
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
ENGINE_VERSION = "1.0.0"
|
|
34
|
+
CONFIG_FILE = "data-catalog.json"
|
|
35
|
+
ENGINE_PATH = "scripts/data-catalog/data_catalog.py"
|
|
36
|
+
WORKFLOW_PATH = ".github/workflows/data-catalog.yml"
|
|
37
|
+
GENERATED_MARK = "<!-- generated by data_catalog.py: edit the table script, then run generate -->"
|
|
38
|
+
WORKFLOW_MARK = "# generated by data_catalog.py init: edit data-catalog.json and rerun init"
|
|
39
|
+
TABLE_VAR = "CATALOG_TABLE"
|
|
40
|
+
FIELDS_VAR = "CATALOG_FIELDS"
|
|
41
|
+
DEFAULT_CONFIG = {
|
|
42
|
+
"version": 1,
|
|
43
|
+
"scripts": [],
|
|
44
|
+
"exclude": [],
|
|
45
|
+
"output": "docs/catalog",
|
|
46
|
+
"languages": ["en"],
|
|
47
|
+
"contracts": ["PUBLISHED_COLUMNS", "OUTPUT_COLUMNS"],
|
|
48
|
+
"fieldsModule": None,
|
|
49
|
+
"tableNames": [],
|
|
50
|
+
"schemaNames": [],
|
|
51
|
+
}
|
|
52
|
+
FIELD_EXTRAS = ("source", "window", "unit")
|
|
53
|
+
LANGUAGE_LABELS = {"en": "English", "zh": "中文", "zh_hant": "繁體中文", "ja": "日本語", "ko": "한국어"}
|
|
54
|
+
LANGUAGE_RE = re.compile(r"^[a-z]{2}(_[a-z]+)?$")
|
|
55
|
+
SKIP_DIRS = {"node_modules", "__pycache__", "venv"}
|
|
56
|
+
# Assignments (module or class body) whose string value names the table a script writes,
|
|
57
|
+
# and the schema/database that may sit beside it (`dst_db = "dwd"`, `dst_tbl = "orders"`).
|
|
58
|
+
TABLE_NAME_RE = re.compile(r"^(?:\w*_)?(?:target|output|sink|dest|destination|dst)_(?:table|tbl)(?:_name)?$|^table_name$", re.I)
|
|
59
|
+
SCHEMA_NAME_RE = re.compile(r"^(?:target|output|sink|dest|destination|dst)_(?:db|database|schema)(?:_name)?$", re.I)
|
|
60
|
+
SQL_KEYWORDS = {
|
|
61
|
+
"end", "from", "where", "and", "or", "not", "null", "then", "else", "when", "case", "as",
|
|
62
|
+
"on", "join", "by", "asc", "desc", "over", "distinct", "is", "in", "like", "between", "with", "select",
|
|
63
|
+
"union", "all", "partition", "rows", "range", "interval", "true", "false",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CatalogError(Exception):
|
|
68
|
+
"""A metadata or config problem, reported as `path:line: message`."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Config and file discovery
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def read_config_file(root: Path) -> dict:
|
|
76
|
+
try:
|
|
77
|
+
data = json.loads((root / CONFIG_FILE).read_text(encoding="utf-8"))
|
|
78
|
+
except json.JSONDecodeError as exc:
|
|
79
|
+
raise CatalogError(f"{CONFIG_FILE}: invalid JSON: {exc}") from exc
|
|
80
|
+
if not isinstance(data, dict):
|
|
81
|
+
raise CatalogError(f"{CONFIG_FILE}: must be a JSON object")
|
|
82
|
+
return data
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def load_config(root: Path) -> dict:
|
|
86
|
+
if not (root / CONFIG_FILE).exists():
|
|
87
|
+
raise CatalogError(f"{CONFIG_FILE} not found in {root}; run `init` first")
|
|
88
|
+
config = {**DEFAULT_CONFIG, **read_config_file(root)}
|
|
89
|
+
validate_config(config)
|
|
90
|
+
return config
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def validate_config(config: dict) -> None:
|
|
94
|
+
config["output"] = str(config.get("output") or DEFAULT_CONFIG["output"]).strip().strip("/") or DEFAULT_CONFIG["output"]
|
|
95
|
+
if not config["scripts"]:
|
|
96
|
+
raise CatalogError(f"{CONFIG_FILE}: `scripts` must list at least one glob")
|
|
97
|
+
langs = config["languages"]
|
|
98
|
+
if not langs or not all(isinstance(lang, str) and LANGUAGE_RE.match(lang) for lang in langs):
|
|
99
|
+
raise CatalogError(f"{CONFIG_FILE}: `languages` must be codes like \"en\" or \"zh\", got {langs!r}")
|
|
100
|
+
clash = [lang for lang in langs if lang in FIELD_EXTRAS or lang in ("name", "grain", "notes")]
|
|
101
|
+
if clash:
|
|
102
|
+
raise CatalogError(f"{CONFIG_FILE}: language codes clash with field arguments: {clash}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def glob_regex(pattern: str) -> re.Pattern:
|
|
106
|
+
out, i = [], 0
|
|
107
|
+
while i < len(pattern):
|
|
108
|
+
if pattern.startswith("**/", i):
|
|
109
|
+
out.append("(?:.*/)?"); i += 3
|
|
110
|
+
elif pattern.startswith("**", i):
|
|
111
|
+
out.append(".*"); i += 2
|
|
112
|
+
elif pattern[i] == "*":
|
|
113
|
+
out.append("[^/]*"); i += 1
|
|
114
|
+
elif pattern[i] == "?":
|
|
115
|
+
out.append("[^/]"); i += 1
|
|
116
|
+
else:
|
|
117
|
+
out.append(re.escape(pattern[i])); i += 1
|
|
118
|
+
return re.compile("".join(out) + r"\Z")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def find_scripts(root: Path, patterns: list[str], exclude: list[str]) -> list[Path]:
|
|
122
|
+
"""One pruned walk: hidden directories and node_modules are never entered."""
|
|
123
|
+
wanted = [glob_regex(p) for p in patterns]
|
|
124
|
+
skipped = [glob_regex(p) for p in exclude]
|
|
125
|
+
found = []
|
|
126
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
127
|
+
dirnames[:] = sorted(d for d in dirnames if not d.startswith(".") and d not in SKIP_DIRS)
|
|
128
|
+
for name in filenames:
|
|
129
|
+
if not name.endswith((".py", ".sql")):
|
|
130
|
+
continue
|
|
131
|
+
path = Path(dirpath) / name
|
|
132
|
+
relpath = rel(root, path)
|
|
133
|
+
if any(r.match(relpath) for r in wanted) and not any(r.match(relpath) for r in skipped):
|
|
134
|
+
found.append(path)
|
|
135
|
+
return sorted(found)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def rel(root: Path, path: Path) -> str:
|
|
139
|
+
return path.relative_to(root).as_posix()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Metadata model
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class FieldDoc:
|
|
148
|
+
name: str
|
|
149
|
+
text: dict
|
|
150
|
+
source: str
|
|
151
|
+
window: str | None
|
|
152
|
+
unit: str | None
|
|
153
|
+
line: int
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class TableDoc:
|
|
158
|
+
name: str
|
|
159
|
+
script: str
|
|
160
|
+
grain: str
|
|
161
|
+
text: dict
|
|
162
|
+
notes: list
|
|
163
|
+
fields: list
|
|
164
|
+
line: int
|
|
165
|
+
contracts: dict = field(default_factory=dict) # name -> list[str] | "derived" | "unchecked"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# Python carrier (ast, never imported)
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def module_assignments(tree: ast.Module) -> dict:
|
|
173
|
+
out = {}
|
|
174
|
+
for node in tree.body:
|
|
175
|
+
if isinstance(node, ast.Assign):
|
|
176
|
+
for target in node.targets:
|
|
177
|
+
if isinstance(target, ast.Name):
|
|
178
|
+
out[target.id] = node.value
|
|
179
|
+
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.value is not None:
|
|
180
|
+
out[node.target.id] = node.value
|
|
181
|
+
return out
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def call_name(node: ast.AST) -> str | None:
|
|
185
|
+
if not isinstance(node, ast.Call):
|
|
186
|
+
return None
|
|
187
|
+
if isinstance(node.func, ast.Name):
|
|
188
|
+
return node.func.id
|
|
189
|
+
if isinstance(node.func, ast.Attribute):
|
|
190
|
+
return node.func.attr
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def eval_text(node: ast.AST, names: dict, where: str, seen: frozenset = frozenset()):
|
|
195
|
+
"""Evaluate a string, tuple of strings, or None built from literals and module constants."""
|
|
196
|
+
if isinstance(node, ast.Constant) and (isinstance(node.value, str) or node.value is None):
|
|
197
|
+
return node.value
|
|
198
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
|
199
|
+
left = eval_text(node.left, names, where, seen)
|
|
200
|
+
right = eval_text(node.right, names, where, seen)
|
|
201
|
+
if isinstance(left, str) and isinstance(right, str):
|
|
202
|
+
return left + right
|
|
203
|
+
if isinstance(left, tuple) and isinstance(right, tuple):
|
|
204
|
+
return left + right
|
|
205
|
+
if isinstance(node, (ast.Tuple, ast.List)):
|
|
206
|
+
return tuple(eval_text(elt, names, where, seen) for elt in node.elts)
|
|
207
|
+
if isinstance(node, ast.Name) and node.id in names and node.id not in seen:
|
|
208
|
+
return eval_text(names[node.id], names, where, seen | {node.id})
|
|
209
|
+
raise CatalogError(f"{where}:{getattr(node, 'lineno', '?')}: metadata must be a string literal "
|
|
210
|
+
"(or module-level string constant), not an expression")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def resolve_sequence(node: ast.AST, names: dict, where: str, seen: frozenset = frozenset()) -> list:
|
|
214
|
+
"""Flatten a tuple/list (with *NAME, A + B, NAME) into its element nodes."""
|
|
215
|
+
if isinstance(node, (ast.Tuple, ast.List)):
|
|
216
|
+
out = []
|
|
217
|
+
for elt in node.elts:
|
|
218
|
+
if isinstance(elt, ast.Starred):
|
|
219
|
+
out.extend(resolve_sequence(elt.value, names, where, seen))
|
|
220
|
+
else:
|
|
221
|
+
out.append(elt)
|
|
222
|
+
return out
|
|
223
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
|
224
|
+
return resolve_sequence(node.left, names, where, seen) + resolve_sequence(node.right, names, where, seen)
|
|
225
|
+
if isinstance(node, ast.Name) and node.id in names and node.id not in seen:
|
|
226
|
+
return resolve_sequence(names[node.id], names, where, seen | {node.id})
|
|
227
|
+
raise CatalogError(f"{where}:{getattr(node, 'lineno', '?')}: {FIELDS_VAR} must be a tuple of Field(...) "
|
|
228
|
+
"built in this module (literals, *NAME or A + B); imported lists cannot be read")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def call_kwargs(call: ast.Call, names: dict, where: str, kind: str) -> dict:
|
|
232
|
+
if len(call.args) > 1 or any(isinstance(a, ast.Starred) for a in call.args):
|
|
233
|
+
raise CatalogError(f"{where}:{call.lineno}: pass only the name positionally to {kind}(...); "
|
|
234
|
+
"everything else by keyword")
|
|
235
|
+
values = {}
|
|
236
|
+
if call.args:
|
|
237
|
+
values["name"] = eval_text(call.args[0], names, where)
|
|
238
|
+
for kw in call.keywords:
|
|
239
|
+
if kw.arg is None:
|
|
240
|
+
raise CatalogError(f"{where}:{call.lineno}: **kwargs is not readable in {kind}(...)")
|
|
241
|
+
values[kw.arg] = eval_text(kw.value, names, where)
|
|
242
|
+
return values
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def require_text(values: dict, key: str, where: str, line: int, label: str) -> str:
|
|
246
|
+
value = values.get(key)
|
|
247
|
+
if not isinstance(value, str) or not value.strip():
|
|
248
|
+
raise CatalogError(f"{where}:{line}: {label} is missing `{key}`")
|
|
249
|
+
return value.strip()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def build_field(values: dict, languages: list, where: str, line: int) -> FieldDoc:
|
|
253
|
+
name = require_text(values, "name", where, line, "Field")
|
|
254
|
+
known = {"name", *FIELD_EXTRAS, *languages}
|
|
255
|
+
unknown = sorted(set(values) - known)
|
|
256
|
+
if unknown:
|
|
257
|
+
raise CatalogError(f"{where}:{line}: Field {name!r} has unknown argument(s) {unknown}; "
|
|
258
|
+
f"languages configured: {languages}")
|
|
259
|
+
text = {lang: require_text(values, lang, where, line, f"Field {name!r}") for lang in languages}
|
|
260
|
+
source = require_text(values, "source", where, line, f"Field {name!r}")
|
|
261
|
+
return FieldDoc(name, text, source, (values.get("window") or None), (values.get("unit") or None), line)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def build_table(values: dict, fields: list, languages: list, where: str, line: int, script: str) -> TableDoc:
|
|
265
|
+
name = require_text(values, "name", where, line, "Table")
|
|
266
|
+
known = {"name", "grain", "notes", *languages}
|
|
267
|
+
unknown = sorted(set(values) - known)
|
|
268
|
+
if unknown:
|
|
269
|
+
raise CatalogError(f"{where}:{line}: Table {name!r} has unknown argument(s) {unknown}")
|
|
270
|
+
grain = require_text(values, "grain", where, line, f"Table {name!r}")
|
|
271
|
+
text = {lang: require_text(values, lang, where, line, f"Table {name!r}") for lang in languages}
|
|
272
|
+
notes = values.get("notes") or ()
|
|
273
|
+
if isinstance(notes, str):
|
|
274
|
+
notes = (notes,)
|
|
275
|
+
return TableDoc(name, script, grain, text, [n.strip() for n in notes if n and n.strip()], fields, line)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def contract_state(node: ast.AST, names: dict):
|
|
279
|
+
if any(isinstance(n, ast.Name) and n.id == FIELDS_VAR for n in ast.walk(node)):
|
|
280
|
+
return "derived"
|
|
281
|
+
try:
|
|
282
|
+
items = resolve_sequence(node, names, "", frozenset())
|
|
283
|
+
values = [eval_text(item, names, "") for item in items]
|
|
284
|
+
except CatalogError:
|
|
285
|
+
return "unchecked"
|
|
286
|
+
if all(isinstance(v, str) for v in values):
|
|
287
|
+
return values
|
|
288
|
+
return "unchecked"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def parse_python(text: str, where: str, languages: list, contracts: list) -> TableDoc | None:
|
|
292
|
+
try:
|
|
293
|
+
tree = ast.parse(text)
|
|
294
|
+
except SyntaxError as exc:
|
|
295
|
+
raise CatalogError(f"{where}:{exc.lineno}: cannot parse: {exc.msg}") from exc
|
|
296
|
+
names = module_assignments(tree)
|
|
297
|
+
table_node, fields_node = names.get(TABLE_VAR), names.get(FIELDS_VAR)
|
|
298
|
+
if table_node is None and fields_node is None:
|
|
299
|
+
return None
|
|
300
|
+
if table_node is None or fields_node is None:
|
|
301
|
+
missing = TABLE_VAR if table_node is None else FIELDS_VAR
|
|
302
|
+
raise CatalogError(f"{where}:1: has one of {TABLE_VAR}/{FIELDS_VAR} but not {missing}")
|
|
303
|
+
if call_name(table_node) != "Table":
|
|
304
|
+
raise CatalogError(f"{where}:{table_node.lineno}: {TABLE_VAR} must be Table(...)")
|
|
305
|
+
fields = []
|
|
306
|
+
for item in resolve_sequence(fields_node, names, where):
|
|
307
|
+
if call_name(item) != "Field":
|
|
308
|
+
raise CatalogError(f"{where}:{getattr(item, 'lineno', '?')}: every {FIELDS_VAR} entry must be Field(...)")
|
|
309
|
+
fields.append(build_field(call_kwargs(item, names, where, "Field"), languages, where, item.lineno))
|
|
310
|
+
table = build_table(call_kwargs(table_node, names, where, "Table"), fields, languages, where, table_node.lineno, where)
|
|
311
|
+
for contract in contracts:
|
|
312
|
+
if contract in names:
|
|
313
|
+
table.contracts[contract] = contract_state(names[contract], names)
|
|
314
|
+
return table
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
# SQL carrier: `-- @table` / `-- @field` comment blocks
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
SQL_DIRECTIVE_RE = re.compile(r"^@(table|field)\s+(\S+)\s*$")
|
|
322
|
+
SQL_DIRECTIVE_START_RE = re.compile(r"^@(table|field)\b")
|
|
323
|
+
SQL_KEY_RE = re.compile(r"^ ([a-z][a-z_]*):\s?(.*)$")
|
|
324
|
+
SQL_CONTINUE_RE = re.compile(r"^ {4,}(\S.*)$")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def parse_sql(text: str, where: str, languages: list) -> TableDoc | None:
|
|
328
|
+
entries = [] # [kind, name, values, line, last_key]
|
|
329
|
+
current = None
|
|
330
|
+
for lineno, raw in enumerate(text.splitlines(), 1):
|
|
331
|
+
stripped = raw.lstrip()
|
|
332
|
+
if not stripped.startswith("--"):
|
|
333
|
+
if stripped:
|
|
334
|
+
break # the block sits above the code; a directive after the first statement is ignored
|
|
335
|
+
current = None
|
|
336
|
+
continue
|
|
337
|
+
body = stripped[2:]
|
|
338
|
+
body = body[1:] if body.startswith(" ") else body
|
|
339
|
+
directive = SQL_DIRECTIVE_RE.match(body)
|
|
340
|
+
if not directive and SQL_DIRECTIVE_START_RE.match(body):
|
|
341
|
+
raise CatalogError(f"{where}:{lineno}: write `-- @table <name>` or `-- @field <name>` with nothing after the name")
|
|
342
|
+
if directive:
|
|
343
|
+
current = [directive.group(1), directive.group(2), {}, lineno, None]
|
|
344
|
+
entries.append(current)
|
|
345
|
+
continue
|
|
346
|
+
if current is None:
|
|
347
|
+
continue
|
|
348
|
+
key = SQL_KEY_RE.match(body)
|
|
349
|
+
cont = SQL_CONTINUE_RE.match(body)
|
|
350
|
+
if key:
|
|
351
|
+
current[4] = key.group(1)
|
|
352
|
+
current[2][key.group(1)] = key.group(2).strip()
|
|
353
|
+
elif cont and current[4]:
|
|
354
|
+
prev = current[2][current[4]]
|
|
355
|
+
current[2][current[4]] = f"{prev} {cont.group(1).strip()}".strip()
|
|
356
|
+
else:
|
|
357
|
+
current = None
|
|
358
|
+
if not entries:
|
|
359
|
+
return None
|
|
360
|
+
tables = [e for e in entries if e[0] == "table"]
|
|
361
|
+
if len(tables) != 1:
|
|
362
|
+
raise CatalogError(f"{where}:{entries[0][3]}: a SQL file needs exactly one `-- @table`, found {len(tables)}")
|
|
363
|
+
fields = []
|
|
364
|
+
for kind, name, values, line, _ in entries:
|
|
365
|
+
if kind == "field":
|
|
366
|
+
if "notes" in values:
|
|
367
|
+
raise CatalogError(f"{where}:{line}: `notes` belongs on @table, not @field")
|
|
368
|
+
fields.append(build_field({"name": name, **values}, languages, where, line))
|
|
369
|
+
_, name, values, line, _ = tables[0]
|
|
370
|
+
notes = values.pop("notes", None)
|
|
371
|
+
values = {"name": name, **values}
|
|
372
|
+
if notes:
|
|
373
|
+
values["notes"] = tuple(n.strip() for n in notes.split(";"))
|
|
374
|
+
return build_table(values, fields, languages, where, line, where)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def parse_script(path: Path, root: Path, config: dict) -> TableDoc | None:
|
|
378
|
+
where = rel(root, path)
|
|
379
|
+
text = path.read_text(encoding="utf-8")
|
|
380
|
+
if path.suffix == ".py":
|
|
381
|
+
return parse_python(text, where, config["languages"], config["contracts"])
|
|
382
|
+
return parse_sql(text, where, config["languages"])
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ---------------------------------------------------------------------------
|
|
386
|
+
# Validation and rendering
|
|
387
|
+
# ---------------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
def collect(root: Path, config: dict):
|
|
390
|
+
"""Parse every configured script; returns (tables, errors, warnings)."""
|
|
391
|
+
tables, errors, warnings = [], [], []
|
|
392
|
+
for path in find_scripts(root, config["scripts"], config["exclude"]):
|
|
393
|
+
try:
|
|
394
|
+
table = parse_script(path, root, config)
|
|
395
|
+
except CatalogError as exc:
|
|
396
|
+
errors.append(str(exc))
|
|
397
|
+
continue
|
|
398
|
+
except (UnicodeDecodeError, OSError) as exc:
|
|
399
|
+
errors.append(f"{rel(root, path)}:1: cannot read: {exc}")
|
|
400
|
+
continue
|
|
401
|
+
if table is None:
|
|
402
|
+
continue
|
|
403
|
+
seen = {}
|
|
404
|
+
for f in table.fields:
|
|
405
|
+
if f.name in seen:
|
|
406
|
+
errors.append(f"{table.script}:{f.line}: field {f.name!r} is defined twice (first at line {seen[f.name]})")
|
|
407
|
+
seen.setdefault(f.name, f.line)
|
|
408
|
+
for contract, state in table.contracts.items():
|
|
409
|
+
if isinstance(state, list):
|
|
410
|
+
missing = [c for c in state if c not in seen]
|
|
411
|
+
extra = [n for n in seen if n not in state]
|
|
412
|
+
if missing:
|
|
413
|
+
errors.append(f"{table.script}:{table.line}: {contract} has column(s) with no Field: {', '.join(missing)}")
|
|
414
|
+
if extra:
|
|
415
|
+
warnings.append(f"{table.script}: Field(s) not in {contract}: {', '.join(extra)} "
|
|
416
|
+
"(fine for partition or provenance columns)")
|
|
417
|
+
warnings.append(f"{table.script}: {contract} is a literal list; derive it from {FIELDS_VAR} "
|
|
418
|
+
"so code and docs cannot drift")
|
|
419
|
+
if not table.contracts:
|
|
420
|
+
check_select_contract(path, table, seen, errors, warnings)
|
|
421
|
+
tables.append(table)
|
|
422
|
+
by_name = {}
|
|
423
|
+
for table in tables:
|
|
424
|
+
if table.name in by_name:
|
|
425
|
+
errors.append(f"{table.script}:{table.line}: table {table.name!r} is also documented in {by_name[table.name]}")
|
|
426
|
+
by_name.setdefault(table.name, table.script)
|
|
427
|
+
errors += page_collisions([t for t in tables if by_name[t.name] == t.script])
|
|
428
|
+
return sorted(tables, key=lambda t: t.name), errors, warnings
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def select_of(path: Path, table_name: str):
|
|
432
|
+
"""(columns, complete) of the SELECT that writes table_name, or None when no such SELECT is found."""
|
|
433
|
+
text = path.read_text(encoding="utf-8")
|
|
434
|
+
chunks = [text] if path.suffix == ".sql" else python_strings(ast.parse(text))
|
|
435
|
+
writes = [w for chunk in chunks for w in sql_writes(chunk)]
|
|
436
|
+
exact = [w for w in writes if same_table(w[0], table_name)]
|
|
437
|
+
chosen = exact or (writes if len(writes) == 1 and is_dynamic(writes[0][0]) else [])
|
|
438
|
+
if not chosen or not chosen[0][1]:
|
|
439
|
+
return None
|
|
440
|
+
return chosen[0][1], chosen[0][2]
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def check_select_contract(path: Path, table: TableDoc, documented: dict, errors: list, warnings: list) -> None:
|
|
444
|
+
"""With no column contract, the SELECT that writes the table is the contract for its comments."""
|
|
445
|
+
found = select_of(path, table.name)
|
|
446
|
+
if found is None:
|
|
447
|
+
warnings.append(f"{table.script}: no column contract and no readable SELECT writing {table.name}; "
|
|
448
|
+
"columns are not checked")
|
|
449
|
+
return
|
|
450
|
+
columns, complete = found
|
|
451
|
+
missing = [c for c in columns if c not in documented]
|
|
452
|
+
if missing:
|
|
453
|
+
errors.append(f"{table.script}:{table.line}: the SELECT writing {table.name} outputs column(s) with no "
|
|
454
|
+
f"definition: {', '.join(missing)}")
|
|
455
|
+
extra = [n for n in documented if n not in columns]
|
|
456
|
+
if extra and complete:
|
|
457
|
+
errors.append(f"{table.script}:{table.line}: definition(s) for column(s) the SELECT does not output: "
|
|
458
|
+
f"{', '.join(extra)}")
|
|
459
|
+
elif not complete:
|
|
460
|
+
warnings.append(f"{table.script}: the SELECT writing {table.name} has `*` or unnamed items; "
|
|
461
|
+
"only its named columns are checked")
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def cell(text: str | None) -> str:
|
|
465
|
+
if not text:
|
|
466
|
+
return ""
|
|
467
|
+
return text.replace("\\", "\\\\").replace("|", "\\|").replace("\r\n", "\n").replace("\n", "<br>")
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def label(lang: str) -> str:
|
|
471
|
+
return LANGUAGE_LABELS.get(lang, lang)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def page_name(table: TableDoc) -> str:
|
|
475
|
+
return re.sub(r"[^A-Za-z0-9_.-]", "_", table.name) + ".md"
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def page_collisions(tables: list) -> list:
|
|
479
|
+
errors, by_page = [], {}
|
|
480
|
+
for t in tables:
|
|
481
|
+
page = page_name(t)
|
|
482
|
+
if page.lower() == "readme.md":
|
|
483
|
+
errors.append(f"{t.script}:{t.line}: table name {t.name!r} would overwrite the catalog index")
|
|
484
|
+
elif page.lower() in by_page:
|
|
485
|
+
other = by_page[page.lower()]
|
|
486
|
+
errors.append(f"{t.script}:{t.line}: tables {t.name!r} and {other.name!r} ({other.script}) map to the same page {page}")
|
|
487
|
+
by_page.setdefault(page.lower(), t)
|
|
488
|
+
return errors
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def link_to(root: Path, output: str, target: str) -> str:
|
|
492
|
+
return Path(os.path.relpath(root / target, root / output)).as_posix()
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def render_table(table: TableDoc, root: Path, config: dict) -> str:
|
|
496
|
+
langs = config["languages"]
|
|
497
|
+
out = [GENERATED_MARK, "", f"# `{table.name}`", ""]
|
|
498
|
+
out.append(f"- **Script:** [{table.script}]({link_to(root, config['output'], table.script)})")
|
|
499
|
+
out.append(f"- **Grain:** {cell(table.grain)}")
|
|
500
|
+
out.append(f"- **Fields:** {len(table.fields)}")
|
|
501
|
+
out.append("")
|
|
502
|
+
for lang in langs:
|
|
503
|
+
out.append(f"**{label(lang)}:** {cell(table.text[lang])}" if len(langs) > 1 else cell(table.text[lang]))
|
|
504
|
+
out.append("")
|
|
505
|
+
if table.notes:
|
|
506
|
+
out.append("**Notes:**")
|
|
507
|
+
out.append("")
|
|
508
|
+
out.extend(f"- {cell(note)}" for note in table.notes)
|
|
509
|
+
out.append("")
|
|
510
|
+
has_window = any(f.window for f in table.fields)
|
|
511
|
+
has_unit = any(f.unit for f in table.fields)
|
|
512
|
+
head = ["Field", *(label(lang) for lang in langs), "Source"]
|
|
513
|
+
head += ["Window"] if has_window else []
|
|
514
|
+
head += ["Unit"] if has_unit else []
|
|
515
|
+
out.append("| " + " | ".join(head) + " |")
|
|
516
|
+
out.append("|" + "---|" * len(head))
|
|
517
|
+
for f in table.fields:
|
|
518
|
+
row = [f"`{f.name}`", *(cell(f.text[lang]) for lang in langs), cell(f.source)]
|
|
519
|
+
row += [cell(f.window)] if has_window else []
|
|
520
|
+
row += [cell(f.unit)] if has_unit else []
|
|
521
|
+
out.append("| " + " | ".join(row) + " |")
|
|
522
|
+
return "\n".join(out) + "\n"
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def render_index(tables: list, root: Path, config: dict) -> str:
|
|
526
|
+
lang = config["languages"][0]
|
|
527
|
+
engine = ENGINE_PATH
|
|
528
|
+
out = [
|
|
529
|
+
GENERATED_MARK, "", "# Data catalog", "",
|
|
530
|
+
"Table and column definitions, generated from the metadata in each table script. "
|
|
531
|
+
f"To change a definition, edit `{TABLE_VAR}` / `{FIELDS_VAR}` (or the `-- @field` block in a .sql file), "
|
|
532
|
+
f"run `python3 {engine} generate`, and commit the script and these pages together.",
|
|
533
|
+
"",
|
|
534
|
+
]
|
|
535
|
+
if not tables:
|
|
536
|
+
out.append("No table script carries catalog metadata yet. Run "
|
|
537
|
+
f"`python3 {engine} audit` to see which scripts need it.")
|
|
538
|
+
return "\n".join(out) + "\n"
|
|
539
|
+
out += ["| Table | Summary | Grain | Fields | Script |", "|---|---|---|---|---|"]
|
|
540
|
+
for t in tables:
|
|
541
|
+
script = f"[{t.script}]({link_to(root, config['output'], t.script)})"
|
|
542
|
+
out.append(f"| [`{t.name}`]({page_name(t)}) | {cell(t.text[lang])} | {cell(t.grain)} | {len(t.fields)} | {script} |")
|
|
543
|
+
return "\n".join(out) + "\n"
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def generation_plan(root: Path, config: dict):
|
|
547
|
+
tables, errors, warnings = collect(root, config)
|
|
548
|
+
plan = {}
|
|
549
|
+
if not errors:
|
|
550
|
+
out_dir = config["output"]
|
|
551
|
+
plan[f"{out_dir}/README.md"] = render_index(tables, root, config)
|
|
552
|
+
for table in tables:
|
|
553
|
+
plan[f"{out_dir}/{page_name(table)}"] = render_table(table, root, config)
|
|
554
|
+
return plan, errors, warnings
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def orphan_pages(root: Path, config: dict, plan: dict) -> list:
|
|
558
|
+
out_dir = root / config["output"]
|
|
559
|
+
if not out_dir.is_dir():
|
|
560
|
+
return []
|
|
561
|
+
orphans = []
|
|
562
|
+
for path in sorted(out_dir.glob("*.md")):
|
|
563
|
+
key = rel(root, path)
|
|
564
|
+
if key in plan:
|
|
565
|
+
continue
|
|
566
|
+
try:
|
|
567
|
+
first = path.read_text(encoding="utf-8").split("\n", 1)[0]
|
|
568
|
+
except (UnicodeDecodeError, OSError):
|
|
569
|
+
continue
|
|
570
|
+
if first == GENERATED_MARK:
|
|
571
|
+
orphans.append(key)
|
|
572
|
+
return orphans
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def cmd_generate(root: Path, check: bool) -> int:
|
|
576
|
+
config = load_config(root)
|
|
577
|
+
plan, errors, warnings = generation_plan(root, config)
|
|
578
|
+
for warning in warnings:
|
|
579
|
+
print(f"warning: {warning}", file=sys.stderr)
|
|
580
|
+
if errors:
|
|
581
|
+
for error in errors:
|
|
582
|
+
print(f"error: {error}", file=sys.stderr)
|
|
583
|
+
print(f"data-catalog: {len(errors)} metadata error(s); nothing written", file=sys.stderr)
|
|
584
|
+
return 1
|
|
585
|
+
stale = [p for p, text in plan.items() if read_or_none(root / p) != text]
|
|
586
|
+
orphans = orphan_pages(root, config, plan)
|
|
587
|
+
if check:
|
|
588
|
+
if stale or orphans:
|
|
589
|
+
for p in stale:
|
|
590
|
+
print(f"stale: {p}", file=sys.stderr)
|
|
591
|
+
for p in orphans:
|
|
592
|
+
print(f"orphan: {p} (its table no longer has metadata)", file=sys.stderr)
|
|
593
|
+
print(f"data-catalog: the catalog does not match the table scripts. Run `python3 {ENGINE_PATH} generate` "
|
|
594
|
+
"and commit the result.", file=sys.stderr)
|
|
595
|
+
return 1
|
|
596
|
+
print(f"data-catalog: catalog is current ({len(plan) - 1} table(s))")
|
|
597
|
+
return 0
|
|
598
|
+
for p in stale:
|
|
599
|
+
target = root / p
|
|
600
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
601
|
+
target.write_text(plan[p], encoding="utf-8")
|
|
602
|
+
print(f"wrote {p}")
|
|
603
|
+
for p in orphans:
|
|
604
|
+
(root / p).unlink()
|
|
605
|
+
print(f"removed {p}")
|
|
606
|
+
print(f"data-catalog: {len(plan) - 1} table(s); {len(stale)} written, {len(orphans)} removed")
|
|
607
|
+
return 0
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def read_or_none(path: Path) -> str | None:
|
|
611
|
+
try:
|
|
612
|
+
return path.read_text(encoding="utf-8")
|
|
613
|
+
except FileNotFoundError:
|
|
614
|
+
return None
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
# ---------------------------------------------------------------------------
|
|
618
|
+
# Audit: what scripts write, which columns, what is documented
|
|
619
|
+
# ---------------------------------------------------------------------------
|
|
620
|
+
|
|
621
|
+
WRITE_RES = [
|
|
622
|
+
re.compile(r"\binsert\s+(?:overwrite|into)\s+(?:table\s+)?([`\"\w.{}$-]+)", re.I),
|
|
623
|
+
re.compile(r"\bcreate\s+(?:or\s+replace\s+)?(?:temporary\s+|temp\s+)?table\s+(?:if\s+not\s+exists\s+)?([`\"\w.{}$-]+)[^;]*?\bas\b\s*(?=with\b|select\b|\()", re.I | re.S),
|
|
624
|
+
re.compile(r"\bmerge\s+into\s+([`\"\w.{}$-]+)", re.I),
|
|
625
|
+
]
|
|
626
|
+
API_WRITE_RES = [
|
|
627
|
+
re.compile(r"""\.(?:saveAsTable|insertInto)\(\s*[fr]?["']([^"']+)["']"""),
|
|
628
|
+
re.compile(r"""["']hoodie\.table\.name["']\s*[:,]\s*[fr]?["']([^"']+)["']"""),
|
|
629
|
+
]
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def mask_sql(sql: str, strings: bool = True) -> str:
|
|
633
|
+
"""Blank out comments (and string literals unless strings=False), keeping offsets."""
|
|
634
|
+
out, i, n = [], 0, len(sql)
|
|
635
|
+
while i < n:
|
|
636
|
+
two = sql[i:i + 2]
|
|
637
|
+
if two == "--":
|
|
638
|
+
j = sql.find("\n", i)
|
|
639
|
+
j = n if j == -1 else j
|
|
640
|
+
out.append(" " * (j - i)); i = j
|
|
641
|
+
elif two == "/*":
|
|
642
|
+
j = sql.find("*/", i + 2)
|
|
643
|
+
j = n if j == -1 else j + 2
|
|
644
|
+
out.append(re.sub(r"[^\n]", " ", sql[i:j])); i = j
|
|
645
|
+
elif sql[i] == "'":
|
|
646
|
+
j = i + 1
|
|
647
|
+
while j < n and not (sql[j] == "'" and sql[j + 1:j + 2] != "'"):
|
|
648
|
+
j += 2 if sql[j:j + 2] in ("''", "\\'", "\\\\") else 1
|
|
649
|
+
j = min(j + 1, n)
|
|
650
|
+
out.append(sql[i:j] if not strings else "'" + " " * (j - i - 2) + "'" if j - i >= 2 else sql[i:j]); i = j
|
|
651
|
+
else:
|
|
652
|
+
out.append(sql[i]); i += 1
|
|
653
|
+
return "".join(out)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def split_top(text: str, sep: str = ",") -> list:
|
|
657
|
+
parts, depth, start = [], 0, 0
|
|
658
|
+
for i, ch in enumerate(text):
|
|
659
|
+
if ch in "([{":
|
|
660
|
+
depth += 1
|
|
661
|
+
elif ch in ")]}":
|
|
662
|
+
depth -= 1
|
|
663
|
+
elif ch == sep and depth == 0:
|
|
664
|
+
parts.append(text[start:i]); start = i + 1
|
|
665
|
+
parts.append(text[start:])
|
|
666
|
+
return parts
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def top_level_word(text: str, word: str, start: int = 0) -> int:
|
|
670
|
+
depth, quote = 0, None
|
|
671
|
+
pattern = re.compile(rf"{word}\b", re.I)
|
|
672
|
+
i = start
|
|
673
|
+
while i < len(text):
|
|
674
|
+
ch = text[i]
|
|
675
|
+
if quote:
|
|
676
|
+
quote = None if ch == quote else quote
|
|
677
|
+
elif ch in "`\"":
|
|
678
|
+
quote = ch
|
|
679
|
+
elif ch in "([{":
|
|
680
|
+
depth += 1
|
|
681
|
+
elif ch in ")]}":
|
|
682
|
+
depth -= 1
|
|
683
|
+
elif depth == 0 and pattern.match(text, i) and (i == 0 or not (text[i - 1].isalnum() or text[i - 1] == "_")):
|
|
684
|
+
if not (word.lower() == "from" and re.search(r"\bdistinct\s*$", text[:i], re.I)):
|
|
685
|
+
return i
|
|
686
|
+
i += 1
|
|
687
|
+
return -1
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
IDENT = r"(?:`[^`]+`|\"[^\"]+\"|[A-Za-z_]\w*)"
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def unquote(ident: str) -> str:
|
|
694
|
+
return ident[1:-1] if ident[:1] in "`\"" else ident
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def select_items(statement: str, original: str | None = None):
|
|
698
|
+
"""Outermost SELECT items as [(name or None, expression)], plus (select_end, body_end) offsets.
|
|
699
|
+
|
|
700
|
+
`statement` is masked text; `original` (same offsets) supplies expressions with literals intact.
|
|
701
|
+
"""
|
|
702
|
+
original = statement if original is None else original
|
|
703
|
+
at = top_level_word(statement, "select")
|
|
704
|
+
if at == -1:
|
|
705
|
+
return None
|
|
706
|
+
body_start = at + len("select")
|
|
707
|
+
end = top_level_word(statement, "from", body_start)
|
|
708
|
+
end = len(statement) if end == -1 else end
|
|
709
|
+
body = statement[body_start:end]
|
|
710
|
+
lead = re.match(r"\s*(?:distinct|all)\b", body, re.I)
|
|
711
|
+
offset = body_start + (lead.end() if lead else 0)
|
|
712
|
+
items, pos = [], offset
|
|
713
|
+
for part in split_top(statement[offset:end]):
|
|
714
|
+
expr_masked = " ".join(part.split())
|
|
715
|
+
expr = " ".join(original[pos:pos + len(part)].split())
|
|
716
|
+
pos += len(part) + 1
|
|
717
|
+
if not expr_masked:
|
|
718
|
+
continue
|
|
719
|
+
items.append((item_name(expr_masked), expr))
|
|
720
|
+
return items, at, end
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def item_name(item: str) -> str | None:
|
|
724
|
+
"""The output name of one SELECT item, or None when it has no name the engine can read for sure."""
|
|
725
|
+
if item == "*" or item.endswith(".*") or "{" in item:
|
|
726
|
+
return None
|
|
727
|
+
alias = re.search(rf"\bas\s+({IDENT})\s*$", item, re.I)
|
|
728
|
+
if alias:
|
|
729
|
+
return unquote(alias.group(1))
|
|
730
|
+
ref = rf"(?:{IDENT}\.)*({IDENT})"
|
|
731
|
+
bare = re.fullmatch(ref, item)
|
|
732
|
+
if bare:
|
|
733
|
+
return None if bare.group(1).lower() in SQL_KEYWORDS else unquote(bare.group(1))
|
|
734
|
+
implicit = re.fullmatch(rf"(.*\S)\s+({IDENT})", item) # `sum(x) total`, `t.col c`, `CASE ... END flag`
|
|
735
|
+
if implicit:
|
|
736
|
+
head, name = implicit.group(1), unquote(implicit.group(2))
|
|
737
|
+
plain = re.fullmatch(ref, head)
|
|
738
|
+
head_ok = head.endswith(")") or re.match(r"(?is)case\b.*\bend$", head) \
|
|
739
|
+
or (plain and plain.group(1).lower() not in SQL_KEYWORDS)
|
|
740
|
+
if head_ok and name.lower() not in SQL_KEYWORDS:
|
|
741
|
+
return name
|
|
742
|
+
return None
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def select_columns(statement: str):
|
|
746
|
+
"""Output column names of a statement's outermost SELECT; (names, complete)."""
|
|
747
|
+
found = select_items(statement)
|
|
748
|
+
if found is None:
|
|
749
|
+
return [], False
|
|
750
|
+
items = found[0]
|
|
751
|
+
names = [n for n, _ in items if n]
|
|
752
|
+
return names, len(names) == len(items)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def statement_at(masked: str, start: int) -> str:
|
|
756
|
+
end = masked.find(";", start)
|
|
757
|
+
return masked[start:end if end != -1 else len(masked)]
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def sql_writes(sql: str):
|
|
761
|
+
"""Tables a SQL text writes, each with the columns of its SELECT."""
|
|
762
|
+
masked = mask_sql(sql)
|
|
763
|
+
found = []
|
|
764
|
+
for pattern in WRITE_RES:
|
|
765
|
+
for match in pattern.finditer(masked):
|
|
766
|
+
table = match.group(1).strip("`\"")
|
|
767
|
+
if table.lower() in ("table", "select", "with", "partition", "if", "directory", "local"):
|
|
768
|
+
continue
|
|
769
|
+
names, complete = select_columns(statement_at(masked, match.end()))
|
|
770
|
+
found.append((table, names, complete))
|
|
771
|
+
return found
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def python_strings(tree: ast.Module) -> list:
|
|
775
|
+
out = []
|
|
776
|
+
fragments = {id(v) for node in ast.walk(tree) if isinstance(node, ast.JoinedStr) for v in node.values}
|
|
777
|
+
docstrings = {id(n.body[0].value) for n in ast.walk(tree)
|
|
778
|
+
if isinstance(n, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef))
|
|
779
|
+
and n.body and isinstance(n.body[0], ast.Expr) and isinstance(n.body[0].value, ast.Constant)}
|
|
780
|
+
for node in ast.walk(tree):
|
|
781
|
+
if id(node) in fragments or id(node) in docstrings:
|
|
782
|
+
continue
|
|
783
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
784
|
+
out.append(node.value)
|
|
785
|
+
elif isinstance(node, ast.JoinedStr):
|
|
786
|
+
parts = [v.value if isinstance(v, ast.Constant) else "{expr}" for v in node.values]
|
|
787
|
+
out.append("".join(str(p) for p in parts))
|
|
788
|
+
return out
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
@dataclass
|
|
792
|
+
class ScriptAudit:
|
|
793
|
+
script: str
|
|
794
|
+
writes: list = field(default_factory=list) # [(table, evidence)]
|
|
795
|
+
columns: list = field(default_factory=list)
|
|
796
|
+
columns_from: str | None = None # "PUBLISHED_COLUMNS" | "sql select" | None
|
|
797
|
+
columns_complete: bool = False
|
|
798
|
+
table: TableDoc | None = None
|
|
799
|
+
findings: list = field(default_factory=list) # [(kind, message)]
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def declared_tables(tree: ast.Module, config: dict) -> list:
|
|
803
|
+
"""Table names assigned in module or class bodies, joined to a sibling schema name."""
|
|
804
|
+
table_names, schema_names = set(config.get("tableNames") or []), set(config.get("schemaNames") or [])
|
|
805
|
+
found = []
|
|
806
|
+
scopes = [tree.body] + [n.body for n in ast.walk(tree) if isinstance(n, ast.ClassDef)]
|
|
807
|
+
for body in scopes:
|
|
808
|
+
strings = {}
|
|
809
|
+
for node in body:
|
|
810
|
+
targets = node.targets if isinstance(node, ast.Assign) else [node.target] if isinstance(node, ast.AnnAssign) else []
|
|
811
|
+
value = getattr(node, "value", None)
|
|
812
|
+
if isinstance(value, ast.Constant) and isinstance(value.value, str):
|
|
813
|
+
for target in targets:
|
|
814
|
+
if isinstance(target, ast.Name):
|
|
815
|
+
strings[target.id] = value.value
|
|
816
|
+
schema = next((v for k, v in strings.items() if k in schema_names or SCHEMA_NAME_RE.match(k)), None)
|
|
817
|
+
for key, value in strings.items():
|
|
818
|
+
if (key in table_names or TABLE_NAME_RE.match(key)) and value.strip():
|
|
819
|
+
full = value if "." in value or not schema else f"{schema}.{value}"
|
|
820
|
+
found.append((full, key))
|
|
821
|
+
return found
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def strip_py_comments(text: str) -> str:
|
|
825
|
+
out = []
|
|
826
|
+
try:
|
|
827
|
+
for tok in tokenize.generate_tokens(io.StringIO(text).readline):
|
|
828
|
+
if tok.type != tokenize.COMMENT:
|
|
829
|
+
out.append(tok)
|
|
830
|
+
return tokenize.untokenize(out)
|
|
831
|
+
except (tokenize.TokenError, IndentationError, SyntaxError):
|
|
832
|
+
return text
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def audit_python(text: str, where: str, config: dict, audit: ScriptAudit) -> None:
|
|
836
|
+
tree = ast.parse(text)
|
|
837
|
+
names = module_assignments(tree)
|
|
838
|
+
audit.writes.extend(declared_tables(tree, config))
|
|
839
|
+
code = strip_py_comments(text)
|
|
840
|
+
for pattern in API_WRITE_RES:
|
|
841
|
+
for match in pattern.finditer(code):
|
|
842
|
+
audit.writes.append((match.group(1), "DataFrame write"))
|
|
843
|
+
for contract in config["contracts"]:
|
|
844
|
+
if contract in names:
|
|
845
|
+
state = contract_state(names[contract], names)
|
|
846
|
+
if isinstance(state, list):
|
|
847
|
+
audit.columns, audit.columns_from, audit.columns_complete = state, contract, True
|
|
848
|
+
break
|
|
849
|
+
if state == "derived" and audit.table is not None:
|
|
850
|
+
audit.columns = [f.name for f in audit.table.fields]
|
|
851
|
+
audit.columns_from, audit.columns_complete = f"{contract} (derived)", True
|
|
852
|
+
break
|
|
853
|
+
for chunk in python_strings(tree):
|
|
854
|
+
for table, cols, complete in sql_writes(chunk):
|
|
855
|
+
audit.writes.append((table, "SQL string"))
|
|
856
|
+
if audit.columns_from is None and cols:
|
|
857
|
+
audit.columns, audit.columns_from, audit.columns_complete = cols, "SQL select", complete
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def audit_sql(text: str, audit: ScriptAudit) -> None:
|
|
861
|
+
for table, cols, complete in sql_writes(text):
|
|
862
|
+
audit.writes.append((table, "SQL statement"))
|
|
863
|
+
if audit.columns_from is None and cols:
|
|
864
|
+
audit.columns, audit.columns_from, audit.columns_complete = cols, "SQL select", complete
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
RELATIVE_RE = re.compile(r"^\s*(同上|同前|如上|same as (the )?(above|previous)|as above|ditto|see above)", re.I)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
CODE_IN_TEXT_RE = re.compile(
|
|
871
|
+
r"\b[a-z_][a-z0-9_]+\.[a-z_][a-z0-9_]+\b" # schema.table or table.column (not "a.m.", "e.g.")
|
|
872
|
+
r"|<>|!=|<=|>=|\s=\s*'" # comparisons
|
|
873
|
+
r"|\b(?:inner|left|right|full|cross)\s+join\b"
|
|
874
|
+
r"|\b(?:date_sub|date_add|coalesce|count|sum|avg|min|max|cast|nvl|if|case)\s*\(", re.I)
|
|
875
|
+
CJK_RE = re.compile(r"[\u3400-\u9fff]")
|
|
876
|
+
LONG_WORDS, LONG_CJK = 30, 60
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def text_size(text: str) -> tuple[int, int]:
|
|
880
|
+
"""(latin words, CJK characters) in a definition."""
|
|
881
|
+
cjk = len(CJK_RE.findall(text))
|
|
882
|
+
return len(CJK_RE.sub(" ", text).split()), cjk
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def shingles(text: str) -> set:
|
|
886
|
+
"""Word 5-grams, plus 8-character runs of CJK text: the unit a repeated phrase is measured in."""
|
|
887
|
+
words = re.findall(r"[a-z0-9_']+", CJK_RE.sub(" ", text.lower()))
|
|
888
|
+
grams = {" ".join(words[i:i + 5]) for i in range(len(words) - 4)}
|
|
889
|
+
for run in re.findall(r"[\u3400-\u9fff]{8,}", text):
|
|
890
|
+
grams |= {run[i:i + 8] for i in range(len(run) - 7)}
|
|
891
|
+
return grams
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def repeated_phrase(fields: list):
|
|
895
|
+
"""The phrase shared by the most rows, when at least 3 rows and half of them carry it."""
|
|
896
|
+
if len(fields) < 3:
|
|
897
|
+
return None
|
|
898
|
+
counts = {}
|
|
899
|
+
for f in fields:
|
|
900
|
+
for gram in set().union(*(shingles(t) for t in (*f.text.values(), f.source))):
|
|
901
|
+
counts[gram] = counts.get(gram, 0) + 1
|
|
902
|
+
best = max(counts.items(), key=lambda kv: (kv[1], len(kv[0])), default=None)
|
|
903
|
+
if best and best[1] >= 3 and best[1] * 2 >= len(fields):
|
|
904
|
+
return best
|
|
905
|
+
return None
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def restates_name(name: str, text: str) -> bool:
|
|
909
|
+
words = re.sub(r"[\W_]+", " ", text).strip().lower()
|
|
910
|
+
return words == re.sub(r"[\W_]+", " ", name).strip().lower() or len(text.strip()) < 4
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def audit_script(path: Path, root: Path, config: dict) -> ScriptAudit:
|
|
914
|
+
where = rel(root, path)
|
|
915
|
+
audit = ScriptAudit(where)
|
|
916
|
+
try:
|
|
917
|
+
text = path.read_text(encoding="utf-8")
|
|
918
|
+
except (UnicodeDecodeError, OSError) as exc:
|
|
919
|
+
audit.findings.append(("unreadable", str(exc)))
|
|
920
|
+
return audit
|
|
921
|
+
try:
|
|
922
|
+
audit.table = parse_script(path, root, config)
|
|
923
|
+
except CatalogError as exc:
|
|
924
|
+
audit.findings.append(("invalid-metadata", str(exc)))
|
|
925
|
+
if ": cannot parse:" in str(exc):
|
|
926
|
+
return audit
|
|
927
|
+
try:
|
|
928
|
+
if path.suffix == ".py":
|
|
929
|
+
audit_python(text, where, config, audit)
|
|
930
|
+
else:
|
|
931
|
+
audit_sql(text, audit)
|
|
932
|
+
except SyntaxError as exc:
|
|
933
|
+
audit.findings.append(("unreadable", f"cannot parse Python: {exc.msg} (line {exc.lineno})"))
|
|
934
|
+
return audit
|
|
935
|
+
seen, unique = set(), []
|
|
936
|
+
for table, evidence in audit.writes:
|
|
937
|
+
if table not in seen:
|
|
938
|
+
seen.add(table); unique.append((table, evidence))
|
|
939
|
+
audit.writes = unique
|
|
940
|
+
return audit
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def short_name(table: str) -> str:
|
|
944
|
+
return table.split(".")[-1].strip("`\"").lower()
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def is_dynamic(table: str) -> bool:
|
|
948
|
+
return "{" in table or "$" in table
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def same_table(a: str, b: str) -> bool:
|
|
952
|
+
"""Equal names; when one side has no schema, equal short names. Templated names match nothing."""
|
|
953
|
+
if is_dynamic(a) or is_dynamic(b):
|
|
954
|
+
return False
|
|
955
|
+
a, b = a.strip("`\"").lower(), b.strip("`\"").lower()
|
|
956
|
+
return a == b or (("." not in a or "." not in b) and short_name(a) == short_name(b))
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def lookup(table: str, by_table: dict):
|
|
960
|
+
return [v for k, v in by_table.items() if same_table(k, table)]
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def judge(audit: ScriptAudit, documented_in: dict, writers_of: dict) -> None:
|
|
964
|
+
"""Findings for one script; documented_in/writers_of are keyed by short table name."""
|
|
965
|
+
t = audit.table
|
|
966
|
+
targets = [w[0] for w in audit.writes]
|
|
967
|
+
if t is None:
|
|
968
|
+
open_targets = [x for x in targets if not is_dynamic(x) and not lookup(x, documented_in)]
|
|
969
|
+
if open_targets and not any(k == "invalid-metadata" for k, _ in audit.findings):
|
|
970
|
+
others = sorted({s for x in open_targets for group in lookup(x, writers_of) for s in group if s != audit.script})
|
|
971
|
+
also = f"; also written by {', '.join(others)} (put the metadata in the script that builds the table)" if others else ""
|
|
972
|
+
audit.findings.append(("no-catalog", f"writes {', '.join(open_targets)} but carries no {TABLE_VAR}/{FIELDS_VAR} "
|
|
973
|
+
f"(or -- @table block){also}"))
|
|
974
|
+
if audit.columns and open_targets:
|
|
975
|
+
audit.findings.append(("undocumented", f"{len(audit.columns)} column(s) with no definition"))
|
|
976
|
+
return
|
|
977
|
+
documented = [f.name for f in t.fields]
|
|
978
|
+
static = [x for x in targets if not is_dynamic(x)]
|
|
979
|
+
if static and not any(same_table(t.name, x) for x in static):
|
|
980
|
+
audit.findings.append(("table-mismatch", f"metadata names {t.name} but the script writes {', '.join(static)}"))
|
|
981
|
+
if audit.columns:
|
|
982
|
+
missing = [c for c in audit.columns if c not in documented]
|
|
983
|
+
if missing:
|
|
984
|
+
audit.findings.append(("undocumented", f"{len(missing)} column(s) with no Field: {preview(missing)}"))
|
|
985
|
+
if audit.columns_complete and audit.columns_from == "SQL select":
|
|
986
|
+
extra = [n for n in documented if n not in audit.columns]
|
|
987
|
+
if extra:
|
|
988
|
+
audit.findings.append(("not-in-output", f"Field(s) not in the SELECT output: {preview(extra)}"))
|
|
989
|
+
else:
|
|
990
|
+
audit.findings.append(("columns-unknown", "output columns could not be read statically; declare a "
|
|
991
|
+
f"contract tuple derived from {FIELDS_VAR}, or compare against the warehouse schema"))
|
|
992
|
+
for contract, state in t.contracts.items():
|
|
993
|
+
if isinstance(state, list):
|
|
994
|
+
audit.findings.append(("contract-not-derived", f"{contract} is a literal list; derive it from {FIELDS_VAR}"))
|
|
995
|
+
relative = [f.name for f in t.fields if any(RELATIVE_RE.match(x) for x in (*f.text.values(), f.source))]
|
|
996
|
+
if relative:
|
|
997
|
+
audit.findings.append(("relative-definition", f"definition or source points at another row (\"same as above\"); "
|
|
998
|
+
f"each row must stand alone: {preview(relative)}"))
|
|
999
|
+
thin = [f.name for f in t.fields if f.name not in relative and (
|
|
1000
|
+
any(restates_name(f.name, f.text[lang]) for lang in f.text) or restates_name(f.name, f.source))]
|
|
1001
|
+
repeated = repeated_phrase(t.fields)
|
|
1002
|
+
if repeated:
|
|
1003
|
+
audit.findings.append(("repeated-text", f"\"{repeated[0]}…\" appears in {repeated[1]} of {len(t.fields)} rows; "
|
|
1004
|
+
"a fact true for every column belongs once in the table notes"))
|
|
1005
|
+
coded = [f.name for f in t.fields if any(CODE_IN_TEXT_RE.search(x) for x in f.text.values())]
|
|
1006
|
+
if coded:
|
|
1007
|
+
audit.findings.append(("code-in-description", "table names, operators or SQL functions in the meaning text; "
|
|
1008
|
+
f"move lineage to source: {preview(coded)}"))
|
|
1009
|
+
long = [f.name for f in t.fields if any(w > LONG_WORDS or c > LONG_CJK for w, c in map(text_size, f.text.values()))]
|
|
1010
|
+
if long:
|
|
1011
|
+
audit.findings.append(("long-definition", f"over {LONG_WORDS} words or {LONG_CJK} Chinese characters; "
|
|
1012
|
+
f"usually a table-level fact or lineage: {preview(long)}"))
|
|
1013
|
+
if thin:
|
|
1014
|
+
audit.findings.append(("thin-definition", f"definition or source only restates the name: {preview(thin)}"))
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def preview(items: list, limit: int = 12) -> str:
|
|
1018
|
+
shown = ", ".join(items[:limit])
|
|
1019
|
+
return shown + (f", … (+{len(items) - limit})" if len(items) > limit else "")
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def cmd_audit(root: Path, globs: list, as_json: bool, verbose: bool, table_names: list, schema_names: list) -> int:
|
|
1023
|
+
config_path = root / CONFIG_FILE
|
|
1024
|
+
config = load_config(root) if config_path.exists() else dict(DEFAULT_CONFIG)
|
|
1025
|
+
config = {**config, "tableNames": [*config["tableNames"], *table_names],
|
|
1026
|
+
"schemaNames": [*config["schemaNames"], *schema_names]}
|
|
1027
|
+
if globs:
|
|
1028
|
+
patterns, exclude = globs, config.get("exclude", [])
|
|
1029
|
+
elif config_path.exists():
|
|
1030
|
+
patterns, exclude = config["scripts"], config["exclude"]
|
|
1031
|
+
else:
|
|
1032
|
+
raise CatalogError(f"no {CONFIG_FILE}: pass globs to audit, e.g. `audit 'dags/**/*.py' '**/*.sql'`")
|
|
1033
|
+
audits = [audit_script(p, root, config) for p in find_scripts(root, patterns, exclude)]
|
|
1034
|
+
documented_in, writers_of = {}, {}
|
|
1035
|
+
for a in audits:
|
|
1036
|
+
if a.table:
|
|
1037
|
+
documented_in.setdefault(a.table.name.lower(), a.script)
|
|
1038
|
+
for table, _ in a.writes:
|
|
1039
|
+
if not is_dynamic(table):
|
|
1040
|
+
writers_of.setdefault(table.strip("`\"").lower(), []).append(a.script)
|
|
1041
|
+
for a in audits:
|
|
1042
|
+
judge(a, documented_in, writers_of)
|
|
1043
|
+
relevant = [a for a in audits if a.writes or a.table or a.findings]
|
|
1044
|
+
writers = [a for a in audits if a.writes or a.table]
|
|
1045
|
+
tables = sorted(set(documented_in) | {k for k in writers_of if not lookup(k, documented_in)})
|
|
1046
|
+
complete = sorted({a.table.name.lower() for a in writers if a.table
|
|
1047
|
+
and not any(k in ("undocumented", "invalid-metadata") for k, _ in a.findings)})
|
|
1048
|
+
if as_json:
|
|
1049
|
+
print(json.dumps({
|
|
1050
|
+
"engine": ENGINE_VERSION,
|
|
1051
|
+
"scanned": len(audits),
|
|
1052
|
+
"writers": len(writers),
|
|
1053
|
+
"tables": len(tables),
|
|
1054
|
+
"documentedTables": len(complete),
|
|
1055
|
+
"scripts": [{
|
|
1056
|
+
"script": a.script,
|
|
1057
|
+
"writes": [{"table": t, "evidence": e} for t, e in a.writes],
|
|
1058
|
+
"documentedTable": a.table.name if a.table else None,
|
|
1059
|
+
"documentedFields": len(a.table.fields) if a.table else 0,
|
|
1060
|
+
"columns": a.columns,
|
|
1061
|
+
"columnsFrom": a.columns_from,
|
|
1062
|
+
"columnsComplete": a.columns_complete,
|
|
1063
|
+
"findings": [{"kind": k, "message": m} for k, m in a.findings],
|
|
1064
|
+
} for a in relevant],
|
|
1065
|
+
}, ensure_ascii=False, indent=2))
|
|
1066
|
+
return 0
|
|
1067
|
+
print(f"data-catalog audit: {len(audits)} script(s) scanned; {len(writers)} write {len(tables)} table(s); "
|
|
1068
|
+
f"{len(complete)} table(s) fully documented")
|
|
1069
|
+
for a in sorted(relevant, key=lambda x: (x.table is not None, x.script)):
|
|
1070
|
+
if not a.findings and not verbose:
|
|
1071
|
+
continue
|
|
1072
|
+
print(f"\n{a.script}")
|
|
1073
|
+
if a.writes:
|
|
1074
|
+
print(" writes: " + ", ".join(f"{t} ({e})" for t, e in a.writes))
|
|
1075
|
+
cols = f"{len(a.columns)} from {a.columns_from}" if a.columns_from else "unknown"
|
|
1076
|
+
if a.columns_from and not a.columns_complete:
|
|
1077
|
+
cols += " (partial: wildcard or dynamic SELECT items)"
|
|
1078
|
+
print(f" columns: {cols}")
|
|
1079
|
+
print(f" catalog: {a.table.name + f' ({len(a.table.fields)} fields)' if a.table else 'none'}")
|
|
1080
|
+
for kind, message in a.findings:
|
|
1081
|
+
print(f" - {kind}: {message}")
|
|
1082
|
+
quiet = len(audits) - len(relevant)
|
|
1083
|
+
if quiet:
|
|
1084
|
+
print(f"\n{quiet} script(s) write no table the audit can see (helpers, DAGs, tests).")
|
|
1085
|
+
return 0
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
# ---------------------------------------------------------------------------
|
|
1089
|
+
# drift: code changed since a git base while its definitions did not
|
|
1090
|
+
# ---------------------------------------------------------------------------
|
|
1091
|
+
|
|
1092
|
+
def git(root: Path, *args: str) -> tuple[int, str]:
|
|
1093
|
+
try:
|
|
1094
|
+
r = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
|
|
1095
|
+
except FileNotFoundError as exc:
|
|
1096
|
+
raise CatalogError("drift needs git on PATH") from exc
|
|
1097
|
+
return r.returncode, r.stdout
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def statement_shape(text: str, suffix: str, table_name: str):
|
|
1101
|
+
"""({column: expression}, rest) for the statement writing table_name; rest is its FROM/WHERE/JOIN/CTE text."""
|
|
1102
|
+
try:
|
|
1103
|
+
chunks = [text] if suffix == ".sql" else python_strings(ast.parse(text))
|
|
1104
|
+
except SyntaxError:
|
|
1105
|
+
return None
|
|
1106
|
+
for chunk in chunks:
|
|
1107
|
+
masked, readable = mask_sql(chunk), mask_sql(chunk, strings=False)
|
|
1108
|
+
for pattern in WRITE_RES:
|
|
1109
|
+
for match in pattern.finditer(masked):
|
|
1110
|
+
if not same_table(match.group(1).strip("`\""), table_name):
|
|
1111
|
+
continue
|
|
1112
|
+
stop = masked.find(";", match.end())
|
|
1113
|
+
stop = len(masked) if stop == -1 else stop
|
|
1114
|
+
found = select_items(masked[match.end():stop], readable[match.end():stop])
|
|
1115
|
+
if found is None:
|
|
1116
|
+
continue
|
|
1117
|
+
items, at, end = found
|
|
1118
|
+
base = match.end()
|
|
1119
|
+
rest = " ".join((readable[match.start():base + at] + " " + readable[base + end:stop]).split())
|
|
1120
|
+
return {n: e for n, e in items if n}, rest
|
|
1121
|
+
return None
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def code_without_metadata(text: str, suffix: str) -> str:
|
|
1125
|
+
if suffix == ".sql":
|
|
1126
|
+
return " ".join(mask_sql(text, strings=False).split())
|
|
1127
|
+
try:
|
|
1128
|
+
tree = ast.parse(text)
|
|
1129
|
+
except SyntaxError:
|
|
1130
|
+
return text
|
|
1131
|
+
tree.body = [n for n in tree.body if not (isinstance(n, (ast.Assign, ast.AnnAssign)) and any(
|
|
1132
|
+
isinstance(t, ast.Name) and t.id in (TABLE_VAR, FIELDS_VAR)
|
|
1133
|
+
for t in (n.targets if isinstance(n, ast.Assign) else [n.target])))]
|
|
1134
|
+
return ast.dump(tree)
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def definitions(table: TableDoc | None) -> tuple[dict, tuple]:
|
|
1138
|
+
if table is None:
|
|
1139
|
+
return {}, ()
|
|
1140
|
+
fields = {f.name: (tuple(sorted(f.text.items())), f.source, f.window, f.unit) for f in table.fields}
|
|
1141
|
+
return fields, (table.grain, tuple(sorted(table.text.items())), tuple(table.notes))
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def drift_items(root: Path, config: dict, base: str) -> list:
|
|
1145
|
+
code, _ = git(root, "rev-parse", "--verify", f"{base}^{{commit}}")
|
|
1146
|
+
if code != 0:
|
|
1147
|
+
raise CatalogError(f"drift: {base!r} is not a commit in this git repository")
|
|
1148
|
+
_, out = git(root, "diff", "--name-only", base, "--")
|
|
1149
|
+
changed = set(out.split())
|
|
1150
|
+
items = []
|
|
1151
|
+
for path in find_scripts(root, config["scripts"], config["exclude"]):
|
|
1152
|
+
where = rel(root, path)
|
|
1153
|
+
if where not in changed:
|
|
1154
|
+
continue
|
|
1155
|
+
code, old_text = git(root, "show", f"{base}:{where}")
|
|
1156
|
+
if code != 0:
|
|
1157
|
+
continue # a new script: generate checks that every column is defined
|
|
1158
|
+
new_text = path.read_text(encoding="utf-8")
|
|
1159
|
+
parse = parse_python if path.suffix == ".py" else None
|
|
1160
|
+
try:
|
|
1161
|
+
new_t = (parse(new_text, where, config["languages"], config["contracts"]) if parse
|
|
1162
|
+
else parse_sql(new_text, where, config["languages"]))
|
|
1163
|
+
except CatalogError:
|
|
1164
|
+
continue # generate reports invalid metadata
|
|
1165
|
+
if new_t is None:
|
|
1166
|
+
continue # undocumented: audit reports it
|
|
1167
|
+
try:
|
|
1168
|
+
old_t = (parse(old_text, where, config["languages"], config["contracts"]) if parse
|
|
1169
|
+
else parse_sql(old_text, where, config["languages"]))
|
|
1170
|
+
except CatalogError:
|
|
1171
|
+
old_t = None
|
|
1172
|
+
old_fields, old_table = definitions(old_t)
|
|
1173
|
+
new_fields, new_table = definitions(new_t)
|
|
1174
|
+
edited = {n for n, d in new_fields.items() if old_fields.get(n) != d}
|
|
1175
|
+
any_edit = bool(edited) or old_table != new_table
|
|
1176
|
+
old_shape = statement_shape(old_text, path.suffix, new_t.name)
|
|
1177
|
+
new_shape = statement_shape(new_text, path.suffix, new_t.name)
|
|
1178
|
+
found = []
|
|
1179
|
+
if old_shape and new_shape:
|
|
1180
|
+
for name, expr in new_shape[0].items():
|
|
1181
|
+
if name in old_shape[0] and old_shape[0][name] != expr and name in new_fields and name not in edited:
|
|
1182
|
+
found.append(f"{name}: its SELECT expression changed; its definition did not")
|
|
1183
|
+
if old_shape[1] != new_shape[1] and not any_edit:
|
|
1184
|
+
found.append("filters, joins or CTEs changed; no definition changed (they shape every column)")
|
|
1185
|
+
elif code_without_metadata(old_text, path.suffix) != code_without_metadata(new_text, path.suffix) and not any_edit:
|
|
1186
|
+
found.append("code changed and no definition changed; the columns could not be read, so check each one")
|
|
1187
|
+
if found:
|
|
1188
|
+
items.append((where, new_t.name, found))
|
|
1189
|
+
return items
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def cmd_drift(root: Path, base: str) -> int:
|
|
1193
|
+
config = load_config(root)
|
|
1194
|
+
items = drift_items(root, config, base)
|
|
1195
|
+
if not items:
|
|
1196
|
+
print(f"data-catalog drift since {base}: none")
|
|
1197
|
+
return 0
|
|
1198
|
+
count = sum(len(f) for _, _, f in items)
|
|
1199
|
+
print(f"data-catalog drift since {base}: {count} item(s) to confirm")
|
|
1200
|
+
for where, table, found in items:
|
|
1201
|
+
print(f"\n{where} ({table})")
|
|
1202
|
+
for line in found:
|
|
1203
|
+
print(f" - {line}")
|
|
1204
|
+
print("\nUpdate each definition to match the code, or confirm it still holds and say so in the PR.")
|
|
1205
|
+
return 1
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
# ---------------------------------------------------------------------------
|
|
1209
|
+
# init: config, engine copy, Field/Table module, workflow
|
|
1210
|
+
# (the CONTRIBUTING.md section and AGENTS.md pointer come from persist-conventions.mjs)
|
|
1211
|
+
# ---------------------------------------------------------------------------
|
|
1212
|
+
|
|
1213
|
+
def render_fields_module(languages: list) -> str:
|
|
1214
|
+
lang_fields = "\n".join(f" {lang}: str" for lang in languages)
|
|
1215
|
+
return f'''"""Column metadata types for the data catalog.
|
|
1216
|
+
|
|
1217
|
+
Generated by data_catalog.py init from data-catalog.json; rerun init after changing
|
|
1218
|
+
`languages` there, never edit this file. Table scripts declare:
|
|
1219
|
+
|
|
1220
|
+
CATALOG_TABLE = Table("schema.table", grain=..., {", ".join(f"{lang}=..." for lang in languages)})
|
|
1221
|
+
{FIELDS_VAR} = (Field("column", {", ".join(f"{lang}=..." for lang in languages)}, source=...), ...)
|
|
1222
|
+
PUBLISHED_COLUMNS = tuple(f.name for f in {FIELDS_VAR})
|
|
1223
|
+
"""
|
|
1224
|
+
from __future__ import annotations
|
|
1225
|
+
|
|
1226
|
+
from dataclasses import dataclass
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
@dataclass(frozen=True)
|
|
1230
|
+
class Field:
|
|
1231
|
+
name: str
|
|
1232
|
+
{lang_fields}
|
|
1233
|
+
source: str
|
|
1234
|
+
window: str | None = None
|
|
1235
|
+
unit: str | None = None
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
@dataclass(frozen=True)
|
|
1239
|
+
class Table:
|
|
1240
|
+
name: str
|
|
1241
|
+
grain: str
|
|
1242
|
+
{lang_fields}
|
|
1243
|
+
notes: tuple[str, ...] = ()
|
|
1244
|
+
'''
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
def render_workflow(config: dict) -> str:
|
|
1248
|
+
paths = [CONFIG_FILE, ENGINE_PATH, f"{config['output']}/**", *config["scripts"]]
|
|
1249
|
+
if config.get("fieldsModule"):
|
|
1250
|
+
paths.append(config["fieldsModule"])
|
|
1251
|
+
path_lines = "\n".join(" - '" + p.replace("'", "''") + "'" for p in dict.fromkeys(paths))
|
|
1252
|
+
return f"""{WORKFLOW_MARK}
|
|
1253
|
+
name: Data catalog
|
|
1254
|
+
|
|
1255
|
+
on:
|
|
1256
|
+
pull_request:
|
|
1257
|
+
paths:
|
|
1258
|
+
{path_lines}
|
|
1259
|
+
workflow_dispatch:
|
|
1260
|
+
|
|
1261
|
+
permissions:
|
|
1262
|
+
contents: read
|
|
1263
|
+
|
|
1264
|
+
concurrency:
|
|
1265
|
+
group: data-catalog-${{{{ github.ref }}}}
|
|
1266
|
+
cancel-in-progress: true
|
|
1267
|
+
|
|
1268
|
+
jobs:
|
|
1269
|
+
catalog:
|
|
1270
|
+
name: Data catalog
|
|
1271
|
+
runs-on: ubuntu-latest
|
|
1272
|
+
timeout-minutes: 5
|
|
1273
|
+
steps:
|
|
1274
|
+
- uses: actions/checkout@v4
|
|
1275
|
+
- uses: actions/setup-python@v5
|
|
1276
|
+
with:
|
|
1277
|
+
python-version: '3.12'
|
|
1278
|
+
- name: Check the catalog matches the table scripts
|
|
1279
|
+
run: python3 {ENGINE_PATH} generate --check
|
|
1280
|
+
"""
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def default_fields_module(root: Path, scripts: list) -> str:
|
|
1284
|
+
py = [p for p in find_scripts(root, scripts, []) if p.suffix == ".py"]
|
|
1285
|
+
if not py:
|
|
1286
|
+
return "catalog_fields.py"
|
|
1287
|
+
common = os.path.commonpath([str(p.parent) for p in py])
|
|
1288
|
+
return rel(root, Path(common) / "catalog_fields.py")
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def init_plan(root: Path, args) -> tuple[dict, list]:
|
|
1292
|
+
path = root / CONFIG_FILE
|
|
1293
|
+
config = {**DEFAULT_CONFIG, **(read_config_file(root) if path.exists() else {})}
|
|
1294
|
+
if args.scripts:
|
|
1295
|
+
config["scripts"] = args.scripts
|
|
1296
|
+
if args.exclude:
|
|
1297
|
+
config["exclude"] = args.exclude
|
|
1298
|
+
if args.languages:
|
|
1299
|
+
config["languages"] = [x.strip() for x in args.languages.split(",") if x.strip()]
|
|
1300
|
+
if args.output:
|
|
1301
|
+
config["output"] = args.output.rstrip("/")
|
|
1302
|
+
if args.fields_module:
|
|
1303
|
+
config["fieldsModule"] = args.fields_module
|
|
1304
|
+
validate_config(config)
|
|
1305
|
+
if not config.get("fieldsModule"):
|
|
1306
|
+
config["fieldsModule"] = default_fields_module(root, config["scripts"])
|
|
1307
|
+
plan = [
|
|
1308
|
+
(CONFIG_FILE, json.dumps(config, ensure_ascii=False, indent=2) + "\n", "config"),
|
|
1309
|
+
(ENGINE_PATH, Path(__file__).read_text(encoding="utf-8"), f"engine {ENGINE_VERSION}"),
|
|
1310
|
+
(config["fieldsModule"], render_fields_module(config["languages"]), "Field/Table types for table scripts"),
|
|
1311
|
+
(WORKFLOW_PATH, render_workflow(config), "CI: fail a PR whose catalog is stale"),
|
|
1312
|
+
]
|
|
1313
|
+
return config, plan
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
def cmd_init(root: Path, args) -> int:
|
|
1317
|
+
config, plan = init_plan(root, args)
|
|
1318
|
+
changed = [(p, text, why) for p, text, why in plan if read_or_none(root / p) != text]
|
|
1319
|
+
for p, text, why in plan:
|
|
1320
|
+
state = "unchanged" if (p, text, why) not in changed else ("write" if args.write else "would write")
|
|
1321
|
+
print(f"{state:12} {p} ({why})")
|
|
1322
|
+
if not args.write:
|
|
1323
|
+
print("\ndry run: rerun with --write to apply")
|
|
1324
|
+
return 0
|
|
1325
|
+
for p, text, _ in changed:
|
|
1326
|
+
target = root / p
|
|
1327
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1328
|
+
target.write_text(text, encoding="utf-8")
|
|
1329
|
+
print()
|
|
1330
|
+
return cmd_generate(root, check=False)
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
# ---------------------------------------------------------------------------
|
|
1334
|
+
# CLI
|
|
1335
|
+
# ---------------------------------------------------------------------------
|
|
1336
|
+
|
|
1337
|
+
def engine_identity() -> str:
|
|
1338
|
+
digest = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()[:12]
|
|
1339
|
+
return f"{ENGINE_VERSION}+{digest}"
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def find_root(start: Path) -> Path:
|
|
1343
|
+
for candidate in [start, *start.parents]:
|
|
1344
|
+
if (candidate / CONFIG_FILE).exists() or (candidate / ".git").exists():
|
|
1345
|
+
return candidate
|
|
1346
|
+
return start
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
def main(argv: list | None = None) -> int:
|
|
1350
|
+
parser = argparse.ArgumentParser(prog="data_catalog.py", description=__doc__.split("\n\n")[0])
|
|
1351
|
+
parser.add_argument("--root", type=Path, help="repo root (default: nearest dir with data-catalog.json or .git)")
|
|
1352
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1353
|
+
p_init = sub.add_parser("init", help="set the repo up (dry run unless --write)")
|
|
1354
|
+
p_init.add_argument("--scripts", action="append", help="glob of table scripts (.py/.sql); repeatable")
|
|
1355
|
+
p_init.add_argument("--exclude", action="append", help="glob to skip; repeatable")
|
|
1356
|
+
p_init.add_argument("--languages", help="comma-separated codes, e.g. en,zh")
|
|
1357
|
+
p_init.add_argument("--output", help="catalog directory (default docs/catalog)")
|
|
1358
|
+
p_init.add_argument("--fields-module", help="path for the generated Field/Table module")
|
|
1359
|
+
p_init.add_argument("--write", action="store_true")
|
|
1360
|
+
p_gen = sub.add_parser("generate", help="write the catalog pages")
|
|
1361
|
+
p_gen.add_argument("--check", action="store_true", help="write nothing; exit 1 when stale or invalid")
|
|
1362
|
+
p_audit = sub.add_parser("audit", help="report scripts with missing or weak column definitions")
|
|
1363
|
+
p_audit.add_argument("globs", nargs="*", help="globs to scan (default: config scripts)")
|
|
1364
|
+
p_audit.add_argument("--json", action="store_true")
|
|
1365
|
+
p_audit.add_argument("--verbose", action="store_true", help="also list scripts with no findings")
|
|
1366
|
+
p_audit.add_argument("--table-name", action="append", default=[], metavar="NAME",
|
|
1367
|
+
help="extra variable/attribute whose string value is the table written; repeatable")
|
|
1368
|
+
p_audit.add_argument("--schema-name", action="append", default=[], metavar="NAME",
|
|
1369
|
+
help="extra variable/attribute holding that table's schema; repeatable")
|
|
1370
|
+
p_drift = sub.add_parser("drift", help="local check: code changed since a git base while its definitions did not")
|
|
1371
|
+
p_drift.add_argument("--base", default="HEAD", help="git ref to compare with (default HEAD: uncommitted work)")
|
|
1372
|
+
sub.add_parser("version", help="print the engine identity")
|
|
1373
|
+
args = parser.parse_args(argv)
|
|
1374
|
+
root = (args.root or find_root(Path.cwd())).resolve()
|
|
1375
|
+
try:
|
|
1376
|
+
if args.command == "version":
|
|
1377
|
+
print(engine_identity())
|
|
1378
|
+
return 0
|
|
1379
|
+
if args.command == "init":
|
|
1380
|
+
return cmd_init(root, args)
|
|
1381
|
+
if args.command == "generate":
|
|
1382
|
+
return cmd_generate(root, args.check)
|
|
1383
|
+
if args.command == "drift":
|
|
1384
|
+
return cmd_drift(root, args.base)
|
|
1385
|
+
return cmd_audit(root, args.globs, args.json, args.verbose, args.table_name, args.schema_name)
|
|
1386
|
+
except CatalogError as exc:
|
|
1387
|
+
print(f"data-catalog: {exc}", file=sys.stderr)
|
|
1388
|
+
return 2
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
if __name__ == "__main__":
|
|
1392
|
+
sys.exit(main())
|