bmad-module-skill-forge 2.2.0 → 4.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 (437) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +49 -49
  3. package/docs/404.md +10 -8
  4. package/docs/_data/pinned.yaml +22 -13
  5. package/docs/_internal/STABILITY.md +58 -35
  6. package/docs/agents.md +39 -53
  7. package/docs/architecture.md +65 -51
  8. package/docs/bmad-synergy.md +53 -51
  9. package/docs/campaign.md +127 -75
  10. package/docs/concepts.md +34 -28
  11. package/docs/examples.md +70 -68
  12. package/docs/forge-auto.md +34 -31
  13. package/docs/getting-started.md +118 -57
  14. package/docs/how-it-works.md +25 -21
  15. package/docs/index.md +15 -15
  16. package/docs/skill-model.md +70 -59
  17. package/docs/troubleshooting.md +143 -18
  18. package/docs/verifying-a-skill.md +87 -57
  19. package/docs/why-skf.md +24 -22
  20. package/docs/workflows.md +274 -110
  21. package/package.json +18 -10
  22. package/src/forger/preferences.yaml +8 -1
  23. package/src/knowledge/agentskills-spec.md +2 -0
  24. package/src/knowledge/ccc-bridge.md +40 -24
  25. package/src/knowledge/confidence-tiers.md +9 -5
  26. package/src/knowledge/overview.md +3 -3
  27. package/src/knowledge/progressive-capability.md +8 -8
  28. package/src/knowledge/provenance-tracking.md +9 -5
  29. package/src/knowledge/qmd-registry.md +3 -3
  30. package/src/knowledge/skf-knowledge-index.csv +2 -2
  31. package/src/knowledge/skill-lifecycle.md +18 -11
  32. package/src/knowledge/split-body-strategy.md +0 -3
  33. package/src/knowledge/tool-resolution.md +4 -2
  34. package/src/knowledge/version-paths.md +101 -15
  35. package/src/module-help.csv +3 -3
  36. package/src/module.yaml +10 -0
  37. package/src/shared/data/ast-grep-recipes.yaml +608 -0
  38. package/src/shared/data/ide-context-files.json +30 -0
  39. package/src/shared/health-check.md +31 -59
  40. package/src/shared/references/description-guard-protocol.md +12 -9
  41. package/src/shared/references/feasibility-report-schema.md +19 -13
  42. package/src/shared/references/headless-gate-convention.md +55 -3
  43. package/src/shared/references/output-contract-schema.md +19 -8
  44. package/src/shared/references/pipeline-contracts.md +94 -29
  45. package/src/shared/references/tessl-review.md +77 -0
  46. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
  47. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
  48. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
  49. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
  50. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
  51. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
  52. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
  53. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
  54. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
  55. package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
  56. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
  57. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
  58. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
  59. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
  60. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
  61. package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
  62. package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
  63. package/src/shared/scripts/skf-atomic-write.py +233 -64
  64. package/src/shared/scripts/skf-build-change-manifest.py +2753 -28
  65. package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
  66. package/src/shared/scripts/skf-check-unit-records.py +184 -0
  67. package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
  68. package/src/shared/scripts/skf-classify-changed-files.py +772 -0
  69. package/src/shared/scripts/skf-comention-pairs.py +1333 -123
  70. package/src/shared/scripts/skf-compare-file-hashes.py +267 -105
  71. package/src/shared/scripts/skf-count-imports.py +1867 -0
  72. package/src/shared/scripts/skf-count-tokens.py +1 -1
  73. package/src/shared/scripts/skf-derive-assembly-shape.py +21 -3
  74. package/src/shared/scripts/skf-description-guard.py +99 -11
  75. package/src/shared/scripts/skf-detect-docs.py +694 -2
  76. package/src/shared/scripts/skf-detect-language.py +486 -107
  77. package/src/shared/scripts/skf-detect-registry.py +887 -0
  78. package/src/shared/scripts/skf-detect-scripts-assets.py +197 -55
  79. package/src/shared/scripts/skf-detect-tools.py +209 -27
  80. package/src/shared/scripts/skf-detect-workspaces.py +506 -30
  81. package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
  82. package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
  83. package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
  84. package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
  85. package/src/shared/scripts/skf-extract-public-api.py +5247 -96
  86. package/src/shared/scripts/skf-extraction-inventory.py +1280 -0
  87. package/src/shared/scripts/skf-extraction-snapshot.py +664 -0
  88. package/src/shared/scripts/skf-fetch-temporal.py +674 -0
  89. package/src/shared/scripts/skf-find-cycles.py +97 -29
  90. package/src/shared/scripts/skf-find-test-report.py +476 -0
  91. package/src/shared/scripts/skf-forge-tier-rw.py +469 -205
  92. package/src/shared/scripts/skf-github-fetch.py +365 -0
  93. package/src/shared/scripts/skf-github-probe.py +700 -0
  94. package/src/shared/scripts/skf-hash-content.py +180 -20
  95. package/src/shared/scripts/skf-language-corpora.py +1 -1
  96. package/src/shared/scripts/skf-load-provenance.py +161 -27
  97. package/src/shared/scripts/skf-manifest-ops.py +1 -1
  98. package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
  99. package/src/shared/scripts/skf-merge-doc-urls.py +143 -20
  100. package/src/shared/scripts/skf-names-present.py +221 -0
  101. package/src/shared/scripts/skf-pair-intersect.py +119 -28
  102. package/src/shared/scripts/skf-parse-gaps.py +1384 -0
  103. package/src/shared/scripts/skf-preapply.py +1 -1
  104. package/src/shared/scripts/skf-preflight.py +176 -28
  105. package/src/shared/scripts/skf-promote-staged.py +430 -0
  106. package/src/shared/scripts/skf-provenance-gap-dispatch.py +64 -8
  107. package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
  108. package/src/shared/scripts/skf-quick-batch.py +774 -0
  109. package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
  110. package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
  111. package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
  112. package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
  113. package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
  114. package/src/shared/scripts/skf-resolve-authoritative-files.py +155 -14
  115. package/src/shared/scripts/skf-resolve-package.py +523 -106
  116. package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
  117. package/src/shared/scripts/skf-run-lock.py +577 -0
  118. package/src/shared/scripts/skf-scan-manifests.py +998 -57
  119. package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
  120. package/src/shared/scripts/skf-severity-classify.py +712 -108
  121. package/src/shared/scripts/skf-shape-detect.py +298 -204
  122. package/src/shared/scripts/skf-shard-body.py +9 -8
  123. package/src/shared/scripts/skf-skill-inventory.py +2412 -89
  124. package/src/shared/scripts/skf-skills-module.py +472 -0
  125. package/src/shared/scripts/skf-source-tree.py +1796 -0
  126. package/src/shared/scripts/skf-structural-diff.py +787 -121
  127. package/src/shared/scripts/skf-tessl-review.py +706 -0
  128. package/src/shared/scripts/skf-update-active-symlink.py +120 -67
  129. package/src/shared/scripts/skf-update-run-state.py +624 -0
  130. package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
  131. package/src/shared/scripts/skf-validate-brief-schema.py +23 -2
  132. package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
  133. package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
  134. package/src/shared/scripts/skf-validate-output.py +712 -64
  135. package/src/shared/scripts/skf-validate-pins.py +1 -1
  136. package/src/shared/scripts/skf-verify-no-trace.py +116 -10
  137. package/src/shared/scripts/skf-verify-provenance-completeness.py +3945 -152
  138. package/src/shared/scripts/skf-write-skill-brief.py +406 -25
  139. package/src/shared/tool-requirements.yaml +201 -0
  140. package/src/skf-analyze-source/SKILL.md +41 -36
  141. package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
  142. package/src/skf-analyze-source/customize.toml +36 -24
  143. package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
  144. package/src/skf-analyze-source/references/continue.md +2 -13
  145. package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
  146. package/src/skf-analyze-source/references/generate-briefs.md +121 -55
  147. package/src/skf-analyze-source/references/headless-contract.md +42 -15
  148. package/src/skf-analyze-source/references/identify-units.md +96 -110
  149. package/src/skf-analyze-source/references/init.md +73 -71
  150. package/src/skf-analyze-source/references/map-and-detect.md +123 -105
  151. package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
  152. package/src/skf-analyze-source/references/recommend.md +51 -70
  153. package/src/skf-analyze-source/references/scan-project.md +60 -84
  154. package/src/skf-analyze-source/references/scan-root.md +39 -0
  155. package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
  156. package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
  157. package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
  158. package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
  159. package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
  160. package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
  161. package/src/skf-audit-skill/SKILL.md +43 -51
  162. package/src/skf-audit-skill/assets/drift-report-template.md +28 -3
  163. package/src/skf-audit-skill/customize.toml +41 -20
  164. package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
  165. package/src/skf-audit-skill/references/doc-drift.md +141 -0
  166. package/src/skf-audit-skill/references/headless-contract.md +29 -0
  167. package/src/skf-audit-skill/references/health-check.md +2 -0
  168. package/src/skf-audit-skill/references/init.md +149 -210
  169. package/src/skf-audit-skill/references/re-index.md +103 -110
  170. package/src/skf-audit-skill/references/report.md +96 -33
  171. package/src/skf-audit-skill/references/semantic-diff.md +34 -25
  172. package/src/skf-audit-skill/references/severity-classify.md +79 -65
  173. package/src/skf-audit-skill/references/severity-rules.md +15 -6
  174. package/src/skf-audit-skill/references/structural-diff.md +80 -90
  175. package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
  176. package/src/skf-audit-skill/scripts/render-drift-tables.py +712 -0
  177. package/src/skf-brief-skill/SKILL.md +37 -26
  178. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  179. package/src/skf-brief-skill/assets/scope-templates.md +18 -25
  180. package/src/skf-brief-skill/assets/skill-brief-schema.md +30 -23
  181. package/src/skf-brief-skill/customize.toml +48 -20
  182. package/src/skf-brief-skill/references/analyze-target.md +144 -127
  183. package/src/skf-brief-skill/references/confirm-brief.md +11 -9
  184. package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
  185. package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
  186. package/src/skf-brief-skill/references/gather-intent.md +87 -182
  187. package/src/skf-brief-skill/references/headless-args.md +84 -16
  188. package/src/skf-brief-skill/references/health-check.md +1 -1
  189. package/src/skf-brief-skill/references/invocation-contract.md +18 -13
  190. package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
  191. package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
  192. package/src/skf-brief-skill/references/scope-definition.md +123 -47
  193. package/src/skf-brief-skill/references/step-auto-brief.md +26 -100
  194. package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
  195. package/src/skf-brief-skill/references/version-resolution.md +7 -10
  196. package/src/skf-brief-skill/references/write-brief.md +66 -57
  197. package/src/skf-campaign/SKILL.md +31 -34
  198. package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
  199. package/src/skf-campaign/customize.toml +47 -16
  200. package/src/skf-campaign/references/campaign-contracts.md +39 -29
  201. package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
  202. package/src/skf-campaign/references/health-check.md +7 -16
  203. package/src/skf-campaign/references/step-01-setup.md +46 -84
  204. package/src/skf-campaign/references/step-02-strategy.md +31 -30
  205. package/src/skf-campaign/references/step-03-pins.md +26 -18
  206. package/src/skf-campaign/references/step-04-provenance.md +24 -23
  207. package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
  208. package/src/skf-campaign/references/step-06-batch.md +41 -34
  209. package/src/skf-campaign/references/step-07-capstone.md +40 -25
  210. package/src/skf-campaign/references/step-08-verify.md +28 -29
  211. package/src/skf-campaign/references/step-09-refine.md +29 -30
  212. package/src/skf-campaign/references/step-10-export.md +48 -36
  213. package/src/skf-campaign/references/step-11-maintenance.md +31 -34
  214. package/src/skf-campaign/references/step-resume.md +37 -68
  215. package/src/skf-campaign/scripts/campaign-deps.py +57 -18
  216. package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
  217. package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
  218. package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
  219. package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
  220. package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
  221. package/src/skf-campaign/scripts/campaign-report.py +204 -37
  222. package/src/skf-campaign/scripts/campaign-state.py +1128 -0
  223. package/src/skf-campaign/scripts/campaign-status.py +10 -74
  224. package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
  225. package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
  226. package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
  227. package/src/skf-campaign/templates/campaign-report-template.md +5 -1
  228. package/src/skf-campaign/templates/kickoff-template.md +0 -14
  229. package/src/skf-create-skill/SKILL.md +27 -20
  230. package/src/skf-create-skill/assets/compile-assembly-rules.md +11 -8
  231. package/src/skf-create-skill/assets/skill-sections.md +21 -11
  232. package/src/skf-create-skill/customize.toml +20 -8
  233. package/src/skf-create-skill/references/authoritative-files-protocol.md +36 -58
  234. package/src/skf-create-skill/references/batch-mode.md +81 -0
  235. package/src/skf-create-skill/references/ccc-index-check.md +27 -0
  236. package/src/skf-create-skill/references/compile.md +119 -70
  237. package/src/skf-create-skill/references/component-extraction.md +113 -91
  238. package/src/skf-create-skill/references/enrich.md +41 -23
  239. package/src/skf-create-skill/references/entry-points-by-hand.md +37 -0
  240. package/src/skf-create-skill/references/extract.md +204 -162
  241. package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
  242. package/src/skf-create-skill/references/extraction-patterns-tracing.md +11 -11
  243. package/src/skf-create-skill/references/extraction-patterns.md +588 -201
  244. package/src/skf-create-skill/references/generate-artifacts.md +145 -93
  245. package/src/skf-create-skill/references/health-check.md +1 -1
  246. package/src/skf-create-skill/references/load-brief.md +53 -36
  247. package/src/skf-create-skill/references/relabel-rule.md +12 -0
  248. package/src/skf-create-skill/references/report.md +37 -57
  249. package/src/skf-create-skill/references/source-resolution-protocols.md +58 -169
  250. package/src/skf-create-skill/references/step-auto-shard.md +20 -93
  251. package/src/skf-create-skill/references/step-doc-rot.md +44 -60
  252. package/src/skf-create-skill/references/step-doc-sources.md +28 -19
  253. package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
  254. package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
  255. package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
  256. package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
  257. package/src/skf-create-skill/references/validate.md +173 -99
  258. package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
  259. package/src/skf-create-stack-skill/SKILL.md +29 -56
  260. package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
  261. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
  262. package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
  263. package/src/skf-create-stack-skill/customize.toml +23 -12
  264. package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
  265. package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
  266. package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
  267. package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
  268. package/src/skf-create-stack-skill/references/generate-output.md +138 -128
  269. package/src/skf-create-stack-skill/references/init.md +90 -32
  270. package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
  271. package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
  272. package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
  273. package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
  274. package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
  275. package/src/skf-create-stack-skill/references/report.md +39 -33
  276. package/src/skf-create-stack-skill/references/validate.md +20 -39
  277. package/src/skf-drop-skill/SKILL.md +28 -51
  278. package/src/skf-drop-skill/customize.toml +44 -30
  279. package/src/skf-drop-skill/references/execute.md +83 -146
  280. package/src/skf-drop-skill/references/health-check.md +2 -0
  281. package/src/skf-drop-skill/references/invocation-contract.md +64 -0
  282. package/src/skf-drop-skill/references/report.md +46 -30
  283. package/src/skf-drop-skill/references/select.md +122 -124
  284. package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
  285. package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
  286. package/src/skf-export-skill/SKILL.md +40 -62
  287. package/src/skf-export-skill/assets/managed-section-format.md +14 -50
  288. package/src/skf-export-skill/assets/snippet-format.md +3 -1
  289. package/src/skf-export-skill/customize.toml +42 -13
  290. package/src/skf-export-skill/references/generate-snippet.md +54 -46
  291. package/src/skf-export-skill/references/health-check.md +2 -0
  292. package/src/skf-export-skill/references/invocation-contract.md +15 -0
  293. package/src/skf-export-skill/references/load-skill.md +88 -62
  294. package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
  295. package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
  296. package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
  297. package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
  298. package/src/skf-export-skill/references/package.md +15 -73
  299. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
  300. package/src/skf-export-skill/references/result-envelope.md +31 -16
  301. package/src/skf-export-skill/references/summary.md +41 -17
  302. package/src/skf-export-skill/references/token-report.md +4 -12
  303. package/src/skf-export-skill/references/update-context.md +118 -237
  304. package/src/skf-forger/SKILL.md +61 -37
  305. package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
  306. package/src/skf-forger/customize.toml +43 -0
  307. package/src/skf-forger/references/pipeline-mode.md +73 -18
  308. package/src/skf-forger/scripts/forge-status.py +299 -0
  309. package/src/skf-forger/scripts/parse-pipeline.py +322 -54
  310. package/src/skf-forger/scripts/pipeline-gate.py +428 -0
  311. package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
  312. package/src/skf-quick-skill/SKILL.md +54 -53
  313. package/src/skf-quick-skill/assets/skill-template.md +4 -2
  314. package/src/skf-quick-skill/customize.toml +51 -20
  315. package/src/skf-quick-skill/references/batch-mode.md +74 -21
  316. package/src/skf-quick-skill/references/compile.md +30 -31
  317. package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
  318. package/src/skf-quick-skill/references/finalize.md +69 -18
  319. package/src/skf-quick-skill/references/halt-contract.md +41 -29
  320. package/src/skf-quick-skill/references/health-check.md +5 -3
  321. package/src/skf-quick-skill/references/quick-extract.md +114 -45
  322. package/src/skf-quick-skill/references/registry-resolution.md +9 -64
  323. package/src/skf-quick-skill/references/resolve-target.md +116 -66
  324. package/src/skf-quick-skill/references/write-and-validate.md +79 -40
  325. package/src/skf-refine-architecture/SKILL.md +43 -53
  326. package/src/skf-refine-architecture/customize.toml +49 -20
  327. package/src/skf-refine-architecture/references/compile.md +97 -54
  328. package/src/skf-refine-architecture/references/exit-codes.md +30 -0
  329. package/src/skf-refine-architecture/references/finding-storage.md +21 -0
  330. package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
  331. package/src/skf-refine-architecture/references/improvements.md +14 -20
  332. package/src/skf-refine-architecture/references/init.md +98 -57
  333. package/src/skf-refine-architecture/references/issue-detection.md +32 -39
  334. package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
  335. package/src/skf-refine-architecture/references/report.md +65 -54
  336. package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
  337. package/src/skf-rename-skill/SKILL.md +19 -54
  338. package/src/skf-rename-skill/customize.toml +32 -26
  339. package/src/skf-rename-skill/references/execute.md +230 -184
  340. package/src/skf-rename-skill/references/exit-codes.md +7 -7
  341. package/src/skf-rename-skill/references/health-check.md +1 -7
  342. package/src/skf-rename-skill/references/invocation-contract.md +25 -0
  343. package/src/skf-rename-skill/references/report.md +63 -52
  344. package/src/skf-rename-skill/references/select.md +197 -115
  345. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
  346. package/src/skf-setup/SKILL.md +26 -26
  347. package/src/skf-setup/customize.toml +14 -10
  348. package/src/skf-setup/references/auto-index.md +50 -63
  349. package/src/skf-setup/references/ccc-index.md +29 -90
  350. package/src/skf-setup/references/detect-and-tier.md +58 -67
  351. package/src/skf-setup/references/health-check.md +2 -0
  352. package/src/skf-setup/references/invocation-contract.md +14 -0
  353. package/src/skf-setup/references/report.md +43 -166
  354. package/src/skf-setup/references/tier-rules.md +5 -5
  355. package/src/skf-setup/references/write-config.md +34 -57
  356. package/src/skf-test-skill/SKILL.md +17 -57
  357. package/src/skf-test-skill/assets/output-section-formats.md +33 -34
  358. package/src/skf-test-skill/customize.toml +27 -13
  359. package/src/skf-test-skill/references/coherence-check.md +134 -62
  360. package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
  361. package/src/skf-test-skill/references/coverage-check.md +209 -264
  362. package/src/skf-test-skill/references/detect-mode.md +5 -5
  363. package/src/skf-test-skill/references/external-validators.md +94 -82
  364. package/src/skf-test-skill/references/health-check.md +6 -7
  365. package/src/skf-test-skill/references/init.md +164 -91
  366. package/src/skf-test-skill/references/invocation-contract.md +66 -0
  367. package/src/skf-test-skill/references/migration-section-rules.md +5 -2
  368. package/src/skf-test-skill/references/report.md +164 -142
  369. package/src/skf-test-skill/references/score.md +80 -107
  370. package/src/skf-test-skill/references/scoring-rules.md +42 -155
  371. package/src/skf-test-skill/references/source-access-protocol.md +18 -94
  372. package/src/skf-test-skill/references/step-hard-gate.md +51 -33
  373. package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
  374. package/src/skf-test-skill/scripts/build-result-context.py +380 -0
  375. package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
  376. package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
  377. package/src/skf-test-skill/scripts/compute-score.py +223 -45
  378. package/src/skf-test-skill/scripts/gap-ledger.py +1666 -0
  379. package/src/skf-test-skill/scripts/hard-gate.py +203 -0
  380. package/src/skf-test-skill/scripts/load-coverage-inputs.py +1198 -0
  381. package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
  382. package/src/skf-test-skill/scripts/reconcile-coverage.py +547 -79
  383. package/src/skf-test-skill/scripts/score-signatures.py +479 -0
  384. package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
  385. package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
  386. package/src/skf-test-skill/scripts/verify-declared-numerator.py +133 -56
  387. package/src/skf-test-skill/templates/test-report-template.md +21 -15
  388. package/src/skf-update-skill/SKILL.md +38 -25
  389. package/src/skf-update-skill/customize.toml +27 -12
  390. package/src/skf-update-skill/references/detect-changes.md +253 -223
  391. package/src/skf-update-skill/references/gap-driven.md +336 -0
  392. package/src/skf-update-skill/references/health-check.md +31 -5
  393. package/src/skf-update-skill/references/init.md +254 -84
  394. package/src/skf-update-skill/references/invocation-contract.md +12 -0
  395. package/src/skf-update-skill/references/manual-section-rules.md +6 -23
  396. package/src/skf-update-skill/references/merge.md +182 -49
  397. package/src/skf-update-skill/references/re-extract.md +53 -262
  398. package/src/skf-update-skill/references/report.md +102 -35
  399. package/src/skf-update-skill/references/write.md +357 -155
  400. package/src/skf-update-skill/scripts/skf-new-file-diff.py +183 -46
  401. package/src/skf-verify-stack/SKILL.md +23 -42
  402. package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
  403. package/src/skf-verify-stack/customize.toml +55 -22
  404. package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
  405. package/src/skf-verify-stack/references/coverage.md +67 -64
  406. package/src/skf-verify-stack/references/exit-codes.md +21 -8
  407. package/src/skf-verify-stack/references/init.md +107 -62
  408. package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
  409. package/src/skf-verify-stack/references/integrations.md +106 -65
  410. package/src/skf-verify-stack/references/report.md +59 -42
  411. package/src/skf-verify-stack/references/requirements.md +32 -9
  412. package/src/skf-verify-stack/references/synthesize.md +53 -39
  413. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
  414. package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
  415. package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
  416. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
  417. package/tools/cli/commands/install.js +11 -4
  418. package/tools/cli/commands/status.js +15 -3
  419. package/tools/cli/commands/update.js +11 -4
  420. package/tools/cli/lib/tool-check.js +320 -0
  421. package/tools/cli/lib/version-check.js +22 -9
  422. package/tools/cli/skf-cli.js +4 -1
  423. package/.gitattributes +0 -16
  424. package/.nvmrc +0 -1
  425. package/docs/_internal/RELEASING.md +0 -679
  426. package/release-audits/v1.0.0-launch-audit.md +0 -728
  427. package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
  428. package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
  429. package/src/skf-campaign/manifest.yaml +0 -15
  430. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
  431. package/src/skf-create-skill/references/ecosystem-check.md +0 -74
  432. package/src/skf-drop-skill/references/headless-contract.md +0 -18
  433. package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
  434. package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
  435. package/src/skf-update-skill/references/validate.md +0 -124
  436. package/tools/validate-docs-drift.js +0 -233
  437. package/tools/validate-docs-links.js +0 -665
@@ -0,0 +1,1198 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # dependencies = ["pyyaml"]
5
+ # ///
6
+ """SKF test-skill coverage inputs: the counts, name sets and candidates coverage-check reads.
7
+
8
+ coverage-check.md decides whether a bookkeeping variant is a real export,
9
+ and makes the judgments the denominator clause needs where no file holds
10
+ them (--pattern-reference-intent, and --curated-subset and --barrel where
11
+ the extraction does not say); the clause, everything it counts, folds or
12
+ unions on the way runs here, so no count is made by hand. Three subcommands, each
13
+ reading files and writing one JSON object:
14
+
15
+ census docs-only detection. Counts the citations SKILL.md and each .md
16
+ file directly in references/ write, by form: `[EXT:` against
17
+ the local forms `[AST:`, `[SRC:` and a stack's `[from skill:`
18
+ (`[QMD:` and `[DOC:` are counted and decide nothing). A skill is
19
+ docs-only when it cites `[EXT:` and no local form.
20
+
21
+ metadata the metadata.json and provenance-map counts. Cluster A
22
+ (`stats.exports_public_api`, the `exports[]` length) and Cluster
23
+ B (`stats.exports_documented`, the provenance entry names, the
24
+ `confidence_distribution`) under the keys
25
+ check-metadata-coherence.py reads; the provenance named exports
26
+ (`::` impl-block methods left out); the inflation signature
27
+ (`stats.exports_documented` equals `stats.effective_denominator`,
28
+ or, for a map with a named entry and no `::` entry, its distinct
29
+ entry names do: the stored count counts each name once, the map
30
+ holds a name passed on from two files twice)
31
+ with the declared names verify-declared-numerator.py looks up
32
+ (the `exports[]` names, else the provenance named exports); and,
33
+ for a stack, its composition surface: the distinct provenance
34
+ named exports when the map has entries, else each library and
35
+ each integration pair (`{a} + {b}`, as the stack's Library Pair
36
+ Integrations heading writes it), as reconcile-coverage.py's
37
+ stack branch takes them.
38
+
39
+ surface the enumerated source surface, from every input given (their
40
+ union):
41
+ --extraction skf-extract-public-api.py --mode full output:
42
+ the names in entry_point_diff.public, a
43
+ namespace with a `members` list given as its
44
+ members (the names its module passes on, each
45
+ at its file, once with a public name of the
46
+ same name and file), less the outside_scope
47
+ names (defined in a file the brief scopes
48
+ out); exports[] only gives each name's
49
+ kind, line and signature line, looked up by the
50
+ name its defining file gives it (an aliased
51
+ re-export's `local`) and that file.
52
+ extraction_gaps are listed to read by eye, and
53
+ the runner's warnings join `warnings`. A Python
54
+ top __init__.py inside the folder of another top
55
+ that exports a public name (a sub-package
56
+ behind a folder with no __init__.py) is no
57
+ barrel, unless scope.include or tier_a_include
58
+ lists its file: its names leave `all`, `root`
59
+ and the umbrella ratio and are listed in
60
+ `excluded.nestedEntries`, but keep their records
61
+ (so the scope.include and tier_a_include sets)
62
+ and their outside_scope and extraction_gaps
63
+ rows. When the runner lists an
64
+ include glob that matches no file
65
+ (scope.unmatched_include: a brief older than the
66
+ source), each outside_scope name a root entry
67
+ point exports is restored, with the file and
68
+ line of its outside_scope row, when no include,
69
+ exclude or declined scope-expansion amendment
70
+ (latest action skipped or demoted-include, from
71
+ --brief) matches its file. A tier_a_include glob
72
+ the runner lists as matching no file
73
+ (scope.unmatched_tier_a_include, [] when the key
74
+ is absent) counts no name. An extraction the
75
+ per-file scan replaces (extraction.fallback.needed)
76
+ gives no name and no set: only its scope, its
77
+ stale globs and its `extraction` object, so the
78
+ fallback scan passes it beside its results.
79
+ --quick skf-extract-public-api.py --mode quick output,
80
+ each name at its defined_in (the file that
81
+ defines it), else at its source_file
82
+ --per-file a subagent's per-file result (exports_found and
83
+ types_found), schema-checked as
84
+ score-signatures.py checks it
85
+ --fallback-scan
86
+ the --per-file results are the fallback
87
+ per-file scan's, each at the file that defines
88
+ its names (a Quick-tier or State 4 result is at
89
+ its entry file): as with an extraction, no
90
+ tier_a_include set is no tier A list
91
+ --name a name read by eye whose file is not known
92
+ --provenance the provenance entries' named exports (State 2)
93
+ --metadata the metadata.json exports[] names (States 2, 3)
94
+ A surface read from the source (--extraction, --quick or
95
+ --per-file) takes no name from --provenance and --metadata:
96
+ they are the baselines of the candidates and guards. Without
97
+ one, with --provenance and --metadata both, `state2` gives the
98
+ provenance count, the metadata count and their union count,
99
+ which compute-score.py turns into the State 2 divergence. --fold
100
+ folds the bookkeeping variants among the provenance and metadata
101
+ names to their base name (`X_def`, `X_exact`, `a11y_X`, and
102
+ `<prefix>X` for each --fold-prefix when `X` is itself a name)
103
+ unless --keep names the variant as a real export, and
104
+ `canonical` gives the provenance map's fold summary; a map
105
+ whose distinct named exports number
106
+ `stats.effective_denominator` (--metadata) is already canonical
107
+ and is not folded (`canonical.skipped`), and `residual` is the
108
+ canonical count less that stored count. --brief gives the
109
+ skill brief's scope, scope type, language and scope-expansion
110
+ amendments; an extraction's recorded scope wins over its scope.
111
+
112
+ Name sets (surface `sets`, each sorted): `all` (the union above, less a
113
+ nested Python top's names);
114
+ `scope.include` and `tier_a_include` when the scope has those globs and a
115
+ record names a file: the names whose file (an extraction's definition file,
116
+ the quick output's defined_in, else its source_file, a per-file result's
117
+ file, a provenance entry's source_file) matches a glob of the list and no
118
+ scope.exclude glob,
119
+ and for `tier_a_include` a scope.include glob too when the scope has one,
120
+ so a name with no file is in `all` only and a restored name is in no
121
+ tier_a_include set (a tier_a_include glob an extraction lists as matching
122
+ no file is left out of the list, and so is one whose folder or file
123
+ differs from every scope.include glob's before any `*` or `?`, such as
124
+ `gen/**` beside `src/**`, and so is one a scope.exclude glob covers
125
+ segment by segment, such as `src/gen/**` or `src/gen/extra.ts` beside an
126
+ exclude of `src/gen/**`; a list that counts no name, each glob left out
127
+ or matching only excluded files, files scope.include leaves out or files
128
+ no record names, gives no tier_a_include set); and, from an
129
+ extraction only, `subpaths` (the names a non-root `exports` map subpath
130
+ without `*` reaches) and `root` (the names the root entry points reach).
131
+ reconcile-coverage.py and score-signatures.py --surface-set pick one.
132
+
133
+ Candidates and guards (with an extraction, --brief or --metadata): each
134
+ set's count, the metadata `stats.effective_denominator` (--metadata), and
135
+ the extractor's own effective_denominator and its basis. Both guards run
136
+ only on a surface read from the source. The deflation guard compares the
137
+ re-derived scope.include set (the `all` set without one) with
138
+ `stats.effective_denominator` and fires above DEFLATION_PCT when there is
139
+ no tier A list (with an extraction or --fallback-scan, no tier_a_include
140
+ set; otherwise no tier_a_include glob left in the list, since a name read
141
+ by eye at Quick tier or State 4, and a quick name with no defined_in,
142
+ carries its entry file, not the file that defines it); a stored 0 is
143
+ deflation, not a
144
+ denominator (`pct` null), and fires under that condition when any name is
145
+ re-derived. The inflation guard compares the scope.include set with the
146
+ provenance entry count (--provenance) and fires above INFLATION_PCT under
147
+ the same condition; `guards.tierAInclude` lists the scope's
148
+ tier_a_include globs ([] when it has none) for gap-ledger.py's wording;
149
+ the umbrella ratio (an
150
+ extraction only) is the share of the root entry points' names that are
151
+ re-exports (a namespace with `members` counting as its members there
152
+ too, each a re-export), and above UMBRELLA_RATIO the barrel is an
153
+ umbrella; the
154
+ stale-scope guard (an extraction with scope.include or tier_a_include only)
155
+ fires when the runner lists an include or tier_a_include glob that matches
156
+ no file, and lists those globs (`unmatchedInclude`,
157
+ `unmatchedTierAInclude`) and the names restored for the include globs, each
158
+ with its file and line (`restored`). Globs follow
159
+ skf-extract-public-api.py: `**` spans any number of path segments, none
160
+ included, and `*` and `?` stay inside one.
161
+
162
+ Denominator (surface `denominator`): the Source API Surface Definition's
163
+ clause (source-access-protocol.md), the branch reconcile-coverage.py takes
164
+ and the set it and score-signatures.py --surface-set read: {clause, branch,
165
+ surfaceSet, ladderStep, absentSet, rebuild, barrel}. The inputs: the scope
166
+ type (the brief's, else the extraction's, else metadata.json's
167
+ `scope_type`), metadata.json's `skill_type` and `stats.effective_denominator`,
168
+ the extraction's `arms`, the deflation guard, and:
169
+ monorepo --monorepo (the workspace layout skf-detect-workspaces.py
170
+ found), or the extraction's `arms.monorepo`.
171
+ curated the extraction's `counts.curated_subset` (the rule Create Skill
172
+ stores the count by), else --curated-subset (the workflow's
173
+ judgment that a monorepo's scope.include lists a curated subset).
174
+ barrel (`barrel`: "barrel", "empty-barrel", "no-entry-point" or null)
175
+ a complete extraction's `entry_points.status`; for one the
176
+ per-file scan replaces, "no-entry-point" when it read a file in
177
+ scope and found no entry point (`entry_points.files` empty), as
178
+ a complete run says; a quick output's (an export is a barrel,
179
+ none and no warning an empty one); else --barrel (`barrel`,
180
+ `empty` or `none`, what the workflow read); else null.
181
+ --pattern-reference-intent the workflow's judgment that scope.notes
182
+ flags pattern-reference intent.
183
+ The first clause that applies:
184
+ stack skill_type stack: branch `stack`, no set.
185
+ pattern-reference not a monorepo, no non-empty barrel, a scope type other
186
+ than specific-modules, component-library, docs-only and
187
+ reference-app, not public-api with a multi-subpath
188
+ `exports` map, and either the intent flag or no entry
189
+ point at all with no such map: the tier_a_include set,
190
+ else `all` with `rebuild` `provenance-fold` on a surface
191
+ read from the source (build it again from the provenance
192
+ map alone, folded), `all` on one that is not.
193
+ empty-barrel an empty barrel: the scope.include set.
194
+ stratified-scope --monorepo and --curated-subset, not full-library.
195
+ specific-modules scope type specific-modules, not --monorepo.
196
+ multi-entry arms.multi_subpath_exports, not --monorepo, a
197
+ full-library or public-api scope.
198
+ standard-barrel any other: the `all` set.
199
+ The last three walk a ladder, `ladderStep` naming the rung taken: rung 1,
200
+ `stats.effective_denominator` when metadata.json has one above 0 (a stored
201
+ 0 is no denominator), is the `scalar` branch, whose `surfaceSet` (the set score-signatures.py scores) is
202
+ tier_a_include, else scope.include, else all, unless the deflation guard
203
+ fires: then the barrel branch over scope.include. The next rungs are sets,
204
+ each taken when the surface has it, the last taken in any case:
205
+ tier_a_include then scope.include (stratified-scope), scope.include
206
+ (specific-modules), tier_a_include then subpaths (multi-entry). A set the
207
+ surface lacks is counted as `all`, and `absentSet` names it.
208
+
209
+ Fallback verdict: `extraction.fallback.needed` is true when the extractor
210
+ found no ast-grep (`no-ast-grep`), read no file in scope, read files only
211
+ in languages it has no recipe for (`files_without_recipes` and no export),
212
+ or found files no recipe reads while the skill's language (the --brief
213
+ `language`, else the metadata.json `language`) is not one RECIPE_LANGUAGES
214
+ holds, so the per-file scan has to run instead. An `incomplete` run keeps
215
+ its exports; `extraction.readByEye` lists the files its errors and file
216
+ issues name.
217
+
218
+ Usage:
219
+ uv run load-coverage-inputs.py census --skill-dir <dir> [--output <file>]
220
+ uv run load-coverage-inputs.py metadata --metadata <metadata.json> \\
221
+ [--provenance <provenance-map.json>] [--output <file>]
222
+ uv run load-coverage-inputs.py surface [--extraction <file>] [--quick <file>]... \\
223
+ [--per-file <file>]... [--fallback-scan] [--name <name>]... [--provenance <file>] \\
224
+ [--metadata <file>] [--brief <skill-brief.yaml>] [--fold] [--fold-prefix <prefix>]... \\
225
+ [--keep <name>]... [--monorepo] [--pattern-reference-intent] [--curated-subset] \\
226
+ [--barrel barrel|empty|none] [--output <file>]
227
+
228
+ Exit codes:
229
+ 0 the result was printed (and written to --output)
230
+ 1 an input file is missing, unreadable or not the JSON (or the YAML
231
+ brief) it should be (one line on stderr)
232
+ 2 a per-file result breaks the schema: {"valid": false, "violations":
233
+ [...]} names each breach and nothing is written to --output; or a
234
+ usage error (argparse)
235
+ """
236
+
237
+ from __future__ import annotations
238
+
239
+ import argparse
240
+ import importlib.util
241
+ import json
242
+ import math
243
+ import posixpath
244
+ import re
245
+ import sys
246
+ from pathlib import Path
247
+
248
+ DEFLATION_PCT = 25
249
+ INFLATION_PCT = 25
250
+ UMBRELLA_RATIO = 0.5
251
+ METHOD_SEPARATOR = "::"
252
+ CITATION_FORMS = {
253
+ "EXT": "[EXT:",
254
+ "AST": "[AST:",
255
+ "SRC": "[SRC:",
256
+ "from skill": "[from skill:",
257
+ "QMD": "[QMD:",
258
+ "DOC": "[DOC:",
259
+ }
260
+ LOCAL_FORMS = ("AST", "SRC", "from skill")
261
+ # Bookkeeping variants folded to their base name, in rule order.
262
+ FOLD_SUFFIXES = ("_def", "_exact")
263
+ FOLD_PREFIX = "a11y_"
264
+ ROOT_SUBPATHS = (None, ".")
265
+ # The latest action of a scope-expansion amendment that keeps its path out of scope.
266
+ DECLINED_ACTIONS = ("skipped", "demoted-include")
267
+ # The rungs each laddered clause tries in order, `scalar` being stats.effective_denominator.
268
+ CLAUSE_LADDERS = {
269
+ "stratified-scope": ("scalar", "tier_a_include", "scope.include"),
270
+ "specific-modules": ("scalar", "scope.include"),
271
+ "multi-entry": ("scalar", "tier_a_include", "subpaths"),
272
+ }
273
+ # --barrel: what the workflow read of the entry points, where surface cannot tell -> the extractor's status.
274
+ BARREL_FLAG = {"barrel": "barrel", "empty": "empty-barrel", "none": "no-entry-point"}
275
+ # The scope types the pattern-reference clause never applies to (a reference app's brief carries its own
276
+ # pattern-surface denominator).
277
+ NOT_PATTERN_REFERENCE = frozenset({"specific-modules", "component-library", "docs-only", "reference-app"})
278
+ # The languages skf-extract-public-api.py's recipes read (its LANGUAGE_FAMILIES).
279
+ RECIPE_LANGUAGES = frozenset({"python", "rust", "go", "golang", "typescript", "ts", "tsx", "javascript", "js",
280
+ "jsx", "vue"})
281
+
282
+
283
+ class InputError(Exception):
284
+ """An input file that cannot be used."""
285
+
286
+
287
+ class SchemaError(Exception):
288
+ """Per-file results that break the schema: `violations` names each breach."""
289
+
290
+ def __init__(self, violations: list[str]):
291
+ super().__init__("; ".join(violations))
292
+ self.violations = violations
293
+
294
+
295
+ _SIBLINGS: dict[str, object] = {}
296
+ SHARED_SCRIPTS = Path(__file__).resolve().parent.parent.parent / "shared" / "scripts"
297
+
298
+
299
+ def _sibling(filename: str, folder: Path | None = None):
300
+ """A script beside this one, loaded once: validate-inventory.py (the doc
301
+ text) and score-signatures.py (the per-file result schema); or, with
302
+ `folder`, one in that folder: SHARED_SCRIPTS'
303
+ skf-resolve-authoritative-files.py (a skill brief's YAML)."""
304
+ module = _SIBLINGS.get(filename)
305
+ if module is None:
306
+ path = (folder or Path(__file__).resolve().parent) / filename
307
+ spec = importlib.util.spec_from_file_location("skf_" + filename[:-3].replace("-", "_"), path)
308
+ if spec is None or spec.loader is None or not path.is_file():
309
+ where = f"in {folder}" if folder else f"beside {Path(__file__).name}"
310
+ raise InputError(f"{filename} not found {where}")
311
+ module = importlib.util.module_from_spec(spec)
312
+ spec.loader.exec_module(module)
313
+ _SIBLINGS[filename] = module
314
+ return module
315
+
316
+
317
+ def round2(value: float) -> float:
318
+ """Round to 2 decimals with JS-compatible half-up rounding (matches compute-score.py)."""
319
+ return math.floor(value * 100 + 0.5) / 100
320
+
321
+
322
+ def _read_json(path: str, flag: str):
323
+ try:
324
+ return json.loads(Path(path).read_bytes().decode("utf-8-sig"))
325
+ except (OSError, UnicodeDecodeError) as exc:
326
+ raise InputError(f"cannot read {flag} {path}: {exc}") from exc
327
+ except json.JSONDecodeError as exc:
328
+ raise InputError(f"{flag} {path} is not JSON: {exc.msg}") from exc
329
+
330
+
331
+ def _object(path: str, flag: str) -> dict:
332
+ data = _read_json(path, flag)
333
+ if not isinstance(data, dict):
334
+ raise InputError(f"{flag} {path} is not a JSON object")
335
+ return data
336
+
337
+
338
+ def _count(value) -> int | None:
339
+ return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
340
+
341
+
342
+ def _export_name(item) -> str | None:
343
+ """The name of a metadata exports[] item: a string, or an object's name."""
344
+ if isinstance(item, str):
345
+ return item or None
346
+ if isinstance(item, dict):
347
+ for key in ("name", "export_name"):
348
+ if isinstance(item.get(key), str) and item[key]:
349
+ return item[key]
350
+ return None
351
+
352
+
353
+ def _entries(provenance: dict | None) -> list[dict]:
354
+ entries = provenance.get("entries") if isinstance(provenance, dict) else None
355
+ return [e for e in entries if isinstance(e, dict)] if isinstance(entries, list) else []
356
+
357
+
358
+ def _entry_names(provenance: dict | None) -> list[str]:
359
+ return [e["export_name"] for e in _entries(provenance)
360
+ if isinstance(e.get("export_name"), str) and e["export_name"]]
361
+
362
+
363
+ # --------------------------------------------------------------------------
364
+ # census
365
+ # --------------------------------------------------------------------------
366
+
367
+
368
+ def census(skill_dir: str) -> dict:
369
+ root = Path(skill_dir)
370
+ if not (root / "SKILL.md").is_file():
371
+ raise InputError(f"--skill-dir {skill_dir} holds no SKILL.md")
372
+ try:
373
+ text = _sibling("validate-inventory.py").load_doc_text(skill_dir)
374
+ except (OSError, UnicodeDecodeError) as exc:
375
+ raise InputError(f"cannot read the skill text under {skill_dir}: {exc}") from exc
376
+ counts = {form: text.count(marker) for form, marker in CITATION_FORMS.items()}
377
+ local = sum(counts[form] for form in LOCAL_FORMS)
378
+ refs = root / "references"
379
+ files = ["SKILL.md"] + ([f"references/{p.name}" for p in sorted(refs.glob("*.md"))] if refs.is_dir() else [])
380
+ return {
381
+ "citations": counts,
382
+ "localCitations": local,
383
+ "docsOnly": counts["EXT"] > 0 and local == 0,
384
+ "files": files,
385
+ }
386
+
387
+
388
+ # --------------------------------------------------------------------------
389
+ # metadata
390
+ # --------------------------------------------------------------------------
391
+
392
+
393
+ def _pair_name(pair) -> str | None:
394
+ if isinstance(pair, str):
395
+ return pair or None
396
+ if isinstance(pair, (list, tuple)) and len(pair) == 2 and all(isinstance(p, str) and p for p in pair):
397
+ return f"{pair[0]} + {pair[1]}"
398
+ if isinstance(pair, dict) and all(isinstance(pair.get(k), str) and pair[k] for k in ("a", "b")):
399
+ return f"{pair['a']} + {pair['b']}"
400
+ return None
401
+
402
+
403
+ def _library_name(lib) -> str | None:
404
+ if isinstance(lib, str):
405
+ return lib or None
406
+ if isinstance(lib, dict) and isinstance(lib.get("name"), str) and lib["name"]:
407
+ return lib["name"]
408
+ return None
409
+
410
+
411
+ def metadata_inputs(meta: dict, provenance: dict | None) -> dict:
412
+ stats = meta.get("stats") if isinstance(meta.get("stats"), dict) else {}
413
+ exports = meta.get("exports")
414
+ names = [n for n in (_export_name(e) for e in exports) if n] if isinstance(exports, list) else []
415
+ entry_names = _entry_names(provenance) if provenance is not None else None
416
+ named = sorted({n for n in entry_names or [] if METHOD_SEPARATOR not in n})
417
+ dist = stats.get("confidence_distribution")
418
+ if not isinstance(dist, dict):
419
+ dist = meta.get("confidence_distribution") if isinstance(meta.get("confidence_distribution"), dict) else None
420
+ if dist is not None:
421
+ dist = {tier: dist[tier] for tier in ("t1", "t1_low", "t2", "t3") if _count(dist.get(tier)) is not None}
422
+ documented = _count(stats.get("exports_documented"))
423
+ effective = _count(stats.get("effective_denominator"))
424
+ skill_type = meta.get("skill_type") if isinstance(meta.get("skill_type"), str) else None
425
+
426
+ stack = None
427
+ if skill_type == "stack":
428
+ if named:
429
+ stack = {"basis": "provenance", "denominator": len(named), "compositionNames": named}
430
+ else:
431
+ libraries = [n for n in (_library_name(lib) for lib in meta.get("libraries") or []) if n]
432
+ pairs = [n for n in (_pair_name(p) for p in meta.get("integration_pairs") or []) if n]
433
+ stack = {"basis": "composition", "denominator": len(libraries) + len(pairs),
434
+ "compositionNames": libraries + pairs}
435
+
436
+ return {
437
+ "skillType": skill_type,
438
+ "scopeType": meta.get("scope_type") if isinstance(meta.get("scope_type"), str) else None,
439
+ "clusterA": {
440
+ "exports_public_api": _count(stats.get("exports_public_api")),
441
+ "exports_length": len(exports) if isinstance(exports, list) else None,
442
+ },
443
+ "clusterB": {"exports_documented": documented},
444
+ "provenanceExportNames": entry_names,
445
+ "confidenceDistribution": dist or None,
446
+ "namedExports": {"count": len(named), "names": named} if entry_names is not None else None,
447
+ "effectiveDenominator": effective,
448
+ # The stored count counts each name once, while the map (so stats.exports_documented) holds an entry for
449
+ # each name and file: a name the entry points pass on from two files is two entries, one name. Only a map
450
+ # with no `::` entry takes that arm: the stored count holds no method, so a map with methods could match it
451
+ # only by chance, and its methods would stand in for an undocumented name.
452
+ "inflationSignature": effective is not None and (
453
+ documented == effective or (bool(entry_names) and not any(METHOD_SEPARATOR in n for n in entry_names)
454
+ and len(set(entry_names)) == effective)),
455
+ "declaredNames": sorted(set(names)) if names else named,
456
+ "stack": stack,
457
+ }
458
+
459
+
460
+ # --------------------------------------------------------------------------
461
+ # surface
462
+ # --------------------------------------------------------------------------
463
+
464
+
465
+ _GLOB_CACHE: dict[str, re.Pattern[str]] = {}
466
+
467
+
468
+ def glob_match(rel_path: str, pattern: str) -> bool:
469
+ """skf-resolve-authoritative-files.py's glob rule, which the extractor applies."""
470
+ compiled = _GLOB_CACHE.get(pattern)
471
+ if compiled is None:
472
+ parts = pattern.split("/")
473
+ out: list[str] = []
474
+ for i, part in enumerate(parts):
475
+ if part == "**":
476
+ out.append("(?:.*/)?" if i + 1 < len(parts) else ".*")
477
+ continue
478
+ out.append("".join("[^/]*" if ch == "*" else "[^/]" if ch == "?" else re.escape(ch) for ch in part))
479
+ if i + 1 < len(parts):
480
+ out.append("/")
481
+ compiled = re.compile("^" + "".join(out) + "$")
482
+ _GLOB_CACHE[pattern] = compiled
483
+ return bool(compiled.match(rel_path))
484
+
485
+
486
+ def _norm(path) -> str:
487
+ text = str(path or "").strip().replace("\\", "/")
488
+ while text.startswith("./"):
489
+ text = text[2:]
490
+ return text.lstrip("/")
491
+
492
+
493
+ def _record(name, kind, file, line, signature_line, origin) -> dict:
494
+ return {"name": name, "kind": kind, "file": file, "line": line, "signatureLine": signature_line,
495
+ "origin": origin}
496
+
497
+
498
+ def _globs(value) -> list[str]:
499
+ return [g for g in (_norm(v) for v in value if isinstance(v, str)) if g] if isinstance(value, list) else []
500
+
501
+
502
+ def _scope(include, exclude, tier_a_include) -> dict:
503
+ return {"include": _globs(include), "exclude": _globs(exclude), "tier_a_include": _globs(tier_a_include)}
504
+
505
+
506
+ def read_brief(path: str) -> dict:
507
+ """A skill brief's scope globs, scope type and language, the fields skf-extract-public-api.py reads, and the
508
+ latest action of each scope-expansion amendment by its path (`amendments`), a legacy headless skip
509
+ read as a deferral as skf-resolve-authoritative-files.py reads it."""
510
+ try:
511
+ import yaml # --brief only; `uv run` installs it from the header
512
+ except ImportError as exc:
513
+ raise InputError("--brief is read with PyYAML: run the script with `uv run`") from exc
514
+ resolver = _sibling("skf-resolve-authoritative-files.py", SHARED_SCRIPTS)
515
+ try:
516
+ data = resolver.parse_brief_yaml(Path(path).read_bytes().decode("utf-8-sig"))
517
+ except (OSError, UnicodeDecodeError) as exc:
518
+ raise InputError(f"cannot read --brief {path}: {exc}") from exc
519
+ except yaml.YAMLError as exc:
520
+ raise InputError(f"--brief {path} is not valid YAML: {' '.join(str(exc).split())}") from exc
521
+ if not isinstance(data, dict):
522
+ raise InputError(f"--brief {path} is not a YAML mapping")
523
+ scope = data.get("scope") if isinstance(data.get("scope"), dict) else {}
524
+ amendments: dict[str, str] = {}
525
+ for amend in scope.get("amendments") if isinstance(scope.get("amendments"), list) else []:
526
+ if isinstance(amend, dict) and amend.get("category") == "scope-expansion":
527
+ amended = _norm(amend["path"]) if isinstance(amend.get("path"), str) else ""
528
+ action = resolver._amendment_action(amend)
529
+ if amended and action is not None:
530
+ amendments[amended] = action
531
+ return {"scope": _scope(scope.get("include"), scope.get("exclude"), scope.get("tier_a_include")),
532
+ "type": scope.get("type") if isinstance(scope.get("type"), str) else None,
533
+ "language": data.get("language") if isinstance(data.get("language"), str) else None,
534
+ "amendments": amendments}
535
+
536
+
537
+ def matching(records: list[dict], globs: list[str], excludes: list[str]) -> list[str]:
538
+ """The names whose record names a file one of `globs` matches and no exclude glob does."""
539
+ return sorted({r["name"] for r in records if r.get("file")
540
+ and any(glob_match(_norm(r["file"]), g) for g in globs)
541
+ and not any(glob_match(_norm(r["file"]), g) for g in excludes)})
542
+
543
+
544
+ def _apart(a: str, b: str) -> bool:
545
+ """True when, before either has a `*` or `?`, the two globs spell a different segment at the same
546
+ place, so no file can match both. Any other pair counts as overlapping, even one no file can match
547
+ both of (`src` beside `src/index.ts`), so this never parts two that can share a file."""
548
+ for x, y in zip(a.split("/"), b.split("/")):
549
+ if any(c in x or c in y for c in "*?"):
550
+ return False
551
+ if x != y:
552
+ return True
553
+ return False
554
+
555
+
556
+ def _inside(a: str, b: str) -> bool:
557
+ """True when glob `b` matches every path glob `a` can match: segment by segment each of `b`'s is
558
+ `**`, the same as `a`'s, or `*` beside one of `a`'s that is not `**`, and `a` has as many segments
559
+ as `b`, or more when `b` ends in `**`. Any other pair counts as not inside, even one where `b`
560
+ does cover `a` (`gen/**` in `**/gen/**`), so this never drops a glob that can count a name."""
561
+ xs, ys = a.split("/"), b.split("/")
562
+ if len(xs) < len(ys) or (len(xs) > len(ys) and ys[-1] != "**"):
563
+ return False
564
+ return all(y == "**" or x == y or (y == "*" and x != "**") for x, y in zip(xs, ys))
565
+
566
+
567
+ def _nested_tops(entry_files: list[dict], named: set[str], exporting: set[str]) -> dict[str, str]:
568
+ """{top: the outermost top whose folder holds it} for each Python top
569
+ __init__.py inside the folder of another top that `exporting` lists (one
570
+ that exports at least one public name), a sub-package behind a folder
571
+ with no __init__.py, unless `named` lists its file. A top that exports
572
+ nothing (a stray __init__.py at the source root, say) nests no other."""
573
+ tops = sorted({e["file"] for e in entry_files
574
+ if e.get("language") == "python" and isinstance(e.get("file"), str) and e["file"]})
575
+ nested = {}
576
+ for top in tops:
577
+ folder = posixpath.dirname(top)
578
+ outer = [o for o in tops if o != top and o in exporting
579
+ and (not posixpath.dirname(o) or folder.startswith(posixpath.dirname(o) + "/"))]
580
+ if outer and _norm(top) not in named:
581
+ nested[top] = min(outer, key=lambda o: (o.count("/"), o))
582
+ return nested
583
+
584
+
585
+ def _members_in_place(public: list[dict]) -> list[dict]:
586
+ """The public names, a namespace item with a `members` list (the names
587
+ its module passes on, `pkg.sub.name`) given as its members, each with
588
+ the namespace's entry and language and `via: namespace`. A member whose
589
+ name and file another public name, or an earlier member, has is left
590
+ out; a namespace whose members the runner could not read (null), or one
591
+ from a runner that lists none, stays itself."""
592
+ expanded = [p for p in public if not (p.get("via") == "namespace" and isinstance(p.get("members"), list))]
593
+ seen = {(p["name"], p.get("file")) for p in expanded}
594
+ for item in public:
595
+ if item.get("via") != "namespace" or not isinstance(item.get("members"), list):
596
+ continue
597
+ for member in item["members"]:
598
+ if not isinstance(member, dict) or not isinstance(member.get("name"), str) or not member["name"]:
599
+ continue
600
+ key = (member["name"], member.get("file"))
601
+ if key in seen:
602
+ continue
603
+ seen.add(key)
604
+ expanded.append({"name": member["name"], "language": item.get("language"), "entry": item.get("entry"),
605
+ "via": "namespace", "local": member.get("local"), "file": member.get("file"),
606
+ "line": member.get("line")})
607
+ return expanded
608
+
609
+
610
+ def _extraction_summary(data: dict, language: str | None) -> dict:
611
+ """The surface's `extraction` object: the runner's status and counts,
612
+ the files to read by eye and the fallback verdict."""
613
+ counts = data.get("counts") if isinstance(data.get("counts"), dict) else {}
614
+ arms = data.get("arms") if isinstance(data.get("arms"), dict) else {}
615
+ entry_points = data.get("entry_points") if isinstance(data.get("entry_points"), dict) else {}
616
+ without = data.get("files_without_recipes") if isinstance(data.get("files_without_recipes"), dict) else {}
617
+ status = data.get("status")
618
+ reason = None
619
+ if status == "no-ast-grep":
620
+ reason = "no ast-grep the runner can run"
621
+ elif not data.get("files_in_scope"):
622
+ reason = "no file in scope"
623
+ elif without and not data.get("exports"):
624
+ reason = f"no recipe reads the files in scope ({', '.join(sorted(without))})"
625
+ elif without and language and language.strip().lower() not in RECIPE_LANGUAGES:
626
+ reason = (f"no recipe reads the skill's language, {language} "
627
+ f"({', '.join(sorted(without))} in scope)")
628
+ errors = [e for e in data.get("errors") or [] if isinstance(e, dict)]
629
+ read_by_eye = sorted({str(i["file"]) for i in data.get("file_issues") or []
630
+ if isinstance(i, dict) and i.get("file") and i.get("issue") != "no-recipes"}
631
+ | {str(e["first_file"]) for e in errors if e.get("first_file")}
632
+ | {str(f) for e in errors if isinstance(e.get("unread"), list) for f in e["unread"] if f})
633
+ return {
634
+ "status": status,
635
+ "filesInScope": data.get("files_in_scope"),
636
+ "truncated": data.get("truncated"),
637
+ "effectiveDenominator": _count(counts.get("effective_denominator")),
638
+ "effectiveDenominatorBasis": counts.get("effective_denominator_basis"),
639
+ "exportsPublicApi": _count(counts.get("exports_public_api")),
640
+ "arms": arms,
641
+ "curatedSubset": counts.get("curated_subset") if isinstance(counts.get("curated_subset"), bool) else None,
642
+ "entryPointStatus": entry_points.get("status"),
643
+ # A run that read a file in scope and found no entry point says so whether or not ast-grep ran.
644
+ "entryPointsFound": (bool(entry_points["files"]) if isinstance(entry_points.get("files"), list)
645
+ and data.get("files_in_scope") else None),
646
+ "readByEye": read_by_eye,
647
+ "fallback": {"needed": reason is not None, "reason": reason},
648
+ }
649
+
650
+
651
+ NO_UMBRELLA = {"entries": [], "names": 0, "reexported": 0, "ratio": None, "umbrella": False}
652
+
653
+
654
+ def from_extraction(data: dict, language: str | None = None, brief: dict | None = None) -> dict:
655
+ """The public surface of an extraction, less what lies outside scope,
656
+ plus the root exports a stale scope.include glob left out. A nested
657
+ Python top's records come apart (`nestedRecords`): they count in the
658
+ scope sets, not in `all`. An extraction the per-file scan replaces
659
+ (`extraction.fallback.needed`) gives only its scope, its stale globs and
660
+ its `extraction` object: no name and no set."""
661
+ if data.get("mode") != "full":
662
+ raise InputError("--extraction is not skf-extract-public-api.py --mode full output")
663
+
664
+ # The scope: the one the extractor recorded, else the brief's.
665
+ recorded = data.get("scope") if isinstance(data.get("scope"), dict) else {}
666
+ scope = _scope(recorded.get("include"), recorded.get("exclude"), recorded.get("tier_a_include"))
667
+ if not any(scope.values()) and brief is not None:
668
+ scope = brief["scope"]
669
+ unmatched = _globs(recorded.get("unmatched_include"))
670
+ # A tier A glob that matches no file: the runner's list, never re-matched here.
671
+ unmatched_tier_a = _globs(recorded.get("unmatched_tier_a_include"))
672
+ extraction = _extraction_summary(data, language)
673
+ if extraction["fallback"]["needed"]:
674
+ return {"records": [], "nestedRecords": [], "sets": {}, "outside": [], "nested": [],
675
+ "staleScope": {"unmatchedInclude": unmatched, "unmatchedTierAInclude": unmatched_tier_a,
676
+ "restored": []},
677
+ "gaps": [], "scope": scope, "scopeType": recorded.get("type"), "umbrella": dict(NO_UMBRELLA),
678
+ "extraction": extraction}
679
+
680
+ diff = data.get("entry_point_diff") if isinstance(data.get("entry_point_diff"), dict) else {}
681
+ public = _members_in_place([p for p in diff.get("public") or []
682
+ if isinstance(p, dict) and isinstance(p.get("name"), str)])
683
+ outside = [o for o in diff.get("outside_scope") or [] if isinstance(o, dict)]
684
+ gaps = [g for g in diff.get("extraction_gaps") or [] if isinstance(g, dict)]
685
+
686
+ entry_points = data.get("entry_points") if isinstance(data.get("entry_points"), dict) else {}
687
+ entry_files = [e for e in entry_points.get("files") or [] if isinstance(e, dict)]
688
+ # A Python top nested in another that exports a name is no barrel: its
689
+ # names leave `all`, `root` and the umbrella ratio, and keep their
690
+ # records (so the scope sets a specific-modules or stratified brief
691
+ # counts) and their outside_scope and extraction_gaps rows.
692
+ nested = _nested_tops(entry_files, set(scope["include"]) | set(scope["tier_a_include"]),
693
+ {p.get("entry") for p in public})
694
+ nested_names = {top: sorted({p["name"] for p in public if p.get("entry") == top}) for top in nested}
695
+
696
+ subpath_files = {e.get("file") for e in entry_files
697
+ if e.get("subpath") not in ROOT_SUBPATHS and "*" not in str(e.get("subpath"))}
698
+ root_files = {e.get("file") for e in entry_files if e.get("subpath") in ROOT_SUBPATHS} - set(nested)
699
+
700
+ # A scope.include glob that matches no file: the brief predates the
701
+ # source, so a root export defined in a file no glob covers comes back,
702
+ # unless the brief excludes or declined that file.
703
+ declined = [path for path, action in ((brief or {}).get("amendments") or {}).items()
704
+ if action in DECLINED_ACTIONS]
705
+ restored = [o for o in outside if unmatched and o.get("entry") in root_files
706
+ and isinstance(o.get("name"), str) and isinstance(o.get("file"), str) and o["file"]
707
+ and not any(glob_match(_norm(o["file"]), g)
708
+ for g in scope["include"] + scope["exclude"] + declined)]
709
+ restored_at = {(o["name"], o["file"]): o.get("line") for o in restored}
710
+ outside = [o for o in outside if (o.get("name"), o.get("file")) not in restored_at]
711
+
712
+ outside_keys = {(o.get("name"), o.get("file")) for o in outside}
713
+ index = {(e.get("export_name"), e.get("source_file")): e
714
+ for e in data.get("exports") or [] if isinstance(e, dict)}
715
+ records, nested_records = [], []
716
+ for info in public:
717
+ name, file = info["name"], info.get("file")
718
+ if (name, file) in outside_keys:
719
+ continue
720
+ if (name, file) in restored_at:
721
+ # No recipe reads a file out of scope, so its kind is unknown: the
722
+ # name carries the file and line of its outside_scope row.
723
+ records.append(_record(name, None, file, restored_at[(name, file)], None, "restored"))
724
+ continue
725
+ # An aliased re-export (`export { Options as Config }`) is recorded
726
+ # under the name its defining file gives it, `local`.
727
+ found = index.get((info.get("local") or name, file)) or index.get((name, file))
728
+ kind = found.get("export_type") if found else None
729
+ (nested_records if info.get("entry") in nested else records).append(
730
+ _record(name, None if kind == "re-export" else kind, file,
731
+ found.get("source_line") if found else info.get("line"),
732
+ found.get("signature_line") if found else None, "extraction"))
733
+
734
+ root_names = [p for p in public if p.get("entry") in root_files]
735
+ reexported = [p for p in root_names if p.get("via") not in (None, "declaration")]
736
+
737
+ sets = {}
738
+ if subpath_files:
739
+ sets["subpaths"] = sorted({p["name"] for p in public if p.get("entry") in subpath_files})
740
+ if root_files:
741
+ sets["root"] = sorted({p["name"] for p in root_names})
742
+
743
+ return {
744
+ "records": records,
745
+ "nestedRecords": nested_records,
746
+ "sets": sets,
747
+ "outside": [{"name": o.get("name"), "file": o.get("file")} for o in outside],
748
+ "nested": [{"entry": top, "within": nested[top], "names": nested_names[top]} for top in sorted(nested)],
749
+ "staleScope": {"unmatchedInclude": unmatched, "unmatchedTierAInclude": unmatched_tier_a,
750
+ "restored": [{"name": o["name"], "file": o["file"], "line": o.get("line")}
751
+ for o in sorted(restored, key=lambda o: (o["name"], o["file"]))]},
752
+ "gaps": [{"name": g.get("name"), "file": g.get("file"), "line": g.get("line")} for g in gaps],
753
+ "scope": scope,
754
+ "scopeType": recorded.get("type") if isinstance(recorded.get("type"), str) else None,
755
+ "umbrella": {
756
+ "entries": sorted(f for f in root_files if f),
757
+ "names": len(root_names),
758
+ "reexported": len(reexported),
759
+ "ratio": round2(len(reexported) / len(root_names)) if root_names else None,
760
+ "umbrella": bool(root_names) and len(reexported) / len(root_names) > UMBRELLA_RATIO,
761
+ },
762
+ "extraction": extraction,
763
+ }
764
+
765
+
766
+ def from_quick(data: dict) -> list[dict]:
767
+ exports = data.get("exports")
768
+ if not isinstance(exports, list):
769
+ raise InputError("--quick is not skf-extract-public-api.py --mode quick output (no exports list)")
770
+ return [_record(e["name"], e.get("type"), e.get("defined_in") or e.get("source_file"), None, None, "quick")
771
+ for e in exports if isinstance(e, dict) and isinstance(e.get("name"), str) and e["name"]]
772
+
773
+
774
+ def from_per_file(path: str) -> tuple[list[dict], list[str]]:
775
+ """(the records of one per-file result, its schema breaches)."""
776
+ scorer = _sibling("score-signatures.py")
777
+ try:
778
+ text = Path(path).read_bytes().decode("utf-8-sig")
779
+ except (OSError, UnicodeDecodeError) as exc:
780
+ raise InputError(f"cannot read --per-file {path}: {exc}") from exc
781
+ results, errors = scorer.parse_result_text(text, Path(path).name)
782
+ if errors:
783
+ return [], errors
784
+ records = []
785
+ for result in results:
786
+ types = set(result.get("types_found") or [])
787
+ for name in sorted(set(result.get("exports_found") or []) | types):
788
+ if name:
789
+ records.append(_record(name, "type" if name in types else None, result["file"], None, None,
790
+ "per-file"))
791
+ return records, []
792
+
793
+
794
+ def fold_names(names: list[str], prefixes: list[str], keep: set[str],
795
+ tallied: set[str] | None = None) -> tuple[dict[str, str], dict]:
796
+ """({variant: base}, the fold tally of `tallied`, else of every name) under the canonicalization rules."""
797
+ present = set(names)
798
+ variants: dict[str, str] = {}
799
+ tally = {"_def": 0, "_exact": 0, FOLD_PREFIX: 0, "other": 0}
800
+ for name in sorted(present):
801
+ if name in keep:
802
+ continue
803
+ rule, base = None, None
804
+ for suffix in FOLD_SUFFIXES:
805
+ if name.endswith(suffix) and len(name) > len(suffix):
806
+ rule, base = suffix, name[: -len(suffix)]
807
+ break
808
+ if rule is None and name.startswith(FOLD_PREFIX) and len(name) > len(FOLD_PREFIX):
809
+ rule, base = FOLD_PREFIX, name[len(FOLD_PREFIX):]
810
+ if rule is None:
811
+ for prefix in prefixes:
812
+ if prefix and name.startswith(prefix) and name[len(prefix):] in present:
813
+ rule, base = "other", name[len(prefix):]
814
+ break
815
+ if rule is not None:
816
+ variants[name] = base
817
+ if tallied is None or name in tallied:
818
+ tally[rule] += 1
819
+ return variants, tally
820
+
821
+
822
+ def _over(clause: str, sets: dict, name: str, step: int | None, rebuild: str | None = None) -> dict:
823
+ """The barrel branch over set `name`, the `all` set when the surface lacks it."""
824
+ present = name in sets
825
+ return {"clause": clause, "branch": "barrel", "surfaceSet": name if present else "all", "ladderStep": step,
826
+ "absentSet": None if present else name, "rebuild": rebuild}
827
+
828
+
829
+ def pick_denominator(sets: dict, stats_eff: int | None, deflation_fires: bool, barrel: str | None, arms: dict,
830
+ scope_type: str | None, skill_type: str | None, monorepo: bool, pattern_reference: bool,
831
+ curated: bool, source_read: bool) -> dict:
832
+ """The Source API Surface Definition's clause for a skill, with the branch
833
+ and the name set reconcile-coverage.py and score-signatures.py read, and
834
+ the entry points' state it was picked by (see "Denominator" above)."""
835
+ return {**_pick(sets, stats_eff, deflation_fires, barrel, arms, scope_type, skill_type, monorepo,
836
+ pattern_reference, curated, source_read), "barrel": barrel}
837
+
838
+
839
+ def _pick(sets: dict, stats_eff: int | None, deflation_fires: bool, barrel: str | None, arms: dict,
840
+ scope_type: str | None, skill_type: str | None, monorepo: bool, pattern_reference: bool,
841
+ curated: bool, source_read: bool) -> dict:
842
+ if skill_type == "stack":
843
+ return {"clause": "stack", "branch": "stack", "surfaceSet": None, "ladderStep": None, "absentSet": None,
844
+ "rebuild": None}
845
+ multi = bool(arms.get("multi_subpath_exports"))
846
+ if (not monorepo and barrel != "barrel" and scope_type not in NOT_PATTERN_REFERENCE
847
+ and not (multi and scope_type == "public-api")
848
+ and (pattern_reference or (barrel == "no-entry-point" and not multi))):
849
+ if "tier_a_include" in sets:
850
+ return _over("pattern-reference", sets, "tier_a_include", None)
851
+ # The canonicalized provenance map: a surface read from the source is built again from the map alone.
852
+ return _over("pattern-reference", sets, "all", None, "provenance-fold" if source_read else None)
853
+ if barrel == "empty-barrel":
854
+ return _over("empty-barrel", sets, "scope.include", None)
855
+ if monorepo and curated and scope_type != "full-library":
856
+ clause = "stratified-scope"
857
+ elif scope_type == "specific-modules" and not monorepo:
858
+ clause = "specific-modules"
859
+ elif multi and not monorepo and scope_type in ("full-library", "public-api"):
860
+ clause = "multi-entry"
861
+ else:
862
+ return _over("standard-barrel", sets, "all", None)
863
+ ladder = CLAUSE_LADDERS[clause]
864
+ for step, rung in enumerate(ladder, 1):
865
+ if rung != "scalar":
866
+ if rung in sets or step == len(ladder):
867
+ return _over(clause, sets, rung, step)
868
+ elif stats_eff:
869
+ # A stored 0 is no denominator: the next rung.
870
+ if deflation_fires:
871
+ return _over(clause, sets, "scope.include", step)
872
+ scored = next((name for name in ("tier_a_include", "scope.include") if name in sets), "all")
873
+ return {"clause": clause, "branch": "scalar", "surfaceSet": scored, "ladderStep": step,
874
+ "absentSet": None, "rebuild": None}
875
+ raise AssertionError(f"the {clause} ladder ends on a set")
876
+
877
+
878
+ def surface(args: argparse.Namespace) -> dict:
879
+ records: list[dict] = []
880
+ sets: dict[str, list[str]] = {}
881
+ warnings: list[str] = []
882
+ out: dict = {"candidates": None, "guards": None, "denominator": None, "state2": None, "canonical": None,
883
+ "extraction": None, "extractionGaps": [], "excluded": {"outsideScope": [], "nestedEntries": []}}
884
+
885
+ meta = _object(args.metadata, "--metadata") if args.metadata else None
886
+ provenance = _object(args.provenance, "--provenance") if args.provenance else None
887
+ brief = read_brief(args.brief) if args.brief else None
888
+ language = (brief or {}).get("language")
889
+ if not language and meta is not None and isinstance(meta.get("language"), str):
890
+ language = meta["language"]
891
+ stats = meta.get("stats") if isinstance(meta, dict) and isinstance(meta.get("stats"), dict) else {}
892
+ stats_eff = _count(stats.get("effective_denominator"))
893
+
894
+ extracted = None
895
+ if args.extraction:
896
+ data = _object(args.extraction, "--extraction")
897
+ extracted = from_extraction(data, language, brief)
898
+ records += extracted["records"]
899
+ sets.update(extracted["sets"])
900
+ out["extraction"] = extracted["extraction"]
901
+ out["extractionGaps"] = extracted["gaps"]
902
+ out["excluded"] = {"outsideScope": extracted["outside"], "nestedEntries": extracted["nested"]}
903
+ warnings += [f"{Path(args.extraction).name}: {w}" for w in data.get("warnings") or [] if isinstance(w, str)]
904
+ quick_outputs = []
905
+ for path in args.quick or []:
906
+ data = _object(path, "--quick")
907
+ quick_outputs.append(data)
908
+ records += from_quick(data)
909
+ warnings += [f"{Path(path).name}: {w}" for w in data.get("warnings") or [] if isinstance(w, str)]
910
+ violations: list[str] = []
911
+ for path in args.per_file or []:
912
+ found, errors = from_per_file(path)
913
+ records += found
914
+ violations += errors
915
+ if violations:
916
+ raise SchemaError(violations)
917
+ records += [_record(name, None, None, None, None, "by-eye") for name in args.name or [] if name]
918
+
919
+ # A surface read from the source takes no name from the provenance map or
920
+ # metadata.json: they are only the baselines of the candidates and guards.
921
+ source_read = extracted is not None or bool(args.quick) or bool(args.per_file)
922
+ if not source_read and (provenance is not None or meta is not None):
923
+ named = [e for e in _entries(provenance)
924
+ if isinstance(e.get("export_name"), str) and e["export_name"]
925
+ and METHOD_SEPARATOR not in e["export_name"]]
926
+ prov_names = sorted({e["export_name"] for e in named})
927
+ exports = meta.get("exports") if meta is not None else None
928
+ meta_names = sorted({n for n in (_export_name(e) for e in exports) if n}) if isinstance(exports, list) else []
929
+ base_of: dict[str, str] = {}
930
+ # A map whose raw count is the stored effective_denominator is already canonical: no fold.
931
+ skipped = bool(args.fold) and provenance is not None and stats_eff == len(prov_names)
932
+ if args.fold:
933
+ # One fold over both lists, so a variant folded out of the map
934
+ # never comes back through metadata.json.
935
+ base_of, tally = fold_names(sorted(set(prov_names) | set(meta_names)), args.fold_prefix or [],
936
+ set(args.keep or []), tallied=set(prov_names))
937
+ if skipped:
938
+ base_of, tally = {}, dict.fromkeys(tally, 0)
939
+ if provenance is not None:
940
+ canonical = sorted({base_of.get(n, n) for n in prov_names})
941
+ out["canonical"] = {
942
+ "raw": len(prov_names),
943
+ "canonical": len(canonical),
944
+ "folded": tally,
945
+ "variants": {n: base_of[n] for n in prov_names if n in base_of},
946
+ "skipped": skipped,
947
+ "residual": None if stats_eff is None else len(canonical) - stats_eff,
948
+ "summary": (f"Provenance-map canonicalization: {len(prov_names)} raw entries → "
949
+ f"{len(canonical)} canonical bases ({len(prov_names) - len(canonical)} "
950
+ f"bookkeeping variants folded: _def×{tally['_def']}, _exact×{tally['_exact']}, "
951
+ f"a11y_×{tally[FOLD_PREFIX]}, other×{tally['other']})"
952
+ + (": the raw count equals `stats.effective_denominator`, so the map is already "
953
+ "canonical" if skipped else "")),
954
+ }
955
+ seen = set()
956
+ for e in named:
957
+ name = base_of.get(e["export_name"], e["export_name"])
958
+ if name in seen:
959
+ continue
960
+ seen.add(name)
961
+ records.append(_record(name, e.get("export_type"), e.get("source_file"), e.get("source_line"),
962
+ None, "provenance"))
963
+ meta_bases = sorted({base_of.get(n, n) for n in meta_names})
964
+ records += [_record(n, None, None, None, None, "metadata") for n in meta_bases]
965
+ if seen and meta_bases:
966
+ out["state2"] = {
967
+ "provenanceCount": len(seen),
968
+ "metadataCount": len(meta_bases),
969
+ "unionCount": len(seen | set(meta_bases)),
970
+ "metadataOnly": sorted(set(meta_bases) - seen),
971
+ }
972
+
973
+ sets["all"] = sorted({r["name"] for r in records})
974
+ # A nested Python top's records count in the scope sets, never in `all`.
975
+ records += extracted["nestedRecords"] if extracted is not None else []
976
+ records.sort(key=lambda r: (r["name"], str(r["file"] or ""), r["origin"]))
977
+
978
+ # The scope: the one the extractor recorded, else the brief's.
979
+ scope = extracted["scope"] if extracted is not None and any(extracted["scope"].values()) else None
980
+ if scope is None:
981
+ scope = brief["scope"] if brief is not None else _scope(None, None, None)
982
+ # A tier A glob the runner lists as matching no file counts no name, and
983
+ # so does one whose folder or file differs from every scope.include
984
+ # glob's before any `*` or `?` (`gen/**` beside `src/**`) or one a
985
+ # scope.exclude glob covers segment by segment (`src/gen/**` beside an
986
+ # exclude of `src/gen/**`), whatever the tree holds: the set comes from
987
+ # the other globs, over the names whose file a scope.include glob matches
988
+ # too (any file with none), so a restored name is in no set. A tier A
989
+ # list that counts no name (every glob stale, or matching only excluded
990
+ # files, files scope.include leaves out or files that define no name)
991
+ # narrows nothing: there is no set.
992
+ stale_tier_a = set(extracted["staleScope"]["unmatchedTierAInclude"]) if extracted is not None else set()
993
+ tier_a = [g for g in scope["tier_a_include"] if g not in stale_tier_a
994
+ and not (scope["include"] and all(_apart(g, i) for i in scope["include"]))
995
+ and not any(_inside(g, e) for e in scope["exclude"])]
996
+ filed = [r for r in records if r["file"]]
997
+ if filed and scope["include"]:
998
+ sets["scope.include"] = matching(filed, scope["include"], scope["exclude"])
999
+ in_include = [r for r in filed if not scope["include"]
1000
+ or any(glob_match(_norm(r["file"]), g) for g in scope["include"])]
1001
+ tier_a_names = matching(in_include, tier_a, scope["exclude"]) if tier_a else []
1002
+ if tier_a_names:
1003
+ sets["tier_a_include"] = tier_a_names
1004
+
1005
+ if extracted is not None or brief is not None or meta is not None:
1006
+ include_union = len(sets["scope.include"]) if "scope.include" in sets else len(sets["all"])
1007
+ extraction = extracted["extraction"] if extracted is not None else {}
1008
+ out["candidates"] = {
1009
+ "statsEffectiveDenominator": stats_eff,
1010
+ "tierAIncludeUnion": len(sets["tier_a_include"]) if "tier_a_include" in sets else None,
1011
+ "scopeIncludeUnion": len(sets["scope.include"]) if "scope.include" in sets else None,
1012
+ "subpathUnion": len(sets["subpaths"]) if "subpaths" in sets else None,
1013
+ "rootBarrel": len(sets["root"]) if "root" in sets else None,
1014
+ "extractorEffectiveDenominator": extraction.get("effectiveDenominator"),
1015
+ "extractorBasis": extraction.get("effectiveDenominatorBasis"),
1016
+ }
1017
+ # With an extraction or the fallback scan's results (each at the file
1018
+ # that defines its names), no tier A set is no tier A list; otherwise
1019
+ # the globs left in the list decide (a name read by eye at Quick tier
1020
+ # or State 4, and a quick name with no defined_in, carries its entry
1021
+ # file, not the file that defines it).
1022
+ no_tier_a = ("tier_a_include" not in sets if extracted is not None or args.fallback_scan
1023
+ else not tier_a)
1024
+ deflation = {"applicable": source_read and stats_eff is not None, "effectiveDenominator": stats_eff,
1025
+ "rederived": include_union if source_read else None, "pct": None, "fires": False}
1026
+ if deflation["applicable"] and stats_eff:
1027
+ deflation["pct"] = round2((include_union - stats_eff) / stats_eff * 100)
1028
+ deflation["fires"] = no_tier_a and deflation["pct"] > DEFLATION_PCT
1029
+ elif deflation["applicable"]:
1030
+ # A stored 0 is deflation, not a denominator, once a name is re-derived.
1031
+ deflation["fires"] = no_tier_a and include_union > 0
1032
+ entry_count = len(_entries(provenance)) if provenance is not None else 0
1033
+ inflation = {"applicable": source_read and no_tier_a and bool(scope["include"]) and entry_count > 0,
1034
+ "scopeIncludeUnion": include_union if scope["include"] else None,
1035
+ "provenanceEntries": entry_count if provenance is not None else None,
1036
+ "pct": None, "fires": False}
1037
+ if inflation["applicable"]:
1038
+ inflation["pct"] = round2((include_union - entry_count) / entry_count * 100)
1039
+ inflation["fires"] = inflation["pct"] > INFLATION_PCT
1040
+ stale_scope = {"applicable": extracted is not None and bool(scope["include"] or scope["tier_a_include"]),
1041
+ "fires": False, "unmatchedInclude": [], "unmatchedTierAInclude": [], "restored": []}
1042
+ if stale_scope["applicable"]:
1043
+ stale_scope.update(extracted["staleScope"])
1044
+ stale_scope["fires"] = bool(stale_scope["unmatchedInclude"] or stale_scope["unmatchedTierAInclude"])
1045
+ out["guards"] = {
1046
+ "deflation": deflation,
1047
+ "inflation": inflation,
1048
+ "staleScope": stale_scope,
1049
+ "tierAInclude": list(scope["tier_a_include"]),
1050
+ "umbrella": extracted["umbrella"] if extracted is not None else dict(NO_UMBRELLA),
1051
+ "thresholds": {"deflationPct": DEFLATION_PCT, "inflationPct": INFLATION_PCT,
1052
+ "umbrellaRatio": UMBRELLA_RATIO},
1053
+ }
1054
+
1055
+ # The clause: an extraction's entry-point status (one the per-file scan replaces says only that it found no
1056
+ # entry point), else a quick output's (an export is a barrel; none, and no warning, an empty one), else what
1057
+ # the workflow read (--barrel).
1058
+ barrel = None
1059
+ summary = extracted["extraction"] if extracted is not None else None
1060
+ if summary is not None and not summary["fallback"]["needed"]:
1061
+ barrel = summary["entryPointStatus"]
1062
+ elif summary is not None and summary["entryPointsFound"] is False:
1063
+ barrel = "no-entry-point"
1064
+ elif quick_outputs:
1065
+ barrel = "barrel" if any(q["exports"] for q in quick_outputs) else (
1066
+ None if any(q.get("warnings") for q in quick_outputs) else "empty-barrel")
1067
+ if barrel is None and args.barrel:
1068
+ barrel = BARREL_FLAG[args.barrel]
1069
+ recorded_type = extracted["scopeType"] if extracted is not None else None
1070
+ meta_type = meta.get("scope_type") if isinstance(meta, dict) and isinstance(meta.get("scope_type"), str) else None
1071
+ arms = summary["arms"] if summary is not None else {}
1072
+ # The extraction's verdict, the rule Create Skill stores the count by, else the workflow's judgment.
1073
+ curated = summary["curatedSubset"] if summary is not None and summary["curatedSubset"] is not None \
1074
+ else bool(args.curated_subset)
1075
+ out["denominator"] = pick_denominator(
1076
+ sets, stats_eff, bool((out["guards"] or {}).get("deflation", {}).get("fires")), barrel, arms,
1077
+ (brief or {}).get("type") or recorded_type or meta_type,
1078
+ meta.get("skill_type") if isinstance(meta, dict) else None, bool(args.monorepo or arms.get("monorepo")),
1079
+ bool(args.pattern_reference_intent), curated, source_read)
1080
+
1081
+ return {
1082
+ "inputs": {
1083
+ "extraction": args.extraction, "quick": args.quick or [], "perFile": args.per_file or [],
1084
+ "names": args.name or [], "provenance": args.provenance, "metadata": args.metadata,
1085
+ "brief": args.brief, "fold": bool(args.fold),
1086
+ },
1087
+ "exports": records,
1088
+ "sets": sets,
1089
+ **out,
1090
+ "warnings": warnings,
1091
+ }
1092
+
1093
+
1094
+ # --------------------------------------------------------------------------
1095
+ # CLI
1096
+ # --------------------------------------------------------------------------
1097
+
1098
+
1099
+ def _cmd_census(args: argparse.Namespace) -> dict:
1100
+ return census(args.skill_dir)
1101
+
1102
+
1103
+ def _cmd_metadata(args: argparse.Namespace) -> dict:
1104
+ meta = _object(args.metadata, "--metadata")
1105
+ provenance = _object(args.provenance, "--provenance") if args.provenance else None
1106
+ return metadata_inputs(meta, provenance)
1107
+
1108
+
1109
+ def _build_parser() -> argparse.ArgumentParser:
1110
+ parser = argparse.ArgumentParser(
1111
+ prog="load-coverage-inputs",
1112
+ description="The counts, name sets and denominator candidates coverage-check reads, from files.",
1113
+ )
1114
+ sub = parser.add_subparsers(dest="cmd", required=True)
1115
+
1116
+ p = sub.add_parser("census", help="count the citation forms: is the skill docs-only?")
1117
+ p.add_argument("--skill-dir", required=True, help="the skill package: SKILL.md and references/")
1118
+ p.add_argument("--output", help="also write the result to this file")
1119
+ p.set_defaults(func=_cmd_census)
1120
+
1121
+ p = sub.add_parser("metadata", help="the metadata.json and provenance-map counts")
1122
+ p.add_argument("--metadata", required=True, help="the skill's metadata.json")
1123
+ p.add_argument("--provenance", help="the provenance map, when there is one")
1124
+ p.add_argument("--output", help="also write the result to this file")
1125
+ p.set_defaults(func=_cmd_metadata)
1126
+
1127
+ p = sub.add_parser("surface", help="the enumerated source surface, its name sets and candidates")
1128
+ p.add_argument("--extraction", help="skf-extract-public-api.py --mode full output")
1129
+ p.add_argument("--quick", action="append", help="skf-extract-public-api.py --mode quick output (repeatable)")
1130
+ p.add_argument("--per-file", action="append", help="a subagent per-file result (repeatable)")
1131
+ p.add_argument("--fallback-scan", action="store_true",
1132
+ help="the --per-file results are the fallback scan's, each at the file that defines its "
1133
+ "names: as with --extraction, no tier_a_include set is no tier A list, so the deflation "
1134
+ "and inflation guards run; without either, a tier_a_include glob keeps them off unless "
1135
+ "its folder or file differs from every scope.include glob's before any wildcard "
1136
+ "(gen/** beside src/**) or a scope.exclude glob covers it segment by segment "
1137
+ "(src/gen/** beside an exclude of src/gen/**)")
1138
+ p.add_argument("--name", action="append", help="a name read by eye whose file is not known (repeatable)")
1139
+ p.add_argument("--provenance", help="the provenance map: its named exports")
1140
+ p.add_argument("--metadata", help="metadata.json: its exports[] names, stats and language")
1141
+ p.add_argument("--brief", help="the skill brief: its scope globs and language")
1142
+ p.add_argument("--fold", action="store_true", help="fold the provenance and metadata bookkeeping variants")
1143
+ p.add_argument("--fold-prefix", action="append", help="a renderer prefix to fold (repeatable)")
1144
+ p.add_argument("--keep", action="append", help="a variant that is a real export (repeatable)")
1145
+ p.add_argument("--monorepo", action="store_true",
1146
+ help="the source is a monorepo (skf-detect-workspaces.py's is_monorepo)")
1147
+ p.add_argument("--pattern-reference-intent", action="store_true",
1148
+ help="the brief's scope.notes flags pattern-reference intent (your judgment)")
1149
+ p.add_argument("--curated-subset", action="store_true",
1150
+ help="with --monorepo: the brief's scope.include lists a curated subset rather than the full "
1151
+ "workspace (your judgment; an extraction's counts.curated_subset wins)")
1152
+ p.add_argument("--barrel", choices=tuple(BARREL_FLAG),
1153
+ help="the entry points as you read them, where no extraction or quick output tells: none (no "
1154
+ "barrel file at any recognized entry-point path), empty (the primary one exports nothing) "
1155
+ "or barrel")
1156
+ p.add_argument("--output", help="also write the result to this file")
1157
+ p.set_defaults(func=surface)
1158
+ return parser
1159
+
1160
+
1161
+ def main(argv: list[str] | None = None) -> int:
1162
+ args = _build_parser().parse_args(argv)
1163
+ try:
1164
+ result = args.func(args)
1165
+ except InputError as exc:
1166
+ print(f"error: {exc}", file=sys.stderr)
1167
+ return 1
1168
+ except SchemaError as exc:
1169
+ if args.output:
1170
+ Path(args.output).unlink(missing_ok=True)
1171
+ print(json.dumps({"valid": False, "violations": exc.violations}, indent=2))
1172
+ return 2
1173
+ if args.output:
1174
+ target = Path(args.output)
1175
+ target.parent.mkdir(parents=True, exist_ok=True)
1176
+ target.write_bytes((json.dumps(result, indent=2, ensure_ascii=False) + "\n").encode("utf-8"))
1177
+ print(json.dumps(result, indent=2))
1178
+ return 0
1179
+
1180
+
1181
+ def _force_utf8(*streams) -> None:
1182
+ """Reconfigure stdout and stderr to UTF-8, keeping each stream's error handler.
1183
+
1184
+ A Windows console pipes them as cp1252, which cannot print every character
1185
+ of the --help text, so --help would stop with UnicodeEncodeError.
1186
+ """
1187
+ for stream in streams:
1188
+ if hasattr(stream, "reconfigure"):
1189
+ errors = getattr(stream, "errors", None)
1190
+ if errors is None:
1191
+ stream.reconfigure(encoding="utf-8")
1192
+ else:
1193
+ stream.reconfigure(encoding="utf-8", errors=errors)
1194
+
1195
+
1196
+ if __name__ == "__main__":
1197
+ _force_utf8(sys.stdout, sys.stderr)
1198
+ sys.exit(main())