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
@@ -1,49 +1,165 @@
1
1
  # /// script
2
- # requires-python = ">=3.10"
2
+ # requires-python = ">=3.11"
3
3
  # dependencies = []
4
4
  # ///
5
- """SKF Resolve Package — resolve a package name to a GitHub repository URL.
5
+ """SKF Resolve Package: read a quick-skill target, and resolve a package
6
+ name to its GitHub repository.
6
7
 
7
- Walks the canonical fallback chain documented in
8
- `src/skf-quick-skill/references/registry-resolution.md`:
8
+ `parse-target` splits what the user typed into its parts. `resolve` asks
9
+ every registry of the canonical chain, in this order:
9
10
 
10
11
  1. npm registry (JavaScript/TypeScript)
11
12
  2. PyPI registry (Python)
12
13
  3. crates.io registry (Rust)
13
14
 
14
- Per-call timeout (default 10s); a timeout is treated as a soft failure
15
- and the resolver falls through to the next entry. Web-search fallback
16
- is intentionally NOT in this helper — registries are deterministic;
17
- web search is judgment, and stays in the LLM step.
15
+ The first registry in that order that gives a GitHub repository is the
16
+ pick; every other registry that holds the name is listed beside it, so a
17
+ bare name such as `numpy`, which npm and PyPI both hold, never resolves to
18
+ one project without the other being named. Per-call timeout (default
19
+ 10s); a timeout is treated as a soft failure, like a 404. Web-search
20
+ fallback is intentionally NOT in this helper: registries are
21
+ deterministic; web search is judgment, and stays in the LLM step
22
+ (`src/skf-quick-skill/references/registry-resolution.md`).
18
23
 
19
24
  CLI:
20
25
 
21
- python3 skf-resolve-package.py <package_name> [--timeout 10]
22
-
23
- Output JSON (stdout):
24
-
25
- status: "ok" | "fallthrough"
26
- package_name: "<input>"
27
- resolved_url: "https://github.com/<owner>/<repo>" (when status == "ok")
28
- repo_owner: "<owner>" (when status == "ok")
29
- repo_name: "<repo>" (when status == "ok")
30
- registry_used: "npm" | "pypi" | "crates" (when status == "ok")
31
- registries_tried: ["npm", ...]
26
+ uv run skf-resolve-package.py parse-target [--target <text>] [--local-first]
27
+ uv run skf-resolve-package.py resolve <package_name> \\
28
+ [--registry npm|pypi|crates] [--language <hint>] [--timeout 10]
29
+
30
+ parse-target reads the target from stdin when --target is not given, so a
31
+ target holding quotes or `$` reaches it unchanged. --local-first makes a
32
+ target that names an existing file or folder (relative to the working
33
+ folder, a leading `~` expanded) a local-path whatever its shape, before
34
+ any other shape is tried: `skill-brief.yaml`, `briefs/skill-brief.yaml`,
35
+ `libs/mylib` or a path with a space, which the shapes below read as a
36
+ package, a github `<owner>/<repo>` or unparsed text. The form earlier SKF
37
+ versions called, `skf-resolve-package.py <package_name> [--timeout 10]`,
38
+ still runs resolve.
39
+
40
+ parse-target output (stdout JSON; every key is always present, null when
41
+ the kind has none):
42
+
43
+ kind: "github" | "package" | "registry-page" | "other-host" |
44
+ "local-path" | "unparsed" (KINDS)
45
+ input: the target as read, without surrounding whitespace or
46
+ one pair of quotes, backticks or angle brackets
47
+ target_version: the version a trailing `@<version>` or `==<version>`
48
+ pins, or a registry page's version
49
+ dist_tag: package: the npm dist-tag a trailing `@<tag>` names
50
+ (`latest`, `canary`); it pins no version
51
+ owner, repo: github: the repository
52
+ url: github: https://github.com/<owner>/<repo>;
53
+ other-host: the URL as given
54
+ ref, subdir: github: the ref and the folder of a
55
+ `/tree/<ref>/<folder>` URL (the ref is one path part)
56
+ package_name: package, registry-page: the name to resolve
57
+ registry: "npm" | "pypi" | "crates" | null: the one registry
58
+ that can hold the name: a registry page's, PyPI for a
59
+ `==` pin, npm for a scoped `@scope/name`
60
+ skill_name: github, package, registry-page: the name the skill is
61
+ written under: the package name, else the last part
62
+ of a `/tree/` folder, else the repository name, in
63
+ lower case with each run of other characters turned
64
+ into one hyphen (`@babel/core` is `babel-core`,
65
+ `vercel/next.js` is `next-js`)
66
+ host: other-host: the host name, in lower case
67
+ path: local-path: the path as given
68
+
69
+ Shapes, in the order they are tried:
70
+ local-path starts with `.`, `/`, `\\`, `~`, a drive letter or
71
+ `file://`
72
+ package `<name>==<version>` (PyPI, extras dropped)
73
+ github an https, http, git, ssh or git+ URL of github.com
74
+ (www. included, `:` or a port after the host),
75
+ `github.com/<owner>/<repo>`,
76
+ `git@github.com:<owner>/<repo>`,
77
+ `github:<owner>/<repo>` or `<owner>/<repo>`; `.git`, a
78
+ trailing slash, a query, a fragment and any other path
79
+ after the repository are dropped
80
+ registry-page https://www.npmjs.com/package/<name>[/v/<version>],
81
+ https://pypi.org/project/<name>[/<version>] (or
82
+ pypi.python.org/pypi/...),
83
+ https://crates.io/crates/<name>[/<version>]; a page
84
+ tail that is no version (crates.io's `/versions`) is
85
+ dropped
86
+ other-host any other URL, `gitlab:` or `bitbucket:` shortcuts, or
87
+ `<host>/<path>` whose first part is a host name
88
+ package an npm, PyPI or crates.io name, `@scope/name` included
89
+ unparsed anything else: prose, an empty target
90
+
91
+ Every shape but local-path may end in `@<version>`: the text after the
92
+ last `@` (not the one that opens a scope) is the version when it is one
93
+ word of letters, digits, `.`, `_`, `+` and `-` that starts with a digit,
94
+ or with `v` and a digit (`0.5.0`, `v2.1.0-beta`). `==<version>` takes
95
+ the same word. After a package name, a word that starts with a letter
96
+ instead is an npm dist-tag.
97
+
98
+ resolve output (stdout JSON):
99
+
100
+ status: "ok" | "ambiguous" | "fallthrough" (STATUSES)
101
+ package_name: "<input>"
102
+ resolved_url: "https://github.com/<owner>/<repo>" (ok, ambiguous)
103
+ repo_owner: "<owner>" (ok, ambiguous)
104
+ repo_name: "<repo>" (ok, ambiguous)
105
+ skill_name: the package's skill name, as parse-target
106
+ writes it (ok, ambiguous)
107
+ registry_used: "npm" | "pypi" | "crates" (ok, ambiguous)
108
+ source_subdir: the package's folder in the repository, else null
109
+ (ok, ambiguous): npm's repository.directory, or the
110
+ folder of a `/tree/<ref>/<folder>` repository URL
111
+ also_found_in: every other registry that answered for the name, in
112
+ chain order (ok, ambiguous; empty for ok): {registry,
113
+ outcome, resolved_url, repo_owner, repo_name,
114
+ source_subdir}, the repository fields null unless its
115
+ outcome is ok
116
+ warning: the one-line warning a run that keeps the pick of an
117
+ ambiguous name records, `also_found_in: ...` (null
118
+ unless ambiguous)
119
+ name_found_in: the registries that answered for the name with
120
+ anything other than a 404 or a timeout (outcome ok,
121
+ no-github-link or error), in chain order
122
+ registries_tried: ["npm", ...]
32
123
  registry_outcomes: {"npm": "ok|404|timeout|error|no-github-link", ...}
33
124
 
125
+ ok: one registry answered for the name, and it resolved it.
126
+ ambiguous: the pick resolved the name, and another registry of the
127
+ chain answered for it too (name_found_in holds both, and
128
+ also_found_in the others), so the name may belong to two
129
+ projects. The fields name the pick's repository; the
130
+ caller decides.
131
+ fallthrough: no registry gave a GitHub URL; the LLM step falls back to
132
+ web search.
133
+
134
+ An error (a registry that could not be read: an HTTP error other than
135
+ 404, a refused connection, an unreadable answer) counts as an answer,
136
+ since that registry may still hold the name: while npm cannot be read,
137
+ a name PyPI resolves is ambiguous. A timeout does not count (#582's
138
+ rule, accepted): a registry that times out is left out of
139
+ name_found_in, and registry_outcomes shows the timeout.
140
+
141
+ --registry queries that registry alone. --language picks the registry
142
+ of a language hint (LANGUAGE_REGISTRIES, any letter case: javascript,
143
+ typescript, js and ts are npm, python and py PyPI, rust crates.io);
144
+ any other hint leaves the chain whole, and --registry wins over it. A
145
+ result from one registry is never ambiguous.
146
+
34
147
  Exit codes:
35
148
 
36
- 0 status == "ok"
37
- 1 status == "fallthrough" — every registry replied without a GitHub
38
- URL; the LLM step should fall back to web search.
149
+ 0 parse-target printed its result; resolve: status == "ok"
150
+ 1 status == "fallthrough"
151
+ 2 usage error (argparse)
152
+ 3 status == "ambiguous"
39
153
  """
40
154
 
41
155
  from __future__ import annotations
42
156
 
43
157
  import argparse
44
158
  import json
159
+ import os
45
160
  import re
46
161
  import socket
162
+ import string
47
163
  import sys
48
164
  import urllib.error
49
165
  import urllib.parse
@@ -53,68 +169,292 @@ from typing import Optional
53
169
  REGISTRY_TIMEOUT_SECONDS = 10.0
54
170
  USER_AGENT = "skf-resolve-package/1.0 (+https://github.com/armelhbobdad/bmad-module-skill-forge)"
55
171
 
172
+ KINDS = ("github", "package", "registry-page", "other-host", "local-path", "unparsed")
173
+ REGISTRIES = ("npm", "pypi", "crates")
174
+ STATUSES = ("ok", "ambiguous", "fallthrough")
175
+ EXIT_CODES = {"ok": 0, "fallthrough": 1, "ambiguous": 3}
176
+ COMMANDS = ("parse-target", "resolve")
177
+ LANGUAGE_REGISTRIES = {"javascript": "npm", "typescript": "npm", "js": "npm", "ts": "npm",
178
+ "python": "pypi", "py": "pypi", "rust": "crates"}
179
+ # PyPI project_urls labels that name the repository, in the order they are
180
+ # read, as PEP 753 normalizes them; the homepage comes last.
181
+ PYPI_SOURCE_LABELS = ("source", "sourcecode", "repository", "github")
182
+
183
+ # skf-github-probe.py loads this file to read --repo with github_target:
184
+ # keep that name and what it takes and returns.
185
+
186
+ # A github.com URL: scheme URLs (userinfo, and a port or the scp-like `:`
187
+ # after the host, allowed), the scp-like ssh form and a URL without a
188
+ # scheme. The path is split by _github_location.
56
189
  _GITHUB_URL_RE = re.compile(
57
- r"https?://(?:www\.)?github\.com/([^/\s]+)/([^/\s.]+?)(?:\.git)?/?$",
190
+ r"^(?:(?:https?|git|ssh)://(?:[^@/\s]+@)?(?:www\.)?github\.com(?::\d+)?[/:]"
191
+ r"|git@github\.com:"
192
+ r"|(?:www\.)?github\.com/)"
193
+ r"(?P<path>\S*)$",
58
194
  re.IGNORECASE,
59
195
  )
196
+ # A GitHub owner or repository name: letters, digits, `-`, `_` and `.`,
197
+ # with at least one letter or digit.
198
+ _NAME_RE = re.compile(r"^(?=.*[A-Za-z0-9])[A-Za-z0-9_.-]+$")
199
+ # `<owner>/<repo>`: a GitHub owner has no dot, so `<host>/<path>` never matches.
200
+ _SHORTHAND_RE = re.compile(r"^(?P<owner>[A-Za-z0-9][A-Za-z0-9_-]*)/(?P<repo>[A-Za-z0-9_.-]+)$")
201
+ # A version starts with a digit, or with `v` and a digit; npm's dist-tags
202
+ # (`latest`, `canary`) start with a letter.
203
+ _VERSION_RE = re.compile(r"^[vV]?[0-9][A-Za-z0-9._+-]*$")
204
+ _DIST_TAG_RE = re.compile(r"^[A-Za-z][A-Za-z0-9._-]*$")
205
+ _PACKAGE_RE = re.compile(r"^(?:@(?P<scope>[A-Za-z0-9][A-Za-z0-9._-]*)/)?[A-Za-z0-9][A-Za-z0-9._-]*$")
206
+ _PYPI_PIN_RE = re.compile(r"^(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?==(?P<version>.*)$")
207
+ _SCHEME_RE = re.compile(r"^(?P<scheme>[A-Za-z][A-Za-z0-9+.-]*)://")
208
+ _SCP_RE = re.compile(r"^[^@/\s:]+@(?P<host>[^:/\s]+):")
209
+ _HOST_RE = re.compile(r"^(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z]{2,}(?::\d+)?$")
210
+ _DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]")
211
+
212
+ _SHORTCUT_HOSTS = {"gitlab:": "gitlab.com", "bitbucket:": "bitbucket.org"}
213
+ _PAIRS = {'"': '"', "'": "'", "`": "`", "<": ">"}
214
+ _NPM_HOSTS = frozenset({"npmjs.com", "www.npmjs.com", "npmjs.org", "www.npmjs.org"})
215
+ _PYPI_HOSTS = frozenset({"pypi.org", "www.pypi.org"})
216
+ _CRATES_HOSTS = frozenset({"crates.io", "www.crates.io"})
217
+ _REGISTRY_HOSTS = _NPM_HOSTS | _PYPI_HOSTS | _CRATES_HOSTS | {"pypi.python.org"}
218
+
219
+
220
+ # --------------------------------------------------------------------------
221
+ # GitHub URLs
222
+ # --------------------------------------------------------------------------
223
+
224
+
225
+ def _clean_subdir(value) -> Optional[str]:
226
+ """A repository-relative folder as `a/b`, or None when empty or leaving the repository."""
227
+ if not isinstance(value, str):
228
+ return None
229
+ parts = [p for p in value.replace("\\", "/").split("/") if p and p != "."]
230
+ if not parts or ".." in parts:
231
+ return None
232
+ return "/".join(parts)
233
+
234
+
235
+ def _github_location(text: str) -> Optional[dict]:
236
+ """owner, repo, ref and subdir of a github.com repository URL, else None.
237
+
238
+ ref and subdir come from a `/tree/<ref>/<folder>` tail; any other tail,
239
+ a query and a fragment are dropped.
240
+ """
241
+ if not text or not isinstance(text, str):
242
+ return None
243
+ s = text.strip()
244
+ if s[:7].lower() == "github:": # npm's shortcut: github:<owner>/<repo>
245
+ s = "github.com/" + s[7:]
246
+ if s[:4].lower() == "git+":
247
+ s = s[4:]
248
+ m = _GITHUB_URL_RE.match(s)
249
+ if not m:
250
+ return None
251
+ path = re.split(r"[?#]", m.group("path"), maxsplit=1)[0]
252
+ parts = [urllib.parse.unquote(p) for p in path.split("/") if p]
253
+ if len(parts) < 2:
254
+ return None
255
+ owner, repo = parts[0], parts[1]
256
+ if repo.lower().endswith(".git"):
257
+ repo = repo[: -len(".git")]
258
+ if not all(_NAME_RE.fullmatch(p) for p in (owner, repo)) or owner.startswith("-"):
259
+ return None
260
+ ref = subdir = None
261
+ tail = parts[2:]
262
+ if len(tail) >= 2 and tail[0] == "tree":
263
+ ref, subdir = tail[1], _clean_subdir("/".join(tail[2:]))
264
+ return {"owner": owner, "repo": repo, "ref": ref, "subdir": subdir}
265
+
266
+
267
+ def github_target(text: str) -> Optional[dict]:
268
+ """_github_location of a github.com URL or of `<owner>/<repo>`, else None."""
269
+ loc = _github_location(text)
270
+ if loc is None and isinstance(text, str) and _SHORTHAND_RE.fullmatch(text.strip()):
271
+ loc = _github_location(f"github.com/{text.strip()}")
272
+ return loc
60
273
 
61
274
 
62
275
  def parse_github_url(url: str) -> Optional[tuple[str, str, str]]:
63
276
  """Parse a GitHub URL/string into (canonical_url, owner, repo).
64
277
 
65
- Handles the variants registries actually emit:
278
+ Handles the variants registries actually emit, dotted repository names
279
+ (`vercel/next.js`) included:
66
280
  - https://github.com/owner/repo
67
281
  - http://github.com/owner/repo
68
282
  - github.com/owner/repo
69
283
  - git+https://github.com/owner/repo.git
70
- - git@github.com:owner/repo.git
284
+ - git://github.com/owner/repo.git, ssh://git@github.com/owner/repo.git
285
+ - git@github.com:owner/repo.git, ssh://git@github.com:owner/repo.git
71
286
  - github:owner/repo (npm shortcut)
287
+ - any of these with a `/tree/<ref>/<folder>` or other path, a query
288
+ or a fragment after the repository
72
289
  """
73
- if not url or not isinstance(url, str):
290
+ loc = _github_location(url)
291
+ if loc is None:
74
292
  return None
293
+ owner, repo = loc["owner"], loc["repo"]
294
+ return f"https://github.com/{owner}/{repo}", owner, repo
75
295
 
76
- s = url.strip()
77
296
 
78
- if s.startswith("github:"):
79
- rest = s[len("github:") :]
80
- if "/" in rest:
81
- owner, _, repo = rest.partition("/")
82
- repo = repo.removesuffix(".git").rstrip("/")
83
- if owner and repo:
84
- return f"https://github.com/{owner}/{repo}", owner, repo
297
+ def _github_found(url: str, directory=None) -> Optional[tuple[str, str, str, Optional[str]]]:
298
+ """(canonical_url, owner, repo, source_subdir) of a registry's repository URL, else None."""
299
+ loc = _github_location(url)
300
+ if loc is None:
85
301
  return None
302
+ owner, repo = loc["owner"], loc["repo"]
303
+ return f"https://github.com/{owner}/{repo}", owner, repo, _clean_subdir(directory) or loc["subdir"]
86
304
 
87
- if s.startswith("git+"):
88
- s = s[len("git+") :]
89
305
 
90
- if s.startswith("git@github.com:"):
91
- rest = s[len("git@github.com:") :]
92
- rest = rest.removesuffix(".git").rstrip("/")
93
- if "/" in rest:
94
- owner, _, repo = rest.partition("/")
95
- if owner and repo:
96
- return f"https://github.com/{owner}/{repo}", owner, repo
97
- return None
306
+ # --------------------------------------------------------------------------
307
+ # Targets
308
+ # --------------------------------------------------------------------------
98
309
 
99
- if s.startswith("github.com/"):
100
- s = "https://" + s
101
310
 
102
- m = _GITHUB_URL_RE.match(s)
103
- if m:
104
- owner, repo = m.group(1), m.group(2)
105
- return f"https://github.com/{owner}/{repo}", owner, repo
311
+ # Keep identical to _kebab in skf-skill-inventory.py (test/test-skf-resolve-package.py
312
+ # pins the copy): the inventory derives a skill's name with it.
313
+ def _kebab(segment) -> str:
314
+ """Kebab-case, lowercase a single name segment (the skill-name rule).
315
+
316
+ Lowercase, drop a trailing ``.git``, collapse every run of non-alphanumeric
317
+ characters to a single hyphen, and strip leading / trailing hyphens.
318
+ """
319
+ s = str(segment).strip().lower()
320
+ if s.endswith(".git"):
321
+ s = s[:-4]
322
+ s = re.sub(r"[^a-z0-9]+", "-", s)
323
+ return s.strip("-")
324
+
325
+
326
+ def _split_at(text: str, word_re: re.Pattern) -> tuple[str, Optional[str]]:
327
+ """(before, after) the last `@` (not a leading one) when word_re matches what follows it, else (text, None)."""
328
+ at = text.rfind("@")
329
+ if at > 0 and word_re.fullmatch(text[at + 1:]):
330
+ return text[:at], text[at + 1:]
331
+ return text, None
332
+
333
+
334
+ def _is_local_path(text: str) -> bool:
335
+ return (text.startswith((".", "/", "\\", "~")) or bool(_DRIVE_RE.match(text))
336
+ or text[:7].lower() == "file://")
337
+
338
+
339
+ def _on_disk(text: str) -> bool:
340
+ """True when `text` names an existing file or folder, relative to the working folder, `~` expanded."""
341
+ path = text[7:] if text[:7].lower() == "file://" else text
342
+ return os.path.exists(os.path.expanduser(path)) # False for a name the OS refuses
106
343
 
344
+
345
+ def _url_host(text: str) -> Optional[tuple[str, str]]:
346
+ """(host, path) of a URL-like target, else None."""
347
+ m = _SCHEME_RE.match(text)
348
+ if m:
349
+ authority, _, path = text[m.end():].partition("/")
350
+ host = authority.rpartition("@")[2].split(":")[0]
351
+ return (host.lower(), path) if host else None
352
+ m = _SCP_RE.match(text)
353
+ if m:
354
+ return m.group("host").lower(), text[m.end():]
355
+ first, sep, path = text.partition("/")
356
+ if sep and _HOST_RE.fullmatch(first):
357
+ return first.split(":")[0].lower(), path
107
358
  return None
108
359
 
109
360
 
361
+ def _registry_page(host: str, path: str) -> Optional[tuple[str, str, Optional[str]]]:
362
+ """(registry, package_name, version) of an npm, PyPI or crates.io package page, else None."""
363
+ parts = [urllib.parse.unquote(p) for p in re.split(r"[?#]", path, maxsplit=1)[0].split("/") if p]
364
+ if host in _NPM_HOSTS and len(parts) >= 2 and parts[0] == "package":
365
+ take = 3 if parts[1].startswith("@") else 2
366
+ name, rest = "/".join(parts[1:take]), parts[take:]
367
+ version = rest[1] if len(rest) >= 2 and rest[0] == "v" else None
368
+ registry = "npm"
369
+ elif len(parts) >= 2 and ((host in _PYPI_HOSTS and parts[0] == "project")
370
+ or (host == "pypi.python.org" and parts[0] == "pypi")):
371
+ name, version, registry = parts[1], (parts[2] if len(parts) >= 3 else None), "pypi"
372
+ elif host in _CRATES_HOSTS and len(parts) >= 2 and parts[0] == "crates":
373
+ name, version, registry = parts[1], (parts[2] if len(parts) >= 3 else None), "crates"
374
+ else:
375
+ return None
376
+ m = _PACKAGE_RE.fullmatch(name)
377
+ if not m or (m.group("scope") and registry != "npm"):
378
+ return None
379
+ if version is not None and not _VERSION_RE.fullmatch(version):
380
+ version = None
381
+ return registry, name, version
382
+
383
+
384
+ def parse_target(text: str, local_first: bool = False) -> dict:
385
+ """Split a quick-skill target into its kind and parts (see the module docstring)."""
386
+ raw = (text or "").strip()
387
+ if len(raw) >= 2 and raw[0] in _PAIRS and raw[-1] == _PAIRS[raw[0]]:
388
+ raw = raw[1:-1].strip()
389
+ out = {"kind": "unparsed", "input": raw, "target_version": None, "dist_tag": None, "owner": None,
390
+ "repo": None, "url": None, "ref": None, "subdir": None, "package_name": None, "registry": None,
391
+ "skill_name": None, "host": None, "path": None}
392
+ if raw and local_first and _on_disk(raw):
393
+ out.update(kind="local-path", path=raw[7:] if raw[:7].lower() == "file://" else raw)
394
+ return out
395
+ if not raw or any(ch.isspace() for ch in raw):
396
+ return out
397
+ if _is_local_path(raw):
398
+ out.update(kind="local-path", path=raw[7:] if raw[:7].lower() == "file://" else raw)
399
+ return out
400
+
401
+ pin = _PYPI_PIN_RE.fullmatch(raw)
402
+ if pin:
403
+ if _VERSION_RE.fullmatch(pin.group("version")):
404
+ out.update(kind="package", package_name=pin.group("name"), registry="pypi",
405
+ target_version=pin.group("version"), skill_name=_kebab(pin.group("name")))
406
+ return out
407
+
408
+ body, version = _split_at(raw, _VERSION_RE)
409
+ loc = github_target(body)
410
+ if loc is not None:
411
+ folder = (loc["subdir"] or "").rpartition("/")[2]
412
+ out.update(kind="github", owner=loc["owner"], repo=loc["repo"], ref=loc["ref"], subdir=loc["subdir"],
413
+ url=f"https://github.com/{loc['owner']}/{loc['repo']}", target_version=version,
414
+ skill_name=_kebab(folder) or _kebab(loc["repo"]))
415
+ return out
416
+
417
+ for prefix, host in _SHORTCUT_HOSTS.items():
418
+ if body[:len(prefix)].lower() == prefix:
419
+ out.update(kind="other-host", host=host, url=body, target_version=version)
420
+ return out
421
+ located = _url_host(body)
422
+ if located is not None:
423
+ host, path = located
424
+ if host in ("github.com", "www.github.com"):
425
+ return out # a github.com URL that names no repository
426
+ page = _registry_page(host, path)
427
+ if page is not None:
428
+ registry, name, page_version = page
429
+ out.update(kind="registry-page", registry=registry, package_name=name,
430
+ target_version=version or page_version, skill_name=_kebab(name))
431
+ elif host not in _REGISTRY_HOSTS: # a registry page that names no package stays unparsed
432
+ out.update(kind="other-host", host=host, url=body, target_version=version)
433
+ return out
434
+
435
+ dist_tag = None
436
+ if version is None: # `next@canary`: an npm dist-tag, which pins no version
437
+ body, dist_tag = _split_at(body, _DIST_TAG_RE)
438
+ m = _PACKAGE_RE.fullmatch(body)
439
+ if m:
440
+ out.update(kind="package", package_name=body, registry="npm" if m.group("scope") else None,
441
+ target_version=version, dist_tag=dist_tag, skill_name=_kebab(body))
442
+ return out
443
+
444
+
445
+ # --------------------------------------------------------------------------
446
+ # Registries
447
+ # --------------------------------------------------------------------------
448
+
449
+
110
450
  def _http_get_json(url: str, timeout: float) -> tuple[Optional[dict], str]:
111
451
  """Fetch JSON. Returns (payload_or_None, outcome).
112
452
 
113
453
  Outcome values:
114
- "ok" — valid JSON object returned
115
- "404" — HTTP 404 (package does not exist on this registry)
116
- "timeout" — socket / urlopen timed out
117
- "error" — any other transport / parse error
454
+ "ok" valid JSON object returned
455
+ "404" HTTP 404 (package does not exist on this registry)
456
+ "timeout" socket / urlopen timed out
457
+ "error" any other transport / parse error
118
458
  """
119
459
  req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": USER_AGENT})
120
460
  try:
@@ -135,56 +475,68 @@ def _http_get_json(url: str, timeout: float) -> tuple[Optional[dict], str]:
135
475
  return None, "error"
136
476
 
137
477
 
138
- def try_npm(package_name: str, timeout: float) -> tuple[Optional[tuple[str, str, str]], str]:
139
- """Try the npm registry. Returns (parsed_or_None, outcome)."""
478
+ # Each try_* returns ((canonical_url, owner, repo, source_subdir) or None, outcome).
479
+ # A folder comes only from a repository field, never from a homepage.
480
+
481
+
482
+ def try_npm(package_name: str, timeout: float) -> tuple[Optional[tuple[str, str, str, Optional[str]]], str]:
483
+ """Try the npm registry. Returns (found_or_None, outcome)."""
140
484
  encoded = urllib.parse.quote(package_name, safe="@")
141
485
  url = f"https://registry.npmjs.org/{encoded}"
142
486
  payload, outcome = _http_get_json(url, timeout)
143
487
  if payload is None:
144
488
  return None, outcome
145
- candidates: list[str] = []
489
+ candidates: list[tuple[str, Optional[str], bool]] = [] # (url, directory, repository field)
146
490
  repo = payload.get("repository")
147
491
  if isinstance(repo, dict) and isinstance(repo.get("url"), str):
148
- candidates.append(repo["url"])
492
+ candidates.append((repo["url"], repo.get("directory"), True))
149
493
  elif isinstance(repo, str):
150
- candidates.append(repo)
494
+ candidates.append((repo, None, True))
151
495
  homepage = payload.get("homepage")
152
496
  if isinstance(homepage, str):
153
- candidates.append(homepage)
154
- for c in candidates:
155
- parsed = parse_github_url(c)
156
- if parsed:
157
- return parsed, "ok"
497
+ candidates.append((homepage, None, False))
498
+ for c, directory, is_repository in candidates:
499
+ found = _github_found(c, directory)
500
+ if found:
501
+ return (found if is_repository else (*found[:3], None)), "ok"
158
502
  return None, "no-github-link"
159
503
 
160
504
 
161
- def try_pypi(package_name: str, timeout: float) -> tuple[Optional[tuple[str, str, str]], str]:
162
- """Try the PyPI registry. Returns (parsed_or_None, outcome)."""
505
+ def _pypi_label(key: str) -> str:
506
+ """A project_urls label as PEP 753 normalizes it: punctuation and whitespace removed, lower case."""
507
+ return "".join(ch for ch in key if ch not in string.punctuation and not ch.isspace()).lower()
508
+
509
+
510
+ def try_pypi(package_name: str, timeout: float) -> tuple[Optional[tuple[str, str, str, Optional[str]]], str]:
511
+ """Try the PyPI registry. Returns (found_or_None, outcome)."""
163
512
  encoded = urllib.parse.quote(package_name, safe="")
164
513
  url = f"https://pypi.org/pypi/{encoded}/json"
165
514
  payload, outcome = _http_get_json(url, timeout)
166
515
  if payload is None:
167
516
  return None, outcome
168
517
  info = payload.get("info") or {}
169
- candidates: list[str] = []
518
+ candidates: list[tuple[str, bool]] = [] # (url, repository field)
170
519
  project_urls = info.get("project_urls") or {}
520
+ labels: dict[str, str] = {} # normalized label -> the first URL under it
171
521
  if isinstance(project_urls, dict):
172
- for key in ("Source", "Source Code", "Repository", "Homepage"):
173
- v = project_urls.get(key)
174
- if isinstance(v, str):
175
- candidates.append(v)
522
+ for key, v in project_urls.items():
523
+ if isinstance(key, str) and isinstance(v, str):
524
+ labels.setdefault(_pypi_label(key), v)
525
+ for label in (*PYPI_SOURCE_LABELS, "homepage"):
526
+ if label in labels:
527
+ candidates.append((labels[label], label != "homepage"))
176
528
  home_page = info.get("home_page")
177
529
  if isinstance(home_page, str):
178
- candidates.append(home_page)
179
- for c in candidates:
180
- parsed = parse_github_url(c)
181
- if parsed:
182
- return parsed, "ok"
530
+ candidates.append((home_page, False))
531
+ for c, is_repository in candidates:
532
+ found = _github_found(c)
533
+ if found:
534
+ return (found if is_repository else (*found[:3], None)), "ok"
183
535
  return None, "no-github-link"
184
536
 
185
537
 
186
- def try_crates(package_name: str, timeout: float) -> tuple[Optional[tuple[str, str, str]], str]:
187
- """Try the crates.io registry. Returns (parsed_or_None, outcome)."""
538
+ def try_crates(package_name: str, timeout: float) -> tuple[Optional[tuple[str, str, str, Optional[str]]], str]:
539
+ """Try the crates.io registry. Returns (found_or_None, outcome)."""
188
540
  encoded = urllib.parse.quote(package_name, safe="")
189
541
  url = f"https://crates.io/api/v1/crates/{encoded}"
190
542
  payload, outcome = _http_get_json(url, timeout)
@@ -194,9 +546,9 @@ def try_crates(package_name: str, timeout: float) -> tuple[Optional[tuple[str, s
194
546
  for key in ("repository", "homepage"):
195
547
  v = crate.get(key)
196
548
  if isinstance(v, str):
197
- parsed = parse_github_url(v)
198
- if parsed:
199
- return parsed, "ok"
549
+ found = _github_found(v)
550
+ if found:
551
+ return (found if key == "repository" else (*found[:3], None)), "ok"
200
552
  return None, "no-github-link"
201
553
 
202
554
 
@@ -207,57 +559,122 @@ _RESOLVER_NAMES: tuple[tuple[str, str], ...] = (
207
559
  )
208
560
 
209
561
 
210
- def resolve_package(package_name: str, timeout: float = REGISTRY_TIMEOUT_SECONDS) -> dict:
562
+ # How the also_found_in warning names a registry that gave no repository.
563
+ _NO_REPOSITORY = {"no-github-link": "no GitHub repository", "error": "could not be read"}
564
+
565
+
566
+ def _also_found_warning(package_name: str, registry_used: str, url: str, also: list[dict]) -> str:
567
+ """The one line a run that keeps the pick of an ambiguous name records as a warning."""
568
+ others = ", ".join(f"{entry['registry']} ({entry['resolved_url'] or _NO_REPOSITORY[entry['outcome']]})"
569
+ for entry in also)
570
+ return f"also_found_in: {package_name} resolved on {registry_used} ({url}); also on {others}"
571
+
572
+
573
+ def resolve_package(package_name: str, timeout: float = REGISTRY_TIMEOUT_SECONDS,
574
+ registry: Optional[str] = None, language: Optional[str] = None) -> dict:
575
+ """Ask every registry of the chain, or only `registry` or the language's (see the module docstring)."""
576
+ registry = registry or LANGUAGE_REGISTRIES.get((language or "").strip().lower())
211
577
  registries_tried: list[str] = []
212
578
  outcomes: dict[str, str] = {}
579
+ found: dict[str, tuple[str, str, str, Optional[str]]] = {}
213
580
 
214
581
  # Dynamic lookup so test code can monkeypatch try_npm / try_pypi / try_crates
215
582
  # without re-binding entries in a module-level tuple of function refs.
216
583
  for name, fn_name in _RESOLVER_NAMES:
584
+ if registry is not None and name != registry:
585
+ continue
217
586
  registries_tried.append(name)
218
- fn = globals()[fn_name]
219
- result, outcome = fn(package_name, timeout)
220
- outcomes[name] = outcome
587
+ result, outcomes[name] = globals()[fn_name](package_name, timeout)
221
588
  if result is not None:
222
- url, owner, repo = result
223
- return {
224
- "status": "ok",
225
- "package_name": package_name,
226
- "resolved_url": url,
227
- "repo_owner": owner,
228
- "repo_name": repo,
229
- "registry_used": name,
230
- "registries_tried": registries_tried,
231
- "registry_outcomes": outcomes,
232
- }
233
-
589
+ found[name] = result
590
+ name_found_in = [n for n in registries_tried if outcomes[n] not in ("404", "timeout")]
591
+ picked = next((n for n in registries_tried if n in found), None)
592
+ if picked is None:
593
+ return {
594
+ "status": "fallthrough",
595
+ "package_name": package_name,
596
+ "name_found_in": name_found_in,
597
+ "registries_tried": registries_tried,
598
+ "registry_outcomes": outcomes,
599
+ }
600
+ url, owner, repo, subdir = found[picked]
601
+ also = []
602
+ for name in name_found_in:
603
+ if name != picked:
604
+ other = found.get(name) or (None, None, None, None)
605
+ also.append({"registry": name, "outcome": outcomes[name], "resolved_url": other[0],
606
+ "repo_owner": other[1], "repo_name": other[2], "source_subdir": other[3]})
234
607
  return {
235
- "status": "fallthrough",
608
+ "status": "ambiguous" if also else "ok",
236
609
  "package_name": package_name,
610
+ "resolved_url": url,
611
+ "repo_owner": owner,
612
+ "repo_name": repo,
613
+ "skill_name": _kebab(package_name),
614
+ "registry_used": picked,
615
+ "source_subdir": subdir,
616
+ "also_found_in": also,
617
+ "warning": _also_found_warning(package_name, picked, url, also) if also else None,
618
+ "name_found_in": name_found_in,
237
619
  "registries_tried": registries_tried,
238
620
  "registry_outcomes": outcomes,
239
621
  }
240
622
 
241
623
 
242
- def main(argv: list[str]) -> int:
624
+ # --------------------------------------------------------------------------
625
+ # CLI
626
+ # --------------------------------------------------------------------------
627
+
628
+
629
+ def _read_stdin() -> str:
630
+ """stdin as UTF-8, whatever the locale's code page (cp1252 on Windows)."""
631
+ stream = getattr(sys.stdin, "buffer", None)
632
+ if stream is not None:
633
+ return stream.read().decode("utf-8", errors="replace")
634
+ return sys.stdin.read()
635
+
636
+
637
+ def _build_parser() -> argparse.ArgumentParser:
243
638
  parser = argparse.ArgumentParser(
244
- description="Resolve a package name to a GitHub repository URL via npm/PyPI/crates.io.",
639
+ prog="skf-resolve-package",
640
+ description="Read a quick-skill target, or resolve a package name to a GitHub repository "
641
+ "via npm/PyPI/crates.io.",
245
642
  )
246
- parser.add_argument(
643
+ sub = parser.add_subparsers(dest="cmd", required=True)
644
+ p_parse = sub.add_parser("parse-target", help="split a target into its kind, name, version and repository")
645
+ p_parse.add_argument("--target", help="the target as typed (default: read from stdin)")
646
+ p_parse.add_argument("--local-first", action="store_true",
647
+ help="a target that names an existing file or folder is a local-path, whatever its shape")
648
+ p_resolve = sub.add_parser("resolve", help="resolve a package name to its GitHub repository")
649
+ p_resolve.add_argument(
247
650
  "package_name",
248
- help="Package name to resolve (e.g., lodash, @tanstack/query, requests, serde).",
651
+ help="Package name to resolve (e.g., lodash, @tanstack/react-query, requests, serde).",
249
652
  )
250
- parser.add_argument(
653
+ p_resolve.add_argument("--registry", choices=REGISTRIES, help="query only this registry")
654
+ p_resolve.add_argument("--language",
655
+ help="a language hint: javascript, typescript, python or rust queries only its "
656
+ "registry (--registry wins); any other language queries them all")
657
+ p_resolve.add_argument(
251
658
  "--timeout",
252
659
  type=float,
253
660
  default=REGISTRY_TIMEOUT_SECONDS,
254
661
  help=f"Per-registry timeout in seconds (default: {REGISTRY_TIMEOUT_SECONDS}).",
255
662
  )
256
- args = parser.parse_args(argv)
663
+ return parser
664
+
257
665
 
258
- result = resolve_package(args.package_name, timeout=args.timeout)
666
+ def main(argv: list[str]) -> int:
667
+ if argv and argv[0] not in COMMANDS and not argv[0].startswith("-"):
668
+ argv = ["resolve", *argv] # the form before the subcommands: <package_name> [--timeout N]
669
+ args = _build_parser().parse_args(argv)
670
+ if args.cmd == "parse-target":
671
+ text = args.target if args.target is not None else _read_stdin()
672
+ print(json.dumps(parse_target(text, local_first=args.local_first), indent=2))
673
+ return 0
674
+ result = resolve_package(args.package_name, timeout=args.timeout, registry=args.registry,
675
+ language=args.language)
259
676
  print(json.dumps(result, indent=2))
260
- return 0 if result["status"] == "ok" else 1
677
+ return EXIT_CODES[result["status"]]
261
678
 
262
679
 
263
680
  if __name__ == "__main__":