bmad-module-skill-forge 2.2.0 → 3.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 (435) 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 +135 -18
  18. package/docs/verifying-a-skill.md +83 -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 +2 -2
  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 +32 -12
  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 +30 -14
  64. package/src/shared/scripts/skf-build-change-manifest.py +2203 -24
  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 +694 -0
  69. package/src/shared/scripts/skf-comention-pairs.py +1333 -123
  70. package/src/shared/scripts/skf-compare-file-hashes.py +1 -1
  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 +3 -2
  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 +1 -1
  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 +4084 -85
  86. package/src/shared/scripts/skf-extraction-inventory.py +746 -0
  87. package/src/shared/scripts/skf-extraction-snapshot.py +539 -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 +444 -203
  92. package/src/shared/scripts/skf-github-fetch.py +365 -0
  93. package/src/shared/scripts/skf-github-probe.py +661 -0
  94. package/src/shared/scripts/skf-hash-content.py +166 -12
  95. package/src/shared/scripts/skf-language-corpora.py +1 -1
  96. package/src/shared/scripts/skf-load-provenance.py +159 -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 +1 -1
  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 +161 -27
  105. package/src/shared/scripts/skf-promote-staged.py +430 -0
  106. package/src/shared/scripts/skf-provenance-gap-dispatch.py +39 -7
  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 +128 -13
  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 +670 -119
  127. package/src/shared/scripts/skf-tessl-review.py +706 -0
  128. package/src/shared/scripts/skf-update-active-symlink.py +5 -5
  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 +4 -1
  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 +2919 -54
  138. package/src/shared/scripts/skf-write-skill-brief.py +367 -19
  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 +27 -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/init.md +148 -210
  168. package/src/skf-audit-skill/references/re-index.md +96 -108
  169. package/src/skf-audit-skill/references/report.md +96 -33
  170. package/src/skf-audit-skill/references/semantic-diff.md +27 -18
  171. package/src/skf-audit-skill/references/severity-classify.md +79 -65
  172. package/src/skf-audit-skill/references/severity-rules.md +15 -6
  173. package/src/skf-audit-skill/references/structural-diff.md +75 -90
  174. package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
  175. package/src/skf-audit-skill/scripts/render-drift-tables.py +685 -0
  176. package/src/skf-brief-skill/SKILL.md +36 -25
  177. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  178. package/src/skf-brief-skill/assets/scope-templates.md +17 -24
  179. package/src/skf-brief-skill/assets/skill-brief-schema.md +29 -15
  180. package/src/skf-brief-skill/customize.toml +48 -20
  181. package/src/skf-brief-skill/references/analyze-target.md +142 -125
  182. package/src/skf-brief-skill/references/confirm-brief.md +10 -8
  183. package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
  184. package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
  185. package/src/skf-brief-skill/references/gather-intent.md +87 -182
  186. package/src/skf-brief-skill/references/headless-args.md +84 -16
  187. package/src/skf-brief-skill/references/health-check.md +1 -1
  188. package/src/skf-brief-skill/references/invocation-contract.md +18 -13
  189. package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
  190. package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
  191. package/src/skf-brief-skill/references/scope-definition.md +123 -47
  192. package/src/skf-brief-skill/references/step-auto-brief.md +24 -80
  193. package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
  194. package/src/skf-brief-skill/references/version-resolution.md +3 -2
  195. package/src/skf-brief-skill/references/write-brief.md +66 -57
  196. package/src/skf-campaign/SKILL.md +31 -34
  197. package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
  198. package/src/skf-campaign/customize.toml +47 -16
  199. package/src/skf-campaign/references/campaign-contracts.md +39 -29
  200. package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
  201. package/src/skf-campaign/references/health-check.md +7 -16
  202. package/src/skf-campaign/references/step-01-setup.md +46 -84
  203. package/src/skf-campaign/references/step-02-strategy.md +31 -30
  204. package/src/skf-campaign/references/step-03-pins.md +26 -18
  205. package/src/skf-campaign/references/step-04-provenance.md +24 -23
  206. package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
  207. package/src/skf-campaign/references/step-06-batch.md +41 -34
  208. package/src/skf-campaign/references/step-07-capstone.md +40 -25
  209. package/src/skf-campaign/references/step-08-verify.md +28 -29
  210. package/src/skf-campaign/references/step-09-refine.md +29 -30
  211. package/src/skf-campaign/references/step-10-export.md +48 -36
  212. package/src/skf-campaign/references/step-11-maintenance.md +31 -34
  213. package/src/skf-campaign/references/step-resume.md +37 -68
  214. package/src/skf-campaign/scripts/campaign-deps.py +57 -18
  215. package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
  216. package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
  217. package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
  218. package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
  219. package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
  220. package/src/skf-campaign/scripts/campaign-report.py +204 -37
  221. package/src/skf-campaign/scripts/campaign-state.py +1128 -0
  222. package/src/skf-campaign/scripts/campaign-status.py +10 -74
  223. package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
  224. package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
  225. package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
  226. package/src/skf-campaign/templates/campaign-report-template.md +5 -1
  227. package/src/skf-campaign/templates/kickoff-template.md +0 -14
  228. package/src/skf-create-skill/SKILL.md +27 -20
  229. package/src/skf-create-skill/assets/compile-assembly-rules.md +6 -5
  230. package/src/skf-create-skill/assets/skill-sections.md +15 -5
  231. package/src/skf-create-skill/customize.toml +20 -8
  232. package/src/skf-create-skill/references/authoritative-files-protocol.md +41 -42
  233. package/src/skf-create-skill/references/batch-mode.md +81 -0
  234. package/src/skf-create-skill/references/ccc-index-check.md +27 -0
  235. package/src/skf-create-skill/references/compile.md +107 -29
  236. package/src/skf-create-skill/references/component-extraction.md +113 -91
  237. package/src/skf-create-skill/references/enrich.md +41 -23
  238. package/src/skf-create-skill/references/entry-points-by-hand.md +20 -0
  239. package/src/skf-create-skill/references/extract.md +200 -161
  240. package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
  241. package/src/skf-create-skill/references/extraction-patterns-tracing.md +2 -2
  242. package/src/skf-create-skill/references/extraction-patterns.md +584 -196
  243. package/src/skf-create-skill/references/generate-artifacts.md +145 -93
  244. package/src/skf-create-skill/references/health-check.md +1 -1
  245. package/src/skf-create-skill/references/load-brief.md +53 -36
  246. package/src/skf-create-skill/references/relabel-rule.md +12 -0
  247. package/src/skf-create-skill/references/report.md +28 -59
  248. package/src/skf-create-skill/references/source-resolution-protocols.md +49 -155
  249. package/src/skf-create-skill/references/step-auto-shard.md +20 -93
  250. package/src/skf-create-skill/references/step-doc-rot.md +44 -60
  251. package/src/skf-create-skill/references/step-doc-sources.md +28 -19
  252. package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
  253. package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
  254. package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
  255. package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
  256. package/src/skf-create-skill/references/validate.md +173 -99
  257. package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
  258. package/src/skf-create-stack-skill/SKILL.md +29 -56
  259. package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
  260. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
  261. package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
  262. package/src/skf-create-stack-skill/customize.toml +23 -12
  263. package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
  264. package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
  265. package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
  266. package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
  267. package/src/skf-create-stack-skill/references/generate-output.md +138 -128
  268. package/src/skf-create-stack-skill/references/init.md +90 -32
  269. package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
  270. package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
  271. package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
  272. package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
  273. package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
  274. package/src/skf-create-stack-skill/references/report.md +39 -33
  275. package/src/skf-create-stack-skill/references/validate.md +20 -39
  276. package/src/skf-drop-skill/SKILL.md +28 -51
  277. package/src/skf-drop-skill/customize.toml +44 -30
  278. package/src/skf-drop-skill/references/execute.md +79 -146
  279. package/src/skf-drop-skill/references/invocation-contract.md +64 -0
  280. package/src/skf-drop-skill/references/report.md +46 -30
  281. package/src/skf-drop-skill/references/select.md +118 -124
  282. package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
  283. package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
  284. package/src/skf-export-skill/SKILL.md +40 -62
  285. package/src/skf-export-skill/assets/managed-section-format.md +14 -50
  286. package/src/skf-export-skill/assets/snippet-format.md +3 -1
  287. package/src/skf-export-skill/customize.toml +42 -13
  288. package/src/skf-export-skill/references/generate-snippet.md +54 -46
  289. package/src/skf-export-skill/references/health-check.md +2 -0
  290. package/src/skf-export-skill/references/invocation-contract.md +15 -0
  291. package/src/skf-export-skill/references/load-skill.md +88 -62
  292. package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
  293. package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
  294. package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
  295. package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
  296. package/src/skf-export-skill/references/package.md +15 -73
  297. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
  298. package/src/skf-export-skill/references/result-envelope.md +31 -16
  299. package/src/skf-export-skill/references/summary.md +41 -17
  300. package/src/skf-export-skill/references/token-report.md +4 -12
  301. package/src/skf-export-skill/references/update-context.md +118 -237
  302. package/src/skf-forger/SKILL.md +61 -37
  303. package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
  304. package/src/skf-forger/customize.toml +43 -0
  305. package/src/skf-forger/references/pipeline-mode.md +73 -18
  306. package/src/skf-forger/scripts/forge-status.py +299 -0
  307. package/src/skf-forger/scripts/parse-pipeline.py +322 -54
  308. package/src/skf-forger/scripts/pipeline-gate.py +428 -0
  309. package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
  310. package/src/skf-quick-skill/SKILL.md +54 -53
  311. package/src/skf-quick-skill/assets/skill-template.md +4 -2
  312. package/src/skf-quick-skill/customize.toml +51 -20
  313. package/src/skf-quick-skill/references/batch-mode.md +74 -21
  314. package/src/skf-quick-skill/references/compile.md +30 -31
  315. package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
  316. package/src/skf-quick-skill/references/finalize.md +69 -18
  317. package/src/skf-quick-skill/references/halt-contract.md +41 -29
  318. package/src/skf-quick-skill/references/health-check.md +5 -3
  319. package/src/skf-quick-skill/references/quick-extract.md +114 -45
  320. package/src/skf-quick-skill/references/registry-resolution.md +9 -64
  321. package/src/skf-quick-skill/references/resolve-target.md +116 -66
  322. package/src/skf-quick-skill/references/write-and-validate.md +79 -40
  323. package/src/skf-refine-architecture/SKILL.md +43 -53
  324. package/src/skf-refine-architecture/customize.toml +49 -20
  325. package/src/skf-refine-architecture/references/compile.md +97 -54
  326. package/src/skf-refine-architecture/references/exit-codes.md +30 -0
  327. package/src/skf-refine-architecture/references/finding-storage.md +21 -0
  328. package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
  329. package/src/skf-refine-architecture/references/improvements.md +14 -20
  330. package/src/skf-refine-architecture/references/init.md +98 -57
  331. package/src/skf-refine-architecture/references/issue-detection.md +32 -39
  332. package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
  333. package/src/skf-refine-architecture/references/report.md +65 -54
  334. package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
  335. package/src/skf-rename-skill/SKILL.md +19 -54
  336. package/src/skf-rename-skill/customize.toml +32 -26
  337. package/src/skf-rename-skill/references/execute.md +230 -184
  338. package/src/skf-rename-skill/references/exit-codes.md +7 -7
  339. package/src/skf-rename-skill/references/health-check.md +1 -7
  340. package/src/skf-rename-skill/references/invocation-contract.md +25 -0
  341. package/src/skf-rename-skill/references/report.md +63 -52
  342. package/src/skf-rename-skill/references/select.md +197 -115
  343. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
  344. package/src/skf-setup/SKILL.md +26 -26
  345. package/src/skf-setup/customize.toml +14 -10
  346. package/src/skf-setup/references/auto-index.md +50 -63
  347. package/src/skf-setup/references/ccc-index.md +29 -90
  348. package/src/skf-setup/references/detect-and-tier.md +58 -67
  349. package/src/skf-setup/references/health-check.md +2 -0
  350. package/src/skf-setup/references/invocation-contract.md +14 -0
  351. package/src/skf-setup/references/report.md +43 -166
  352. package/src/skf-setup/references/tier-rules.md +5 -5
  353. package/src/skf-setup/references/write-config.md +34 -57
  354. package/src/skf-test-skill/SKILL.md +17 -57
  355. package/src/skf-test-skill/assets/output-section-formats.md +33 -34
  356. package/src/skf-test-skill/customize.toml +27 -13
  357. package/src/skf-test-skill/references/coherence-check.md +134 -62
  358. package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
  359. package/src/skf-test-skill/references/coverage-check.md +217 -254
  360. package/src/skf-test-skill/references/detect-mode.md +5 -5
  361. package/src/skf-test-skill/references/external-validators.md +94 -82
  362. package/src/skf-test-skill/references/health-check.md +6 -7
  363. package/src/skf-test-skill/references/init.md +163 -88
  364. package/src/skf-test-skill/references/invocation-contract.md +66 -0
  365. package/src/skf-test-skill/references/migration-section-rules.md +5 -2
  366. package/src/skf-test-skill/references/report.md +162 -142
  367. package/src/skf-test-skill/references/score.md +80 -107
  368. package/src/skf-test-skill/references/scoring-rules.md +41 -155
  369. package/src/skf-test-skill/references/source-access-protocol.md +19 -92
  370. package/src/skf-test-skill/references/step-hard-gate.md +51 -33
  371. package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
  372. package/src/skf-test-skill/scripts/build-result-context.py +287 -0
  373. package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
  374. package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
  375. package/src/skf-test-skill/scripts/compute-score.py +223 -45
  376. package/src/skf-test-skill/scripts/gap-ledger.py +1381 -0
  377. package/src/skf-test-skill/scripts/hard-gate.py +203 -0
  378. package/src/skf-test-skill/scripts/load-coverage-inputs.py +760 -0
  379. package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
  380. package/src/skf-test-skill/scripts/reconcile-coverage.py +273 -79
  381. package/src/skf-test-skill/scripts/score-signatures.py +459 -0
  382. package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
  383. package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
  384. package/src/skf-test-skill/scripts/verify-declared-numerator.py +127 -52
  385. package/src/skf-test-skill/templates/test-report-template.md +21 -15
  386. package/src/skf-update-skill/SKILL.md +38 -25
  387. package/src/skf-update-skill/customize.toml +27 -12
  388. package/src/skf-update-skill/references/detect-changes.md +233 -222
  389. package/src/skf-update-skill/references/gap-driven.md +336 -0
  390. package/src/skf-update-skill/references/health-check.md +31 -5
  391. package/src/skf-update-skill/references/init.md +250 -84
  392. package/src/skf-update-skill/references/invocation-contract.md +12 -0
  393. package/src/skf-update-skill/references/manual-section-rules.md +6 -23
  394. package/src/skf-update-skill/references/merge.md +180 -47
  395. package/src/skf-update-skill/references/re-extract.md +53 -262
  396. package/src/skf-update-skill/references/report.md +100 -35
  397. package/src/skf-update-skill/references/write.md +331 -149
  398. package/src/skf-update-skill/scripts/skf-new-file-diff.py +29 -7
  399. package/src/skf-verify-stack/SKILL.md +23 -42
  400. package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
  401. package/src/skf-verify-stack/customize.toml +55 -22
  402. package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
  403. package/src/skf-verify-stack/references/coverage.md +67 -64
  404. package/src/skf-verify-stack/references/exit-codes.md +21 -8
  405. package/src/skf-verify-stack/references/init.md +107 -62
  406. package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
  407. package/src/skf-verify-stack/references/integrations.md +106 -65
  408. package/src/skf-verify-stack/references/report.md +59 -42
  409. package/src/skf-verify-stack/references/requirements.md +32 -9
  410. package/src/skf-verify-stack/references/synthesize.md +53 -39
  411. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
  412. package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
  413. package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
  414. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
  415. package/tools/cli/commands/install.js +11 -4
  416. package/tools/cli/commands/status.js +15 -3
  417. package/tools/cli/commands/update.js +11 -4
  418. package/tools/cli/lib/tool-check.js +320 -0
  419. package/tools/cli/lib/version-check.js +22 -9
  420. package/tools/cli/skf-cli.js +4 -1
  421. package/.gitattributes +0 -16
  422. package/.nvmrc +0 -1
  423. package/docs/_internal/RELEASING.md +0 -679
  424. package/release-audits/v1.0.0-launch-audit.md +0 -728
  425. package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
  426. package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
  427. package/src/skf-campaign/manifest.yaml +0 -15
  428. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
  429. package/src/skf-create-skill/references/ecosystem-check.md +0 -74
  430. package/src/skf-drop-skill/references/headless-contract.md +0 -18
  431. package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
  432. package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
  433. package/src/skf-update-skill/references/validate.md +0 -124
  434. package/tools/validate-docs-drift.js +0 -233
  435. package/tools/validate-docs-links.js +0 -665
@@ -0,0 +1,976 @@
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Render Stack Metadata: a stack's export labels, library tiers, pair tiers, metadata.json fields and provenance entries.
6
+
7
+ create-stack-skill relabels its code-mode export records and sets each
8
+ library's tier from them (parallel-extract section 3a), gives each
9
+ integration the tier of its pair (detect-integrations section 3), writes the
10
+ counted and ranked fields of metadata.json (generate-output section 6) and
11
+ writes the provenance map from the run's files in either mode: its entries,
12
+ its integrations and, in compose mode, its constituents (generate-output
13
+ section 7). This helper holds those rules once, so no step file restates
14
+ them, and skf-validate-output.py --skill-type stack recomputes the dominant
15
+ tier with dominant_tier here.
16
+
17
+ Rules:
18
+ Tiers, from the strongest: T1, T1-low, T2, T3.
19
+
20
+ library_tier(records)
21
+ A code-mode library is T1 when it has export records and every one
22
+ names extraction_method ast_bridge (an ast-grep rule matched it), and
23
+ T1-low otherwise: one export read by eye, or none recorded at all.
24
+
25
+ combine_pair_tier(a, b, mode)
26
+ An integration takes the weaker of its two libraries' tiers, one rule
27
+ in code mode and compose mode: T1 + T1 is T1; T1 + T1-low and
28
+ T1-low + T1-low are T1-low; T1 + T2, T1-low + T2 and T2 + T2 are T2; a
29
+ pair with a T3 member is T3. A T2 member lowers the pair like any
30
+ other tier: its temporal annotations never keep a stronger one.
31
+ `mode` must name a mode, and both modes take this rule.
32
+
33
+ dominant_tier(distribution)
34
+ The tier of the largest bin of a confidence_distribution. A tie goes
35
+ to the weaker tier, so the tier never overstates confidence, and a
36
+ distribution with no positive bin (no library) reads T1-low. This is
37
+ the stack's confidence_tier.
38
+
39
+ confidence_distribution
40
+ Each library once, in the bin of its tier, so the four bins sum to
41
+ library_count. The evidence report bins the provenance entries
42
+ instead (skf-render-metadata-stats.py).
43
+
44
+ source_authority
45
+ The lowest authority among the libraries: official, then community,
46
+ then internal, so a community and an internal library give internal.
47
+ A library that records none counts as community, so a constituent
48
+ without one never lifts a stack to official, and a code-mode stack,
49
+ whose libraries are dependencies rather than skills with an
50
+ authority, is community.
51
+
52
+ relabel(records)
53
+ The label check is skf-render-metadata-stats.py's, run beside this
54
+ script on the records as its stack shape: each violation's field
55
+ takes the `expected` value the check gives, never the reverse, and the
56
+ check runs again until it passes. Three violations name no value to
57
+ copy, so the rule here sets one: an unknown or missing
58
+ extraction_method becomes its canonical spelling when it is a known
59
+ method in another case, and source_reading otherwise, since nothing
60
+ shows an ast-grep rule matched it; an ast-grep record with no node
61
+ kind becomes ast_bridge, the stack's name for that tool, which pairs
62
+ with no kind; and a record with no valid signature_source takes T1
63
+ when an ast-grep rule matched it (ast_bridge or ast-grep) and T1-low
64
+ otherwise.
65
+
66
+ Input (--input <json-file-or-'-'>, where '-' reads stdin):
67
+ {
68
+ "mode": "code" | "compose",
69
+ "libraries": [
70
+ {"name": "<library>",
71
+ "confidence": "T1|T1-low|T2|T3", # per_library_extractions[].confidence
72
+ "source_authority": "official|community|internal"}, # optional
73
+ ...
74
+ ],
75
+ "integrations": [{"a": "<library>", "b": "<library>"}, ...] # optional
76
+ }
77
+ Tier, mode and authority tokens compare case-insensitively and are printed
78
+ in their canonical spelling. Each library is named once, and each pair
79
+ names two different libraries of `libraries`, once in either order.
80
+
81
+ Export records (--records <json-file>, or '-' for library-tiers): the
82
+ file parallel-extract section 3a writes and relabels,
83
+ {"entries": [{"source_library": "<library>",
84
+ "extraction_method": "ast_bridge|source_reading", ...}, ...]}
85
+ and, for library-tiers, --libraries, the stack's libraries, comma-separated.
86
+ Every record names one of them; a library with no record is T1-low.
87
+
88
+ Subcommands:
89
+ relabel relabels the records against the label check, rewrites the
90
+ file atomically when a label changed (parallel-extract
91
+ section 3a), and prints each relabeled export and the
92
+ records' label counts (one per record, by signature_source):
93
+ {"relabeled": [{"entry_index": I, "export_name": "<name>",
94
+ "source_library": "<library>",
95
+ "changes": [{"field": "<field>",
96
+ "from": <old>, "to": <new>}]}, ...],
97
+ "confidence_distribution": {"t1": 0, "t1_low": 0, "t2": 0, "t3": 0},
98
+ "coherence": {"ok": true, "violations": []}}
99
+ A removed ast_node_type shows "to": null.
100
+ library-tiers
101
+ each library's tier, in the order --libraries gives
102
+ (parallel-extract section 3a):
103
+ {"libraries": [{"name": "<library>", "tier": "T1|T1-low",
104
+ "export_count": N, "ast_bridge_count": M}, ...]}
105
+ pair-tiers the tier of each integration (detect-integrations section 3):
106
+ {"mode": "<mode>",
107
+ "integrations": [{"a": "<a>", "b": "<b>", "tier": "<tier>"}, ...]}
108
+ metadata the computed fields of metadata.json (generate-output section
109
+ 6), with the libraries and pairs in the order given:
110
+ {"mode": "<mode>",
111
+ "library_count": N,
112
+ "integration_count": M,
113
+ "libraries": ["<library>", ...],
114
+ "integration_pairs": [["<a>", "<b>"], ...],
115
+ "confidence_distribution": {"t1": 0, "t1_low": 0, "t2": 0, "t3": 0},
116
+ "confidence_tier": "<dominant tier>",
117
+ "source_authority": "<authority>",
118
+ "integrations": [{"a": "<a>", "b": "<b>", "tier": "<tier>"}, ...]}
119
+ provenance writes provenance-map.json to --target atomically
120
+ (generate-output section 7), in the mode the extraction
121
+ bundle --bundle names: the object --input holds (the map's
122
+ provenance_version, skill_name, skill_type, source_repo,
123
+ source_commit, source_ref and generated_at) with `entries` set
124
+ to the records' entries (as they stand in code mode) and
125
+ `integrations` built
126
+ from the bundle, one per pair of its `integrations`: libraries
127
+ [a, b], pattern_type from its type, its detection_method,
128
+ co_import_files and confidence from its tier. Each replaces
129
+ the field of that name the input holds.
130
+ code mode takes --pairs, the skf-pair-intersect.py result
131
+ (detect-integrations section 1): a pair's co_import_files are
132
+ the files of its entry there, {path, line_a, line_b}, matched
133
+ on the two libraries in either order (line_a and line_b swap
134
+ when the bundle names the pair the other way round); a bundle
135
+ pair with no entry is an input error.
136
+ compose mode takes --inventory, step 4's
137
+ skf-enumerate-stack-skills.py result, --skills-root and
138
+ --project-root. Each entry takes its constituent's tier, the
139
+ confidence of the per_library_extractions[] entry whose library
140
+ is its source_library, as its confidence and signature_source,
141
+ and extraction_method compose-from-skill (an entry naming no
142
+ library of the bundle is an input error), so the records hold
143
+ only the cited fields. Its co_import_files are [], and it adds
144
+ `constituents`, one per per_library_extractions[] entry in its
145
+ order: skill_name its library, skill_path the folder
146
+ {skills-root}/{skill_dir} relative to the project root with a
147
+ trailing slash (absolute when no relative path reaches it; a
148
+ folder that does not exist is an input error), its version,
149
+ composed_at the input's generated_at, and metadata_hash copied
150
+ from the inventory's `skills[]` entry whose name is its
151
+ skill_dir, never computed. It prints
152
+ {"target": "<path>", "entry_count": N, "integration_count": M}
153
+ with "constituent_count": K added in compose mode.
154
+
155
+ CLI:
156
+ uv run skf-render-stack-metadata.py relabel --records export-records.json
157
+ uv run skf-render-stack-metadata.py library-tiers --records export-records.json --libraries "react,zod"
158
+ uv run skf-render-stack-metadata.py pair-tiers --input -
159
+ uv run skf-render-stack-metadata.py metadata --input stack.json
160
+ uv run skf-render-stack-metadata.py provenance --records export-records.json --bundle extraction-bundle.json \\
161
+ --pairs pair-intersect.json --input - --target provenance-map.json
162
+ uv run skf-render-stack-metadata.py provenance --records compose-entries.json --bundle extraction-bundle.json \\
163
+ --inventory stack-inventory.json --skills-root skills --project-root . --input - --target provenance-map.json
164
+
165
+ Exit codes:
166
+ 0 the JSON is printed (relabel: the records pass the label check)
167
+ 1 relabel only: a violation no rule fixes is left; the JSON lists it in
168
+ coherence.violations, and the file holds every label that was fixed
169
+ 2 a usage, read, write or input error, or skf-render-metadata-stats.py
170
+ missing beside this script for relabel: one line on stderr, no JSON,
171
+ nothing written
172
+ """
173
+
174
+ from __future__ import annotations
175
+
176
+ import argparse
177
+ import contextlib
178
+ import importlib.util
179
+ import json
180
+ import math
181
+ import os
182
+ import re
183
+ import secrets
184
+ import sys
185
+ import time
186
+ from pathlib import Path
187
+
188
+ # Tiers from the strongest to the weakest: T1-low is weaker than T1, T2 than
189
+ # T1-low, T3 than T2.
190
+ TIERS = ("T1", "T1-low", "T2", "T3")
191
+ MODES = ("code", "compose")
192
+ # The extraction_method of an export an ast-grep rule matched: the only one
193
+ # that keeps a code-mode library at T1.
194
+ AST_METHOD = "ast_bridge"
195
+ # The extraction_method of every compose-mode entry: copied from a constituent skill.
196
+ COMPOSE_METHOD = "compose-from-skill"
197
+ # Authorities from the highest to the lowest.
198
+ AUTHORITIES = ("official", "community", "internal")
199
+ # The authority of a library that records none.
200
+ DEFAULT_AUTHORITY = "community"
201
+
202
+ # The confidence_distribution bins of metadata.json, from the strongest tier
203
+ # to the weakest. dominant_tier reads them in this order and lets a later bin
204
+ # win a tie, so a tie resolves toward the weaker tier.
205
+ _DISTRIBUTION_BINS = (("t1", "T1"), ("t1_low", "T1-low"), ("t2", "T2"), ("t3", "T3"))
206
+
207
+ # The tier of a distribution that records no evidence (the conservative default).
208
+ _NO_EVIDENCE_TIER = "T1-low"
209
+
210
+ # The label check relabel runs: skf-render-metadata-stats.py beside this
211
+ # script (src/shared/scripts/ and _bmad/skf/shared/scripts/ alike).
212
+ _STATS_HELPER = "skf-render-metadata-stats.py"
213
+ # The stats helper's shape for a stack's records.
214
+ _STATS_SHAPE = "stack"
215
+ # The method a record takes when nothing shows an ast-grep rule matched it.
216
+ _READ_BY_EYE_METHOD = "source_reading"
217
+ # The extraction_method values that name an ast-grep match, and the label a
218
+ # record of each kind takes when it has no valid signature_source.
219
+ _AST_METHODS = ("ast_bridge", "ast-grep")
220
+ # The violation field of a record: provenance.entries[<index>].<field>.
221
+ _RECORD_FIELD_RE = re.compile(r"^provenance\.entries\[(\d+)\]\.(\w+)$")
222
+ # The `expected` value of an ast-grep record's missing node kind.
223
+ _NODE_KIND_EXPECTED = "non-null"
224
+ # Relabeling passes before relabel reports what is left: each pass fixes
225
+ # every violation it finds, so two or three passes settle every record.
226
+ _RELABEL_PASSES = 5
227
+ # Windows refuses to replace a file another process holds open, and a virus
228
+ # scanner or the search indexer may open a just-written file for a moment:
229
+ # _write_atomic retries the rename there for up to this long before failing.
230
+ _IS_WINDOWS = os.name == "nt"
231
+ _REPLACE_WAIT_SECONDS = 5.0
232
+ _REPLACE_POLL_SECONDS = 0.02
233
+
234
+
235
+ class InputError(ValueError):
236
+ """An input the caller must fix: exit 2 with one line on stderr."""
237
+
238
+
239
+ class WriteError(OSError):
240
+ """A file that could not be written: exit 2 with one line on stderr."""
241
+
242
+
243
+ def _token(value, allowed: tuple[str, ...], what: str) -> str:
244
+ """`value` in its canonical spelling from `allowed`, compared case-insensitively."""
245
+ if isinstance(value, str):
246
+ for token in allowed:
247
+ if value.strip().lower() == token.lower():
248
+ return token
249
+ raise InputError(f"{what} must be one of {', '.join(allowed)}; got {value!r}")
250
+
251
+
252
+ # --------------------------------------------------------------------------
253
+ # Rules
254
+ # --------------------------------------------------------------------------
255
+
256
+
257
+ def library_tier(methods: list[str]) -> str:
258
+ """A code-mode library's tier from the extraction_method of each of its export records.
259
+
260
+ T1 when there is at least one record and every one is ast_bridge, T1-low
261
+ otherwise: an export read by eye, or no export recorded, never earns T1.
262
+ """
263
+ return "T1" if methods and all(method == AST_METHOD for method in methods) else "T1-low"
264
+
265
+
266
+ def combine_pair_tier(a: str, b: str, mode: str) -> str:
267
+ """The tier of an integration between a library of tier `a` and one of tier `b`.
268
+
269
+ The weaker of the two, in both modes. Raises InputError on an unknown tier
270
+ or mode.
271
+ """
272
+ _token(mode, MODES, "mode")
273
+ return max(_token(a, TIERS, "tier"), _token(b, TIERS, "tier"), key=TIERS.index)
274
+
275
+
276
+ # Keep identical to _bin_count and dominant_tier in skf-enumerate-stack-skills.py
277
+ # (test/test-skf-render-stack-metadata.py pins the copies).
278
+ def _bin_count(value):
279
+ """A confidence_distribution bin's count: a positive finite number, else 0."""
280
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
281
+ return 0
282
+ if isinstance(value, float) and not math.isfinite(value):
283
+ return 0
284
+ return value if value > 0 else 0
285
+
286
+
287
+ def dominant_tier(distribution) -> str:
288
+ """The tier of the largest bin of a metadata.json confidence_distribution.
289
+
290
+ The bins are read from the strongest tier to the weakest and a later bin
291
+ wins a tie, so a tie resolves toward the weaker tier (T1-low over T1, T2
292
+ over T1-low, T3 over T2) and the tier never overstates confidence. A bin
293
+ counts only when it holds a positive number. T1-low when none does: the
294
+ distribution is absent, not an object or all zero.
295
+ """
296
+ tier, best = _NO_EVIDENCE_TIER, 0
297
+ if isinstance(distribution, dict):
298
+ for key, bin_tier in _DISTRIBUTION_BINS:
299
+ count = _bin_count(distribution.get(key))
300
+ if count and count >= best:
301
+ tier, best = bin_tier, count
302
+ return tier
303
+
304
+
305
+ def library_distribution(tiers: list[str]) -> dict[str, int]:
306
+ """confidence_distribution with each library once, in the bin of its tier."""
307
+ bin_of = {tier: key for key, tier in _DISTRIBUTION_BINS}
308
+ distribution = {key: 0 for key, _ in _DISTRIBUTION_BINS}
309
+ for tier in tiers:
310
+ distribution[bin_of[tier]] += 1
311
+ return distribution
312
+
313
+
314
+ def lowest_authority(authorities: list[str | None]) -> str:
315
+ """The lowest of the authorities, a library that records none (None)
316
+ counting as DEFAULT_AUTHORITY; DEFAULT_AUTHORITY for no library."""
317
+ return max((authority or DEFAULT_AUTHORITY for authority in authorities),
318
+ key=AUTHORITIES.index, default=DEFAULT_AUTHORITY)
319
+
320
+
321
+ # --------------------------------------------------------------------------
322
+ # Input and projections
323
+ # --------------------------------------------------------------------------
324
+
325
+
326
+ def parse_stack(data) -> tuple[str, list[dict], list[tuple[str, str]]]:
327
+ """(mode, libraries, pairs) from the input JSON. Raises InputError."""
328
+ if not isinstance(data, dict):
329
+ raise InputError(f"the input must be a JSON object; got {type(data).__name__}")
330
+ mode = _token(data.get("mode"), MODES, "mode")
331
+
332
+ raw_libraries = data.get("libraries")
333
+ if not isinstance(raw_libraries, list):
334
+ raise InputError("`libraries` must be an array of {name, confidence} objects")
335
+ libraries: list[dict] = []
336
+ tiers: dict[str, str] = {}
337
+ for i, item in enumerate(raw_libraries):
338
+ where = f"libraries[{i}]"
339
+ if not isinstance(item, dict):
340
+ raise InputError(f"{where} must be an object with `name` and `confidence`; got {item!r}")
341
+ name = item.get("name")
342
+ if not isinstance(name, str) or not name.strip():
343
+ raise InputError(f"{where}.name must be a non-empty string; got {name!r}")
344
+ if name in tiers:
345
+ raise InputError(f"library {name!r} is listed twice")
346
+ tier = _token(item.get("confidence"), TIERS, f"{where}.confidence")
347
+ authority = item.get("source_authority")
348
+ if authority is not None:
349
+ authority = _token(authority, AUTHORITIES, f"{where}.source_authority")
350
+ tiers[name] = tier
351
+ libraries.append({"name": name, "confidence": tier, "source_authority": authority})
352
+
353
+ raw_pairs = data.get("integrations", [])
354
+ if not isinstance(raw_pairs, list):
355
+ raise InputError("`integrations` must be an array of {a, b} objects")
356
+ pairs: list[tuple[str, str]] = []
357
+ seen: set[frozenset[str]] = set()
358
+ for i, item in enumerate(raw_pairs):
359
+ where = f"integrations[{i}]"
360
+ if not isinstance(item, dict):
361
+ raise InputError(f"{where} must be an object with `a` and `b`; got {item!r}")
362
+ a, b = item.get("a"), item.get("b")
363
+ for side, name in (("a", a), ("b", b)):
364
+ if not isinstance(name, str) or name not in tiers:
365
+ raise InputError(f"{where}.{side} {name!r} is not one of the libraries")
366
+ if a == b:
367
+ raise InputError(f"{where} pairs {a!r} with itself")
368
+ key = frozenset((a, b))
369
+ if key in seen:
370
+ raise InputError(f"the pair {a!r} + {b!r} is listed twice")
371
+ seen.add(key)
372
+ pairs.append((a, b))
373
+ return mode, libraries, pairs
374
+
375
+
376
+ def parse_library_names(value: str) -> list[str]:
377
+ """The comma-separated --libraries list, each name once. Raises InputError."""
378
+ names = [name.strip() for name in value.split(",") if name.strip()]
379
+ if not names:
380
+ raise InputError("--libraries must name at least one library")
381
+ seen: set[str] = set()
382
+ for name in names:
383
+ if name in seen:
384
+ raise InputError(f"library {name!r} is listed twice in --libraries")
385
+ seen.add(name)
386
+ return names
387
+
388
+
389
+ def library_tiers(data, names: list[str]) -> list[dict]:
390
+ """Each library of `names` with its tier and record counts, from the export records.
391
+
392
+ `data` is the parsed records file, {"entries": [...]}. Raises InputError
393
+ on a record that names no library, or one --libraries does not list.
394
+ """
395
+ methods: dict[str, list[str]] = {name: [] for name in names}
396
+ for i, record in enumerate(parse_records(data)):
397
+ where = f"entries[{i}]"
398
+ library = record.get("source_library")
399
+ if not isinstance(library, str) or library not in methods:
400
+ raise InputError(f"{where}.source_library {library!r} is not one of --libraries")
401
+ method = record.get("extraction_method")
402
+ methods[library].append(method.strip().lower() if isinstance(method, str) else "")
403
+ return [
404
+ {"name": name, "tier": library_tier(found), "export_count": len(found),
405
+ "ast_bridge_count": sum(method == AST_METHOD for method in found)}
406
+ for name, found in methods.items()
407
+ ]
408
+
409
+
410
+ def parse_records(data) -> list[dict]:
411
+ """The `entries` of an export records file, each an object. Raises InputError."""
412
+ if not isinstance(data, dict) or not isinstance(data.get("entries"), list):
413
+ raise InputError("the export records must be a JSON object with an `entries` array")
414
+ for i, record in enumerate(data["entries"]):
415
+ if not isinstance(record, dict):
416
+ raise InputError(f"entries[{i}] must be an object; got {record!r}")
417
+ return data["entries"]
418
+
419
+
420
+ # --------------------------------------------------------------------------
421
+ # Relabeling
422
+ # --------------------------------------------------------------------------
423
+
424
+
425
+ def _load_stats_rules():
426
+ """skf-render-metadata-stats.py beside this script, as a module. Raises ImportError."""
427
+ path = Path(__file__).resolve().parent / _STATS_HELPER
428
+ if not path.is_file():
429
+ raise ImportError(f"{_STATS_HELPER} not found beside {Path(__file__).name}: no label check can run")
430
+ spec = importlib.util.spec_from_file_location("skf_render_metadata_stats_rules", path)
431
+ if spec is None or spec.loader is None:
432
+ raise ImportError(f"{_STATS_HELPER} beside {Path(__file__).name} cannot be loaded")
433
+ module = importlib.util.module_from_spec(spec)
434
+ try:
435
+ spec.loader.exec_module(module)
436
+ except (OSError, SyntaxError) as exc:
437
+ raise ImportError(f"{_STATS_HELPER} beside {Path(__file__).name} cannot be loaded: {exc}") from exc
438
+ return module
439
+
440
+
441
+ def label_check(stats, entries: list[dict]) -> tuple[dict, dict]:
442
+ """(confidence_distribution, coherence) of the stats helper's stack check on the records."""
443
+ prov = {"entries": entries}
444
+ derived = stats.derive_stats(prov, {}, _STATS_SHAPE)
445
+ return derived["confidence_distribution"], stats.coherence_compute(derived, prov)
446
+
447
+
448
+ def _set(changes: dict, entries: list[dict], index: int, field: str, value) -> None:
449
+ """Set (or, for None on ast_node_type, remove) one field of a record and note the change."""
450
+ record = entries[index]
451
+ old = record.get(field)
452
+ if field == "ast_node_type" and value is None:
453
+ record.pop(field, None)
454
+ else:
455
+ record[field] = value
456
+ changes.setdefault(index, []).append({"field": field, "from": old, "to": value})
457
+
458
+
459
+ def _known_method(value, known: tuple[str, ...]) -> str | None:
460
+ """`value` as a known extraction_method in its canonical spelling, or None."""
461
+ if isinstance(value, str):
462
+ token = value.strip().lower()
463
+ if token in known:
464
+ return token
465
+ return None
466
+
467
+
468
+ def relabel_pass(stats, entries: list[dict], violations: list[dict], changes: dict) -> bool:
469
+ """Apply one fix per violation to the records in place. True when a label changed."""
470
+ changed = False
471
+ for violation in violations:
472
+ field = violation.get("field")
473
+ if field == "confidence_distribution":
474
+ # Records with no valid signature_source: each takes the label its method implies.
475
+ for index, record in enumerate(entries):
476
+ signature = record.get("signature_source")
477
+ if isinstance(signature, str) and signature.strip().upper() in stats._SIG_MAP:
478
+ continue
479
+ method = _known_method(record.get("extraction_method"), _AST_METHODS)
480
+ _set(changes, entries, index, "signature_source", "T1" if method else "T1-low")
481
+ changed = True
482
+ continue
483
+ match = _RECORD_FIELD_RE.match(field) if isinstance(field, str) else None
484
+ if match is None:
485
+ continue # no rule for it: it stays in coherence.violations
486
+ index, name = int(match.group(1)), match.group(2)
487
+ expected = violation.get("expected")
488
+ if name == "extraction_method":
489
+ method = _known_method(entries[index].get("extraction_method"), stats._KNOWN_METHODS)
490
+ _set(changes, entries, index, name, method or _READ_BY_EYE_METHOD)
491
+ elif name == "ast_node_type" and expected == _NODE_KIND_EXPECTED:
492
+ _set(changes, entries, index, "extraction_method", _AST_METHODS[0])
493
+ else:
494
+ _set(changes, entries, index, name, expected)
495
+ changed = True
496
+ return changed
497
+
498
+
499
+ def relabel(stats, entries: list[dict]) -> tuple[list[dict], dict, dict]:
500
+ """Relabel the records in place until they pass the label check.
501
+
502
+ Returns (relabeled, confidence_distribution, coherence), `relabeled`
503
+ holding each changed record once, in entries[] order, with its changes.
504
+ """
505
+ changes: dict[int, list[dict]] = {}
506
+ for _ in range(_RELABEL_PASSES):
507
+ _distribution, coherence = label_check(stats, entries)
508
+ if coherence["ok"] or not relabel_pass(stats, entries, coherence["violations"], changes):
509
+ break
510
+ distribution, coherence = label_check(stats, entries)
511
+ relabeled = [
512
+ {"entry_index": index, "export_name": entries[index].get("export_name"),
513
+ "source_library": entries[index].get("source_library"), "changes": found}
514
+ for index, found in sorted(changes.items())
515
+ ]
516
+ return relabeled, distribution, coherence
517
+
518
+
519
+ # --------------------------------------------------------------------------
520
+ # Provenance map
521
+ # --------------------------------------------------------------------------
522
+
523
+
524
+ def bundle_mode(bundle) -> str:
525
+ """The extraction bundle's `mode`, code or compose. Raises InputError."""
526
+ if not isinstance(bundle, dict):
527
+ raise InputError(f"the extraction bundle must be a JSON object; got {type(bundle).__name__}")
528
+ return _token(bundle.get("mode"), MODES, "the bundle's mode")
529
+
530
+
531
+ def pair_files(data) -> dict[frozenset[str], tuple[str, list[dict]]]:
532
+ """Each pair of a skf-pair-intersect.py result, keyed by its two libraries:
533
+ its `a` and its co-import files, each {path, line_a, line_b}.
534
+
535
+ Raises InputError on a result with no `pairs` array, or a pair that names
536
+ no two libraries or whose files are not {path, ...} objects.
537
+ """
538
+ if not isinstance(data, dict) or not isinstance(data.get("pairs"), list):
539
+ raise InputError("the pair-intersect result (--pairs) must be a JSON object with a `pairs` array")
540
+ out: dict[frozenset[str], tuple[str, list[dict]]] = {}
541
+ for i, pair in enumerate(data["pairs"]):
542
+ where = f"the pair-intersect result's pairs[{i}]"
543
+ if not isinstance(pair, dict):
544
+ raise InputError(f"{where} must be an object; got {pair!r}")
545
+ for key in ("a", "b"):
546
+ if not isinstance(pair.get(key), str) or not pair[key].strip():
547
+ raise InputError(f"{where}.{key} must be a non-empty string; got {pair.get(key)!r}")
548
+ files = pair.get("files")
549
+ if not isinstance(files, list) or not all(
550
+ isinstance(item, dict) and isinstance(item.get("path"), str) for item in files
551
+ ):
552
+ raise InputError(f"{where}.files must be an array of {{path, line_a, line_b}} objects")
553
+ out[frozenset((pair["a"], pair["b"]))] = (pair["a"], files)
554
+ return out
555
+
556
+
557
+ def _co_import_files(a: str, found: tuple[str, list[dict]]) -> list[dict]:
558
+ """A pair's co-import files with `line_a` the line that imports `a`: the
559
+ pair-intersect files as they stand, or with the two lines swapped when
560
+ that result names the pair the other way round."""
561
+ first, files = found
562
+ if first == a:
563
+ return files
564
+ return [dict(item, line_a=item.get("line_b"), line_b=item.get("line_a")) for item in files]
565
+
566
+
567
+ def bundle_integrations(bundle, pairs: dict | None = None) -> list[dict]:
568
+ """The provenance map's integrations[] from the extraction bundle's, in its order.
569
+
570
+ Each bundle pair {a, b, type, tier, detection_method, ...} gives
571
+ {libraries: [a, b], pattern_type, detection_method, co_import_files,
572
+ confidence}. With `pairs` (code mode, from pair_files) its co-import files
573
+ are its entry's there; without (compose mode) they are []. Raises
574
+ InputError on a bundle with no `integrations` array, a pair missing one of
575
+ those fields, or a code-mode pair `pairs` has no entry for.
576
+ """
577
+ if not isinstance(bundle, dict) or not isinstance(bundle.get("integrations"), list):
578
+ raise InputError("the extraction bundle must be a JSON object with an `integrations` array")
579
+ out = []
580
+ for i, pair in enumerate(bundle["integrations"]):
581
+ where = f"the bundle's integrations[{i}]"
582
+ if not isinstance(pair, dict):
583
+ raise InputError(f"{where} must be an object; got {pair!r}")
584
+ for key in ("a", "b", "type", "detection_method"):
585
+ if not isinstance(pair.get(key), str) or not pair[key].strip():
586
+ raise InputError(f"{where}.{key} must be a non-empty string; got {pair.get(key)!r}")
587
+ co_import_files: list[dict] = []
588
+ if pairs is not None:
589
+ found = pairs.get(frozenset((pair["a"], pair["b"])))
590
+ if found is None:
591
+ raise InputError(
592
+ f"{where} ({pair['a']} + {pair['b']}) has no entry in the pair-intersect result (--pairs)")
593
+ co_import_files = _co_import_files(pair["a"], found)
594
+ out.append({
595
+ "libraries": [pair["a"], pair["b"]],
596
+ "pattern_type": pair["type"],
597
+ "detection_method": pair["detection_method"],
598
+ "co_import_files": co_import_files,
599
+ "confidence": _token(pair.get("tier"), TIERS, f"{where}.tier"),
600
+ })
601
+ return out
602
+
603
+
604
+ def inventory_hashes(data) -> dict[str, str]:
605
+ """Each skill's metadata_hash in a skf-enumerate-stack-skills.py result, by name.
606
+
607
+ Raises InputError on a result with no `skills` array. A skill without a
608
+ string hash is left out, so a constituent naming it is refused.
609
+ """
610
+ if not isinstance(data, dict) or not isinstance(data.get("skills"), list):
611
+ raise InputError("the stack inventory (--inventory) must be a JSON object with a `skills` array")
612
+ return {
613
+ skill["name"]: skill["metadata_hash"]
614
+ for skill in data["skills"]
615
+ if isinstance(skill, dict) and isinstance(skill.get("name"), str)
616
+ and isinstance(skill.get("metadata_hash"), str) and skill["metadata_hash"].strip()
617
+ }
618
+
619
+
620
+ def _skill_path(skills_root: str, skill_dir: str, project_root: str) -> str:
621
+ """The skill's folder, {skills_root}/{skill_dir}, relative to the project
622
+ root, forward-slash, with a trailing slash; absolute when no relative
623
+ path reaches it (another drive on Windows)."""
624
+ folder = os.path.abspath(os.path.join(skills_root, skill_dir))
625
+ try:
626
+ path = os.path.relpath(folder, os.path.abspath(project_root))
627
+ except ValueError:
628
+ path = folder
629
+ return Path(path).as_posix().rstrip("/") + "/"
630
+
631
+
632
+ def compose_constituents(bundle, hashes: dict[str, str], skills_root: str, project_root: str,
633
+ composed_at) -> list[dict]:
634
+ """The compose-mode map's constituents[], one per per_library_extractions[] entry, in its order.
635
+
636
+ skill_name is the entry's library, skill_path its skill folder (see
637
+ _skill_path), version its version, composed_at the map's generated_at
638
+ and metadata_hash the hash step 4's inventory recorded for its skill_dir,
639
+ copied, never computed. Raises InputError on a missing field, a skill
640
+ folder that does not exist, or a skill_dir the inventory does not hold.
641
+ """
642
+ if not isinstance(composed_at, str) or not composed_at.strip():
643
+ raise InputError(f"compose mode takes composed_at from the input's generated_at, "
644
+ f"a non-empty string; got {composed_at!r}")
645
+ entries = bundle.get("per_library_extractions")
646
+ if not isinstance(entries, list):
647
+ raise InputError("the extraction bundle must have a `per_library_extractions` array")
648
+ out = []
649
+ for i, entry in enumerate(entries):
650
+ where = f"the bundle's per_library_extractions[{i}]"
651
+ if not isinstance(entry, dict):
652
+ raise InputError(f"{where} must be an object; got {entry!r}")
653
+ for key in ("library", "skill_dir"):
654
+ if not isinstance(entry.get(key), str) or not entry[key].strip():
655
+ raise InputError(f"{where}.{key} must be a non-empty string; got {entry.get(key)!r}")
656
+ skill_dir, version = entry["skill_dir"], entry.get("version")
657
+ if skill_dir in (".", "..") or "/" in skill_dir or "\\" in skill_dir:
658
+ raise InputError(f"{where}.skill_dir must be a folder name; got {skill_dir!r}")
659
+ if version is not None and not isinstance(version, str):
660
+ raise InputError(f"{where}.version must be a string or null; got {version!r}")
661
+ if not os.path.isdir(os.path.join(skills_root, skill_dir)):
662
+ raise InputError(f"{where}: no skill folder {skill_dir!r} under --skills-root {skills_root}")
663
+ if skill_dir not in hashes:
664
+ raise InputError(f"{where}: the stack inventory (--inventory) holds no metadata_hash for {skill_dir!r}")
665
+ out.append({
666
+ "skill_name": entry["library"],
667
+ "skill_path": _skill_path(skills_root, skill_dir, project_root),
668
+ "version": version,
669
+ "composed_at": composed_at,
670
+ "metadata_hash": hashes[skill_dir],
671
+ })
672
+ return out
673
+
674
+
675
+ def compose_entries(bundle, entries: list[dict]) -> list[dict]:
676
+ """The compose-mode map's entries: each record with confidence and
677
+ signature_source set to its constituent's tier, the confidence of the
678
+ per_library_extractions[] entry whose library is its source_library, and
679
+ extraction_method set to COMPOSE_METHOD.
680
+
681
+ Raises InputError on a record naming no library of the bundle, or a
682
+ constituent whose confidence is not a tier.
683
+ """
684
+ tiers: dict[str, tuple[object, str]] = {}
685
+ for i, entry in enumerate(bundle.get("per_library_extractions") or []):
686
+ if isinstance(entry, dict) and isinstance(entry.get("library"), str):
687
+ tiers[entry["library"]] = (entry.get("confidence"),
688
+ f"the bundle's per_library_extractions[{i}].confidence")
689
+ out = []
690
+ for i, record in enumerate(entries):
691
+ library = record.get("source_library")
692
+ if not isinstance(library, str) or library not in tiers:
693
+ raise InputError(f"entries[{i}].source_library {library!r} is not a library of the bundle's "
694
+ f"per_library_extractions")
695
+ value, where = tiers[library]
696
+ tier = _token(value, TIERS, where)
697
+ out.append(dict(record, confidence=tier, extraction_method=COMPOSE_METHOD, signature_source=tier))
698
+ return out
699
+
700
+
701
+ def provenance_map(base, entries: list[dict], integrations: list[dict] | None = None,
702
+ constituents: list[dict] | None = None) -> dict:
703
+ """The provenance map: `base` with `entries` set to the records and, when
704
+ given, `integrations` set to the bundle's and `constituents` to the
705
+ compose-mode constituents.
706
+
707
+ The entries take the place of any `entries` in `base`, or go before its
708
+ `integrations` (the schema's order), or last; the given integrations and
709
+ constituents take the place of any in `base`, or go last, in that order.
710
+ Raises InputError when `base` is not an object.
711
+ """
712
+ if not isinstance(base, dict):
713
+ raise InputError(f"the provenance map input must be a JSON object; got {type(base).__name__}")
714
+ fields = dict(base)
715
+ if integrations is not None:
716
+ fields["integrations"] = integrations
717
+ if constituents is not None:
718
+ fields["constituents"] = constituents
719
+ out: dict = {}
720
+ for key, value in fields.items():
721
+ if key == "entries" or (key == "integrations" and "entries" not in fields):
722
+ out["entries"] = entries
723
+ if key == "entries":
724
+ continue
725
+ out[key] = value
726
+ out.setdefault("entries", entries)
727
+ return out
728
+
729
+
730
+ def pair_tiers(mode: str, libraries: list[dict], pairs: list[tuple[str, str]]) -> list[dict]:
731
+ """Each pair with the tier combine_pair_tier gives it."""
732
+ tier = {library["name"]: library["confidence"] for library in libraries}
733
+ return [{"a": a, "b": b, "tier": combine_pair_tier(tier[a], tier[b], mode)} for a, b in pairs]
734
+
735
+
736
+ def stack_metadata(mode: str, libraries: list[dict], pairs: list[tuple[str, str]]) -> dict:
737
+ """The computed fields of a stack's metadata.json."""
738
+ distribution = library_distribution([library["confidence"] for library in libraries])
739
+ return {
740
+ "mode": mode,
741
+ "library_count": len(libraries),
742
+ "integration_count": len(pairs),
743
+ "libraries": [library["name"] for library in libraries],
744
+ "integration_pairs": [[a, b] for a, b in pairs],
745
+ "confidence_distribution": distribution,
746
+ "confidence_tier": dominant_tier(distribution),
747
+ "source_authority": lowest_authority([library["source_authority"] for library in libraries]),
748
+ "integrations": pair_tiers(mode, libraries, pairs),
749
+ }
750
+
751
+
752
+ # --------------------------------------------------------------------------
753
+ # CLI
754
+ # --------------------------------------------------------------------------
755
+
756
+
757
+ def _read_input(source: str):
758
+ """The parsed JSON of a file path, or of stdin for '-'. Raises InputError."""
759
+ try:
760
+ text = sys.stdin.read() if source == "-" else Path(source).read_text(encoding="utf-8")
761
+ except (OSError, UnicodeDecodeError) as exc:
762
+ raise InputError(f"cannot read the input {source}: {exc}") from exc
763
+ try:
764
+ return json.loads(text)
765
+ except json.JSONDecodeError as exc:
766
+ raise InputError(f"the input is not valid JSON: {exc}") from exc
767
+
768
+
769
+ def _write_atomic(path: Path, data: dict) -> None:
770
+ """Write `data` as JSON to `path` atomically: a temp file of its own beside
771
+ it, then a rename. Raises WriteError, leaving `path` as it was."""
772
+ tmp = path.with_name(f"{path.name}.{os.getpid()}-{secrets.token_hex(4)}.skf-tmp")
773
+ payload = (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
774
+ # O_EXCL: never write into another writer's temp file. O_BINARY (Windows
775
+ # only; 0 elsewhere) keeps the \n line ends.
776
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
777
+ try:
778
+ path.parent.mkdir(parents=True, exist_ok=True)
779
+ fd = os.open(tmp, flags, 0o644)
780
+ except OSError as exc:
781
+ raise WriteError(f"cannot write {path}: {exc.strerror or exc}") from exc
782
+ try:
783
+ with open(fd, "wb") as fh:
784
+ fh.write(payload)
785
+ fh.flush()
786
+ os.fsync(fh.fileno())
787
+ deadline = time.monotonic() + _REPLACE_WAIT_SECONDS
788
+ while True:
789
+ try:
790
+ os.replace(tmp, path)
791
+ break
792
+ except PermissionError:
793
+ if not _IS_WINDOWS or time.monotonic() >= deadline:
794
+ raise
795
+ time.sleep(_REPLACE_POLL_SECONDS)
796
+ except BaseException as exc:
797
+ with contextlib.suppress(OSError):
798
+ tmp.unlink()
799
+ if isinstance(exc, OSError):
800
+ raise WriteError(f"cannot write {path}: {exc.strerror or exc}") from exc
801
+ raise
802
+
803
+
804
+ def _records_file(value: str, command: str, flag: str = "--records") -> str:
805
+ """The path a command reads from a file, never stdin (--records, or --bundle)."""
806
+ if value == "-":
807
+ raise InputError(f"{command} reads {flag} from a file, not stdin")
808
+ return value
809
+
810
+
811
+ def _cmd_relabel(args: argparse.Namespace) -> tuple[dict, int]:
812
+ stats = _load_stats_rules()
813
+ data = _read_input(_records_file(args.records, "relabel"))
814
+ entries = parse_records(data)
815
+ relabeled, distribution, coherence = relabel(stats, entries)
816
+ if relabeled:
817
+ _write_atomic(Path(args.records), data)
818
+ result = {"relabeled": relabeled, "confidence_distribution": distribution, "coherence": coherence}
819
+ return result, 0 if coherence["ok"] else 1
820
+
821
+
822
+ def _cmd_library_tiers(args: argparse.Namespace) -> dict:
823
+ names = parse_library_names(args.libraries)
824
+ return {"libraries": library_tiers(_read_input(args.records), names)}
825
+
826
+
827
+ def _cmd_pair_tiers(args: argparse.Namespace) -> dict:
828
+ mode, libraries, pairs = parse_stack(_read_input(args.input))
829
+ return {"mode": mode, "integrations": pair_tiers(mode, libraries, pairs)}
830
+
831
+
832
+ def _cmd_metadata(args: argparse.Namespace) -> dict:
833
+ return stack_metadata(*parse_stack(_read_input(args.input)))
834
+
835
+
836
+ def _cmd_provenance(args: argparse.Namespace) -> dict:
837
+ entries = parse_records(_read_input(_records_file(args.records, "provenance")))
838
+ bundle = _read_input(_records_file(args.bundle, "provenance", "--bundle"))
839
+ mode = bundle_mode(bundle)
840
+ compose_flags = {"--inventory": args.inventory, "--skills-root": args.skills_root,
841
+ "--project-root": args.project_root}
842
+ if mode == "code":
843
+ given = [flag for flag, value in compose_flags.items() if value is not None]
844
+ if given:
845
+ raise InputError(f"{', '.join(given)} is for a compose-mode bundle; this bundle's mode is code")
846
+ if args.pairs is None:
847
+ raise InputError("a code-mode bundle needs --pairs, the skf-pair-intersect.py result")
848
+ pairs = pair_files(_read_input(_records_file(args.pairs, "provenance", "--pairs")))
849
+ elif args.pairs is not None:
850
+ raise InputError("--pairs is for a code-mode bundle; this bundle's mode is compose")
851
+ else:
852
+ missing = [flag for flag, value in compose_flags.items() if value is None]
853
+ if missing:
854
+ raise InputError(f"a compose-mode bundle needs {', '.join(missing)}")
855
+ pairs = None
856
+ base = _read_input(args.input)
857
+ if not isinstance(base, dict):
858
+ raise InputError(f"the provenance map input must be a JSON object; got {type(base).__name__}")
859
+ integrations = bundle_integrations(bundle, pairs)
860
+ constituents = None
861
+ if mode == "compose":
862
+ hashes = inventory_hashes(_read_input(_records_file(args.inventory, "provenance", "--inventory")))
863
+ constituents = compose_constituents(bundle, hashes, args.skills_root, args.project_root,
864
+ base.get("generated_at"))
865
+ entries = compose_entries(bundle, entries)
866
+ target = Path(args.target)
867
+ _write_atomic(target, provenance_map(base, entries, integrations, constituents))
868
+ result = {"target": str(target), "entry_count": len(entries), "integration_count": len(integrations)}
869
+ if constituents is not None:
870
+ result["constituent_count"] = len(constituents)
871
+ return result
872
+
873
+
874
+ def _build_parser() -> argparse.ArgumentParser:
875
+ parser = argparse.ArgumentParser(
876
+ prog="skf-render-stack-metadata",
877
+ description=(
878
+ "Relabel a code-mode stack's export records against the label check of "
879
+ "skf-render-metadata-stats.py, compute its library tiers (T1 only when an "
880
+ "ast-grep rule matched every export), its pair tiers (the weaker of the "
881
+ "two libraries' tiers, in both modes) and the counted and ranked fields "
882
+ "of its metadata.json: counts, libraries, integration_pairs, "
883
+ "confidence_distribution (each library once), confidence_tier (the "
884
+ "dominant tier) and source_authority (the lowest, community for a "
885
+ "library that records none), and write its provenance map from the "
886
+ "records, the extraction bundle and, in code mode, the pair-intersect "
887
+ "result or, in compose mode, the stack inventory."
888
+ ),
889
+ )
890
+ sub = parser.add_subparsers(dest="command", required=True)
891
+ text = ("relabel the export records against the label check, rewrite the file when a "
892
+ "label changed, and print the relabeled exports and the label counts as JSON")
893
+ command = sub.add_parser("relabel", help=text, description=text)
894
+ command.add_argument(
895
+ "--records", required=True, help="path to the export records JSON ({entries: [...]}), rewritten in place")
896
+ command.set_defaults(func=_cmd_relabel)
897
+ text = "print each library's tier from its checked export records as JSON"
898
+ command = sub.add_parser("library-tiers", help=text, description=text)
899
+ command.add_argument(
900
+ "--records",
901
+ required=True,
902
+ help="path to the export records JSON ({entries: [...]}), or '-' for stdin",
903
+ )
904
+ command.add_argument(
905
+ "--libraries",
906
+ required=True,
907
+ help="the stack's libraries, comma-separated; a library with no record is T1-low",
908
+ )
909
+ command.set_defaults(func=_cmd_library_tiers)
910
+ for name, func, text in (
911
+ ("pair-tiers", _cmd_pair_tiers, "print the tier of each integration pair as JSON"),
912
+ ("metadata", _cmd_metadata, "print the computed metadata.json fields of the stack as JSON"),
913
+ ):
914
+ command = sub.add_parser(name, help=text, description=text)
915
+ command.add_argument(
916
+ "--input",
917
+ required=True,
918
+ help="path to the stack JSON ({mode, libraries, integrations}), or '-' for stdin",
919
+ )
920
+ command.set_defaults(func=func)
921
+ text = ("write provenance-map.json from the run's files: its entries and integrations, "
922
+ "and its constituents in compose mode")
923
+ command = sub.add_parser("provenance", help=text, description=text)
924
+ command.add_argument("--records", required=True, help="path to the export records JSON ({entries: [...]})")
925
+ command.add_argument(
926
+ "--bundle", required=True,
927
+ help="path to the extraction bundle JSON, whose mode and integrations[] the map takes")
928
+ command.add_argument(
929
+ "--pairs", help="code mode: path to the skf-pair-intersect.py result, which holds each pair's co-import files")
930
+ command.add_argument(
931
+ "--inventory",
932
+ help="compose mode: path to step 4's skf-enumerate-stack-skills.py result, which holds each metadata_hash")
933
+ command.add_argument("--skills-root", help="compose mode: the skills output folder that holds each constituent")
934
+ command.add_argument("--project-root", help="compose mode: the project root each skill_path is relative to")
935
+ command.add_argument(
936
+ "--input",
937
+ required=True,
938
+ help="path to the map's other fields as JSON (an object), or '-' for stdin",
939
+ )
940
+ command.add_argument("--target", required=True, help="the provenance-map.json to write")
941
+ command.set_defaults(func=_cmd_provenance)
942
+ return parser
943
+
944
+
945
+ def main(argv: list[str] | None = None) -> int:
946
+ args = _build_parser().parse_args(argv)
947
+ try:
948
+ result = args.func(args)
949
+ except (InputError, ImportError, WriteError) as exc:
950
+ print(f"error: {' '.join(str(exc).split())}", file=sys.stderr)
951
+ return 2
952
+ result, code = result if isinstance(result, tuple) else (result, 0)
953
+ # ASCII escapes: a Windows console's code page cannot encode every name.
954
+ sys.stdout.write(json.dumps(result, indent=2) + "\n")
955
+ return code
956
+
957
+
958
+ def _force_utf8(*streams) -> None:
959
+ """Reconfigure the standard streams to UTF-8, keeping each one's error handler.
960
+
961
+ A Windows console pipes them as cp1252, which cannot carry every character
962
+ a library name or an error message may hold. stdin is reconfigured before
963
+ its first read, so `--input -` reads the UTF-8 JSON a step pipes.
964
+ """
965
+ for stream in streams:
966
+ if hasattr(stream, "reconfigure"):
967
+ errors = getattr(stream, "errors", None)
968
+ if errors is None:
969
+ stream.reconfigure(encoding="utf-8")
970
+ else:
971
+ stream.reconfigure(encoding="utf-8", errors=errors)
972
+
973
+
974
+ if __name__ == "__main__":
975
+ _force_utf8(sys.stdin, sys.stdout, sys.stderr)
976
+ sys.exit(main())