@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.
Files changed (633) hide show
  1. package/README.md +30 -2
  2. package/bin/cli.mjs +14 -0
  3. package/bin/select-core.mjs +172 -0
  4. package/package.json +146 -4
  5. package/skills/app-dev/airflow/LICENSE +3 -0
  6. package/skills/app-dev/airflow/SKILL.md +199 -0
  7. package/skills/app-dev/airflow/release-check.mjs +93 -0
  8. package/skills/app-dev/android-foundation/LICENSE +3 -0
  9. package/skills/app-dev/android-foundation/SKILL.md +192 -0
  10. package/skills/app-dev/android-foundation/assets/android-ci-job.yml +217 -0
  11. package/skills/app-dev/android-foundation/references/ci.md +87 -0
  12. package/skills/app-dev/android-foundation/references/gotchas.md +221 -0
  13. package/skills/app-dev/android-foundation/references/setup.md +164 -0
  14. package/skills/app-dev/android-foundation/release-check.mjs +93 -0
  15. package/skills/app-dev/android-foundation/scripts/device-run.sh +176 -0
  16. package/skills/app-dev/android-foundation/scripts/device-run.test.mjs +172 -0
  17. package/skills/app-dev/app-debug/LICENSE +3 -0
  18. package/skills/app-dev/app-debug/SKILL.md +110 -0
  19. package/skills/app-dev/app-debug/references/common-errors.md +128 -0
  20. package/skills/app-dev/app-debug/release-check.mjs +93 -0
  21. package/skills/app-dev/better-auth/LICENSE +3 -0
  22. package/skills/app-dev/better-auth/SKILL.md +598 -0
  23. package/skills/app-dev/better-auth/references/feishu-api.md +270 -0
  24. package/skills/app-dev/better-auth/release-check.mjs +93 -0
  25. package/skills/app-dev/data-catalog/LICENSE +3 -0
  26. package/skills/app-dev/data-catalog/SKILL.md +204 -0
  27. package/skills/app-dev/data-catalog/agents/openai.yaml +3 -0
  28. package/skills/app-dev/data-catalog/references/data-catalog-conventions.md +75 -0
  29. package/skills/app-dev/data-catalog/references/field-definitions.md +116 -0
  30. package/skills/app-dev/data-catalog/references/format.md +121 -0
  31. package/skills/app-dev/data-catalog/release-check.mjs +93 -0
  32. package/skills/app-dev/data-catalog/scripts/data-catalog.test.mjs +36 -0
  33. package/skills/app-dev/data-catalog/scripts/data_catalog.py +1392 -0
  34. package/skills/app-dev/data-catalog/scripts/persist-conventions-core.mjs +117 -0
  35. package/skills/app-dev/data-catalog/scripts/persist-conventions.mjs +17 -0
  36. package/skills/app-dev/data-catalog/scripts/test_data_catalog.py +571 -0
  37. package/skills/app-dev/ios-foundation/LICENSE +3 -0
  38. package/skills/app-dev/ios-foundation/SKILL.md +184 -0
  39. package/skills/app-dev/ios-foundation/references/setup.md +243 -0
  40. package/skills/app-dev/ios-foundation/release-check.mjs +93 -0
  41. package/skills/app-dev/monorepo/LICENSE +3 -0
  42. package/skills/app-dev/monorepo/SKILL.md +462 -0
  43. package/skills/app-dev/monorepo/release-check.mjs +93 -0
  44. package/skills/app-dev/nextjs-fullstack/LICENSE +3 -0
  45. package/skills/app-dev/nextjs-fullstack/SKILL.md +211 -0
  46. package/skills/app-dev/nextjs-fullstack/conventions.md +318 -0
  47. package/skills/app-dev/nextjs-fullstack/frontend-conventions.md +61 -0
  48. package/skills/app-dev/nextjs-fullstack/nextjs16.md +287 -0
  49. package/skills/app-dev/nextjs-fullstack/release-check.mjs +93 -0
  50. package/skills/app-dev/nextjs-fullstack/server-actions.md +409 -0
  51. package/skills/app-dev/prisma-setup/LICENSE +3 -0
  52. package/skills/app-dev/prisma-setup/SKILL.md +181 -0
  53. package/skills/app-dev/prisma-setup/nextjs.md +258 -0
  54. package/skills/app-dev/prisma-setup/release-check.mjs +93 -0
  55. package/skills/app-dev/prisma-setup/turborepo.md +301 -0
  56. package/skills/app-dev/pyspark/LICENSE +3 -0
  57. package/skills/app-dev/pyspark/SKILL.md +127 -0
  58. package/skills/app-dev/pyspark/assets/templates/etl.py +748 -0
  59. package/skills/app-dev/pyspark/assets/templates/utils/__init__.py +1 -0
  60. package/skills/app-dev/pyspark/assets/templates/utils/hudi_metadata.py +14 -0
  61. package/skills/app-dev/pyspark/references/diagnosis-and-profiling.md +169 -0
  62. package/skills/app-dev/pyspark/references/etl-contract.md +129 -0
  63. package/skills/app-dev/pyspark/references/production-validation.md +131 -0
  64. package/skills/app-dev/pyspark/references/reconciliation.md +38 -0
  65. package/skills/app-dev/pyspark/references/transformation-design.md +190 -0
  66. package/skills/app-dev/pyspark/references/velocity-feature-calculation.md +193 -0
  67. package/skills/app-dev/pyspark/release-check.mjs +93 -0
  68. package/skills/app-dev/pyspark/scripts/spark_eventlog_summary.py +223 -0
  69. package/skills/app-dev/python-backend/LICENSE +3 -0
  70. package/skills/app-dev/python-backend/SKILL.md +325 -0
  71. package/skills/app-dev/python-backend/assets/uv-install.sh +48 -0
  72. package/skills/app-dev/python-backend/references/uv-installs.md +83 -0
  73. package/skills/app-dev/python-backend/release-check.mjs +93 -0
  74. package/skills/app-dev/shadcn/LICENSE +3 -0
  75. package/skills/app-dev/shadcn/SKILL.md +120 -0
  76. package/skills/app-dev/shadcn/assets/shadcn-small.png +0 -0
  77. package/skills/app-dev/shadcn/assets/shadcn.png +0 -0
  78. package/skills/app-dev/shadcn/cli.md +411 -0
  79. package/skills/app-dev/shadcn/customization.md +224 -0
  80. package/skills/app-dev/shadcn/mcp.md +101 -0
  81. package/skills/app-dev/shadcn/release-check.mjs +93 -0
  82. package/skills/app-dev/shadcn/rules/base-vs-radix.md +323 -0
  83. package/skills/app-dev/shadcn/rules/component-selection.md +67 -0
  84. package/skills/app-dev/shadcn/rules/composition.md +195 -0
  85. package/skills/app-dev/shadcn/rules/data-table.md +201 -0
  86. package/skills/app-dev/shadcn/rules/forms.md +255 -0
  87. package/skills/app-dev/shadcn/rules/icons.md +103 -0
  88. package/skills/app-dev/shadcn/rules/styling.md +167 -0
  89. package/skills/app-dev/zod-v4/LICENSE +3 -0
  90. package/skills/app-dev/zod-v4/SKILL.md +286 -0
  91. package/skills/app-dev/zod-v4/release-check.mjs +93 -0
  92. package/skills/cicd/cloud-build/LICENSE +3 -0
  93. package/skills/cicd/cloud-build/SKILL.md +707 -0
  94. package/skills/cicd/cloud-build/release-check.mjs +93 -0
  95. package/skills/cicd/cloud-debug/LICENSE +3 -0
  96. package/skills/cicd/cloud-debug/SKILL.md +315 -0
  97. package/skills/cicd/cloud-debug/references/build-failures.md +238 -0
  98. package/skills/cicd/cloud-debug/references/deploy-failures.md +376 -0
  99. package/skills/cicd/cloud-debug/references/pipeline-failures.md +378 -0
  100. package/skills/cicd/cloud-debug/release-check.mjs +93 -0
  101. package/skills/cicd/cloud-deploy/LICENSE +3 -0
  102. package/skills/cicd/cloud-deploy/SKILL.md +230 -0
  103. package/skills/cicd/cloud-deploy/references/config-templates.md +257 -0
  104. package/skills/cicd/cloud-deploy/release-check.mjs +93 -0
  105. package/skills/cicd/docker/LICENSE +3 -0
  106. package/skills/cicd/docker/SKILL.md +151 -0
  107. package/skills/cicd/docker/release-check.mjs +93 -0
  108. package/skills/cicd/github-actions/LICENSE +3 -0
  109. package/skills/cicd/github-actions/SKILL.md +111 -0
  110. package/skills/cicd/github-actions/references/audit-profile-optimize.md +150 -0
  111. package/skills/cicd/github-actions/references/ci-case-studies.md +141 -0
  112. package/skills/cicd/github-actions/references/conditional-ci.md +214 -0
  113. package/skills/cicd/github-actions/references/debugging.md +13 -0
  114. package/skills/cicd/github-actions/references/pipeline.md +87 -0
  115. package/skills/cicd/github-actions/references/post-deploy-and-publishing.md +33 -0
  116. package/skills/cicd/github-actions/references/runners.md +253 -0
  117. package/skills/cicd/github-actions/references/security.md +29 -0
  118. package/skills/cicd/github-actions/references/speed-and-audit.md +115 -0
  119. package/skills/cicd/github-actions/references/two-pass-reading.md +92 -0
  120. package/skills/cicd/github-actions/release-check.mjs +93 -0
  121. package/skills/cicd/github-actions/scripts/ci-run-audit-core.mjs +612 -0
  122. package/skills/cicd/github-actions/scripts/ci-run-audit.mjs +4 -0
  123. package/skills/cicd/github-actions/scripts/ci-run-audit.test.mjs +637 -0
  124. package/skills/cicd/github-flow/LICENSE +3 -0
  125. package/skills/cicd/github-flow/SKILL.md +123 -0
  126. package/skills/cicd/github-flow/references/ci-conventions.md +14 -0
  127. package/skills/cicd/github-flow/release-check.mjs +93 -0
  128. package/skills/cicd/github-flow/scripts/flow-core.mjs +105 -0
  129. package/skills/cicd/github-flow/scripts/flow.mjs +81 -0
  130. package/skills/cicd/github-flow/scripts/flow.test.mjs +114 -0
  131. package/skills/cicd/github-flow/scripts/persist-conventions-core.mjs +117 -0
  132. package/skills/cicd/github-flow/scripts/persist-conventions.mjs +49 -0
  133. package/skills/cicd/github-flow/scripts/persist-conventions.test.mjs +108 -0
  134. package/skills/cicd/google-cloud/LICENSE +3 -0
  135. package/skills/cicd/google-cloud/SKILL.md +119 -0
  136. package/skills/cicd/google-cloud/references/gcs.md +469 -0
  137. package/skills/cicd/google-cloud/references/iam.md +451 -0
  138. package/skills/cicd/google-cloud/references/project.md +349 -0
  139. package/skills/cicd/google-cloud/references/secrets.md +336 -0
  140. package/skills/cicd/google-cloud/references/vpc.md +312 -0
  141. package/skills/cicd/google-cloud/release-check.mjs +93 -0
  142. package/skills/cicd/google-cloud/scripts/create-sa.sh +36 -0
  143. package/skills/cicd/google-cloud/scripts/gcp-config.sh +31 -0
  144. package/skills/cicd/google-cloud/scripts/grant-iap.sh +41 -0
  145. package/skills/cicd/google-cloud/scripts/setup-secrets.sh +48 -0
  146. package/skills/cicd/mergify/LICENSE +3 -0
  147. package/skills/cicd/mergify/SKILL.md +102 -0
  148. package/skills/cicd/mergify/assets/templates/mergify.yml +215 -0
  149. package/skills/cicd/mergify/assets/templates/ruleset.json +46 -0
  150. package/skills/cicd/mergify/references/branch-protection.md +282 -0
  151. package/skills/cicd/mergify/references/configuration.md +274 -0
  152. package/skills/cicd/mergify/references/diagnosis.md +109 -0
  153. package/skills/cicd/mergify/references/stacks.md +84 -0
  154. package/skills/cicd/mergify/references/traps.md +119 -0
  155. package/skills/cicd/mergify/references/watch-contract.md +274 -0
  156. package/skills/cicd/mergify/release-check.mjs +93 -0
  157. package/skills/cicd/mergify/scripts/audit-core.mjs +208 -0
  158. package/skills/cicd/mergify/scripts/audit.mjs +4 -0
  159. package/skills/cicd/mergify/scripts/audit.test.mjs +201 -0
  160. package/skills/cicd/mergify/scripts/entrypoints.test.mjs +106 -0
  161. package/skills/cicd/mergify/scripts/pr-620-mergify-comments.fixture.json +30 -0
  162. package/skills/cicd/mergify/scripts/stack-push-core.mjs +258 -0
  163. package/skills/cicd/mergify/scripts/stack-push.mjs +4 -0
  164. package/skills/cicd/mergify/scripts/stack-push.test.mjs +233 -0
  165. package/skills/cicd/mergify/scripts/watch-pr-delivery-core.mjs +1065 -0
  166. package/skills/cicd/mergify/scripts/watch-pr-delivery.mjs +4 -0
  167. package/skills/cicd/mergify/scripts/watch-pr-delivery.test.mjs +1826 -0
  168. package/skills/cicd/podman/LICENSE +3 -0
  169. package/skills/cicd/podman/SKILL.md +71 -0
  170. package/skills/cicd/podman/agents/openai.yaml +4 -0
  171. package/skills/cicd/podman/assets/templates/podman-compose-socket-directory.yml +6 -0
  172. package/skills/cicd/podman/assets/templates/podman-service-override.conf +3 -0
  173. package/skills/cicd/podman/references/compose-compatibility.md +70 -0
  174. package/skills/cicd/podman/references/networking-and-ports.md +74 -0
  175. package/skills/cicd/podman/references/rootless-services-and-sockets.md +156 -0
  176. package/skills/cicd/podman/references/troubleshooting.md +98 -0
  177. package/skills/cicd/podman/release-check.mjs +93 -0
  178. package/skills/content/article-review/LICENSE +3 -0
  179. package/skills/content/article-review/SKILL.md +66 -0
  180. package/skills/content/article-review/references/report-format.md +68 -0
  181. package/skills/content/article-review/references/template-guide.md +79 -0
  182. package/skills/content/article-review/references/template-wechat-payments.md +125 -0
  183. package/skills/content/article-review/release-check.mjs +93 -0
  184. package/skills/content/article-to-image/LICENSE +3 -0
  185. package/skills/content/article-to-image/SKILL.md +91 -0
  186. package/skills/content/article-to-image/references/formats.md +53 -0
  187. package/skills/content/article-to-image/release-check.mjs +93 -0
  188. package/skills/content/flowchart-svg/LICENSE +3 -0
  189. package/skills/content/flowchart-svg/SKILL.md +43 -0
  190. package/skills/content/flowchart-svg/release-check.mjs +93 -0
  191. package/skills/content/flowchart-svg/scripts/export_png.sh +8 -0
  192. package/skills/content/image-gen/LICENSE +3 -0
  193. package/skills/content/image-gen/SKILL.md +192 -0
  194. package/skills/content/image-gen/release-check.mjs +93 -0
  195. package/skills/content/image-gen/scripts/credentials.mjs +217 -0
  196. package/skills/content/image-gen/scripts/credentials.test.mjs +212 -0
  197. package/skills/content/image-gen/scripts/generate.mjs +240 -0
  198. package/skills/content/image-gen/scripts/options.mjs +293 -0
  199. package/skills/content/image-gen/scripts/options.test.mjs +245 -0
  200. package/skills/content/mermaid/LICENSE +3 -0
  201. package/skills/content/mermaid/SKILL.md +635 -0
  202. package/skills/content/mermaid/release-check.mjs +93 -0
  203. package/skills/content/wechat-article-writer/LICENSE +3 -0
  204. package/skills/content/wechat-article-writer/SKILL.md +92 -0
  205. package/skills/content/wechat-article-writer/references/style-selector.md +133 -0
  206. package/skills/content/wechat-article-writer/references/writing-rules.md +152 -0
  207. package/skills/content/wechat-article-writer/release-check.mjs +93 -0
  208. package/skills/content/wechat-article-writer/templates/wechat-comparison.md +52 -0
  209. package/skills/content/wechat-article-writer/templates/wechat-howto.md +51 -0
  210. package/skills/content/wechat-article-writer/templates/wechat-insight.md +50 -0
  211. package/skills/content/wechat-article-writer/templates/wechat-pitfall.md +52 -0
  212. package/skills/content/wechat-publisher/LICENSE +3 -0
  213. package/skills/content/wechat-publisher/SKILL.md +171 -0
  214. package/skills/content/wechat-publisher/references/api.md +105 -0
  215. package/skills/content/wechat-publisher/references/layout-rules.md +57 -0
  216. package/skills/content/wechat-publisher/references/wechat-safe-template.html +86 -0
  217. package/skills/content/wechat-publisher/release-check.mjs +93 -0
  218. package/skills/content/wechat-publisher/scripts/md_to_wechat_html.py +194 -0
  219. package/skills/content/wechat-publisher/scripts/publish.py +239 -0
  220. package/skills/content/wechat-search/LICENSE +3 -0
  221. package/skills/content/wechat-search/SKILL.md +211 -0
  222. package/skills/content/wechat-search/release-check.mjs +93 -0
  223. package/skills/content/wechat-search/scripts/start-chrome.sh +83 -0
  224. package/skills/content/xai-search/LICENSE +3 -0
  225. package/skills/content/xai-search/SKILL.md +79 -0
  226. package/skills/content/xai-search/release-check.mjs +93 -0
  227. package/skills/content/xai-search/scripts/xai_search.sh +187 -0
  228. package/skills/content/xai-search/tests/smoke_test.sh +36 -0
  229. package/skills/core/distill-writing/LICENSE +3 -0
  230. package/skills/core/distill-writing/SKILL.md +74 -0
  231. package/skills/core/distill-writing/references/extraction.md +95 -0
  232. package/skills/core/distill-writing/references/updating.md +46 -0
  233. package/skills/core/distill-writing/release-check.mjs +93 -0
  234. package/skills/core/doc-to-md/LICENSE +3 -0
  235. package/skills/core/doc-to-md/SKILL.md +78 -0
  236. package/skills/core/doc-to-md/release-check.mjs +93 -0
  237. package/skills/core/doc-to-md/scripts/convert.py +157 -0
  238. package/skills/core/editorial-review/LICENSE +3 -0
  239. package/skills/core/editorial-review/SKILL.md +79 -0
  240. package/skills/core/editorial-review/references/checklist.md +94 -0
  241. package/skills/core/editorial-review/release-check.mjs +93 -0
  242. package/skills/core/house-style/LICENSE +3 -0
  243. package/skills/core/house-style/SKILL.md +33 -0
  244. package/skills/core/house-style/references/defaults.md +66 -0
  245. package/skills/core/house-style/references/rules.md +267 -0
  246. package/skills/core/house-style/references/sources.md +27 -0
  247. package/skills/core/house-style/references/style-en.md +131 -0
  248. package/skills/core/house-style/references/style-zh.md +141 -0
  249. package/skills/core/house-style/references/terminology-zh.md +223 -0
  250. package/skills/core/house-style/release-check.mjs +93 -0
  251. package/skills/core/house-style/templates/brd.md +103 -0
  252. package/skills/core/house-style/templates/commercial-proposal.md +93 -0
  253. package/skills/core/house-style/templates/compliance-policy.md +109 -0
  254. package/skills/core/house-style/templates/gap-analysis.md +105 -0
  255. package/skills/core/house-style/templates/general.md +63 -0
  256. package/skills/core/house-style/templates/integration-guide.md +98 -0
  257. package/skills/core/house-style/templates/prd.md +81 -0
  258. package/skills/core/house-style/templates/risk-operations-procedure.md +94 -0
  259. package/skills/core/house-style/templates/technical-spec.md +96 -0
  260. package/skills/core/polish-cn/LICENSE +3 -0
  261. package/skills/core/polish-cn/SKILL.md +102 -0
  262. package/skills/core/polish-cn/references/brief.md +93 -0
  263. package/skills/core/polish-cn/references/models.md +76 -0
  264. package/skills/core/polish-cn/release-check.mjs +93 -0
  265. package/skills/core/polish-cn/scripts/polish-cn.test.mjs +244 -0
  266. package/skills/core/polish-cn/scripts/polish_blocks.py +489 -0
  267. package/skills/core/reg-parser/LICENSE +3 -0
  268. package/skills/core/reg-parser/SKILL.md +212 -0
  269. package/skills/core/reg-parser/references/checklist-schema.md +223 -0
  270. package/skills/core/reg-parser/references/checklists/hk-amlo-checklist.json +1306 -0
  271. package/skills/core/reg-parser/references/checklists/hk-amlo-checklist.md +469 -0
  272. package/skills/core/reg-parser/references/checklists/hk-fi-corporate-onboarding-cdd-checklist.json +634 -0
  273. package/skills/core/reg-parser/references/checklists/hk-fi-corporate-onboarding-cdd-checklist.md +233 -0
  274. package/skills/core/reg-parser/references/checklists/hk-mso-guideline-checklist.json +2194 -0
  275. package/skills/core/reg-parser/references/checklists/hk-mso-guideline-checklist.md +771 -0
  276. package/skills/core/reg-parser/references/checklists/kyc-aml-checklist.md +258 -0
  277. package/skills/core/reg-parser/references/checklists/visa-vars-checklist.md +1058 -0
  278. package/skills/core/reg-parser/references/mandates/hk-amlo-cap615.md +389 -0
  279. package/skills/core/reg-parser/references/mandates/visa-vars.md +205 -0
  280. package/skills/core/reg-parser/release-check.mjs +93 -0
  281. package/skills/core/reg-parser/scripts/compare_checklist.py +230 -0
  282. package/skills/core/reg-parser/scripts/dump_checklist.py +361 -0
  283. package/skills/core/reg-parser/scripts/preprocess-mandate.sh +187 -0
  284. package/skills/core/reg-parser/scripts/reg-parser.test.mjs +165 -0
  285. package/skills/core/reg-parser/scripts/test_quarantine.sh +105 -0
  286. package/skills/core/reg-parser/scripts/validate_checklist.py +563 -0
  287. package/skills/core/reg-parser/scripts/validate_mandate.py +273 -0
  288. package/skills/core/terminology/LICENSE +3 -0
  289. package/skills/core/terminology/SKILL.md +62 -0
  290. package/skills/core/terminology/references/format.md +50 -0
  291. package/skills/core/terminology/references/init.md +32 -0
  292. package/skills/core/terminology/release-check.mjs +93 -0
  293. package/skills/core/write-doc/LICENSE +3 -0
  294. package/skills/core/write-doc/SKILL.md +141 -0
  295. package/skills/core/write-doc/references/bilingual.md +60 -0
  296. package/skills/core/write-doc/release-check.mjs +93 -0
  297. package/skills/engineering/better-review/LICENSE +3 -0
  298. package/skills/engineering/better-review/SKILL.md +249 -0
  299. package/skills/engineering/better-review/agents/openai.yaml +9 -0
  300. package/skills/engineering/better-review/references/domain-hazards.md +53 -0
  301. package/skills/engineering/better-review/references/passes.md +147 -0
  302. package/skills/engineering/better-review/references/result.md +151 -0
  303. package/skills/engineering/better-review/release-check.mjs +93 -0
  304. package/skills/engineering/better-review/scripts/pr-context.mjs +212 -0
  305. package/skills/engineering/better-review/scripts/pr-context.test.mjs +168 -0
  306. package/skills/engineering/better-review/scripts/rereview-scope.mjs +503 -0
  307. package/skills/engineering/better-review/scripts/rereview-scope.test.mjs +587 -0
  308. package/skills/engineering/better-review/scripts/scan-diff.mjs +365 -0
  309. package/skills/engineering/better-review/scripts/scan-diff.test.mjs +414 -0
  310. package/skills/engineering/better-review/scripts/vendor-sync.test.mjs +96 -0
  311. package/skills/engineering/e2e-test/LICENSE +3 -0
  312. package/skills/engineering/e2e-test/SKILL.md +98 -0
  313. package/skills/engineering/e2e-test/assets/ci-gates.cloudbuild.yaml +24 -0
  314. package/skills/engineering/e2e-test/references/authoring/auth-flows.md +159 -0
  315. package/skills/engineering/e2e-test/references/authoring/playwright-config.md +73 -0
  316. package/skills/engineering/e2e-test/references/authoring/playwright-patterns.md +144 -0
  317. package/skills/engineering/e2e-test/references/ci-integration.md +25 -0
  318. package/skills/engineering/e2e-test/references/ci-playwright-container.md +290 -0
  319. package/skills/engineering/e2e-test/references/presentation-sweep.md +104 -0
  320. package/skills/engineering/e2e-test/references/reviewing.md +41 -0
  321. package/skills/engineering/e2e-test/references/route-manifest.md +51 -0
  322. package/skills/engineering/e2e-test/release-check.mjs +93 -0
  323. package/skills/engineering/e2e-test/scripts/detect-routes-nextjs.mjs +200 -0
  324. package/skills/engineering/e2e-test/scripts/presentation-checks.mjs +171 -0
  325. package/skills/engineering/e2e-test/scripts/presentation-checks.test.mjs +106 -0
  326. package/skills/engineering/e2e-test/scripts/presentation-perceivability.mjs +179 -0
  327. package/skills/engineering/e2e-test/scripts/presentation-perceivability.test.mjs +117 -0
  328. package/skills/engineering/e2e-test/scripts/presentation-reachability.mjs +154 -0
  329. package/skills/engineering/e2e-test/scripts/presentation-reachability.test.mjs +69 -0
  330. package/skills/engineering/e2e-test/scripts/presentation-render-health.mjs +141 -0
  331. package/skills/engineering/e2e-test/scripts/presentation-render-health.test.mjs +98 -0
  332. package/skills/engineering/e2e-test/scripts/presentation-sweep.mjs +147 -0
  333. package/skills/engineering/e2e-test/scripts/presentation-sweep.test.mjs +71 -0
  334. package/skills/engineering/e2e-test/scripts/presentation-temporal.mjs +127 -0
  335. package/skills/engineering/e2e-test/scripts/presentation-temporal.test.mjs +59 -0
  336. package/skills/engineering/e2e-test/scripts/project-route-manifest.mjs +82 -0
  337. package/skills/engineering/e2e-test/scripts/project-route-manifest.test.mjs +76 -0
  338. package/skills/engineering/e2e-test/scripts/validate-manifest.mjs +106 -0
  339. package/skills/engineering/engineering-runtime/LICENSE +3 -0
  340. package/skills/engineering/engineering-runtime/SKILL.md +11 -0
  341. package/skills/engineering/engineering-runtime/agents/openai.yaml +6 -0
  342. package/skills/engineering/engineering-runtime/coherence/workflow.json +597 -0
  343. package/skills/engineering/engineering-runtime/engineering-runtime.test.mjs +68 -0
  344. package/skills/engineering/engineering-runtime/release-check.mjs +93 -0
  345. package/skills/engineering/engineering-runtime/scripts/exact-head-artifact.mjs +131 -0
  346. package/skills/engineering/engineering-runtime/scripts/head-check-set.mjs +398 -0
  347. package/skills/engineering/engineering-runtime/scripts/invocation-policy.mjs +187 -0
  348. package/skills/engineering/engineering-runtime/scripts/invocation-policy.test.mjs +95 -0
  349. package/skills/engineering/engineering-runtime/scripts/main-module.mjs +79 -0
  350. package/skills/engineering/engineering-runtime/scripts/main-module.test.mjs +288 -0
  351. package/skills/engineering/engineering-runtime/scripts/mergify-yaml.mjs +11 -0
  352. package/skills/engineering/engineering-runtime/scripts/package-lock.json +43 -0
  353. package/skills/engineering/engineering-runtime/scripts/package.json +10 -0
  354. package/skills/engineering/engineering-runtime/scripts/required-check-plan.mjs +223 -0
  355. package/skills/engineering/engineering-runtime/scripts/required-check-plan.test.mjs +96 -0
  356. package/skills/engineering/engineering-runtime/scripts/review-completion.mjs +56 -0
  357. package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +595 -0
  358. package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +189 -0
  359. package/skills/engineering/engineering-runtime/scripts/yaml-mapping.mjs +18 -0
  360. package/skills/engineering/ensure-coverage/LICENSE +3 -0
  361. package/skills/engineering/ensure-coverage/SKILL.md +129 -0
  362. package/skills/engineering/ensure-coverage/references/breadth/coverage-ledger.md +92 -0
  363. package/skills/engineering/ensure-coverage/references/breadth/inventory-contract.md +85 -0
  364. package/skills/engineering/ensure-coverage/references/breadth/surface-baseline.md +44 -0
  365. package/skills/engineering/ensure-coverage/references/breadth/surface-discovery.md +16 -0
  366. package/skills/engineering/ensure-coverage/references/depth/characterization.md +68 -0
  367. package/skills/engineering/ensure-coverage/references/depth/coverage.config.example.json +19 -0
  368. package/skills/engineering/ensure-coverage/references/depth/grading.md +35 -0
  369. package/skills/engineering/ensure-coverage/references/depth/mock-policy.md +57 -0
  370. package/skills/engineering/ensure-coverage/references/depth/test-smells.md +39 -0
  371. package/skills/engineering/ensure-coverage/references/enforcement/ci-contract.md +44 -0
  372. package/skills/engineering/ensure-coverage/references/examples/coverage-ledger.md +110 -0
  373. package/skills/engineering/ensure-coverage/references/examples/refund-flow.md +35 -0
  374. package/skills/engineering/ensure-coverage/references/presentation/axis.md +64 -0
  375. package/skills/engineering/ensure-coverage/references/process/audit-mode.md +27 -0
  376. package/skills/engineering/ensure-coverage/references/process/output-template.md +139 -0
  377. package/skills/engineering/ensure-coverage/references/process/test-strategy.md +56 -0
  378. package/skills/engineering/ensure-coverage/release-check.mjs +93 -0
  379. package/skills/engineering/ensure-coverage/schemas/coverage-config.schema.json +67 -0
  380. package/skills/engineering/ensure-coverage/schemas/coverage-file.schema.json +140 -0
  381. package/skills/engineering/ensure-coverage/scripts/README.md +99 -0
  382. package/skills/engineering/ensure-coverage/scripts/adapters/nextjs-inventory.mjs +182 -0
  383. package/skills/engineering/ensure-coverage/scripts/adapters/nextjs-inventory.test.mjs +96 -0
  384. package/skills/engineering/ensure-coverage/scripts/check-quarantine-expiry.mjs +105 -0
  385. package/skills/engineering/ensure-coverage/scripts/check-quarantine-expiry.test.mjs +47 -0
  386. package/skills/engineering/ensure-coverage/scripts/ci-audit.mjs +137 -0
  387. package/skills/engineering/ensure-coverage/scripts/ci-audit.test.mjs +86 -0
  388. package/skills/engineering/ensure-coverage/scripts/coverage-ledger.mjs +674 -0
  389. package/skills/engineering/ensure-coverage/scripts/coverage-ledger.test.mjs +447 -0
  390. package/skills/engineering/ensure-coverage/scripts/design-parity.mjs +591 -0
  391. package/skills/engineering/ensure-coverage/scripts/design-parity.test.mjs +552 -0
  392. package/skills/engineering/ensure-coverage/scripts/lint-tests.mjs +282 -0
  393. package/skills/engineering/ensure-coverage/scripts/lint-tests.test.mjs +289 -0
  394. package/skills/engineering/ensure-coverage/scripts/mock-policy-config.mjs +176 -0
  395. package/skills/engineering/ensure-coverage/scripts/mock-policy-config.test.mjs +119 -0
  396. package/skills/engineering/ensure-coverage/scripts/package.json +19 -0
  397. package/skills/engineering/golden-path/LICENSE +3 -0
  398. package/skills/engineering/golden-path/SKILL.md +230 -0
  399. package/skills/engineering/golden-path/agents/openai.yaml +3 -0
  400. package/skills/engineering/golden-path/assets/golden-paths.example.json +57 -0
  401. package/skills/engineering/golden-path/references/contract.md +239 -0
  402. package/skills/engineering/golden-path/references/migration.md +111 -0
  403. package/skills/engineering/golden-path/release-check.mjs +93 -0
  404. package/skills/engineering/golden-path/scripts/golden-path-core.mjs +1779 -0
  405. package/skills/engineering/golden-path/scripts/golden-path.mjs +6 -0
  406. package/skills/engineering/golden-path/scripts/golden-path.test.mjs +1457 -0
  407. package/skills/engineering/harness-runtime/LICENSE +3 -0
  408. package/skills/engineering/harness-runtime/SKILL.md +19 -0
  409. package/skills/engineering/harness-runtime/agents/openai.yaml +6 -0
  410. package/skills/engineering/harness-runtime/bin/discover-models.mjs +4 -0
  411. package/skills/engineering/harness-runtime/bin/model-catalog.mjs +4 -0
  412. package/skills/engineering/harness-runtime/contracts.md +15 -0
  413. package/skills/engineering/harness-runtime/discover-models.mjs +392 -0
  414. package/skills/engineering/harness-runtime/discover-models.test.mjs +363 -0
  415. package/skills/engineering/harness-runtime/fixtures/native-question-schemas.json +33 -0
  416. package/skills/engineering/harness-runtime/fixtures/question-responses.json +54 -0
  417. package/skills/engineering/harness-runtime/harness-runtime.test.mjs +967 -0
  418. package/skills/engineering/harness-runtime/index.mjs +767 -0
  419. package/skills/engineering/harness-runtime/model-catalog-seed.test.mjs +829 -0
  420. package/skills/engineering/harness-runtime/model-catalog.mjs +787 -0
  421. package/skills/engineering/harness-runtime/model-catalog.test.mjs +366 -0
  422. package/skills/engineering/harness-runtime/native-question-contracts.md +37 -0
  423. package/skills/engineering/harness-runtime/references/model-catalog-seed.json +408 -0
  424. package/skills/engineering/harness-runtime/references/model-catalog.md +57 -0
  425. package/skills/engineering/harness-runtime/release-check.mjs +93 -0
  426. package/skills/engineering/resolve-issues/LICENSE +3 -0
  427. package/skills/engineering/resolve-issues/SKILL.md +250 -0
  428. package/skills/engineering/resolve-issues/agents/openai.yaml +9 -0
  429. package/skills/engineering/resolve-issues/plugin-hooks.json +17 -0
  430. package/skills/engineering/resolve-issues/references/canary.md +28 -0
  431. package/skills/engineering/resolve-issues/references/ci.md +105 -0
  432. package/skills/engineering/resolve-issues/references/done.md +25 -0
  433. package/skills/engineering/resolve-issues/references/dropbox.md +90 -0
  434. package/skills/engineering/resolve-issues/references/expire.md +318 -0
  435. package/skills/engineering/resolve-issues/references/exploration.md +202 -0
  436. package/skills/engineering/resolve-issues/references/implementation.md +303 -0
  437. package/skills/engineering/resolve-issues/references/preflight.md +445 -0
  438. package/skills/engineering/resolve-issues/references/review.md +228 -0
  439. package/skills/engineering/resolve-issues/references/shared-modules.md +44 -0
  440. package/skills/engineering/resolve-issues/references/task-plan.md +264 -0
  441. package/skills/engineering/resolve-issues/release-check.mjs +93 -0
  442. package/skills/engineering/resolve-issues/scripts/cleanup-runs.mjs +364 -0
  443. package/skills/engineering/resolve-issues/scripts/cleanup-runs.test.mjs +280 -0
  444. package/skills/engineering/resolve-issues/scripts/discover-models.mjs +9 -0
  445. package/skills/engineering/resolve-issues/scripts/dropbox.mjs +473 -0
  446. package/skills/engineering/resolve-issues/scripts/dropbox.test.mjs +346 -0
  447. package/skills/engineering/resolve-issues/scripts/findings-reuse.mjs +98 -0
  448. package/skills/engineering/resolve-issues/scripts/findings-reuse.test.mjs +88 -0
  449. package/skills/engineering/resolve-issues/scripts/github-reviews.mjs +384 -0
  450. package/skills/engineering/resolve-issues/scripts/github-reviews.test.mjs +264 -0
  451. package/skills/engineering/resolve-issues/scripts/install-hooks.mjs +135 -0
  452. package/skills/engineering/resolve-issues/scripts/install-hooks.test.mjs +79 -0
  453. package/skills/engineering/resolve-issues/scripts/issue-tree.mjs +162 -0
  454. package/skills/engineering/resolve-issues/scripts/issue-tree.test.mjs +107 -0
  455. package/skills/engineering/resolve-issues/scripts/manual-invocation.test.mjs +14 -0
  456. package/skills/engineering/resolve-issues/scripts/model-catalog.mjs +9 -0
  457. package/skills/engineering/resolve-issues/scripts/next-actions-capacity.test.mjs +149 -0
  458. package/skills/engineering/resolve-issues/scripts/next-actions.mjs +856 -0
  459. package/skills/engineering/resolve-issues/scripts/next-actions.test.mjs +915 -0
  460. package/skills/engineering/resolve-issues/scripts/plan-sync.mjs +269 -0
  461. package/skills/engineering/resolve-issues/scripts/plan-sync.test.mjs +156 -0
  462. package/skills/engineering/resolve-issues/scripts/preflight-models.mjs +736 -0
  463. package/skills/engineering/resolve-issues/scripts/preflight-models.test.mjs +448 -0
  464. package/skills/engineering/resolve-issues/scripts/ready-check.mjs +185 -0
  465. package/skills/engineering/resolve-issues/scripts/ready-check.test.mjs +217 -0
  466. package/skills/engineering/resolve-issues/scripts/rehydrate.mjs +98 -0
  467. package/skills/engineering/resolve-issues/scripts/rehydrate.test.mjs +91 -0
  468. package/skills/engineering/resolve-issues/scripts/review-verdict.mjs +509 -0
  469. package/skills/engineering/resolve-issues/scripts/review-verdict.test.mjs +1060 -0
  470. package/skills/engineering/resolve-issues/scripts/size-check.mjs +492 -0
  471. package/skills/engineering/resolve-issues/scripts/size-check.test.mjs +458 -0
  472. package/skills/engineering/resolve-release/LICENSE +3 -0
  473. package/skills/engineering/resolve-release/SKILL.md +215 -0
  474. package/skills/engineering/resolve-release/agents/openai.yaml +8 -0
  475. package/skills/engineering/resolve-release/flows/cloud-run-canary.md +13 -0
  476. package/skills/engineering/resolve-release/flows/cloud-run.md +7 -0
  477. package/skills/engineering/resolve-release/flows/root-tag-npm.md +13 -0
  478. package/skills/engineering/resolve-release/flows/workspace-npm.md +22 -0
  479. package/skills/engineering/resolve-release/references/canary.md +51 -0
  480. package/skills/engineering/resolve-release/references/candidate.md +41 -0
  481. package/skills/engineering/resolve-release/references/cut.md +131 -0
  482. package/skills/engineering/resolve-release/references/dark-launch.md +66 -0
  483. package/skills/engineering/resolve-release/references/flows.md +150 -0
  484. package/skills/engineering/resolve-release/references/preflight.md +154 -0
  485. package/skills/engineering/resolve-release/references/prepared-metadata.md +260 -0
  486. package/skills/engineering/resolve-release/references/promotion.md +82 -0
  487. package/skills/engineering/resolve-release/references/publish.md +232 -0
  488. package/skills/engineering/resolve-release/references/workspace-npm.md +103 -0
  489. package/skills/engineering/resolve-release/release-check.mjs +93 -0
  490. package/skills/engineering/resolve-release/scripts/apply.mjs +212 -0
  491. package/skills/engineering/resolve-release/scripts/apply.test.mjs +99 -0
  492. package/skills/engineering/resolve-release/scripts/dark-flags.mjs +134 -0
  493. package/skills/engineering/resolve-release/scripts/dark-flags.test.mjs +100 -0
  494. package/skills/engineering/resolve-release/scripts/flow.mjs +500 -0
  495. package/skills/engineering/resolve-release/scripts/flow.test.mjs +340 -0
  496. package/skills/engineering/resolve-release/scripts/green-head.mjs +231 -0
  497. package/skills/engineering/resolve-release/scripts/green-head.test.mjs +101 -0
  498. package/skills/engineering/resolve-release/scripts/npm-version-absence.mjs +138 -0
  499. package/skills/engineering/resolve-release/scripts/npm-version-absence.test.mjs +426 -0
  500. package/skills/engineering/resolve-release/scripts/plan-npm.test.mjs +659 -0
  501. package/skills/engineering/resolve-release/scripts/plan-toml-boundary.test.mjs +317 -0
  502. package/skills/engineering/resolve-release/scripts/plan-uv.test.mjs +291 -0
  503. package/skills/engineering/resolve-release/scripts/plan.mjs +806 -0
  504. package/skills/engineering/resolve-release/scripts/plan.test.mjs +428 -0
  505. package/skills/engineering/rush-issues/LICENSE +3 -0
  506. package/skills/engineering/rush-issues/SKILL.md +58 -0
  507. package/skills/engineering/rush-issues/agents/openai.yaml +8 -0
  508. package/skills/engineering/rush-issues/rehydrate.mjs +10 -0
  509. package/skills/engineering/rush-issues/release-check.mjs +93 -0
  510. package/skills/engineering/rush-release/LICENSE +3 -0
  511. package/skills/engineering/rush-release/SKILL.md +35 -0
  512. package/skills/engineering/rush-release/agents/openai.yaml +8 -0
  513. package/skills/engineering/rush-release/release-check.mjs +93 -0
  514. package/skills/engineering/test-quality/LICENSE +3 -0
  515. package/skills/engineering/test-quality/SKILL.md +79 -0
  516. package/skills/engineering/test-quality/references/audit.md +80 -0
  517. package/skills/engineering/test-quality/references/lint.md +24 -0
  518. package/skills/engineering/test-quality/references/principles.md +143 -0
  519. package/skills/engineering/test-quality/references/testing-conventions.md +9 -0
  520. package/skills/engineering/test-quality/release-check.mjs +93 -0
  521. package/skills/engineering/test-quality/scripts/lint-tests.mjs +574 -0
  522. package/skills/engineering/test-quality/scripts/lint-tests.test.mjs +591 -0
  523. package/skills/engineering/test-quality/scripts/mock-policy-config.mjs +176 -0
  524. package/skills/engineering/test-quality/scripts/mock-policy-config.test.mjs +119 -0
  525. package/skills/engineering/test-quality/scripts/persist-conventions-core.mjs +117 -0
  526. package/skills/engineering/test-quality/scripts/persist-conventions.mjs +17 -0
  527. package/skills/engineering/test-quality/scripts/persist-conventions.test.mjs +104 -0
  528. package/skills/risk-compliance/compliance-audit/LICENSE +3 -0
  529. package/skills/risk-compliance/compliance-audit/SKILL.md +439 -0
  530. package/skills/risk-compliance/compliance-audit/references/findings-schema.md +293 -0
  531. package/skills/risk-compliance/compliance-audit/references/normalization-rules.md +45 -0
  532. package/skills/risk-compliance/compliance-audit/references/vars-checklist.md +1068 -0
  533. package/skills/risk-compliance/compliance-audit/references/vars-findings-schema.md +398 -0
  534. package/skills/risk-compliance/compliance-audit/release-check.mjs +93 -0
  535. package/skills/risk-compliance/compliance-audit/scripts/compliance-audit.test.mjs +14 -0
  536. package/skills/risk-compliance/compliance-audit/scripts/export_tracker_xlsx.py +221 -0
  537. package/skills/risk-compliance/compliance-audit/scripts/reconcile_tracker.py +458 -0
  538. package/skills/risk-compliance/compliance-audit/scripts/test_export_tracker_xlsx.py +97 -0
  539. package/skills/risk-compliance/compliance-audit/scripts/test_validate_tracker.py +157 -0
  540. package/skills/risk-compliance/compliance-audit/scripts/validate_tracker.py +829 -0
  541. package/skills/risk-compliance/contract-compare/LICENSE +3 -0
  542. package/skills/risk-compliance/contract-compare/SKILL.md +199 -0
  543. package/skills/risk-compliance/contract-compare/references/comparison-dimensions.md +209 -0
  544. package/skills/risk-compliance/contract-compare/release-check.mjs +93 -0
  545. package/skills/risk-compliance/contract-review/LICENSE +3 -0
  546. package/skills/risk-compliance/contract-review/SKILL.md +240 -0
  547. package/skills/risk-compliance/contract-review/references/review-criteria.md +223 -0
  548. package/skills/risk-compliance/contract-review/release-check.mjs +93 -0
  549. package/skills/risk-compliance/doc-authenticity/LICENSE +3 -0
  550. package/skills/risk-compliance/doc-authenticity/SKILL.md +160 -0
  551. package/skills/risk-compliance/doc-authenticity/agents/openai.yaml +8 -0
  552. package/skills/risk-compliance/doc-authenticity/manual-invocation.test.mjs +12 -0
  553. package/skills/risk-compliance/doc-authenticity/references/agreement.md +66 -0
  554. package/skills/risk-compliance/doc-authenticity/references/bank-statement.md +150 -0
  555. package/skills/risk-compliance/doc-authenticity/references/forensic-signals.md +157 -0
  556. package/skills/risk-compliance/doc-authenticity/references/image-signals.md +114 -0
  557. package/skills/risk-compliance/doc-authenticity/references/invoice.md +66 -0
  558. package/skills/risk-compliance/doc-authenticity/references/issuer-profiles.md +86 -0
  559. package/skills/risk-compliance/doc-authenticity/references/verdict-and-report.md +95 -0
  560. package/skills/risk-compliance/doc-authenticity/references/verdict.schema.json +81 -0
  561. package/skills/risk-compliance/doc-authenticity/release-check.mjs +93 -0
  562. package/skills/risk-compliance/doc-authenticity/scripts/image_forensics.py +294 -0
  563. package/skills/risk-compliance/doc-authenticity/scripts/pdf_forensics.py +401 -0
  564. package/skills/risk-compliance/doc-authenticity/scripts/validate_codes.py +224 -0
  565. package/skills/risk-compliance/doc-authenticity/scripts/validate_verdict.py +308 -0
  566. package/skills/risk-compliance/doc-authenticity/scripts/validate_verdict.test.mjs +212 -0
  567. package/skills/risk-compliance/fraud-analysis/LICENSE +3 -0
  568. package/skills/risk-compliance/fraud-analysis/SKILL.md +114 -0
  569. package/skills/risk-compliance/fraud-analysis/references/archetypes/authorized-payment-scam.md +41 -0
  570. package/skills/risk-compliance/fraud-analysis/references/archetypes/first-party-fraud.md +44 -0
  571. package/skills/risk-compliance/fraud-analysis/references/archetypes/third-party-fraud.md +27 -0
  572. package/skills/risk-compliance/fraud-analysis/references/contexts/bank-transfer.md +24 -0
  573. package/skills/risk-compliance/fraud-analysis/references/contexts/card-payment.md +30 -0
  574. package/skills/risk-compliance/fraud-analysis/references/contexts/payment-collection.md +20 -0
  575. package/skills/risk-compliance/fraud-analysis/references/contexts/payout.md +20 -0
  576. package/skills/risk-compliance/fraud-analysis/references/feature-engineering.md +158 -0
  577. package/skills/risk-compliance/fraud-analysis/references/mechanisms/account-takeover.md +36 -0
  578. package/skills/risk-compliance/fraud-analysis/references/report-rationale.md +45 -0
  579. package/skills/risk-compliance/fraud-analysis/references/report-template.md +190 -0
  580. package/skills/risk-compliance/fraud-analysis/references/review-checklist.md +175 -0
  581. package/skills/risk-compliance/fraud-analysis/references/taxonomy.md +79 -0
  582. package/skills/risk-compliance/fraud-analysis/references/terminology.md +108 -0
  583. package/skills/risk-compliance/fraud-analysis/references/workflow.md +175 -0
  584. package/skills/risk-compliance/fraud-analysis/release-check.mjs +93 -0
  585. package/skills/risk-compliance/payment-analysis/LICENSE +3 -0
  586. package/skills/risk-compliance/payment-analysis/SKILL.md +180 -0
  587. package/skills/risk-compliance/payment-analysis/references/auth-rate-actions.md +30 -0
  588. package/skills/risk-compliance/payment-analysis/references/chargebacks.md +208 -0
  589. package/skills/risk-compliance/payment-analysis/references/chinese-polish.md +51 -0
  590. package/skills/risk-compliance/payment-analysis/references/event-layers.md +127 -0
  591. package/skills/risk-compliance/payment-analysis/references/fx.md +59 -0
  592. package/skills/risk-compliance/payment-analysis/references/journey.md +79 -0
  593. package/skills/risk-compliance/payment-analysis/references/metrics.md +67 -0
  594. package/skills/risk-compliance/payment-analysis/references/report-template.md +119 -0
  595. package/skills/risk-compliance/payment-analysis/references/terminology.md +95 -0
  596. package/skills/risk-compliance/payment-analysis/references/visualization.md +56 -0
  597. package/skills/risk-compliance/payment-analysis/release-check.mjs +93 -0
  598. package/skills/risk-compliance/report-writing/LICENSE +3 -0
  599. package/skills/risk-compliance/report-writing/SKILL.md +220 -0
  600. package/skills/risk-compliance/report-writing/references/audit-report-template.md +121 -0
  601. package/skills/risk-compliance/report-writing/references/external-assessment-template.md +167 -0
  602. package/skills/risk-compliance/report-writing/references/management-report-template.md +145 -0
  603. package/skills/risk-compliance/report-writing/references/vars-report-template.md +224 -0
  604. package/skills/risk-compliance/report-writing/release-check.mjs +93 -0
  605. package/skills/risk-compliance/similar-domain/LICENSE +3 -0
  606. package/skills/risk-compliance/similar-domain/SKILL.md +203 -0
  607. package/skills/risk-compliance/similar-domain/references/entities.md +67 -0
  608. package/skills/risk-compliance/similar-domain/references/fingerprints.md +134 -0
  609. package/skills/risk-compliance/similar-domain/references/pivots.md +82 -0
  610. package/skills/risk-compliance/similar-domain/references/report-template.md +93 -0
  611. package/skills/risk-compliance/similar-domain/references/scoring.md +130 -0
  612. package/skills/risk-compliance/similar-domain/references/terminology.md +41 -0
  613. package/skills/risk-compliance/similar-domain/references/workflow.md +85 -0
  614. package/skills/risk-compliance/similar-domain/release-check.mjs +93 -0
  615. package/skills/risk-compliance/tld-detection/LICENSE +3 -0
  616. package/skills/risk-compliance/tld-detection/SKILL.md +245 -0
  617. package/skills/risk-compliance/tld-detection/agents/openai.yaml +9 -0
  618. package/skills/risk-compliance/tld-detection/references/chinese-polish.md +55 -0
  619. package/skills/risk-compliance/tld-detection/references/clean.md +51 -0
  620. package/skills/risk-compliance/tld-detection/references/clustering.md +235 -0
  621. package/skills/risk-compliance/tld-detection/references/decision.md +189 -0
  622. package/skills/risk-compliance/tld-detection/references/fx-conversion.md +232 -0
  623. package/skills/risk-compliance/tld-detection/references/report-template.md +272 -0
  624. package/skills/risk-compliance/tld-detection/references/streams.md +141 -0
  625. package/skills/risk-compliance/tld-detection/references/terminology.md +95 -0
  626. package/skills/risk-compliance/tld-detection/references/workflow.md +95 -0
  627. package/skills/risk-compliance/tld-detection/release-check.mjs +93 -0
  628. package/skills/risk-compliance/tld-detection/scripts/detect_ladders.py +390 -0
  629. package/skills/risk-compliance/tld-detection/scripts/detect_ladders.test.mjs +14 -0
  630. package/skills/risk-compliance/tld-detection/scripts/detect_streams.py +650 -0
  631. package/skills/risk-compliance/tld-detection/scripts/detect_streams.test.mjs +14 -0
  632. package/skills/risk-compliance/tld-detection/tests/test_detect_ladders.py +134 -0
  633. 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())