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,1280 @@
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = ["pyyaml"]
4
+ # ///
5
+ """SKF Extraction Inventory: write, extend and read create-skill's extraction inventory on disk.
6
+
7
+ skf-create-skill step 3 keeps its extraction inventory as JSON beside the
8
+ staging folder (`_bmad-output/.skf-stage/{skill-name}.inventory.json`), so
9
+ the exports, their citations and the extraction rules survive a context
10
+ compaction between extraction and compile. `init` seeds it from the recipe
11
+ runner's JSON and the script/asset detector's JSON, so the model never
12
+ types a record those tools already wrote; the model then sends only what it
13
+ produced itself (`patch`, `add`, `set`). Step 3c adds the T3 items it
14
+ extracts from fetched documentation, step 4 the T2 annotations it finds in
15
+ QMD, compile writes the provenance map (`provenance`) and metadata.json
16
+ (`metadata`) from it, and step 7 writes `extraction-rules.yaml` from it
17
+ (`rules`). Every write goes through a temporary file and one rename.
18
+
19
+ Subcommands:
20
+
21
+ init --inventory <file> --skill <name>
22
+ --mode source|docs-only|component-library
23
+ --tier Quick|Forge|Forge+|Deep
24
+ [--extraction <runner JSON>] [--detected <detector JSON>]
25
+ Write a new inventory, replacing any file at --inventory. From
26
+ --extraction (skf-extract-public-api.py --mode full): every
27
+ export as the runner recorded it (confidence T1, extraction_method
28
+ ast-grep), with `internal: true` on each one its
29
+ entry_point_diff.internal lists; files_scanned (its
30
+ files_in_scope); aggregates, counts and arms; extraction_rules
31
+ (recipe_set, the recipes ids, scope, ast_grep_version); and the
32
+ runner warnings as lines (the head cap, each errors[] item, each
33
+ file_issues[] file, each files_without_recipes extension, each
34
+ warnings[] entry). From --detected (skf-detect-scripts-assets.py
35
+ detect): scripts_inventory and assets_inventory.
36
+ Public surface. When the runner's scope.type is public-api (in
37
+ any case) and its entry-point diff can be read, each export of a
38
+ language family whose entry_points.by_language status is
39
+ `barrel` is marked `public: true` when its name and file are a
40
+ pair of the diff's `public` (its name, or the name `local` gives
41
+ it in that file) or `extraction_gaps`, or when a `public` item of
42
+ its name and family has no file; `public: false` otherwise. A
43
+ `public` item with `via` namespace and a `members` list (what
44
+ the submodule passes on, which users reach as `pkg.sub.name`)
45
+ gives its members' pairs (each member's name, or its `local`
46
+ one, and its file; a member with no file its name) in place of
47
+ its own; one whose `members` is null (a module the runner could
48
+ not read), or that has no `members` key (an older runner's),
49
+ gives its own pair, as any item does. It is the rule
50
+ skf-build-change-manifest.py's `records` marks an update's
51
+ exports by (that script loads it from here). The rule's inputs
52
+ are kept as `public_surface`, so `add` marks an export read by
53
+ eye the same way. Any other scope type, and a family with no
54
+ barrel, mark nothing. A public-api run with no entry-point diff,
55
+ an incomplete one, one with errors or one whose
56
+ entry_points.unresolved is not empty marks nothing either
57
+ (`public_surface` null), with one warning that says why: every
58
+ export is then documented. One warning names the exports marked
59
+ `public: false`, each as `name (file)` (the first ten, then how
60
+ many more).
61
+
62
+ add --inventory <file> --field exports|t2_annotations|t3_items|warnings
63
+ Read a JSON list on stdin (objects; strings for warnings) and
64
+ append it to that list. An entry already in the list (equal
65
+ JSON; for exports, the same export_name and source_file) is not
66
+ added twice and counts in `duplicates`. An exports entry needs an
67
+ export_name; a missing confidence, extraction_method,
68
+ ast_node_type or ast_recipe defaults to T1-low, source-read,
69
+ null and null (an export read by eye). Its source_file is
70
+ relative to the source root, written and compared with `/`
71
+ separators and no leading `./`, as the runner writes its files
72
+ (`./pkg/api.py` and `pkg\\api.py` are `pkg/api.py`); an absolute
73
+ one (a leading `/` or a drive letter) is refused, and the call
74
+ adds nothing. An exports entry is
75
+ marked `public` by the inventory's `public_surface`, as `init`
76
+ marks the runner's (a `public` key the payload sends is not
77
+ kept), and the warning naming the exports marked `public: false`
78
+ is written again, in its place. A t3_items entry whose
79
+ export_name names an export the inventory already holds is
80
+ dropped, not added: T3 content never overrides a T1, T1-low or
81
+ T2 entry for the same export.
82
+
83
+ patch --inventory <file>
84
+ Read a JSON list of objects on stdin, each an export_name, an
85
+ optional source_file, and any of signature, params and
86
+ return_type, and set those fields on the export it names (by
87
+ export_name and source_file; by export_name alone when no
88
+ source_file is given). A name that matches no export is listed
89
+ in `unmatched`, one that matches several in `ambiguous`; neither
90
+ changes anything. params lists each parameter as the runner
91
+ records one, {name, type, default, optional}, or as a typed
92
+ string ('userId: string'). The source_file is compared as `add`
93
+ compares it: `./pkg/api.py` matches the export at `pkg/api.py`.
94
+
95
+ set --inventory <file>
96
+ Read a JSON object on stdin and set each of its keys: top_exports,
97
+ co_imports, promoted_docs, component_catalog (lists),
98
+ authoritative_files_scan (an object or null), files_scanned (an
99
+ integer), counts and arms (objects, merged into what is there)
100
+ and intent_mapping. intent_mapping maps `scripts` and `assets`
101
+ to {"intent": "<free text>", "kept": [source_file, ...]}: the
102
+ inventory keeps only the kept entries of that list and records
103
+ the others in the mapping's `left_out`. A top_exports name every
104
+ export of which is marked `public: false` is not kept and is
105
+ listed in `dropped`.
106
+
107
+ provenance --inventory <file> --target <provenance-map.json> [--add-entries]
108
+ Write the provenance map to --target, so compile never types an
109
+ entry the inventory holds. stdin holds the map's header, a JSON
110
+ object: source_repo and source_commit (required, null allowed),
111
+ and source_ref (default null), provenance_version (default
112
+ "2.0"), skill_name (default the inventory's), skill_type
113
+ (default "single") and generated_at (default now, ISO-8601
114
+ UTC); any other key is refused. entries[] holds the entry of
115
+ every export of the inventory, in its order, but one marked
116
+ `public: false` (off a public-api skill's public surface, which
117
+ neither entries[] nor `unsigned` lists): export_name,
118
+ export_type, params, return_type, source_file, source_line,
119
+ confidence, extraction_method, ast_node_type and
120
+ signature_source, labeled by the tool that found it. An export
121
+ an ast-grep rule matched is T1 (extraction_method ast-grep, the
122
+ ast_node_type its recipe declares, signature_source T1); any
123
+ other was read by eye and is T1-low (extraction_method
124
+ source-read, ast_node_type null, signature_source T1-low).
125
+ params holds the map's typed strings, one per parameter the
126
+ runner (or a patch) recorded: `name: type`, `name?: type` for a
127
+ JS/TS parameter that is optional and has no default,
128
+ ` = default` after a default, and the type alone for a
129
+ parameter with no name; a string a patch or an add sent is kept
130
+ as it is. params and return_type are null on an export whose
131
+ params is null: any export but a function, or a function nobody
132
+ read the signature of, which `unsigned` lists (patch them and
133
+ run provenance again). The inventory is not changed.
134
+ With --add-entries, stdin holds instead a JSON object with an
135
+ `entries` list, a `file_entries` list or both, appended to the
136
+ map at --target: the entries the inventory does not hold (T2 and
137
+ T3) and the file entries. An entry whose export_name and
138
+ source_file the map already holds, or a file entry whose
139
+ file_name it holds, is not added and counts in `duplicates`, so
140
+ an inventory export's entry is never replaced. An entry whose
141
+ export_name and source_file are an inventory export's marked
142
+ `public: false` is not added either and is listed in
143
+ `not_public`, so the map never holds an export off the surface.
144
+
145
+ metadata --inventory <file> --brief <skill-brief.yaml> --stats <file>
146
+ --skf-root <dir> --target <metadata.json>
147
+ Write the skill's metadata.json to --target, so compile types
148
+ only the fields it decides. stdin holds them, a JSON object:
149
+ version and language (non-empty strings), source_root,
150
+ source_commit, source_ref and qmd_version (strings or null),
151
+ each required, and ast_grep_version (a string or null, default
152
+ null) and stats_extra (an object, default {}); any other key is
153
+ refused. The rest comes from the files:
154
+ name, scope_type (the brief's scope.type, verbatim), source_repo
155
+ and source_authority (default "community") from --brief;
156
+ description from the frontmatter of the SKILL.md beside
157
+ --target (compile's staged one); confidence_tier (the
158
+ inventory's tier); exports[], the names of the inventory's
159
+ exports but one marked `internal: true` or `public: false`,
160
+ each once, in its order (a docs-only inventory's t3_items
161
+ names; none for a reference-app brief); ast_node_count and
162
+ tool_versions.ast_grep, only when ast-grep ran: when the
163
+ runner ran it (extraction_rules.ast_grep_version is set), the
164
+ runner's aggregates.exports and that version; otherwise, when
165
+ exports carry extraction_method ast-grep (the recipes run
166
+ without the runner), their number and stdin's
167
+ ast_grep_version, else "unknown" with a warning (stdin's
168
+ ast_grep_version is read in that case only);
169
+ tool_versions.ast_grep is null when ast-grep did not run, and
170
+ ast_node_count left out; scripts[] and assets[], {file:
171
+ scripts/<name> or assets/<name>, purpose, source_file,
172
+ confidence} per scripts_inventory or assets_inventory entry,
173
+ left out when it is empty; and stats and
174
+ confidence_distribution verbatim from --stats (the JSON
175
+ skf-render-metadata-stats.py wrote), with stats_extra's keys
176
+ added: a component-library brief's own stats, or a
177
+ reference-app brief's notes alone (another scope type takes
178
+ none, and a key the stats helper wrote is refused). It stamps
179
+ generation_date (UTC, to the second), reads tool_versions.skf
180
+ under --skf-root (the installed module) as
181
+ skf-render-quick-metadata.py does (package.json's version, else
182
+ the VERSION file's first line, else "unknown", with a warning),
183
+ and writes skill_type "single", spec_version "1.3",
184
+ generated_by "create-skill", dependencies [] and compatibility
185
+ "". The inventory is not changed.
186
+
187
+ rules --inventory <file> --language <language> --target <file>
188
+ Write extraction-rules.yaml (the skill, its language, tier and
189
+ extraction mode, and the inventory's extraction_rules) to
190
+ --target. The inventory is not changed.
191
+
192
+ summary --inventory <file>
193
+ Print the inventory's counts without changing it, and in
194
+ `not_public` each export marked `public: false` ({name, file}),
195
+ which the evidence report lists.
196
+
197
+ Inventory shape (the keys this helper reads or writes):
198
+
199
+ {
200
+ "skill_name": "...", "extraction_mode": "...", "tier": "...",
201
+ "files_scanned": N | null,
202
+ "exports": [{"export_name": "...", "source_file": "...", ...}, ...],
203
+ "top_exports": ["name", ...],
204
+ "aggregates": {...}, "counts": {...}, "arms": {...},
205
+ "scripts_inventory": [...], "assets_inventory": [...],
206
+ "intent_mapping": {"scripts": {"intent", "kept", "left_out"}, ...},
207
+ "co_imports": [...], "promoted_docs": [...],
208
+ "authoritative_files_scan": {...} | null,
209
+ "extraction_rules": {"recipe_set", "recipes", "scope", "ast_grep_version"},
210
+ "public_surface": {"by_language": {...}, "public": [...],
211
+ "extraction_gaps": [...]} | null,
212
+ "t2_annotations": [...], "t3_items": [...], "warnings": ["..."]
213
+ }
214
+
215
+ An export may carry `internal: true` (the runner's entry_point_diff.internal
216
+ lists it) and `public: true` or `public: false` (see init's Public surface).
217
+
218
+ Output (stdout, exit 0), for every subcommand but rules, provenance and
219
+ metadata:
220
+
221
+ {
222
+ "status": "ok",
223
+ "inventory": "<path, / separators>",
224
+ "extraction_mode": "...",
225
+ "counts": {"files_scanned": N | null, "exports": N, "not_public": N,
226
+ "t1": N, "t1_low": N, "by_type": {"<export_type>": N, ...},
227
+ "top_exports": N, "co_imports": N, "scripts": N,
228
+ "assets": N, "t2_annotations": N, "t2_past": N,
229
+ "t2_future": N, "functions_enriched": N, "t3_items": N,
230
+ "items": N},
231
+ "added": N, "duplicates": N, "dropped": ["name"], # add
232
+ "patched": N, "unmatched": [...], "ambiguous": [...], # patch
233
+ "set": ["key", ...], "dropped": ["name"], # set
234
+ "not_public": [{"name", "file"}, ...] # summary
235
+ }
236
+
237
+ not_public counts the exports marked `public: false`, which the provenance
238
+ map leaves out: when it equals `exports`, the map holds no export. `items`
239
+ is exports plus t3_items, less not_public: what compile can document. t1
240
+ and t1_low count the exports by their `confidence`, by_type by their
241
+ `export_type`.
242
+ t2_past and t2_future count the T2 annotations by their `temporal`, and
243
+ functions_enriched the distinct export_name values among them; an
244
+ annotation of a name every export of which is marked `public: false`
245
+ counts in none of t2_annotations, t2_past, t2_future and
246
+ functions_enriched. rules
247
+ prints {"status": "ok", "target": "<path>", "bytes": N}, provenance
248
+ {"status": "ok", "target": "<path>", "entries": N, "unsigned": ["name
249
+ (file)", ...]}, and provenance --add-entries {"status": "ok", "target":
250
+ "<path>", "entries": N, "file_entries": N, "added": N, "duplicates": N,
251
+ "not_public": [{"name", "file"}, ...]}, the counts of the map after the
252
+ merge, and metadata {"status": "ok", "target": "<path>", "exports": N,
253
+ "commit_short": "<source_commit's first 8 characters, or unknown>",
254
+ "tool_versions": {...}, "warnings": [...]}.
255
+
256
+ Exit codes:
257
+ 0 success
258
+ 1 input error, nothing changed: an inventory that is missing, not JSON
259
+ or has no exports list, an --extraction, --detected, --brief or
260
+ --stats file that cannot be read, a provenance map at --target that
261
+ --add-entries cannot read, a staged SKILL.md with no frontmatter
262
+ description, a stdin payload of the wrong shape, an exports entry
263
+ whose source_file is absolute, an unknown key or a kept file the
264
+ inventory does not list, or a write that failed
265
+ 2 usage error (argparse)
266
+ """
267
+
268
+ from __future__ import annotations
269
+
270
+ import argparse
271
+ import importlib.util
272
+ import json
273
+ import os
274
+ import re
275
+ import sys
276
+ import time
277
+ from pathlib import Path
278
+
279
+ FIELDS = ("exports", "t2_annotations", "t3_items", "warnings")
280
+ MODES = ("source", "docs-only", "component-library")
281
+ TIERS = ("Quick", "Forge", "Forge+", "Deep")
282
+ LISTS = ("top_exports", "co_imports", "promoted_docs", "component_catalog", "scripts_inventory",
283
+ "assets_inventory", "t2_annotations", "t3_items", "warnings")
284
+ PATCHABLE = ("signature", "params", "return_type")
285
+ # set: the key, the JSON types it takes, and whether an object merges into the one there.
286
+ SETTABLE = {
287
+ "top_exports": ((list,), False),
288
+ "co_imports": ((list,), False),
289
+ "promoted_docs": ((list,), False),
290
+ "component_catalog": ((list,), False),
291
+ "authoritative_files_scan": ((dict, type(None)), False),
292
+ "files_scanned": ((int,), False),
293
+ "counts": ((dict,), True),
294
+ "arms": ((dict,), True),
295
+ "intent_mapping": ((dict,), False),
296
+ }
297
+ # intent_mapping kinds and the inventory list each one filters.
298
+ INTENT_LISTS = {"scripts": "scripts_inventory", "assets": "assets_inventory"}
299
+ BY_EYE_DEFAULTS = {"confidence": "T1-low", "extraction_method": "source-read", "ast_node_type": None,
300
+ "ast_recipe": None}
301
+ YAML_KEY_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*")
302
+
303
+
304
+ class InventoryError(Exception):
305
+ """Exit 1."""
306
+
307
+
308
+ def load(path: Path) -> dict:
309
+ try:
310
+ data = json.loads(path.read_text(encoding="utf-8"))
311
+ except (OSError, UnicodeDecodeError, ValueError) as exc:
312
+ raise InventoryError(f"cannot read the inventory {path.as_posix()}: {exc}") from exc
313
+ if not isinstance(data, dict) or not isinstance(data.get("exports"), list):
314
+ raise InventoryError(f"the inventory {path.as_posix()} is not an object with an exports list")
315
+ for key in LISTS:
316
+ if key in data and not isinstance(data[key], list):
317
+ raise InventoryError(f"the inventory's {key} is not a list")
318
+ return data
319
+
320
+
321
+ def _read_json(path: Path, label: str):
322
+ try:
323
+ return json.loads(path.read_text(encoding="utf-8"))
324
+ except (OSError, UnicodeDecodeError, ValueError) as exc:
325
+ raise InventoryError(f"cannot read the {label} {path.as_posix()}: {exc}") from exc
326
+
327
+
328
+ def _off_surface_names(data: dict) -> set[str]:
329
+ """The export names every inventory export of which is marked `public: false`."""
330
+ marks: dict[str, bool] = {}
331
+ for export in data.get("exports") or []:
332
+ if isinstance(export, dict) and isinstance(export.get("export_name"), str):
333
+ name = export["export_name"]
334
+ marks[name] = marks.get(name, True) and export.get("public") is False
335
+ return {name for name, off in marks.items() if off}
336
+
337
+
338
+ def _not_public(data: dict) -> list[dict]:
339
+ """{name, file} of each export marked `public: false`, in the inventory's order."""
340
+ return [{"name": e.get("export_name"), "file": e.get("source_file")} for e in data.get("exports") or []
341
+ if isinstance(e, dict) and e.get("public") is False]
342
+
343
+
344
+ def counts(data: dict) -> dict:
345
+ exports = [e for e in data["exports"] if isinstance(e, dict)]
346
+ off = _off_surface_names(data)
347
+ raw_notes = [n for n in data.get("t2_annotations") or [] if isinstance(n, dict)]
348
+ notes = [n for n in raw_notes if not (isinstance(n.get("export_name"), str) and n["export_name"] in off)]
349
+
350
+ def _length(key: str) -> int:
351
+ value = data.get(key)
352
+ return len(value) if isinstance(value, list) else 0
353
+
354
+ by_type: dict[str, int] = {}
355
+ for export in exports:
356
+ kind = export.get("export_type")
357
+ if isinstance(kind, str) and kind:
358
+ by_type[kind] = by_type.get(kind, 0) + 1
359
+ files = data.get("files_scanned")
360
+ not_public = sum(1 for e in exports if e.get("public") is False)
361
+ return {
362
+ "files_scanned": files if isinstance(files, int) else None,
363
+ "exports": len(data["exports"]),
364
+ "not_public": not_public,
365
+ "t1": sum(1 for e in exports if e.get("confidence") == "T1"),
366
+ "t1_low": sum(1 for e in exports if e.get("confidence") == "T1-low"),
367
+ "by_type": dict(sorted(by_type.items())),
368
+ "top_exports": _length("top_exports"),
369
+ "co_imports": _length("co_imports"),
370
+ "scripts": _length("scripts_inventory"),
371
+ "assets": _length("assets_inventory"),
372
+ "t2_annotations": _length("t2_annotations") - (len(raw_notes) - len(notes)),
373
+ "t2_past": sum(1 for n in notes if n.get("temporal") == "T2-past"),
374
+ "t2_future": sum(1 for n in notes if n.get("temporal") == "T2-future"),
375
+ "functions_enriched": len({n["export_name"] for n in notes if isinstance(n.get("export_name"), str)}),
376
+ "t3_items": _length("t3_items"),
377
+ "items": len(data["exports"]) - not_public + _length("t3_items"),
378
+ }
379
+
380
+
381
+ def runner_warnings(extraction: dict) -> list[str]:
382
+ """The recipe runner's anomalies, one line each, for Gate 2 and the evidence report."""
383
+ lines: list[str] = []
384
+ recipes = [r for r in extraction.get("recipes") or [] if isinstance(r, dict)]
385
+ if extraction.get("truncated"):
386
+ capped = ", ".join(f"`{r.get('id')}`" for r in recipes if r.get("truncated")) or "a recipe"
387
+ lines.append(f"Extraction hit the head cap: {capped} matched more than {extraction.get('head_cap')} "
388
+ "exports, so some exports were read by eye or left out. Narrow `scope.include` in the "
389
+ "brief until no recipe reaches the cap.")
390
+ for error in extraction.get("errors") or []:
391
+ if isinstance(error, dict):
392
+ lines.append(f"ast-grep did not finish on {error.get('files')} files from `{error.get('first_file')}` "
393
+ f"({error.get('detail')}): their exports are read by eye")
394
+ for issue in extraction.get("file_issues") or []:
395
+ if isinstance(issue, dict):
396
+ lines.append(f"`{issue.get('file')}` ({issue.get('issue')}): its exports are read by eye")
397
+ without = extraction.get("files_without_recipes")
398
+ if isinstance(without, dict):
399
+ for ext, number in sorted(without.items()):
400
+ lines.append(f"{number} `{ext}` files in scope are in a language no recipe reads")
401
+ lines.extend(w for w in extraction.get("warnings") or [] if isinstance(w, str) and w)
402
+ return lines
403
+
404
+
405
+ # --------------------------------------------------------------------------
406
+ # The public surface of a public-api skill (see init's "Public surface"):
407
+ # one rule, which skf-build-change-manifest.py loads from here for update-skill
408
+ # --------------------------------------------------------------------------
409
+
410
+
411
+ def _norm_path(path: object) -> str | None:
412
+ """A file path compared as structural-diff compares it: `/` separators, no leading `./`."""
413
+ if not isinstance(path, str) or not path.strip():
414
+ return None
415
+ text = path.strip().replace("\\", "/")
416
+ while text.startswith("./"):
417
+ text = text[2:]
418
+ return text
419
+
420
+
421
+ def _relative_source(entry: dict) -> bool:
422
+ """Write an exports entry's source_file in _norm_path's form, the runner's; False for an absolute one."""
423
+ path = _norm_path(entry.get("source_file"))
424
+ if path is None:
425
+ return True
426
+ if path.startswith("/") or re.match(r"[A-Za-z]:/", path):
427
+ return False
428
+ entry["source_file"] = path
429
+ return True
430
+
431
+
432
+ PUBLIC_API = "public-api"
433
+ # A record's language family, the one whose entry points declare its names: skf-extract-public-api.py's FAMILY_OF,
434
+ # by the record's `language`, else by its file's extension (that script's EXTENSION_LANGUAGES).
435
+ _FAMILY_OF_LANGUAGE = {"python": "python", "typescript": "javascript", "tsx": "javascript",
436
+ "javascript": "javascript", "vue": "javascript", "rust": "rust", "go": "go"}
437
+ _FAMILY_OF_EXTENSION = {".py": "python", ".pyi": "python", ".rs": "rust", ".go": "go",
438
+ **dict.fromkeys((".ts", ".mts", ".cts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue"),
439
+ "javascript")}
440
+
441
+
442
+ class _PublicSurface:
443
+ """The public surface of a public-api skill's extraction (see init's "Public surface" in the module
444
+ docstring)."""
445
+
446
+ def __init__(self, diff: dict, statuses: dict):
447
+ self.barrels = {family for family, status in statuses.items() if status == "barrel"}
448
+ self.pairs: set[tuple[str, str]] = set()
449
+ self.unfiled: set[tuple[object, str]] = set() # (family, name) of a public name no file defines
450
+ for key in ("public", "extraction_gaps"):
451
+ for item in diff.get(key) or []:
452
+ if not isinstance(item, dict) or not isinstance(item.get("name"), str) or not item["name"]:
453
+ continue
454
+ family = item.get("language")
455
+ members = item.get("members") if key == "public" and item.get("via") == "namespace" else None
456
+ if isinstance(members, list):
457
+ # a namespace whose module the runner read: its members, in place of the module itself
458
+ for member in members:
459
+ if isinstance(member, dict) and isinstance(member.get("name"), str) and member["name"]:
460
+ self._add(family, member["name"], member.get("local"), member.get("file"), True)
461
+ continue
462
+ self._add(family, item["name"], item.get("local") if key == "public" else None, item.get("file"),
463
+ key == "public")
464
+
465
+ def _add(self, family: object, name: str, local: object, path: object, listed_public: bool) -> None:
466
+ """The pairs one public name (or gap) gives: (name, file) and (local, file), or, with no file, its
467
+ (family, name) when `public` lists it."""
468
+ file = _norm_path(path)
469
+ if not file:
470
+ if listed_public:
471
+ self.unfiled.add((family, name))
472
+ return
473
+ self.pairs.update((n, file) for n in (name, local) if isinstance(n, str) and n)
474
+
475
+ def public(self, name: str, path: str, language: object = None) -> bool | None:
476
+ """Whether the export is on the public surface; None (no mark) when its family's entry points are no
477
+ barrel."""
478
+ family = _FAMILY_OF_LANGUAGE.get(language) if isinstance(language, str) else None
479
+ if family is None:
480
+ family = _FAMILY_OF_EXTENSION.get(os.path.splitext(path)[1].lower())
481
+ if family not in self.barrels:
482
+ return None
483
+ return (name, path) in self.pairs or (family, name) in self.unfiled
484
+
485
+
486
+ def public_surface(extraction: object) -> tuple[_PublicSurface | None, str | None]:
487
+ """(the public surface, None) of the recipe runner's output when its scope type is public-api; (None, why the
488
+ surface cannot be read from it) for a public-api run with no entry-point diff, an incomplete one, one with
489
+ errors or one whose entry points name a module the trace could not follow; (None, None) for any other scope
490
+ type. With no surface every export is kept, as before."""
491
+ if not isinstance(extraction, dict):
492
+ return None, None
493
+ scope, diff, points = extraction.get("scope"), extraction.get("entry_point_diff"), extraction.get("entry_points")
494
+ kind = scope.get("type") if isinstance(scope, dict) else None
495
+ if not (isinstance(kind, str) and kind.strip().lower() == PUBLIC_API):
496
+ return None, None
497
+ statuses = points.get("by_language") if isinstance(points, dict) else None
498
+ if not isinstance(diff, dict) or not isinstance(statuses, dict):
499
+ return None, "the recipe runner gave no entry-point diff (Quick tier, or a run that could not read the tree)"
500
+ if extraction.get("status") == "incomplete" or extraction.get("errors"):
501
+ return None, "the recipe runner's run is incomplete"
502
+ if points.get("unresolved"):
503
+ return None, "an entry point names a module the runner could not trace (entry_points.unresolved)"
504
+ return _PublicSurface(diff, statuses), None
505
+
506
+
507
+ # The fields of an entry_point_diff item the rule reads, which the inventory's public_surface keeps for `add`, and
508
+ # those of each member of a namespace item.
509
+ SURFACE_FIELDS = {"public": ("name", "language", "file", "local", "via", "members"),
510
+ "extraction_gaps": ("name", "language", "file")}
511
+ MEMBER_FIELDS = ("name", "local", "file")
512
+ NOT_PUBLIC_NAMED = 10 # the names the not-public warning spells out, as update-skill's apply does
513
+ NOT_PUBLIC_LEAD = "Off the public surface: "
514
+
515
+
516
+ def _surface_state(extraction: dict) -> dict:
517
+ """The parts of the runner's JSON the rule reads (public_surface found a surface in it)."""
518
+ diff = extraction["entry_point_diff"]
519
+ state: dict = {"by_language": dict(extraction["entry_points"]["by_language"])}
520
+ for key, fields in SURFACE_FIELDS.items():
521
+ state[key] = [{field: item[field] for field in fields if field in item}
522
+ for item in diff.get(key) or [] if isinstance(item, dict)]
523
+ for item in state["public"]:
524
+ if isinstance(item.get("members"), list):
525
+ item["members"] = [{field: m[field] for field in MEMBER_FIELDS if field in m}
526
+ for m in item["members"] if isinstance(m, dict)]
527
+ return state
528
+
529
+
530
+ def _stored_surface(data: dict) -> _PublicSurface | None:
531
+ """The public surface the inventory's public_surface keeps, or None when init kept none."""
532
+ state = data.get("public_surface")
533
+ if not isinstance(state, dict) or not isinstance(state.get("by_language"), dict):
534
+ return None
535
+ return _PublicSurface(state, state["by_language"])
536
+
537
+
538
+ def _mark(surface: _PublicSurface | None, export: dict) -> None:
539
+ """Set the export's `public` mark by the rule; no other `public` value is kept."""
540
+ export.pop("public", None)
541
+ name, path = export.get("export_name"), _norm_path(export.get("source_file"))
542
+ if surface is None or not isinstance(name, str) or not name or path is None:
543
+ return
544
+ mark = surface.public(name, path, export.get("language"))
545
+ if mark is not None:
546
+ export["public"] = mark
547
+
548
+
549
+ def _not_public_warning(names: list[str]) -> str:
550
+ """The one warning naming the exports marked `public: false` (`name (file)`), for Gate 2 and the evidence
551
+ report."""
552
+ shown = ", ".join(names[:NOT_PUBLIC_NAMED])
553
+ more = f" and {len(names) - NOT_PUBLIC_NAMED} more" if len(names) > NOT_PUBLIC_NAMED else ""
554
+ return (f"{NOT_PUBLIC_LEAD}{len(names)} export(s) of this public-api skill are left out of the provenance map, "
555
+ f"SKILL.md, references/, the context snippet and metadata.json's exports[] (each is marked "
556
+ f"`public: false` in the inventory, and `summary` lists them all): {shown}{more}")
557
+
558
+
559
+ def _write_not_public_warning(data: dict) -> None:
560
+ """Write the not-public warning in place of the one an earlier call wrote (else at the end), or drop it when
561
+ no export is marked `public: false`."""
562
+ names = [f"{item['name']} ({item['file']})" for item in _not_public(data)]
563
+ warnings = data.setdefault("warnings", [])
564
+ at = next((i for i, w in enumerate(warnings) if isinstance(w, str) and w.startswith(NOT_PUBLIC_LEAD)), None)
565
+ if at is not None:
566
+ del warnings[at]
567
+ if names:
568
+ warnings.insert(len(warnings) if at is None else at, _not_public_warning(names))
569
+
570
+
571
+ def init(skill: str, mode: str, tier: str, extraction: dict | None = None, detected: dict | None = None) -> dict:
572
+ """A new inventory, seeded from the runner's JSON and the detector's JSON."""
573
+ data: dict = {
574
+ "skill_name": skill, "extraction_mode": mode, "tier": tier, "files_scanned": None,
575
+ "exports": [], "top_exports": [], "aggregates": {}, "counts": {}, "arms": {},
576
+ "scripts_inventory": [], "assets_inventory": [], "intent_mapping": {}, "co_imports": [],
577
+ "promoted_docs": [], "authoritative_files_scan": None, "extraction_rules": {}, "public_surface": None,
578
+ "t2_annotations": [], "t3_items": [], "warnings": [],
579
+ }
580
+ if extraction is not None:
581
+ if not isinstance(extraction, dict):
582
+ raise InventoryError("the --extraction file is not a JSON object")
583
+ diff = extraction.get("entry_point_diff")
584
+ internal = {(i.get("name"), i.get("source_file"))
585
+ for i in (diff.get("internal") or [] if isinstance(diff, dict) else []) if isinstance(i, dict)}
586
+ surface, problem = public_surface(extraction)
587
+ for record in extraction.get("exports") or []:
588
+ if not isinstance(record, dict):
589
+ continue
590
+ export = dict(record)
591
+ export.setdefault("confidence", "T1")
592
+ export.setdefault("extraction_method", "ast-grep")
593
+ if (export.get("export_name"), export.get("source_file")) in internal:
594
+ export["internal"] = True
595
+ _mark(surface, export)
596
+ data["exports"].append(export)
597
+ files = extraction.get("files_in_scope")
598
+ data["files_scanned"] = files if isinstance(files, int) else None
599
+ for key in ("aggregates", "counts", "arms"):
600
+ value = extraction.get(key)
601
+ data[key] = dict(value) if isinstance(value, dict) else {}
602
+ ast_grep = extraction.get("ast_grep")
603
+ data["extraction_rules"] = {
604
+ "recipe_set": extraction.get("recipe_set"),
605
+ "recipes": [r.get("id") for r in extraction.get("recipes") or [] if isinstance(r, dict)],
606
+ "scope": extraction.get("scope") if isinstance(extraction.get("scope"), dict) else {},
607
+ "ast_grep_version": ast_grep.get("version") if isinstance(ast_grep, dict) else None,
608
+ }
609
+ data["warnings"] = runner_warnings(extraction)
610
+ if problem:
611
+ data["warnings"].append(f"The public surface of this public-api skill could not be applied ({problem}): "
612
+ "no export is marked `public: false`, so every export found is documented")
613
+ if surface is not None:
614
+ data["public_surface"] = _surface_state(extraction)
615
+ _write_not_public_warning(data)
616
+ if detected is not None:
617
+ if not isinstance(detected, dict):
618
+ raise InventoryError("the --detected file is not a JSON object")
619
+ for key in ("scripts_inventory", "assets_inventory"):
620
+ value = detected.get(key) or []
621
+ if not isinstance(value, list) or not all(isinstance(e, dict) for e in value):
622
+ raise InventoryError(f"the --detected file's {key} is not a list of objects")
623
+ data[key] = [dict(e) for e in value]
624
+ return data
625
+
626
+
627
+ def _same(a, b) -> bool:
628
+ return json.dumps(a, sort_keys=True) == json.dumps(b, sort_keys=True)
629
+
630
+
631
+ def add(data: dict, field: str, entries: list) -> tuple[int, int, list[str]]:
632
+ """Append `entries` to data[field]; return (added, duplicates, dropped export names)."""
633
+ if field not in FIELDS:
634
+ raise InventoryError(f"--field must be one of {', '.join(FIELDS)}")
635
+ kind = str if field == "warnings" else dict
636
+ if not isinstance(entries, list) or not all(isinstance(e, kind) for e in entries):
637
+ raise InventoryError(f"stdin must hold a JSON list of {'strings' if kind is str else 'objects'}")
638
+ surface = _stored_surface(data) if field == "exports" else None
639
+ if field == "exports":
640
+ if not all(isinstance(e.get("export_name"), str) and e["export_name"] for e in entries):
641
+ raise InventoryError("every exports entry needs an export_name")
642
+ entries = [{**BY_EYE_DEFAULTS, **e} for e in entries]
643
+ absolute = [e for e in entries if not _relative_source(e)]
644
+ if absolute:
645
+ sent = ", ".join(f"{e['export_name']}: source_file {e['source_file']}" for e in absolute[:10])
646
+ sent += f" and {len(absolute) - 10} more" if len(absolute) > 10 else ""
647
+ verb, them = ("is", "it") if len(absolute) == 1 else ("are", "them")
648
+ raise InventoryError(f"{sent} {verb} absolute; send {them} relative to the source root, with / "
649
+ "separators, as the runner writes its files")
650
+ for entry in entries:
651
+ _mark(surface, entry)
652
+ target = data.setdefault(field, [])
653
+ known = {e.get("export_name") for e in data["exports"] if isinstance(e, dict)} - {None}
654
+ added = duplicates = 0
655
+ dropped: list[str] = []
656
+ for entry in entries:
657
+ if field == "t3_items" and entry.get("export_name") in known:
658
+ dropped.append(entry["export_name"])
659
+ continue
660
+ if field == "exports":
661
+ key = (entry["export_name"], _norm_path(entry.get("source_file")))
662
+ repeat = any(isinstance(e, dict) and (e.get("export_name"), _norm_path(e.get("source_file"))) == key
663
+ for e in target)
664
+ else:
665
+ repeat = any(_same(entry, e) for e in target)
666
+ if repeat:
667
+ duplicates += 1
668
+ continue
669
+ target.append(entry)
670
+ added += 1
671
+ if surface is not None:
672
+ _write_not_public_warning(data)
673
+ return added, duplicates, dropped
674
+
675
+
676
+ def patch(data: dict, entries: list) -> tuple[int, list[str], list[str]]:
677
+ """Set signature, params and return_type on the exports named; return (patched, unmatched, ambiguous)."""
678
+ if not isinstance(entries, list) or not all(isinstance(e, dict) for e in entries):
679
+ raise InventoryError("stdin must hold a JSON list of objects")
680
+ for entry in entries:
681
+ if not isinstance(entry.get("export_name"), str) or not entry["export_name"]:
682
+ raise InventoryError("every patch entry needs an export_name")
683
+ extra = sorted(set(entry) - {"export_name", "source_file", *PATCHABLE})
684
+ if extra:
685
+ raise InventoryError(f"patch sets only {', '.join(PATCHABLE)}; {entry['export_name']} also has "
686
+ f"{', '.join(extra)}")
687
+ patched = 0
688
+ unmatched: list[str] = []
689
+ ambiguous: list[str] = []
690
+ for entry in entries:
691
+ name, source = entry["export_name"], entry.get("source_file")
692
+ found = [e for e in data["exports"] if isinstance(e, dict) and e.get("export_name") == name
693
+ and (source is None or _norm_path(e.get("source_file")) == _norm_path(source))]
694
+ label = name if source is None else f"{name} ({source})"
695
+ if not found:
696
+ unmatched.append(label)
697
+ elif len(found) > 1:
698
+ ambiguous.append(label)
699
+ else:
700
+ found[0].update({k: entry[k] for k in PATCHABLE if k in entry})
701
+ patched += 1
702
+ return patched, unmatched, ambiguous
703
+
704
+
705
+ def set_fields(data: dict, values: dict) -> tuple[list[str], list[str]]:
706
+ """Set the keys of `values` on the inventory (see the module docstring); return (the keys set, the top_exports
707
+ names dropped as off the public surface)."""
708
+ if not isinstance(values, dict):
709
+ raise InventoryError("stdin must hold a JSON object")
710
+ unknown = sorted(set(values) - set(SETTABLE))
711
+ if unknown:
712
+ raise InventoryError(f"set takes only {', '.join(SETTABLE)}; not {', '.join(unknown)}")
713
+ for key, value in values.items():
714
+ types, _ = SETTABLE[key]
715
+ if not isinstance(value, types) or (key == "files_scanned" and isinstance(value, bool)):
716
+ names = " or ".join("null" if t is type(None) else t.__name__ for t in types)
717
+ raise InventoryError(f"{key} must be {names}")
718
+ mapping = _intent_mapping(data, values["intent_mapping"]) if "intent_mapping" in values else {}
719
+ dropped: list[str] = []
720
+ if "top_exports" in values:
721
+ off = _off_surface_names(data)
722
+ dropped = [n for n in values["top_exports"] if isinstance(n, str) and n in off]
723
+ values = {**values, "top_exports": [n for n in values["top_exports"] if not (isinstance(n, str) and n in off)]}
724
+ for key, value in values.items():
725
+ if key == "intent_mapping":
726
+ data["intent_mapping"] = {**(data.get("intent_mapping") or {}), **mapping}
727
+ elif SETTABLE[key][1]:
728
+ data[key] = {**(data.get(key) if isinstance(data.get(key), dict) else {}), **value}
729
+ else:
730
+ data[key] = value
731
+ return list(values), dropped
732
+
733
+
734
+ def _intent_mapping(data: dict, mapping: dict) -> dict:
735
+ """Keep only the kept entries of each list a free-text intent names; return the recorded mapping."""
736
+ unknown = sorted(set(mapping) - set(INTENT_LISTS))
737
+ if unknown:
738
+ raise InventoryError(f"intent_mapping takes only {', '.join(INTENT_LISTS)}; not {', '.join(unknown)}")
739
+ recorded: dict = {}
740
+ filtered: dict[str, list] = {}
741
+ for kind, spec in mapping.items():
742
+ kept_files = spec.get("kept") if isinstance(spec, dict) else None
743
+ if not isinstance(spec, dict) or not isinstance(spec.get("intent"), str) or not isinstance(kept_files, list) \
744
+ or not all(isinstance(f, str) for f in kept_files):
745
+ raise InventoryError(f'intent_mapping.{kind} must be {{"intent": "<text>", "kept": [source_file, ...]}}')
746
+ entries = [e for e in data.get(INTENT_LISTS[kind]) or [] if isinstance(e, dict)]
747
+ listed = {e.get("source_file") for e in entries}
748
+ missing = [f for f in spec["kept"] if f not in listed]
749
+ if missing:
750
+ raise InventoryError(f"{INTENT_LISTS[kind]} lists no {', '.join(missing)}")
751
+ kept = set(spec["kept"])
752
+ filtered[kind] = [e for e in entries if e.get("source_file") in kept]
753
+ earlier = (data.get("intent_mapping") or {}).get(kind)
754
+ left_out = {e.get("source_file") for e in entries if e.get("source_file") not in kept}
755
+ if isinstance(earlier, dict) and earlier.get("intent") == spec["intent"]:
756
+ left_out |= set(earlier.get("left_out") or [])
757
+ recorded[kind] = {"intent": spec["intent"], "kept": sorted(kept), "left_out": sorted(left_out - kept)}
758
+ for kind, entries in filtered.items():
759
+ data[INTENT_LISTS[kind]] = entries
760
+ return recorded
761
+
762
+
763
+ JS_LANGUAGES = frozenset({"typescript", "tsx", "javascript"})
764
+
765
+
766
+ def typed_param(param, language: str | None) -> str | None:
767
+ """One parameter as the provenance map writes it ('userId: string',
768
+ 'options?: TokenOptions', 'retries: int = 3'); a string is kept."""
769
+ if isinstance(param, str):
770
+ return param
771
+ if not isinstance(param, dict):
772
+ return None
773
+ name, type_, default = param.get("name"), param.get("type"), param.get("default")
774
+ if not name:
775
+ return type_ or None
776
+ text = str(name)
777
+ if param.get("optional") and default is None and language in JS_LANGUAGES and not text.startswith("..."):
778
+ text += "?"
779
+ if type_:
780
+ text += f": {type_}"
781
+ if default is not None:
782
+ text += f" = {default}"
783
+ return text
784
+
785
+
786
+ # provenance: the header keys stdin may give, and the default of each (REQUIRED: no default).
787
+ REQUIRED = object()
788
+ MAP_HEADER = {
789
+ "provenance_version": "2.0",
790
+ "skill_name": None, # the inventory's
791
+ "skill_type": "single",
792
+ "source_repo": REQUIRED,
793
+ "source_commit": REQUIRED,
794
+ "source_ref": None,
795
+ "generated_at": None, # now
796
+ }
797
+
798
+
799
+ def _labels(export: dict) -> dict:
800
+ """The labels the tool that found `export` gives its entry (compile.md §6)."""
801
+ if export.get("extraction_method") == "ast-grep":
802
+ return {"confidence": "T1", "extraction_method": "ast-grep", "ast_node_type": export.get("ast_node_type"),
803
+ "signature_source": "T1"}
804
+ return {"confidence": "T1-low", "extraction_method": "source-read", "ast_node_type": None,
805
+ "signature_source": "T1-low"}
806
+
807
+
808
+ def provenance_entries(data: dict) -> tuple[list[dict], list[str]]:
809
+ """(the provenance map entry of every export but one marked `public: false`, the functions among them with no
810
+ params)."""
811
+ entries: list[dict] = []
812
+ unsigned: list[str] = []
813
+ for export in data.get("exports") or []:
814
+ if not isinstance(export, dict) or export.get("public") is False:
815
+ continue
816
+ raw = export.get("params")
817
+ params = None
818
+ if isinstance(raw, list):
819
+ params = [s for s in (typed_param(p, export.get("language")) for p in raw) if s is not None]
820
+ elif export.get("export_type") == "function":
821
+ unsigned.append(f"{export.get('export_name')} ({export.get('source_file')})")
822
+ entries.append({
823
+ "export_name": export.get("export_name"),
824
+ "export_type": export.get("export_type"),
825
+ "params": params,
826
+ "return_type": export.get("return_type") if params is not None else None,
827
+ "source_file": export.get("source_file"),
828
+ "source_line": export.get("source_line"),
829
+ **_labels(export),
830
+ })
831
+ return entries, unsigned
832
+
833
+
834
+ def _utc_now() -> str:
835
+ """Now, in UTC, to the second (ISO-8601)."""
836
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
837
+
838
+
839
+ def map_header(data: dict, given) -> dict:
840
+ """The provenance map's header: stdin's keys over the defaults."""
841
+ if not isinstance(given, dict):
842
+ raise InventoryError("stdin must hold the map's header, a JSON object with source_repo and source_commit")
843
+ unknown = sorted(set(given) - set(MAP_HEADER))
844
+ if unknown:
845
+ raise InventoryError(f"the map's header takes only {', '.join(MAP_HEADER)}; not {', '.join(unknown)}")
846
+ missing = [key for key, default in MAP_HEADER.items() if default is REQUIRED and key not in given]
847
+ if missing:
848
+ raise InventoryError(f"the map's header needs {', '.join(missing)} (null when unknown)")
849
+ defaults = {"skill_name": data.get("skill_name"), "generated_at": _utc_now()}
850
+ return {key: given[key] if key in given else defaults.get(key, default) for key, default in MAP_HEADER.items()}
851
+
852
+
853
+ def provenance_map(data: dict, header) -> tuple[dict, list[str]]:
854
+ """(the provenance map of the inventory, the functions with no params)."""
855
+ entries, unsigned = provenance_entries(data)
856
+ return {**map_header(data, header), "entries": entries}, unsigned
857
+
858
+
859
+ def add_map_entries(provenance: dict, payload, off: frozenset = frozenset()) -> tuple[int, int, list[dict]]:
860
+ """Append stdin's `entries` and `file_entries` to the map; return (added, duplicates, {name, file} of each entry
861
+ not added because `off`, the (export_name, source_file) of the inventory's exports marked `public: false`,
862
+ holds it)."""
863
+ if not isinstance(payload, dict) or not payload or set(payload) - {"entries", "file_entries"}:
864
+ raise InventoryError('stdin must hold {"entries": [...], "file_entries": [...]} (either list, or both)')
865
+ keys = {"entries": ("export_name", "source_file"), "file_entries": ("file_name",)}
866
+ added = duplicates = 0
867
+ not_public: list[dict] = []
868
+ for field, items in payload.items():
869
+ if not isinstance(items, list) or not all(isinstance(e, dict) for e in items):
870
+ raise InventoryError(f"{field} must be a list of objects")
871
+ need = keys[field][0]
872
+ if not all(isinstance(e.get(need), str) and e[need] for e in items):
873
+ raise InventoryError(f"every {field} item needs a {need}")
874
+ target = provenance.setdefault(field, [])
875
+ if not isinstance(target, list):
876
+ raise InventoryError(f"the map's {field} is not a list")
877
+ held = {tuple(e.get(k) for k in keys[field]) for e in target if isinstance(e, dict)}
878
+ for item in items:
879
+ key = tuple(item.get(k) for k in keys[field])
880
+ if field == "entries" and (item.get("export_name"), _norm_path(item.get("source_file"))) in off:
881
+ not_public.append({"name": item.get("export_name"), "file": item.get("source_file")})
882
+ continue
883
+ if key in held:
884
+ duplicates += 1
885
+ continue
886
+ held.add(key)
887
+ target.append(item)
888
+ added += 1
889
+ return added, duplicates, not_public
890
+
891
+
892
+ _SIBLINGS: dict = {}
893
+
894
+
895
+ def _sibling(filename: str):
896
+ """A helper installed beside this script, loaded once as a module: skf-resolve-authoritative-files.py (the
897
+ brief's YAML), skf-render-quick-metadata.py (the installed SKF's version) and skf-description-guard.py (the
898
+ staged SKILL.md's description)."""
899
+ if filename not in _SIBLINGS:
900
+ path = Path(__file__).resolve().parent / filename
901
+ spec = importlib.util.spec_from_file_location("skf_" + filename[4:-3].replace("-", "_"), path)
902
+ if spec is None or spec.loader is None:
903
+ raise InventoryError(f"cannot load {filename} beside {Path(__file__).name}")
904
+ module = importlib.util.module_from_spec(spec)
905
+ try:
906
+ spec.loader.exec_module(module)
907
+ except ImportError as exc:
908
+ raise InventoryError(f"cannot load {filename} ({exc}): run this helper with `uv run`") from exc
909
+ except (OSError, SyntaxError) as exc:
910
+ raise InventoryError(f"cannot load {filename} beside {Path(__file__).name}: {exc}") from exc
911
+ _SIBLINGS[filename] = module
912
+ return _SIBLINGS[filename]
913
+
914
+
915
+ def read_brief(path: Path) -> dict:
916
+ """The skill brief, a YAML mapping."""
917
+ module = _sibling("skf-resolve-authoritative-files.py")
918
+ try:
919
+ brief = module.parse_brief_yaml(path.read_text(encoding="utf-8"))
920
+ except (OSError, UnicodeDecodeError) as exc:
921
+ raise InventoryError(f"cannot read the brief {path.as_posix()}: {exc}") from exc
922
+ except module.yaml.YAMLError as exc:
923
+ raise InventoryError(f"the brief {path.as_posix()} is not valid YAML: {exc}") from exc
924
+ if not isinstance(brief, dict):
925
+ raise InventoryError(f"the brief {path.as_posix()} is not a YAML mapping")
926
+ return brief
927
+
928
+
929
+ # metadata: the stdin keys, each with the JSON types it takes; METADATA_REQUIRED have no default.
930
+ METADATA_INPUT = {
931
+ "version": (str,), "language": (str,),
932
+ "source_root": (str, type(None)), "source_commit": (str, type(None)), "source_ref": (str, type(None)),
933
+ "qmd_version": (str, type(None)), "ast_grep_version": (str, type(None)), "stats_extra": (dict,),
934
+ }
935
+ METADATA_REQUIRED = ("version", "language", "source_root", "source_commit", "source_ref", "qmd_version")
936
+ NON_EMPTY = ("version", "language")
937
+ REFERENCE_APP = "reference-app"
938
+ COMPONENT_LIBRARY = "component-library"
939
+
940
+
941
+ def _metadata_input(given) -> dict:
942
+ """stdin's fields, checked, with the defaults of the optional ones."""
943
+ if not isinstance(given, dict):
944
+ raise InventoryError("stdin must hold a JSON object with " + ", ".join(METADATA_REQUIRED))
945
+ unknown = sorted(set(given) - set(METADATA_INPUT))
946
+ if unknown:
947
+ raise InventoryError(f"metadata takes only {', '.join(METADATA_INPUT)}; not {', '.join(unknown)}")
948
+ missing = [key for key in METADATA_REQUIRED if key not in given]
949
+ if missing:
950
+ raise InventoryError(f"metadata needs {', '.join(missing)} (null when unknown, but for "
951
+ f"{', '.join(NON_EMPTY)})")
952
+ for key, value in given.items():
953
+ if not isinstance(value, METADATA_INPUT[key]) or (key in NON_EMPTY and not value.strip()):
954
+ names = " or ".join("null" if t is type(None) else t.__name__ for t in METADATA_INPUT[key])
955
+ raise InventoryError(f"{key} must be {'a non-empty ' if key in NON_EMPTY else ''}{names}")
956
+ return {"ast_grep_version": None, "stats_extra": {}, **given}
957
+
958
+
959
+ def read_description(skill_md: Path) -> str:
960
+ """The `description` of the staged SKILL.md's frontmatter, which compile §2a wrote."""
961
+ module = _sibling("skf-description-guard.py")
962
+ try:
963
+ description, _hash = module.read_description(skill_md)
964
+ except (OSError, UnicodeDecodeError, ValueError) as exc:
965
+ raise InventoryError(f"cannot read the description of the staged SKILL.md: {exc} (write SKILL.md, "
966
+ "compile §2, before metadata.json)") from exc
967
+ if not description.strip():
968
+ raise InventoryError(f"the description of {skill_md.as_posix()} is empty")
969
+ return description
970
+
971
+
972
+ def metadata_exports(data: dict, scope_type: object) -> list[str]:
973
+ """metadata.json's exports[]: the names of the inventory's exports but one marked internal or not public, each
974
+ once (a docs-only inventory's t3_items names; none for a reference app)."""
975
+ if scope_type == REFERENCE_APP:
976
+ return []
977
+ if data.get("extraction_mode") == "docs-only":
978
+ items = [i for i in data.get("t3_items") or [] if isinstance(i, dict)]
979
+ else:
980
+ items = [e for e in data["exports"] if isinstance(e, dict) and e.get("internal") is not True
981
+ and e.get("public") is not False]
982
+ names = (i.get("export_name") for i in items)
983
+ return list(dict.fromkeys(n for n in names if isinstance(n, str) and n))
984
+
985
+
986
+ def _file_rows(entries: object, folder: str) -> list[dict]:
987
+ """The scripts[] or assets[] rows of a scripts_inventory or assets_inventory."""
988
+ rows = []
989
+ for entry in entries if isinstance(entries, list) else []:
990
+ if not isinstance(entry, dict):
991
+ continue
992
+ source = entry.get("source_file")
993
+ name = entry.get("name") or (Path(str(source)).name if source else None)
994
+ rows.append({"file": f"{folder}/{name}", "purpose": entry.get("purpose") or "", "source_file": source,
995
+ "confidence": entry.get("confidence") or "T1-low"})
996
+ return rows
997
+
998
+
999
+ def _ast_grep_run(data: dict, given_version: str | None) -> tuple[str | None, int | None, list[str]]:
1000
+ """(tool_versions.ast_grep, ast_node_count, warnings): the runner's version and match count when it ran
1001
+ ast-grep, else the number of exports labeled ast-grep (the recipes run without the runner) and the version
1002
+ stdin gave, else (None, None) when ast-grep did not run."""
1003
+ rules = data.get("extraction_rules") if isinstance(data.get("extraction_rules"), dict) else {}
1004
+ version = rules.get("ast_grep_version")
1005
+ if isinstance(version, str) and version:
1006
+ aggregates = data.get("aggregates") if isinstance(data.get("aggregates"), dict) else {}
1007
+ matched = aggregates.get("exports")
1008
+ return version, matched if isinstance(matched, int) and not isinstance(matched, bool) else None, []
1009
+ matched = sum(1 for e in data["exports"] if isinstance(e, dict) and e.get("extraction_method") == "ast-grep")
1010
+ if not matched:
1011
+ return None, None, []
1012
+ if given_version and given_version.strip():
1013
+ return given_version.strip(), matched, []
1014
+ return "unknown", matched, [f"tool_versions.ast_grep is unknown: ast-grep matched {matched} exports without "
1015
+ "the recipe runner, and stdin gave no ast_grep_version"]
1016
+
1017
+
1018
+ def build_metadata(data: dict, brief: dict, stats: object, given, skf_version: str,
1019
+ description: str) -> tuple[dict, list[str]]:
1020
+ """(the skill's metadata.json, its warnings) (see `metadata` in the module docstring)."""
1021
+ fields = _metadata_input(given)
1022
+ if not isinstance(stats, dict) or not isinstance(stats.get("stats"), dict) \
1023
+ or not isinstance(stats.get("confidence_distribution"), dict):
1024
+ raise InventoryError("the --stats file must hold the stats helper's JSON: a stats and a "
1025
+ "confidence_distribution object")
1026
+ clash = sorted(set(fields["stats_extra"]) & set(stats["stats"]))
1027
+ if clash:
1028
+ raise InventoryError(f"stats_extra may not set {', '.join(clash)}: the stats helper writes it")
1029
+ scope = brief.get("scope") if isinstance(brief.get("scope"), dict) else {}
1030
+ scope_type = scope.get("type")
1031
+ if not isinstance(scope_type, str) or not scope_type:
1032
+ raise InventoryError("the brief has no scope.type, which metadata.json records as scope_type")
1033
+ extra = sorted(fields["stats_extra"])
1034
+ if scope_type == REFERENCE_APP and set(extra) - {"notes"}:
1035
+ raise InventoryError(f"a reference app's stats_extra holds only notes, not {', '.join(extra)}: the stats "
1036
+ "helper writes pattern_surfaces_documented")
1037
+ if extra and scope_type not in (REFERENCE_APP, COMPONENT_LIBRARY):
1038
+ raise InventoryError(f"a {scope_type} skill takes no stats_extra (only a component library or a "
1039
+ "reference app adds stats)")
1040
+ ast_grep, node_count, warnings = _ast_grep_run(data, fields["ast_grep_version"])
1041
+ metadata = {
1042
+ "name": brief.get("name") or data.get("skill_name"),
1043
+ "version": fields["version"],
1044
+ "skill_type": "single",
1045
+ "scope_type": scope_type,
1046
+ "source_authority": brief.get("source_authority") or "community",
1047
+ "source_repo": brief.get("source_repo"),
1048
+ "source_root": fields["source_root"],
1049
+ "source_commit": fields["source_commit"],
1050
+ "source_ref": fields["source_ref"],
1051
+ "confidence_tier": data.get("tier"),
1052
+ "spec_version": "1.3",
1053
+ "generation_date": _utc_now(),
1054
+ "description": description,
1055
+ "language": fields["language"],
1056
+ }
1057
+ if node_count is not None:
1058
+ metadata["ast_node_count"] = node_count
1059
+ metadata["exports"] = metadata_exports(data, scope_type)
1060
+ metadata["tool_versions"] = {"ast_grep": ast_grep, "qmd": fields["qmd_version"], "skf": skf_version}
1061
+ metadata["confidence_distribution"] = stats["confidence_distribution"]
1062
+ metadata["stats"] = {**stats["stats"], **fields["stats_extra"]}
1063
+ for key, folder in (("scripts_inventory", "scripts"), ("assets_inventory", "assets")):
1064
+ rows = _file_rows(data.get(key), folder)
1065
+ if rows:
1066
+ metadata[folder] = rows
1067
+ metadata["generated_by"] = "create-skill"
1068
+ metadata["dependencies"] = []
1069
+ metadata["compatibility"] = ""
1070
+ return metadata, warnings
1071
+
1072
+
1073
+ def rules_yaml(data: dict, language: str) -> str:
1074
+ """extraction-rules.yaml: how the skill was extracted, for a later run to reproduce it."""
1075
+ head = {"skill_name": data.get("skill_name"), "language": language, "tier": data.get("tier"),
1076
+ "extraction_mode": data.get("extraction_mode")}
1077
+ rules = data.get("extraction_rules") if isinstance(data.get("extraction_rules"), dict) else {}
1078
+ lines = ["# Generated by skf-extraction-inventory.py rules from the extraction inventory."]
1079
+ lines += _yaml_lines({**head, **rules})
1080
+ return "\n".join(lines) + "\n"
1081
+
1082
+
1083
+ def _yaml_scalar(value) -> str:
1084
+ if isinstance(value, dict):
1085
+ return "{}"
1086
+ if isinstance(value, list):
1087
+ return "[]"
1088
+ return json.dumps(value, ensure_ascii=False) # JSON scalars are YAML scalars
1089
+
1090
+
1091
+ def _yaml_key(key) -> str:
1092
+ text = str(key)
1093
+ return text if YAML_KEY_RE.fullmatch(text) else json.dumps(text, ensure_ascii=False)
1094
+
1095
+
1096
+ def _yaml_lines(value, indent: int = 0) -> list[str]:
1097
+ pad = " " * indent
1098
+ lines: list[str] = []
1099
+ items = value.items() if isinstance(value, dict) else ((None, v) for v in value)
1100
+ for key, item in items:
1101
+ lead = f"{pad}{_yaml_key(key)}:" if isinstance(value, dict) else f"{pad}-"
1102
+ if isinstance(item, (dict, list)) and item:
1103
+ nested = _yaml_lines(item, indent + 1)
1104
+ if isinstance(value, list):
1105
+ lines.append(f"{lead} {nested[0].lstrip()}")
1106
+ lines.extend(nested[1:])
1107
+ else:
1108
+ lines.append(lead)
1109
+ lines.extend(nested)
1110
+ else:
1111
+ lines.append(f"{lead} {_yaml_scalar(item)}")
1112
+ return lines
1113
+
1114
+
1115
+ def write(path: Path, data: dict | str) -> None:
1116
+ """Write `data` (JSON, or text as it is) to `path` through a temporary file and one rename."""
1117
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
1118
+ try:
1119
+ with open(tmp, "w", encoding="utf-8", newline="\n") as fh:
1120
+ if isinstance(data, str):
1121
+ fh.write(data)
1122
+ else:
1123
+ json.dump(data, fh, indent=2, ensure_ascii=False)
1124
+ fh.write("\n")
1125
+ fh.flush()
1126
+ os.fsync(fh.fileno())
1127
+ os.replace(tmp, path)
1128
+ except OSError as exc:
1129
+ try:
1130
+ tmp.unlink()
1131
+ except OSError:
1132
+ pass
1133
+ raise InventoryError(f"cannot write {path.as_posix()}: {exc}") from exc
1134
+
1135
+
1136
+ def _result(path: Path, data: dict, **extra) -> dict:
1137
+ return {"status": "ok", "inventory": path.as_posix(), "extraction_mode": data.get("extraction_mode"),
1138
+ "counts": counts(data), **extra}
1139
+
1140
+
1141
+ def _stdin_json():
1142
+ raw = sys.stdin.read()
1143
+ try:
1144
+ return json.loads(raw) if raw.strip() else None
1145
+ except ValueError as exc:
1146
+ raise InventoryError(f"stdin is not JSON: {exc}") from exc
1147
+
1148
+
1149
+ def _build_parser() -> argparse.ArgumentParser:
1150
+ parser = argparse.ArgumentParser(
1151
+ prog="skf-extraction-inventory.py",
1152
+ description=__doc__,
1153
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1154
+ )
1155
+ sub = parser.add_subparsers(dest="command", required=True)
1156
+ inventory_help = "the extraction inventory JSON"
1157
+ p_init = sub.add_parser("init", help="write a new inventory from the runner's and the detector's JSON")
1158
+ p_init.add_argument("--inventory", required=True, type=Path, help=inventory_help)
1159
+ p_init.add_argument("--skill", required=True, help="the skill name")
1160
+ p_init.add_argument("--mode", required=True, choices=MODES, help="the extraction mode")
1161
+ p_init.add_argument("--tier", required=True, choices=TIERS, help="the forge tier")
1162
+ p_init.add_argument("--extraction", type=Path, default=None,
1163
+ help="the recipe runner's JSON (skf-extract-public-api.py --mode full -o)")
1164
+ p_init.add_argument("--detected", type=Path, default=None,
1165
+ help="the script and asset detector's JSON (skf-detect-scripts-assets.py detect)")
1166
+ p_add = sub.add_parser("add", help="append a JSON list from stdin to one of the inventory's lists")
1167
+ p_add.add_argument("--inventory", required=True, type=Path, help=inventory_help)
1168
+ p_add.add_argument("--field", required=True, choices=FIELDS, help="the list to append to")
1169
+ p_patch = sub.add_parser("patch", help="set signature, params and return_type on exports from stdin")
1170
+ p_patch.add_argument("--inventory", required=True, type=Path, help=inventory_help)
1171
+ p_set = sub.add_parser("set", help="set the keys of a JSON object from stdin")
1172
+ p_set.add_argument("--inventory", required=True, type=Path, help=inventory_help)
1173
+ p_rules = sub.add_parser("rules", help="write extraction-rules.yaml from the inventory")
1174
+ p_rules.add_argument("--inventory", required=True, type=Path, help=inventory_help)
1175
+ p_rules.add_argument("--language", required=True, help="the brief's language")
1176
+ p_rules.add_argument("--target", required=True, type=Path, help="the extraction-rules.yaml to write")
1177
+ p_prov = sub.add_parser("provenance", help="write the provenance map from the inventory, the header on stdin")
1178
+ p_prov.add_argument("--inventory", required=True, type=Path, help=inventory_help)
1179
+ p_prov.add_argument("--target", required=True, type=Path, help="the provenance-map.json to write")
1180
+ p_prov.add_argument("--add-entries", action="store_true",
1181
+ help="append stdin's entries and file_entries to the map at --target instead")
1182
+ p_meta = sub.add_parser("metadata", help="write metadata.json from the inventory, the brief and the stats, "
1183
+ "the fields compile decides on stdin")
1184
+ p_meta.add_argument("--inventory", required=True, type=Path, help=inventory_help)
1185
+ p_meta.add_argument("--brief", required=True, type=Path, help="the skill-brief.yaml")
1186
+ p_meta.add_argument("--stats", required=True, type=Path,
1187
+ help="the JSON skf-render-metadata-stats.py wrote (its -o file)")
1188
+ p_meta.add_argument("--skf-root", required=True, type=Path,
1189
+ help="the installed SKF module folder ({project-root}/_bmad/skf), for tool_versions.skf")
1190
+ p_meta.add_argument("--target", required=True, type=Path,
1191
+ help="the metadata.json to write, beside the staged SKILL.md it reads the description of")
1192
+ p_summary = sub.add_parser("summary", help="print the inventory's counts")
1193
+ p_summary.add_argument("--inventory", required=True, type=Path, help=inventory_help)
1194
+ return parser
1195
+
1196
+
1197
+ def main(argv: list[str] | None = None) -> int:
1198
+ args = _build_parser().parse_args(argv)
1199
+ path = args.inventory
1200
+ try:
1201
+ if args.command == "init":
1202
+ extraction = _read_json(args.extraction, "runner JSON") if args.extraction else None
1203
+ detected = _read_json(args.detected, "detector JSON") if args.detected else None
1204
+ data = init(args.skill, args.mode, args.tier, extraction, detected)
1205
+ write(path, data)
1206
+ result = _result(path, data)
1207
+ elif args.command == "provenance" and args.add_entries:
1208
+ data = load(path)
1209
+ provenance = _read_json(args.target, "provenance map")
1210
+ if not isinstance(provenance, dict) or not isinstance(provenance.get("entries"), list):
1211
+ raise InventoryError(f"the provenance map {args.target.as_posix()} has no entries list: "
1212
+ "run provenance without --add-entries first")
1213
+ off = frozenset((item["name"], _norm_path(item["file"])) for item in _not_public(data))
1214
+ added, duplicates, not_public = add_map_entries(provenance, _stdin_json(), off)
1215
+ write(args.target, provenance)
1216
+ result = {"status": "ok", "target": args.target.as_posix(), "entries": len(provenance["entries"]),
1217
+ "file_entries": len(provenance.get("file_entries") or []), "added": added,
1218
+ "duplicates": duplicates, "not_public": not_public}
1219
+ elif args.command == "provenance":
1220
+ provenance, unsigned = provenance_map(load(path), _stdin_json())
1221
+ write(args.target, provenance)
1222
+ result = {"status": "ok", "target": args.target.as_posix(), "entries": len(provenance["entries"]),
1223
+ "unsigned": unsigned}
1224
+ elif args.command == "metadata":
1225
+ skf_version = _sibling("skf-render-quick-metadata.py").probe_skf_version(args.skf_root)
1226
+ metadata, warnings = build_metadata(load(path), read_brief(args.brief),
1227
+ _read_json(args.stats, "stats JSON"), _stdin_json(), skf_version,
1228
+ read_description(args.target.parent / "SKILL.md"))
1229
+ write(args.target, metadata)
1230
+ commit = metadata["source_commit"]
1231
+ if skf_version == "unknown":
1232
+ warnings.append(f"tool_versions.skf is unknown: {args.skf_root.as_posix()} holds no package.json "
1233
+ "or VERSION file with a version")
1234
+ result = {"status": "ok", "target": args.target.as_posix(), "exports": len(metadata["exports"]),
1235
+ "commit_short": commit[:8] if commit else "unknown",
1236
+ "tool_versions": metadata["tool_versions"], "warnings": warnings}
1237
+ elif args.command == "rules":
1238
+ text = rules_yaml(load(path), args.language)
1239
+ write(args.target, text)
1240
+ result = {"status": "ok", "target": args.target.as_posix(), "bytes": len(text.encode("utf-8"))}
1241
+ else:
1242
+ data = load(path)
1243
+ if args.command == "summary":
1244
+ result = _result(path, data, not_public=_not_public(data))
1245
+ elif args.command == "add":
1246
+ payload = _stdin_json()
1247
+ added, duplicates, dropped = add(data, args.field, [] if payload is None else payload)
1248
+ write(path, data)
1249
+ result = _result(path, data, added=added, duplicates=duplicates, dropped=dropped)
1250
+ elif args.command == "patch":
1251
+ payload = _stdin_json()
1252
+ patched, unmatched, ambiguous = patch(data, [] if payload is None else payload)
1253
+ write(path, data)
1254
+ result = _result(path, data, patched=patched, unmatched=unmatched, ambiguous=ambiguous)
1255
+ else:
1256
+ payload = _stdin_json()
1257
+ keys, dropped = set_fields(data, {} if payload is None else payload)
1258
+ write(path, data)
1259
+ result = _result(path, data, set=keys, dropped=dropped)
1260
+ except InventoryError as exc:
1261
+ print(json.dumps({"status": "error", "message": str(exc)}), file=sys.stderr)
1262
+ return 1
1263
+ print(json.dumps(result, ensure_ascii=False))
1264
+ return 0
1265
+
1266
+
1267
+ def _force_utf8(*streams) -> None:
1268
+ """Reconfigure stdin and the JSON streams to UTF-8 (a Windows console uses cp1252)."""
1269
+ for stream in streams:
1270
+ if hasattr(stream, "reconfigure"):
1271
+ errors = getattr(stream, "errors", None)
1272
+ if errors is None:
1273
+ stream.reconfigure(encoding="utf-8")
1274
+ else:
1275
+ stream.reconfigure(encoding="utf-8", errors=errors)
1276
+
1277
+
1278
+ if __name__ == "__main__":
1279
+ _force_utf8(sys.stdin, sys.stdout, sys.stderr)
1280
+ sys.exit(main())