bmad-module-skill-forge 2.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (435) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +49 -49
  3. package/docs/404.md +10 -8
  4. package/docs/_data/pinned.yaml +22 -13
  5. package/docs/_internal/STABILITY.md +58 -35
  6. package/docs/agents.md +39 -53
  7. package/docs/architecture.md +65 -51
  8. package/docs/bmad-synergy.md +53 -51
  9. package/docs/campaign.md +127 -75
  10. package/docs/concepts.md +34 -28
  11. package/docs/examples.md +70 -68
  12. package/docs/forge-auto.md +34 -31
  13. package/docs/getting-started.md +118 -57
  14. package/docs/how-it-works.md +25 -21
  15. package/docs/index.md +15 -15
  16. package/docs/skill-model.md +70 -59
  17. package/docs/troubleshooting.md +135 -18
  18. package/docs/verifying-a-skill.md +83 -57
  19. package/docs/why-skf.md +24 -22
  20. package/docs/workflows.md +274 -110
  21. package/package.json +18 -10
  22. package/src/forger/preferences.yaml +8 -1
  23. package/src/knowledge/agentskills-spec.md +2 -0
  24. package/src/knowledge/ccc-bridge.md +40 -24
  25. package/src/knowledge/confidence-tiers.md +9 -5
  26. package/src/knowledge/overview.md +3 -3
  27. package/src/knowledge/progressive-capability.md +8 -8
  28. package/src/knowledge/provenance-tracking.md +9 -5
  29. package/src/knowledge/qmd-registry.md +2 -2
  30. package/src/knowledge/skf-knowledge-index.csv +2 -2
  31. package/src/knowledge/skill-lifecycle.md +18 -11
  32. package/src/knowledge/split-body-strategy.md +0 -3
  33. package/src/knowledge/tool-resolution.md +4 -2
  34. package/src/knowledge/version-paths.md +101 -15
  35. package/src/module-help.csv +3 -3
  36. package/src/module.yaml +10 -0
  37. package/src/shared/data/ast-grep-recipes.yaml +608 -0
  38. package/src/shared/data/ide-context-files.json +30 -0
  39. package/src/shared/health-check.md +32 -12
  40. package/src/shared/references/description-guard-protocol.md +12 -9
  41. package/src/shared/references/feasibility-report-schema.md +19 -13
  42. package/src/shared/references/headless-gate-convention.md +55 -3
  43. package/src/shared/references/output-contract-schema.md +19 -8
  44. package/src/shared/references/pipeline-contracts.md +94 -29
  45. package/src/shared/references/tessl-review.md +77 -0
  46. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
  47. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
  48. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
  49. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
  50. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
  51. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
  52. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
  53. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
  54. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
  55. package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
  56. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
  57. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
  58. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
  59. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
  60. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
  61. package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
  62. package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
  63. package/src/shared/scripts/skf-atomic-write.py +30 -14
  64. package/src/shared/scripts/skf-build-change-manifest.py +2203 -24
  65. package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
  66. package/src/shared/scripts/skf-check-unit-records.py +184 -0
  67. package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
  68. package/src/shared/scripts/skf-classify-changed-files.py +694 -0
  69. package/src/shared/scripts/skf-comention-pairs.py +1333 -123
  70. package/src/shared/scripts/skf-compare-file-hashes.py +1 -1
  71. package/src/shared/scripts/skf-count-imports.py +1867 -0
  72. package/src/shared/scripts/skf-count-tokens.py +1 -1
  73. package/src/shared/scripts/skf-derive-assembly-shape.py +3 -2
  74. package/src/shared/scripts/skf-description-guard.py +99 -11
  75. package/src/shared/scripts/skf-detect-docs.py +694 -2
  76. package/src/shared/scripts/skf-detect-language.py +486 -107
  77. package/src/shared/scripts/skf-detect-registry.py +887 -0
  78. package/src/shared/scripts/skf-detect-scripts-assets.py +1 -1
  79. package/src/shared/scripts/skf-detect-tools.py +209 -27
  80. package/src/shared/scripts/skf-detect-workspaces.py +506 -30
  81. package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
  82. package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
  83. package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
  84. package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
  85. package/src/shared/scripts/skf-extract-public-api.py +4084 -85
  86. package/src/shared/scripts/skf-extraction-inventory.py +746 -0
  87. package/src/shared/scripts/skf-extraction-snapshot.py +539 -0
  88. package/src/shared/scripts/skf-fetch-temporal.py +674 -0
  89. package/src/shared/scripts/skf-find-cycles.py +97 -29
  90. package/src/shared/scripts/skf-find-test-report.py +476 -0
  91. package/src/shared/scripts/skf-forge-tier-rw.py +444 -203
  92. package/src/shared/scripts/skf-github-fetch.py +365 -0
  93. package/src/shared/scripts/skf-github-probe.py +661 -0
  94. package/src/shared/scripts/skf-hash-content.py +166 -12
  95. package/src/shared/scripts/skf-language-corpora.py +1 -1
  96. package/src/shared/scripts/skf-load-provenance.py +159 -27
  97. package/src/shared/scripts/skf-manifest-ops.py +1 -1
  98. package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
  99. package/src/shared/scripts/skf-merge-doc-urls.py +1 -1
  100. package/src/shared/scripts/skf-names-present.py +221 -0
  101. package/src/shared/scripts/skf-pair-intersect.py +119 -28
  102. package/src/shared/scripts/skf-parse-gaps.py +1384 -0
  103. package/src/shared/scripts/skf-preapply.py +1 -1
  104. package/src/shared/scripts/skf-preflight.py +161 -27
  105. package/src/shared/scripts/skf-promote-staged.py +430 -0
  106. package/src/shared/scripts/skf-provenance-gap-dispatch.py +39 -7
  107. package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
  108. package/src/shared/scripts/skf-quick-batch.py +774 -0
  109. package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
  110. package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
  111. package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
  112. package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
  113. package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
  114. package/src/shared/scripts/skf-resolve-authoritative-files.py +128 -13
  115. package/src/shared/scripts/skf-resolve-package.py +523 -106
  116. package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
  117. package/src/shared/scripts/skf-run-lock.py +577 -0
  118. package/src/shared/scripts/skf-scan-manifests.py +998 -57
  119. package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
  120. package/src/shared/scripts/skf-severity-classify.py +712 -108
  121. package/src/shared/scripts/skf-shape-detect.py +298 -204
  122. package/src/shared/scripts/skf-shard-body.py +9 -8
  123. package/src/shared/scripts/skf-skill-inventory.py +2412 -89
  124. package/src/shared/scripts/skf-skills-module.py +472 -0
  125. package/src/shared/scripts/skf-source-tree.py +1796 -0
  126. package/src/shared/scripts/skf-structural-diff.py +670 -119
  127. package/src/shared/scripts/skf-tessl-review.py +706 -0
  128. package/src/shared/scripts/skf-update-active-symlink.py +5 -5
  129. package/src/shared/scripts/skf-update-run-state.py +624 -0
  130. package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
  131. package/src/shared/scripts/skf-validate-brief-schema.py +4 -1
  132. package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
  133. package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
  134. package/src/shared/scripts/skf-validate-output.py +712 -64
  135. package/src/shared/scripts/skf-validate-pins.py +1 -1
  136. package/src/shared/scripts/skf-verify-no-trace.py +116 -10
  137. package/src/shared/scripts/skf-verify-provenance-completeness.py +2919 -54
  138. package/src/shared/scripts/skf-write-skill-brief.py +367 -19
  139. package/src/shared/tool-requirements.yaml +201 -0
  140. package/src/skf-analyze-source/SKILL.md +41 -36
  141. package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
  142. package/src/skf-analyze-source/customize.toml +36 -24
  143. package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
  144. package/src/skf-analyze-source/references/continue.md +2 -13
  145. package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
  146. package/src/skf-analyze-source/references/generate-briefs.md +121 -55
  147. package/src/skf-analyze-source/references/headless-contract.md +42 -15
  148. package/src/skf-analyze-source/references/identify-units.md +96 -110
  149. package/src/skf-analyze-source/references/init.md +73 -71
  150. package/src/skf-analyze-source/references/map-and-detect.md +123 -105
  151. package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
  152. package/src/skf-analyze-source/references/recommend.md +51 -70
  153. package/src/skf-analyze-source/references/scan-project.md +60 -84
  154. package/src/skf-analyze-source/references/scan-root.md +39 -0
  155. package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
  156. package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
  157. package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
  158. package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
  159. package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
  160. package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
  161. package/src/skf-audit-skill/SKILL.md +43 -51
  162. package/src/skf-audit-skill/assets/drift-report-template.md +27 -3
  163. package/src/skf-audit-skill/customize.toml +41 -20
  164. package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
  165. package/src/skf-audit-skill/references/doc-drift.md +141 -0
  166. package/src/skf-audit-skill/references/headless-contract.md +29 -0
  167. package/src/skf-audit-skill/references/init.md +148 -210
  168. package/src/skf-audit-skill/references/re-index.md +96 -108
  169. package/src/skf-audit-skill/references/report.md +96 -33
  170. package/src/skf-audit-skill/references/semantic-diff.md +27 -18
  171. package/src/skf-audit-skill/references/severity-classify.md +79 -65
  172. package/src/skf-audit-skill/references/severity-rules.md +15 -6
  173. package/src/skf-audit-skill/references/structural-diff.md +75 -90
  174. package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
  175. package/src/skf-audit-skill/scripts/render-drift-tables.py +685 -0
  176. package/src/skf-brief-skill/SKILL.md +36 -25
  177. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  178. package/src/skf-brief-skill/assets/scope-templates.md +17 -24
  179. package/src/skf-brief-skill/assets/skill-brief-schema.md +29 -15
  180. package/src/skf-brief-skill/customize.toml +48 -20
  181. package/src/skf-brief-skill/references/analyze-target.md +142 -125
  182. package/src/skf-brief-skill/references/confirm-brief.md +10 -8
  183. package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
  184. package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
  185. package/src/skf-brief-skill/references/gather-intent.md +87 -182
  186. package/src/skf-brief-skill/references/headless-args.md +84 -16
  187. package/src/skf-brief-skill/references/health-check.md +1 -1
  188. package/src/skf-brief-skill/references/invocation-contract.md +18 -13
  189. package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
  190. package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
  191. package/src/skf-brief-skill/references/scope-definition.md +123 -47
  192. package/src/skf-brief-skill/references/step-auto-brief.md +24 -80
  193. package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
  194. package/src/skf-brief-skill/references/version-resolution.md +3 -2
  195. package/src/skf-brief-skill/references/write-brief.md +66 -57
  196. package/src/skf-campaign/SKILL.md +31 -34
  197. package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
  198. package/src/skf-campaign/customize.toml +47 -16
  199. package/src/skf-campaign/references/campaign-contracts.md +39 -29
  200. package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
  201. package/src/skf-campaign/references/health-check.md +7 -16
  202. package/src/skf-campaign/references/step-01-setup.md +46 -84
  203. package/src/skf-campaign/references/step-02-strategy.md +31 -30
  204. package/src/skf-campaign/references/step-03-pins.md +26 -18
  205. package/src/skf-campaign/references/step-04-provenance.md +24 -23
  206. package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
  207. package/src/skf-campaign/references/step-06-batch.md +41 -34
  208. package/src/skf-campaign/references/step-07-capstone.md +40 -25
  209. package/src/skf-campaign/references/step-08-verify.md +28 -29
  210. package/src/skf-campaign/references/step-09-refine.md +29 -30
  211. package/src/skf-campaign/references/step-10-export.md +48 -36
  212. package/src/skf-campaign/references/step-11-maintenance.md +31 -34
  213. package/src/skf-campaign/references/step-resume.md +37 -68
  214. package/src/skf-campaign/scripts/campaign-deps.py +57 -18
  215. package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
  216. package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
  217. package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
  218. package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
  219. package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
  220. package/src/skf-campaign/scripts/campaign-report.py +204 -37
  221. package/src/skf-campaign/scripts/campaign-state.py +1128 -0
  222. package/src/skf-campaign/scripts/campaign-status.py +10 -74
  223. package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
  224. package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
  225. package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
  226. package/src/skf-campaign/templates/campaign-report-template.md +5 -1
  227. package/src/skf-campaign/templates/kickoff-template.md +0 -14
  228. package/src/skf-create-skill/SKILL.md +27 -20
  229. package/src/skf-create-skill/assets/compile-assembly-rules.md +6 -5
  230. package/src/skf-create-skill/assets/skill-sections.md +15 -5
  231. package/src/skf-create-skill/customize.toml +20 -8
  232. package/src/skf-create-skill/references/authoritative-files-protocol.md +41 -42
  233. package/src/skf-create-skill/references/batch-mode.md +81 -0
  234. package/src/skf-create-skill/references/ccc-index-check.md +27 -0
  235. package/src/skf-create-skill/references/compile.md +107 -29
  236. package/src/skf-create-skill/references/component-extraction.md +113 -91
  237. package/src/skf-create-skill/references/enrich.md +41 -23
  238. package/src/skf-create-skill/references/entry-points-by-hand.md +20 -0
  239. package/src/skf-create-skill/references/extract.md +200 -161
  240. package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
  241. package/src/skf-create-skill/references/extraction-patterns-tracing.md +2 -2
  242. package/src/skf-create-skill/references/extraction-patterns.md +584 -196
  243. package/src/skf-create-skill/references/generate-artifacts.md +145 -93
  244. package/src/skf-create-skill/references/health-check.md +1 -1
  245. package/src/skf-create-skill/references/load-brief.md +53 -36
  246. package/src/skf-create-skill/references/relabel-rule.md +12 -0
  247. package/src/skf-create-skill/references/report.md +28 -59
  248. package/src/skf-create-skill/references/source-resolution-protocols.md +49 -155
  249. package/src/skf-create-skill/references/step-auto-shard.md +20 -93
  250. package/src/skf-create-skill/references/step-doc-rot.md +44 -60
  251. package/src/skf-create-skill/references/step-doc-sources.md +28 -19
  252. package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
  253. package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
  254. package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
  255. package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
  256. package/src/skf-create-skill/references/validate.md +173 -99
  257. package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
  258. package/src/skf-create-stack-skill/SKILL.md +29 -56
  259. package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
  260. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
  261. package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
  262. package/src/skf-create-stack-skill/customize.toml +23 -12
  263. package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
  264. package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
  265. package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
  266. package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
  267. package/src/skf-create-stack-skill/references/generate-output.md +138 -128
  268. package/src/skf-create-stack-skill/references/init.md +90 -32
  269. package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
  270. package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
  271. package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
  272. package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
  273. package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
  274. package/src/skf-create-stack-skill/references/report.md +39 -33
  275. package/src/skf-create-stack-skill/references/validate.md +20 -39
  276. package/src/skf-drop-skill/SKILL.md +28 -51
  277. package/src/skf-drop-skill/customize.toml +44 -30
  278. package/src/skf-drop-skill/references/execute.md +79 -146
  279. package/src/skf-drop-skill/references/invocation-contract.md +64 -0
  280. package/src/skf-drop-skill/references/report.md +46 -30
  281. package/src/skf-drop-skill/references/select.md +118 -124
  282. package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
  283. package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
  284. package/src/skf-export-skill/SKILL.md +40 -62
  285. package/src/skf-export-skill/assets/managed-section-format.md +14 -50
  286. package/src/skf-export-skill/assets/snippet-format.md +3 -1
  287. package/src/skf-export-skill/customize.toml +42 -13
  288. package/src/skf-export-skill/references/generate-snippet.md +54 -46
  289. package/src/skf-export-skill/references/health-check.md +2 -0
  290. package/src/skf-export-skill/references/invocation-contract.md +15 -0
  291. package/src/skf-export-skill/references/load-skill.md +88 -62
  292. package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
  293. package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
  294. package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
  295. package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
  296. package/src/skf-export-skill/references/package.md +15 -73
  297. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
  298. package/src/skf-export-skill/references/result-envelope.md +31 -16
  299. package/src/skf-export-skill/references/summary.md +41 -17
  300. package/src/skf-export-skill/references/token-report.md +4 -12
  301. package/src/skf-export-skill/references/update-context.md +118 -237
  302. package/src/skf-forger/SKILL.md +61 -37
  303. package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
  304. package/src/skf-forger/customize.toml +43 -0
  305. package/src/skf-forger/references/pipeline-mode.md +73 -18
  306. package/src/skf-forger/scripts/forge-status.py +299 -0
  307. package/src/skf-forger/scripts/parse-pipeline.py +322 -54
  308. package/src/skf-forger/scripts/pipeline-gate.py +428 -0
  309. package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
  310. package/src/skf-quick-skill/SKILL.md +54 -53
  311. package/src/skf-quick-skill/assets/skill-template.md +4 -2
  312. package/src/skf-quick-skill/customize.toml +51 -20
  313. package/src/skf-quick-skill/references/batch-mode.md +74 -21
  314. package/src/skf-quick-skill/references/compile.md +30 -31
  315. package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
  316. package/src/skf-quick-skill/references/finalize.md +69 -18
  317. package/src/skf-quick-skill/references/halt-contract.md +41 -29
  318. package/src/skf-quick-skill/references/health-check.md +5 -3
  319. package/src/skf-quick-skill/references/quick-extract.md +114 -45
  320. package/src/skf-quick-skill/references/registry-resolution.md +9 -64
  321. package/src/skf-quick-skill/references/resolve-target.md +116 -66
  322. package/src/skf-quick-skill/references/write-and-validate.md +79 -40
  323. package/src/skf-refine-architecture/SKILL.md +43 -53
  324. package/src/skf-refine-architecture/customize.toml +49 -20
  325. package/src/skf-refine-architecture/references/compile.md +97 -54
  326. package/src/skf-refine-architecture/references/exit-codes.md +30 -0
  327. package/src/skf-refine-architecture/references/finding-storage.md +21 -0
  328. package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
  329. package/src/skf-refine-architecture/references/improvements.md +14 -20
  330. package/src/skf-refine-architecture/references/init.md +98 -57
  331. package/src/skf-refine-architecture/references/issue-detection.md +32 -39
  332. package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
  333. package/src/skf-refine-architecture/references/report.md +65 -54
  334. package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
  335. package/src/skf-rename-skill/SKILL.md +19 -54
  336. package/src/skf-rename-skill/customize.toml +32 -26
  337. package/src/skf-rename-skill/references/execute.md +230 -184
  338. package/src/skf-rename-skill/references/exit-codes.md +7 -7
  339. package/src/skf-rename-skill/references/health-check.md +1 -7
  340. package/src/skf-rename-skill/references/invocation-contract.md +25 -0
  341. package/src/skf-rename-skill/references/report.md +63 -52
  342. package/src/skf-rename-skill/references/select.md +197 -115
  343. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
  344. package/src/skf-setup/SKILL.md +26 -26
  345. package/src/skf-setup/customize.toml +14 -10
  346. package/src/skf-setup/references/auto-index.md +50 -63
  347. package/src/skf-setup/references/ccc-index.md +29 -90
  348. package/src/skf-setup/references/detect-and-tier.md +58 -67
  349. package/src/skf-setup/references/health-check.md +2 -0
  350. package/src/skf-setup/references/invocation-contract.md +14 -0
  351. package/src/skf-setup/references/report.md +43 -166
  352. package/src/skf-setup/references/tier-rules.md +5 -5
  353. package/src/skf-setup/references/write-config.md +34 -57
  354. package/src/skf-test-skill/SKILL.md +17 -57
  355. package/src/skf-test-skill/assets/output-section-formats.md +33 -34
  356. package/src/skf-test-skill/customize.toml +27 -13
  357. package/src/skf-test-skill/references/coherence-check.md +134 -62
  358. package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
  359. package/src/skf-test-skill/references/coverage-check.md +217 -254
  360. package/src/skf-test-skill/references/detect-mode.md +5 -5
  361. package/src/skf-test-skill/references/external-validators.md +94 -82
  362. package/src/skf-test-skill/references/health-check.md +6 -7
  363. package/src/skf-test-skill/references/init.md +163 -88
  364. package/src/skf-test-skill/references/invocation-contract.md +66 -0
  365. package/src/skf-test-skill/references/migration-section-rules.md +5 -2
  366. package/src/skf-test-skill/references/report.md +162 -142
  367. package/src/skf-test-skill/references/score.md +80 -107
  368. package/src/skf-test-skill/references/scoring-rules.md +41 -155
  369. package/src/skf-test-skill/references/source-access-protocol.md +19 -92
  370. package/src/skf-test-skill/references/step-hard-gate.md +51 -33
  371. package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
  372. package/src/skf-test-skill/scripts/build-result-context.py +287 -0
  373. package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
  374. package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
  375. package/src/skf-test-skill/scripts/compute-score.py +223 -45
  376. package/src/skf-test-skill/scripts/gap-ledger.py +1381 -0
  377. package/src/skf-test-skill/scripts/hard-gate.py +203 -0
  378. package/src/skf-test-skill/scripts/load-coverage-inputs.py +760 -0
  379. package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
  380. package/src/skf-test-skill/scripts/reconcile-coverage.py +273 -79
  381. package/src/skf-test-skill/scripts/score-signatures.py +459 -0
  382. package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
  383. package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
  384. package/src/skf-test-skill/scripts/verify-declared-numerator.py +127 -52
  385. package/src/skf-test-skill/templates/test-report-template.md +21 -15
  386. package/src/skf-update-skill/SKILL.md +38 -25
  387. package/src/skf-update-skill/customize.toml +27 -12
  388. package/src/skf-update-skill/references/detect-changes.md +233 -222
  389. package/src/skf-update-skill/references/gap-driven.md +336 -0
  390. package/src/skf-update-skill/references/health-check.md +31 -5
  391. package/src/skf-update-skill/references/init.md +250 -84
  392. package/src/skf-update-skill/references/invocation-contract.md +12 -0
  393. package/src/skf-update-skill/references/manual-section-rules.md +6 -23
  394. package/src/skf-update-skill/references/merge.md +180 -47
  395. package/src/skf-update-skill/references/re-extract.md +53 -262
  396. package/src/skf-update-skill/references/report.md +100 -35
  397. package/src/skf-update-skill/references/write.md +331 -149
  398. package/src/skf-update-skill/scripts/skf-new-file-diff.py +29 -7
  399. package/src/skf-verify-stack/SKILL.md +23 -42
  400. package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
  401. package/src/skf-verify-stack/customize.toml +55 -22
  402. package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
  403. package/src/skf-verify-stack/references/coverage.md +67 -64
  404. package/src/skf-verify-stack/references/exit-codes.md +21 -8
  405. package/src/skf-verify-stack/references/init.md +107 -62
  406. package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
  407. package/src/skf-verify-stack/references/integrations.md +106 -65
  408. package/src/skf-verify-stack/references/report.md +59 -42
  409. package/src/skf-verify-stack/references/requirements.md +32 -9
  410. package/src/skf-verify-stack/references/synthesize.md +53 -39
  411. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
  412. package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
  413. package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
  414. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
  415. package/tools/cli/commands/install.js +11 -4
  416. package/tools/cli/commands/status.js +15 -3
  417. package/tools/cli/commands/update.js +11 -4
  418. package/tools/cli/lib/tool-check.js +320 -0
  419. package/tools/cli/lib/version-check.js +22 -9
  420. package/tools/cli/skf-cli.js +4 -1
  421. package/.gitattributes +0 -16
  422. package/.nvmrc +0 -1
  423. package/docs/_internal/RELEASING.md +0 -679
  424. package/release-audits/v1.0.0-launch-audit.md +0 -728
  425. package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
  426. package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
  427. package/src/skf-campaign/manifest.yaml +0 -15
  428. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
  429. package/src/skf-create-skill/references/ecosystem-check.md +0 -74
  430. package/src/skf-drop-skill/references/headless-contract.md +0 -18
  431. package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
  432. package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
  433. package/src/skf-update-skill/references/validate.md +0 -124
  434. package/tools/validate-docs-drift.js +0 -233
  435. package/tools/validate-docs-links.js +0 -665
@@ -0,0 +1,694 @@
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = ["pyyaml"]
4
+ # ///
5
+ """SKF Classify Changed Files: update-skill's file-level changes (Category A) in one call.
6
+
7
+ skf-update-skill's detect-changes step sorts the source's files into
8
+ MODIFIED, ADDED and DELETED against the provenance map and the brief's
9
+ scope, before Category B compares the exports of the MODIFIED files. Done
10
+ by hand, that is glob matching (`**`, an exclude that overrides an include)
11
+ and set arithmetic in the prompt, where one missed match silently drops a
12
+ new in-scope file and its exports are never extracted. This helper does it
13
+ in one call, with the tested `glob_match` of
14
+ skf-resolve-authoritative-files.py.
15
+
16
+ Subcommand:
17
+ classify --source-root <path> [--provenance-map <provenance-map.json>]
18
+ [--brief <skill-brief.yaml>] [--language <language>]...
19
+ [--tree-status <status>] [--diff-status <status>]
20
+ [--changed-files <changed-files.json>] [--exclude <path>]...
21
+ [--lists-dir <dir>]
22
+
23
+ --tree-status, --diff-status and --changed-files take the values the
24
+ update-skill init step bound from skf-source-tree.py (`status`,
25
+ `diff_status`, `changed_files`); an empty, `null` or `none` value counts
26
+ as not given. --exclude (repeatable) names a path to leave out entirely:
27
+ the change-detection excludes, such as a document the authoritative-files
28
+ mirror just promoted. Without --brief (a skill built without one, such
29
+ as a quick skill) the scope is empty. --language (repeatable) is the
30
+ skill's language, metadata.json `language`. Without --provenance-map
31
+ (update-skill's degraded mode) the mode is `full`.
32
+
33
+ --lists-dir writes two JSON arrays of paths into that folder for the
34
+ steps that read the files next: modified-files.json (category_a's
35
+ modified files, the ones whose exports are diffed) and extract-files.json
36
+ (the modified and added files and each moved file's new path, whose
37
+ current exports are extracted).
38
+
39
+ The files it sorts:
40
+ tracked a file a provenance-map `entries[].source_file` names
41
+ named a tracked file, or one a `file_entries[].source_file` names
42
+ (a script, an asset or a promoted document: those are
43
+ Category D's, never ADDED here)
44
+ in scope a `scope.include` glob matches it and no `scope.exclude` glob
45
+ does. With an empty `scope.include`, a file whose extension one
46
+ of the tracked files has, or --language's language uses, and
47
+ that no `scope.exclude` glob matches.
48
+ promoted a file a promoted glob matches: the path of a
49
+ `scope.amendments[]` entry whose latest action is `promoted`
50
+ with category `scope-expansion`, when no tracked file matches
51
+ that glob yet (the scope reconciliation promoted it, and its
52
+ files were never extracted)
53
+ Globs follow the brief's semantics: `**` spans any number of folders,
54
+ `*` and `?` never cross a `/`. Walking the source skips symbolic links
55
+ and generated or vendored folders (node_modules, dist, build, .git, ...),
56
+ and an unnamed file in one of them is never ADDED. An --exclude path is
57
+ in no list.
58
+
59
+ Modes, from the statuses:
60
+ diff --diff-status ok (or --changed-files alone): the changed-files.json
61
+ skf-source-tree.py wrote, git's list of the files changed since the
62
+ skill's commit, {"base", "target", "files": [{"status": "A"|"M"|"D",
63
+ "path"}]}. A checkout rewrites every timestamp, so only this list
64
+ says what changed.
65
+ MODIFIED a tracked file with an M or A row
66
+ DELETED a tracked file with a D row, or one the source lacks
67
+ ADDED an unnamed, in-scope file with an A or M row (a file
68
+ that exported nothing when the map was built can gain
69
+ an export), or a promoted file, changed or not
70
+ MOVED a DELETED file with a D row and an ADDED file with an A
71
+ row holding the same contents (one git blob at base and
72
+ at target, each matched once): listed in moved_files,
73
+ and in neither added nor deleted
74
+ tree-without-diff --tree-status ready or offline, the diff unavailable:
75
+ the skill's commit could not be read, so
76
+ MODIFIED every tracked file the source holds
77
+ DELETED a tracked file the source lacks
78
+ ADDED every unnamed, in-scope file
79
+ with a `file-diff-unavailable` warning.
80
+ local anything else (a local source, read as it stands): a walk that
81
+ compares each file's modification time with the provenance map's
82
+ time, the later of its `generated_at` and, when its top-level
83
+ `update_type` is `incremental` or `full` (an update that read the
84
+ source), its top-level `last_update`. A time with a zone is that
85
+ instant. A date alone (update-skill wrote `last_update` as a date
86
+ before v3.0.0) or a time without a zone is the earliest instant it
87
+ can name, 14 hours before its UTC reading (the day starts first at
88
+ UTC+14): a baseline too early only re-checks a few files, while
89
+ one too late would miss a file edited after the update.
90
+ MODIFIED a tracked file modified at or after that time
91
+ DELETED a tracked file the source lacks
92
+ ADDED an unnamed, in-scope file modified at or after that
93
+ time, or a promoted file
94
+ With no usable time every tracked file the source holds is
95
+ MODIFIED and every unnamed, in-scope file ADDED, with a
96
+ `no-baseline-time` warning. No MOVED: a deleted file's contents
97
+ are gone.
98
+ full no --provenance-map: with no map to compare against, every
99
+ in-scope file the walk finds is MODIFIED, and none is ADDED,
100
+ DELETED or MOVED. A run with nothing to scope the walk by (no
101
+ `scope.include` glob and no --language this helper knows) is an
102
+ input error rather than an empty list.
103
+
104
+ An unknown --language value adds an `unknown-language` warning.
105
+
106
+ Output JSON (stdout):
107
+
108
+ {
109
+ "status": "ok",
110
+ "mode": "diff" | "tree-without-diff" | "local" | "full",
111
+ "category_a": {"modified": [...], "added": [...], "deleted": [...]},
112
+ "moved_files": [{"old_path": "...", "new_path": "..."}],
113
+ "summary": {"tracked": N, "modified": N, "added": N, "deleted": N,
114
+ "moved": N, "unchanged": N},
115
+ "baseline_time": "<YYYY-MM-DDTHH:MM:SSZ>" | null,
116
+ "baseline_source": "last_update" | "generated_at" | null,
117
+ "warnings": ["file-diff-unavailable: ..." | "no-baseline-time: ..." |
118
+ "moved-check-skipped: ..." | "unknown-language: ..."]
119
+ }
120
+
121
+ category_a is the slice skf-build-change-manifest.py build reads, and each
122
+ moved_files item has the shape of its category_c.renamed_files items.
123
+ Paths are relative to the source root, with forward slashes, sorted.
124
+ baseline_time is the instant local mode compared the files with (a date
125
+ alone already moved to its earliest instant), and baseline_source the key
126
+ it came from; both are null outside local mode. summary
127
+ counts the tracked files the run read (an --exclude path is not one), and
128
+ unchanged is those neither MODIFIED, DELETED nor moved.
129
+
130
+ Exit codes:
131
+ 0 classified
132
+ 1 input error: a missing source root, provenance map, brief or
133
+ changed-files file, one that cannot be parsed, --diff-status ok
134
+ without --changed-files, a full mode with nothing to scope the walk
135
+ by, or a --lists-dir it cannot write (message on stderr)
136
+ 2 usage error (argparse, usage on stderr)
137
+ """
138
+
139
+ from __future__ import annotations
140
+
141
+ import argparse
142
+ import importlib.util
143
+ import json
144
+ import os
145
+ import sys
146
+ from datetime import datetime, timedelta, timezone
147
+ from pathlib import Path, PurePosixPath
148
+
149
+ _TREE_STATUSES = ("ready", "offline")
150
+ # The update types that re-read the whole source (a normal update and a
151
+ # rebuild without a provenance map); a gap-driven repair reads no changes.
152
+ _SOURCE_READING_UPDATES = ("incremental", "full")
153
+ # The easternmost zone (UTC+14): a day there starts 14 hours before UTC's.
154
+ _EARLIEST_ZONE_OFFSET = timedelta(hours=14)
155
+ _GIT_TIMEOUT_SEC = 60.0
156
+ # --language (metadata.json `language`) -> the source file extensions a skill
157
+ # in that language reads: the recipe runner's language families
158
+ # (skf-extract-public-api.py EXTENSION_LANGUAGES) and the languages it has
159
+ # no recipe for, which are read by eye.
160
+ _JS_EXTENSIONS = (".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".vue")
161
+ _C_EXTENSIONS = (".c", ".h")
162
+ _CPP_EXTENSIONS = (".cpp", ".cc", ".cxx", ".hpp", ".hh", ".h")
163
+ LANGUAGE_EXTENSIONS = {
164
+ **dict.fromkeys(("javascript", "js", "jsx", "typescript", "ts", "tsx", "vue"), _JS_EXTENSIONS),
165
+ "python": (".py", ".pyi"),
166
+ "rust": (".rs",),
167
+ "go": (".go",),
168
+ "golang": (".go",),
169
+ "java": (".java",),
170
+ "kotlin": (".kt", ".kts"),
171
+ "swift": (".swift",),
172
+ "csharp": (".cs",),
173
+ "c#": (".cs",),
174
+ "php": (".php",),
175
+ "ruby": (".rb",),
176
+ "c": _C_EXTENSIONS,
177
+ "cpp": _CPP_EXTENSIONS,
178
+ "c++": _CPP_EXTENSIONS,
179
+ }
180
+ _RESOLVER = None
181
+ _SOURCE_TREE = None
182
+
183
+
184
+ def _sibling(name: str, stem: str):
185
+ path = Path(__file__).resolve().parent / f"{stem}.py"
186
+ spec = importlib.util.spec_from_file_location(name, path)
187
+ if spec is None or spec.loader is None:
188
+ raise ImportError(f"cannot load {path}")
189
+ module = importlib.util.module_from_spec(spec)
190
+ spec.loader.exec_module(module)
191
+ return module
192
+
193
+
194
+ def _resolver():
195
+ """skf-resolve-authoritative-files.py from this folder, loaded once: the
196
+ brief is read and matched with its load_brief, glob_match, scope_match,
197
+ normalize_rel_path and EXCLUDED_DIR_NAMES."""
198
+ global _RESOLVER
199
+ if _RESOLVER is None:
200
+ _RESOLVER = _sibling("skf_resolve_authoritative_files", "skf-resolve-authoritative-files")
201
+ return _RESOLVER
202
+
203
+
204
+ def _source_tree():
205
+ """skf-source-tree.py from this folder, loaded once: git runs through
206
+ its runner (_run stops a call at the time limit; _resolve_outside_cwd
207
+ never takes a git planted in the current folder)."""
208
+ global _SOURCE_TREE
209
+ if _SOURCE_TREE is None:
210
+ _SOURCE_TREE = _sibling("skf_source_tree", "skf-source-tree")
211
+ return _SOURCE_TREE
212
+
213
+
214
+ def _given(value: str | None) -> str:
215
+ """value stripped; "" for an empty, `null` or `none` value."""
216
+ value = (value or "").strip()
217
+ return "" if value.lower() in ("null", "none") else value
218
+
219
+
220
+ def language_extensions(languages: list[str]) -> tuple[set[str], list[str]]:
221
+ """(the extensions the --language values use, the values none is known for).
222
+
223
+ An empty or `null` value is no language.
224
+
225
+ >>> sorted(language_extensions(["Python", "null"])[0]), language_extensions(["cobol"])[1]
226
+ (['.py', '.pyi'], ['cobol'])
227
+ """
228
+ extensions: set[str] = set()
229
+ unknown = []
230
+ for value in languages:
231
+ if not _given(value):
232
+ continue
233
+ known = LANGUAGE_EXTENSIONS.get(_given(value).lower())
234
+ if known is None:
235
+ unknown.append(value)
236
+ else:
237
+ extensions.update(known)
238
+ return extensions, unknown
239
+
240
+
241
+ # --------------------------------------------------------------------------
242
+ # Inputs
243
+ # --------------------------------------------------------------------------
244
+
245
+
246
+ def _read_json(path: Path, what: str) -> object:
247
+ try:
248
+ return json.loads(path.read_text(encoding="utf-8"))
249
+ except OSError as exc:
250
+ raise ValueError(f"cannot read {what} at {path}: {exc}") from exc
251
+ except ValueError as exc:
252
+ raise ValueError(f"{what} at {path} is not valid JSON: {exc}") from exc
253
+
254
+
255
+ def _source_files(provenance: dict, key: str) -> set[str]:
256
+ norm = _resolver().normalize_rel_path
257
+ rows = provenance.get(key)
258
+ if not isinstance(rows, list):
259
+ return set()
260
+ return {
261
+ norm(row["source_file"])
262
+ for row in rows
263
+ if isinstance(row, dict) and isinstance(row.get("source_file"), str) and row["source_file"].strip()
264
+ }
265
+
266
+
267
+ def _parse_time(value: object) -> datetime | None:
268
+ """An ISO-8601 date or date-time as a UTC instant, or None.
269
+
270
+ A time with a zone is that instant. A date alone, or a time without a
271
+ zone, was written in some zone as far east as UTC+14, so it is read as
272
+ the earliest instant it can name: its UTC reading less 14 hours. A
273
+ baseline too early only re-checks a few files; one too late would miss
274
+ a file edited after the update.
275
+ """
276
+ if not isinstance(value, str) or not value.strip():
277
+ return None
278
+ try:
279
+ moment = datetime.fromisoformat(value.strip())
280
+ if moment.tzinfo is None:
281
+ return moment.replace(tzinfo=timezone.utc) - _EARLIEST_ZONE_OFFSET
282
+ return moment.astimezone(timezone.utc)
283
+ except (ValueError, OverflowError):
284
+ return None
285
+
286
+
287
+ def baseline_time(provenance: dict) -> tuple[datetime | None, str | None]:
288
+ """(time, source) a local source's files are compared with: the later of
289
+ generated_at and a source-reading update's last_update."""
290
+ found = []
291
+ generated = _parse_time(provenance.get("generated_at"))
292
+ if generated is not None:
293
+ found.append((generated, "generated_at"))
294
+ if provenance.get("update_type") in _SOURCE_READING_UPDATES:
295
+ updated = _parse_time(provenance.get("last_update"))
296
+ if updated is not None:
297
+ found.append((updated, "last_update"))
298
+ if not found:
299
+ return None, None
300
+ return max(found, key=lambda item: item[0])
301
+
302
+
303
+ def promoted_globs(brief: dict, tracked: set[str]) -> list[str]:
304
+ """The scope-expansion globs the latest scope-expansion amendment promoted
305
+ and no tracked file matches yet. An entry of another category on the same
306
+ path (an auth-doc or a demo-and-registry decision) never stands in for it."""
307
+ resolver = _resolver()
308
+ scope = brief.get("scope") if isinstance(brief.get("scope"), dict) else {}
309
+ amendments = scope.get("amendments") if isinstance(scope.get("amendments"), list) else []
310
+ latest: dict[str, dict] = {}
311
+ for amend in amendments:
312
+ if (isinstance(amend, dict) and amend.get("category") == "scope-expansion"
313
+ and isinstance(amend.get("path"), str) and amend["path"].strip()):
314
+ latest[resolver.normalize_rel_path(amend["path"])] = amend
315
+ return sorted(
316
+ glob
317
+ for glob, amend in latest.items()
318
+ if amend.get("action") == "promoted"
319
+ and not any(resolver.glob_match(path, glob) for path in tracked)
320
+ )
321
+
322
+
323
+ # --------------------------------------------------------------------------
324
+ # Walk and scope
325
+ # --------------------------------------------------------------------------
326
+
327
+
328
+ def _vendored(rel: str) -> bool:
329
+ return any(part in _resolver().EXCLUDED_DIR_NAMES for part in rel.split("/")[:-1])
330
+
331
+
332
+ def walk_source(root: Path) -> list[tuple[str, Path]]:
333
+ """(relative path, path) of every file under root, sorted: no symbolic
334
+ link, and no generated or vendored folder."""
335
+ excluded = _resolver().EXCLUDED_DIR_NAMES
336
+ found = []
337
+ for dirpath, dirnames, filenames in os.walk(root):
338
+ here = Path(dirpath)
339
+ dirnames[:] = [d for d in dirnames if d not in excluded and not (here / d).is_symlink()]
340
+ for name in filenames:
341
+ path = here / name
342
+ if path.is_symlink() or not path.is_file():
343
+ continue
344
+ found.append((path.relative_to(root).as_posix(), path))
345
+ return sorted(found)
346
+
347
+
348
+ def _in_scope(rel: str, includes: list[str], excludes: list[str], extensions: set[str]) -> bool:
349
+ resolver = _resolver()
350
+ if includes:
351
+ return resolver.scope_match(rel, includes, excludes)[0]
352
+ if any(resolver.glob_match(rel, pattern) for pattern in excludes):
353
+ return False
354
+ return PurePosixPath(rel).suffix in extensions
355
+
356
+
357
+ # --------------------------------------------------------------------------
358
+ # Same-content moves (diff mode)
359
+ # --------------------------------------------------------------------------
360
+
361
+
362
+ def _blobs(tree: Path, commit: str, paths: set[str]) -> dict[str, str]:
363
+ """{path: blob id} at commit for the given paths. Raises RuntimeError
364
+ when git cannot list the commit."""
365
+ try:
366
+ runner = _source_tree()
367
+ except (ImportError, OSError) as exc:
368
+ raise RuntimeError(f"cannot load skf-source-tree.py: {exc}") from exc
369
+ exe = runner._resolve_outside_cwd("git")
370
+ if exe is None:
371
+ raise RuntimeError("git is not installed")
372
+ argv = [exe, "-c", f"core.hooksPath={os.devnull}", "-C", str(tree), "ls-tree", "-r", "-z", commit]
373
+ try:
374
+ rc, out, err, _killed = runner._run(argv, _GIT_TIMEOUT_SEC)
375
+ except (OSError, ValueError) as exc:
376
+ raise RuntimeError(f"git could not run: {exc}") from exc
377
+ if rc is None:
378
+ raise RuntimeError(f"git ls-tree timed out after {_GIT_TIMEOUT_SEC:g} seconds")
379
+ if rc != 0:
380
+ raise RuntimeError(runner._last_error(err.decode("utf-8", errors="replace")))
381
+ blobs = {}
382
+ for record in out.split(b"\0"):
383
+ meta, _, raw_path = record.partition(b"\t")
384
+ fields = meta.split()
385
+ if len(fields) != 3 or fields[1] != b"blob":
386
+ continue
387
+ path = raw_path.decode("utf-8", errors="surrogateescape")
388
+ if path in paths:
389
+ blobs[path] = fields[2].decode("ascii", errors="replace")
390
+ return blobs
391
+
392
+
393
+ def same_content_moves(
394
+ tree: Path, base: str, target: str, deleted: set[str], added: set[str]
395
+ ) -> list[dict]:
396
+ """{old_path, new_path} for each deleted file whose blob at base is the
397
+ blob of exactly one added file at target, and no other deleted file's.
398
+ Raises RuntimeError when git cannot list either commit."""
399
+ if not deleted or not added:
400
+ return []
401
+ by_blob: dict[str, tuple[list[str], list[str]]] = {}
402
+ for path, blob in _blobs(tree, base, deleted).items():
403
+ by_blob.setdefault(blob, ([], []))[0].append(path)
404
+ for path, blob in _blobs(tree, target, added).items():
405
+ by_blob.setdefault(blob, ([], []))[1].append(path)
406
+ return sorted(
407
+ ({"old_path": old[0], "new_path": new[0]} for old, new in by_blob.values() if len(old) == 1 and len(new) == 1),
408
+ key=lambda move: move["new_path"],
409
+ )
410
+
411
+
412
+ # --------------------------------------------------------------------------
413
+ # Classify
414
+ # --------------------------------------------------------------------------
415
+
416
+
417
+ def _changed_rows(data: object, path: Path) -> tuple[dict[str, str], str, str]:
418
+ """({path: status}, base, target) of a changed-files.json."""
419
+ norm = _resolver().normalize_rel_path
420
+ if not isinstance(data, dict) or not isinstance(data.get("files"), list):
421
+ raise ValueError(f"changed-files file at {path} has no files[] list")
422
+ rows = {}
423
+ for row in data["files"]:
424
+ if isinstance(row, dict) and row.get("status") in ("A", "M", "D") and isinstance(row.get("path"), str):
425
+ rows[norm(row["path"])] = row["status"]
426
+ base, target = data.get("base"), data.get("target")
427
+ return rows, base if isinstance(base, str) else "", target if isinstance(target, str) else ""
428
+
429
+
430
+ def classify(
431
+ source_root: Path,
432
+ provenance: dict | None,
433
+ brief: dict,
434
+ *,
435
+ tree_status: str = "",
436
+ diff_status: str = "",
437
+ changed: object = None,
438
+ changed_path: Path | None = None,
439
+ excludes: list[str] | None = None,
440
+ languages: list[str] | None = None,
441
+ ) -> dict:
442
+ """Run Category A. See the module docstring for the output shape.
443
+
444
+ provenance is None in full mode, and brief {} for a skill without one.
445
+ changed is the parsed changed-files.json in diff mode (None otherwise).
446
+ Raises ValueError for a full mode with nothing to scope the walk by.
447
+ """
448
+ resolver = _resolver()
449
+ norm = resolver.normalize_rel_path
450
+ left_out = {norm(p) for p in excludes or [] if isinstance(p, str) and p.strip()}
451
+ tracked = _source_files(provenance or {}, "entries") - left_out
452
+ named = tracked | _source_files(provenance or {}, "file_entries")
453
+ includes, scope_excludes, _ = resolver.extract_scope(brief)
454
+ includes = [norm(p) for p in includes]
455
+ scope_excludes = [norm(p) for p in scope_excludes]
456
+ spoken, unknown_languages = language_extensions(languages or [])
457
+ extensions = {PurePosixPath(p).suffix for p in tracked if PurePosixPath(p).suffix} | spoken
458
+ promoted = promoted_globs(brief, tracked)
459
+
460
+ def candidate(rel: str) -> bool:
461
+ """An unnamed, in-scope file: ADDED when it changed, MODIFIED in full mode."""
462
+ return (
463
+ rel not in named
464
+ and rel not in left_out
465
+ and not _vendored(rel)
466
+ and _in_scope(rel, includes, scope_excludes, extensions)
467
+ )
468
+
469
+ def is_promoted(rel: str) -> bool:
470
+ return any(resolver.glob_match(rel, glob) for glob in promoted)
471
+
472
+ def present(rel: str) -> bool:
473
+ return (source_root / rel).is_file()
474
+
475
+ warnings: list[str] = [
476
+ f"unknown-language: no source file extension is known for {value!r}" for value in unknown_languages
477
+ ]
478
+ moved: list[dict] = []
479
+ modified: set[str] = set()
480
+ added: set[str] = set()
481
+ deleted: set[str] = set()
482
+ baseline, baseline_source = None, None
483
+
484
+ if provenance is None:
485
+ mode = "full"
486
+ if not includes and not extensions:
487
+ raise ValueError(
488
+ "nothing scopes the walk without a provenance map: give a --brief whose scope.include "
489
+ "names the source files, or the skill's --language"
490
+ )
491
+ modified.update(rel for rel, _ in walk_source(source_root) if candidate(rel))
492
+ elif diff_status == "ok" or (not diff_status and changed is not None):
493
+ mode = "diff"
494
+ rows, base, target = _changed_rows(changed, changed_path or Path("changed-files.json"))
495
+ for rel in tracked:
496
+ status = rows.get(rel)
497
+ if status == "D" or not present(rel):
498
+ deleted.add(rel)
499
+ elif status in ("A", "M"):
500
+ modified.add(rel)
501
+ for rel, status in rows.items():
502
+ if status in ("A", "M") and candidate(rel) and present(rel):
503
+ added.add(rel)
504
+ if promoted:
505
+ added.update(rel for rel, _ in walk_source(source_root) if candidate(rel) and is_promoted(rel))
506
+ gone = {rel for rel in deleted if rows.get(rel) == "D"}
507
+ new = {rel for rel in added if rows.get(rel) == "A"}
508
+ if gone and new:
509
+ try:
510
+ if not (base and target):
511
+ raise RuntimeError("the changed-files list names no base or target commit")
512
+ moved = same_content_moves(source_root, base, target, gone, new)
513
+ except RuntimeError as exc:
514
+ warnings.append(f"moved-check-skipped: {exc}")
515
+ for move in moved:
516
+ deleted.discard(move["old_path"])
517
+ added.discard(move["new_path"])
518
+ elif tree_status in _TREE_STATUSES:
519
+ mode = "tree-without-diff"
520
+ for rel in tracked:
521
+ (modified if present(rel) else deleted).add(rel)
522
+ added.update(rel for rel, _ in walk_source(source_root) if candidate(rel))
523
+ commit = provenance.get("source_commit")
524
+ commit = commit.strip() if isinstance(commit, str) and commit.strip() else "the skill's commit"
525
+ warnings.append(
526
+ f"file-diff-unavailable: {commit} could not be read, so every tracked file was re-checked"
527
+ )
528
+ else:
529
+ mode = "local"
530
+ baseline, baseline_source = baseline_time(provenance)
531
+ since = baseline.timestamp() if baseline is not None else None
532
+
533
+ def touched(path: Path) -> bool:
534
+ if since is None:
535
+ return True
536
+ try:
537
+ return path.stat().st_mtime >= since
538
+ except OSError:
539
+ return True
540
+
541
+ for rel in tracked:
542
+ path = source_root / rel
543
+ if not path.is_file():
544
+ deleted.add(rel)
545
+ elif touched(path):
546
+ modified.add(rel)
547
+ added.update(
548
+ rel for rel, path in walk_source(source_root)
549
+ if candidate(rel) and (touched(path) or is_promoted(rel))
550
+ )
551
+ if baseline is None:
552
+ warnings.append(
553
+ "no-baseline-time: the provenance map records no usable generated_at or "
554
+ "last_update, so every tracked file was re-checked and every in-scope file "
555
+ "it does not name was listed as added"
556
+ )
557
+
558
+ unchanged = len(tracked - modified - deleted - {m["old_path"] for m in moved})
559
+ return {
560
+ "status": "ok",
561
+ "mode": mode,
562
+ "category_a": {
563
+ "modified": sorted(modified),
564
+ "added": sorted(added),
565
+ "deleted": sorted(deleted),
566
+ },
567
+ "moved_files": moved,
568
+ "summary": {
569
+ "tracked": len(tracked),
570
+ "modified": len(modified),
571
+ "added": len(added),
572
+ "deleted": len(deleted),
573
+ "moved": len(moved),
574
+ "unchanged": unchanged,
575
+ },
576
+ "baseline_time": baseline.strftime("%Y-%m-%dT%H:%M:%SZ") if baseline is not None else None,
577
+ "baseline_source": baseline_source,
578
+ "warnings": warnings,
579
+ }
580
+
581
+
582
+ # --------------------------------------------------------------------------
583
+ # CLI
584
+ # --------------------------------------------------------------------------
585
+
586
+
587
+ def write_lists(result: dict, folder: Path) -> None:
588
+ """--lists-dir: modified-files.json and extract-files.json. Raises
589
+ ValueError when a list cannot be written."""
590
+ category_a = result["category_a"]
591
+ extract = set(category_a["modified"]) | set(category_a["added"])
592
+ extract.update(move["new_path"] for move in result["moved_files"])
593
+ for name, paths in (("modified-files.json", category_a["modified"]), ("extract-files.json", sorted(extract))):
594
+ try:
595
+ (folder / name).write_text(json.dumps(paths, indent=2) + "\n", encoding="utf-8")
596
+ except OSError as exc:
597
+ raise ValueError(f"cannot write {name} in {folder}: {exc}") from exc
598
+
599
+
600
+ def _cmd_classify(args: argparse.Namespace) -> int:
601
+ source_root = Path(args.source_root)
602
+ if not source_root.is_dir():
603
+ print(f"error: source-root not a directory: {source_root}", file=sys.stderr)
604
+ return 1
605
+ for label, value in (("provenance map", args.provenance_map), ("brief", args.brief)):
606
+ if value is not None and not Path(value).is_file():
607
+ print(f"error: {label} not found: {value}", file=sys.stderr)
608
+ return 1
609
+ tree_status = _given(args.tree_status).lower()
610
+ diff_status = _given(args.diff_status).lower()
611
+ changed_files = _given(args.changed_files)
612
+ if diff_status == "ok" and not changed_files and args.provenance_map is not None:
613
+ print("error: --diff-status ok needs --changed-files", file=sys.stderr)
614
+ return 1
615
+ try:
616
+ provenance = None
617
+ if args.provenance_map is not None:
618
+ provenance = _read_json(Path(args.provenance_map), "provenance map")
619
+ if not isinstance(provenance, dict):
620
+ raise ValueError(f"provenance map at {args.provenance_map} must be a JSON object")
621
+ brief = _resolver().load_brief(Path(args.brief)) if args.brief is not None else {}
622
+ changed = None
623
+ if provenance is not None and changed_files and diff_status in ("ok", ""):
624
+ changed = _read_json(Path(changed_files), "changed-files list")
625
+ result = classify(
626
+ source_root,
627
+ provenance,
628
+ brief,
629
+ tree_status=tree_status,
630
+ diff_status=diff_status,
631
+ changed=changed,
632
+ changed_path=Path(changed_files) if changed_files else None,
633
+ excludes=args.exclude or [],
634
+ languages=args.language or [],
635
+ )
636
+ if args.lists_dir is not None:
637
+ write_lists(result, Path(args.lists_dir))
638
+ except ValueError as exc:
639
+ print(f"error: {exc}", file=sys.stderr)
640
+ return 1
641
+ print(json.dumps(result, indent=2))
642
+ return 0
643
+
644
+
645
+ def _build_parser() -> argparse.ArgumentParser:
646
+ # --help (and classify --help) print the module docstring: the modes,
647
+ # their MODIFIED, ADDED and DELETED rules and the output a step cites.
648
+ parser = argparse.ArgumentParser(
649
+ prog="skf-classify-changed-files",
650
+ description=__doc__,
651
+ formatter_class=argparse.RawDescriptionHelpFormatter,
652
+ )
653
+ sub = parser.add_subparsers(dest="cmd", required=True)
654
+ p = sub.add_parser(
655
+ "classify",
656
+ help="classify the file-level changes",
657
+ description=__doc__,
658
+ formatter_class=argparse.RawDescriptionHelpFormatter,
659
+ )
660
+ p.add_argument("--source-root", required=True, help="path to the source tree the run reads")
661
+ p.add_argument("--provenance-map", help="path to provenance-map.json (none: full mode)")
662
+ p.add_argument("--brief", help="path to skill-brief.yaml (none: an empty scope)")
663
+ p.add_argument(
664
+ "--language",
665
+ action="append",
666
+ metavar="LANG",
667
+ help="the skill's language, whose extensions an empty scope.include takes (repeatable)",
668
+ )
669
+ p.add_argument("--tree-status", help="skf-source-tree.py status (ready, offline, ...)")
670
+ p.add_argument("--diff-status", help="skf-source-tree.py diff_status (ok or unavailable)")
671
+ p.add_argument("--changed-files", help="path to skf-source-tree.py's changed-files.json")
672
+ p.add_argument(
673
+ "--exclude",
674
+ action="append",
675
+ metavar="PATH",
676
+ help="a path to leave out entirely (repeatable)",
677
+ )
678
+ p.add_argument(
679
+ "--lists-dir",
680
+ metavar="DIR",
681
+ help="write modified-files.json and extract-files.json into this folder",
682
+ )
683
+ p.set_defaults(func=_cmd_classify)
684
+ return parser
685
+
686
+
687
+ def main(argv: list[str] | None = None) -> int:
688
+ parser = _build_parser()
689
+ args = parser.parse_args(argv)
690
+ return args.func(args)
691
+
692
+
693
+ if __name__ == "__main__":
694
+ sys.exit(main())