bmad-module-skill-forge 2.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (437) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +49 -49
  3. package/docs/404.md +10 -8
  4. package/docs/_data/pinned.yaml +22 -13
  5. package/docs/_internal/STABILITY.md +58 -35
  6. package/docs/agents.md +39 -53
  7. package/docs/architecture.md +65 -51
  8. package/docs/bmad-synergy.md +53 -51
  9. package/docs/campaign.md +127 -75
  10. package/docs/concepts.md +34 -28
  11. package/docs/examples.md +70 -68
  12. package/docs/forge-auto.md +34 -31
  13. package/docs/getting-started.md +118 -57
  14. package/docs/how-it-works.md +25 -21
  15. package/docs/index.md +15 -15
  16. package/docs/skill-model.md +70 -59
  17. package/docs/troubleshooting.md +143 -18
  18. package/docs/verifying-a-skill.md +87 -57
  19. package/docs/why-skf.md +24 -22
  20. package/docs/workflows.md +274 -110
  21. package/package.json +18 -10
  22. package/src/forger/preferences.yaml +8 -1
  23. package/src/knowledge/agentskills-spec.md +2 -0
  24. package/src/knowledge/ccc-bridge.md +40 -24
  25. package/src/knowledge/confidence-tiers.md +9 -5
  26. package/src/knowledge/overview.md +3 -3
  27. package/src/knowledge/progressive-capability.md +8 -8
  28. package/src/knowledge/provenance-tracking.md +9 -5
  29. package/src/knowledge/qmd-registry.md +3 -3
  30. package/src/knowledge/skf-knowledge-index.csv +2 -2
  31. package/src/knowledge/skill-lifecycle.md +18 -11
  32. package/src/knowledge/split-body-strategy.md +0 -3
  33. package/src/knowledge/tool-resolution.md +4 -2
  34. package/src/knowledge/version-paths.md +101 -15
  35. package/src/module-help.csv +3 -3
  36. package/src/module.yaml +10 -0
  37. package/src/shared/data/ast-grep-recipes.yaml +608 -0
  38. package/src/shared/data/ide-context-files.json +30 -0
  39. package/src/shared/health-check.md +31 -59
  40. package/src/shared/references/description-guard-protocol.md +12 -9
  41. package/src/shared/references/feasibility-report-schema.md +19 -13
  42. package/src/shared/references/headless-gate-convention.md +55 -3
  43. package/src/shared/references/output-contract-schema.md +19 -8
  44. package/src/shared/references/pipeline-contracts.md +94 -29
  45. package/src/shared/references/tessl-review.md +77 -0
  46. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
  47. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
  48. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
  49. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
  50. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
  51. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
  52. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
  53. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
  54. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
  55. package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
  56. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
  57. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
  58. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
  59. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
  60. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
  61. package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
  62. package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
  63. package/src/shared/scripts/skf-atomic-write.py +233 -64
  64. package/src/shared/scripts/skf-build-change-manifest.py +2753 -28
  65. package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
  66. package/src/shared/scripts/skf-check-unit-records.py +184 -0
  67. package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
  68. package/src/shared/scripts/skf-classify-changed-files.py +772 -0
  69. package/src/shared/scripts/skf-comention-pairs.py +1333 -123
  70. package/src/shared/scripts/skf-compare-file-hashes.py +267 -105
  71. package/src/shared/scripts/skf-count-imports.py +1867 -0
  72. package/src/shared/scripts/skf-count-tokens.py +1 -1
  73. package/src/shared/scripts/skf-derive-assembly-shape.py +21 -3
  74. package/src/shared/scripts/skf-description-guard.py +99 -11
  75. package/src/shared/scripts/skf-detect-docs.py +694 -2
  76. package/src/shared/scripts/skf-detect-language.py +486 -107
  77. package/src/shared/scripts/skf-detect-registry.py +887 -0
  78. package/src/shared/scripts/skf-detect-scripts-assets.py +197 -55
  79. package/src/shared/scripts/skf-detect-tools.py +209 -27
  80. package/src/shared/scripts/skf-detect-workspaces.py +506 -30
  81. package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
  82. package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
  83. package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
  84. package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
  85. package/src/shared/scripts/skf-extract-public-api.py +5247 -96
  86. package/src/shared/scripts/skf-extraction-inventory.py +1280 -0
  87. package/src/shared/scripts/skf-extraction-snapshot.py +664 -0
  88. package/src/shared/scripts/skf-fetch-temporal.py +674 -0
  89. package/src/shared/scripts/skf-find-cycles.py +97 -29
  90. package/src/shared/scripts/skf-find-test-report.py +476 -0
  91. package/src/shared/scripts/skf-forge-tier-rw.py +469 -205
  92. package/src/shared/scripts/skf-github-fetch.py +365 -0
  93. package/src/shared/scripts/skf-github-probe.py +700 -0
  94. package/src/shared/scripts/skf-hash-content.py +180 -20
  95. package/src/shared/scripts/skf-language-corpora.py +1 -1
  96. package/src/shared/scripts/skf-load-provenance.py +161 -27
  97. package/src/shared/scripts/skf-manifest-ops.py +1 -1
  98. package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
  99. package/src/shared/scripts/skf-merge-doc-urls.py +143 -20
  100. package/src/shared/scripts/skf-names-present.py +221 -0
  101. package/src/shared/scripts/skf-pair-intersect.py +119 -28
  102. package/src/shared/scripts/skf-parse-gaps.py +1384 -0
  103. package/src/shared/scripts/skf-preapply.py +1 -1
  104. package/src/shared/scripts/skf-preflight.py +176 -28
  105. package/src/shared/scripts/skf-promote-staged.py +430 -0
  106. package/src/shared/scripts/skf-provenance-gap-dispatch.py +64 -8
  107. package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
  108. package/src/shared/scripts/skf-quick-batch.py +774 -0
  109. package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
  110. package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
  111. package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
  112. package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
  113. package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
  114. package/src/shared/scripts/skf-resolve-authoritative-files.py +155 -14
  115. package/src/shared/scripts/skf-resolve-package.py +523 -106
  116. package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
  117. package/src/shared/scripts/skf-run-lock.py +577 -0
  118. package/src/shared/scripts/skf-scan-manifests.py +998 -57
  119. package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
  120. package/src/shared/scripts/skf-severity-classify.py +712 -108
  121. package/src/shared/scripts/skf-shape-detect.py +298 -204
  122. package/src/shared/scripts/skf-shard-body.py +9 -8
  123. package/src/shared/scripts/skf-skill-inventory.py +2412 -89
  124. package/src/shared/scripts/skf-skills-module.py +472 -0
  125. package/src/shared/scripts/skf-source-tree.py +1796 -0
  126. package/src/shared/scripts/skf-structural-diff.py +787 -121
  127. package/src/shared/scripts/skf-tessl-review.py +706 -0
  128. package/src/shared/scripts/skf-update-active-symlink.py +120 -67
  129. package/src/shared/scripts/skf-update-run-state.py +624 -0
  130. package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
  131. package/src/shared/scripts/skf-validate-brief-schema.py +23 -2
  132. package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
  133. package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
  134. package/src/shared/scripts/skf-validate-output.py +712 -64
  135. package/src/shared/scripts/skf-validate-pins.py +1 -1
  136. package/src/shared/scripts/skf-verify-no-trace.py +116 -10
  137. package/src/shared/scripts/skf-verify-provenance-completeness.py +3945 -152
  138. package/src/shared/scripts/skf-write-skill-brief.py +406 -25
  139. package/src/shared/tool-requirements.yaml +201 -0
  140. package/src/skf-analyze-source/SKILL.md +41 -36
  141. package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
  142. package/src/skf-analyze-source/customize.toml +36 -24
  143. package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
  144. package/src/skf-analyze-source/references/continue.md +2 -13
  145. package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
  146. package/src/skf-analyze-source/references/generate-briefs.md +121 -55
  147. package/src/skf-analyze-source/references/headless-contract.md +42 -15
  148. package/src/skf-analyze-source/references/identify-units.md +96 -110
  149. package/src/skf-analyze-source/references/init.md +73 -71
  150. package/src/skf-analyze-source/references/map-and-detect.md +123 -105
  151. package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
  152. package/src/skf-analyze-source/references/recommend.md +51 -70
  153. package/src/skf-analyze-source/references/scan-project.md +60 -84
  154. package/src/skf-analyze-source/references/scan-root.md +39 -0
  155. package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
  156. package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
  157. package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
  158. package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
  159. package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
  160. package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
  161. package/src/skf-audit-skill/SKILL.md +43 -51
  162. package/src/skf-audit-skill/assets/drift-report-template.md +28 -3
  163. package/src/skf-audit-skill/customize.toml +41 -20
  164. package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
  165. package/src/skf-audit-skill/references/doc-drift.md +141 -0
  166. package/src/skf-audit-skill/references/headless-contract.md +29 -0
  167. package/src/skf-audit-skill/references/health-check.md +2 -0
  168. package/src/skf-audit-skill/references/init.md +149 -210
  169. package/src/skf-audit-skill/references/re-index.md +103 -110
  170. package/src/skf-audit-skill/references/report.md +96 -33
  171. package/src/skf-audit-skill/references/semantic-diff.md +34 -25
  172. package/src/skf-audit-skill/references/severity-classify.md +79 -65
  173. package/src/skf-audit-skill/references/severity-rules.md +15 -6
  174. package/src/skf-audit-skill/references/structural-diff.md +80 -90
  175. package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
  176. package/src/skf-audit-skill/scripts/render-drift-tables.py +712 -0
  177. package/src/skf-brief-skill/SKILL.md +37 -26
  178. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  179. package/src/skf-brief-skill/assets/scope-templates.md +18 -25
  180. package/src/skf-brief-skill/assets/skill-brief-schema.md +30 -23
  181. package/src/skf-brief-skill/customize.toml +48 -20
  182. package/src/skf-brief-skill/references/analyze-target.md +144 -127
  183. package/src/skf-brief-skill/references/confirm-brief.md +11 -9
  184. package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
  185. package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
  186. package/src/skf-brief-skill/references/gather-intent.md +87 -182
  187. package/src/skf-brief-skill/references/headless-args.md +84 -16
  188. package/src/skf-brief-skill/references/health-check.md +1 -1
  189. package/src/skf-brief-skill/references/invocation-contract.md +18 -13
  190. package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
  191. package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
  192. package/src/skf-brief-skill/references/scope-definition.md +123 -47
  193. package/src/skf-brief-skill/references/step-auto-brief.md +26 -100
  194. package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
  195. package/src/skf-brief-skill/references/version-resolution.md +7 -10
  196. package/src/skf-brief-skill/references/write-brief.md +66 -57
  197. package/src/skf-campaign/SKILL.md +31 -34
  198. package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
  199. package/src/skf-campaign/customize.toml +47 -16
  200. package/src/skf-campaign/references/campaign-contracts.md +39 -29
  201. package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
  202. package/src/skf-campaign/references/health-check.md +7 -16
  203. package/src/skf-campaign/references/step-01-setup.md +46 -84
  204. package/src/skf-campaign/references/step-02-strategy.md +31 -30
  205. package/src/skf-campaign/references/step-03-pins.md +26 -18
  206. package/src/skf-campaign/references/step-04-provenance.md +24 -23
  207. package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
  208. package/src/skf-campaign/references/step-06-batch.md +41 -34
  209. package/src/skf-campaign/references/step-07-capstone.md +40 -25
  210. package/src/skf-campaign/references/step-08-verify.md +28 -29
  211. package/src/skf-campaign/references/step-09-refine.md +29 -30
  212. package/src/skf-campaign/references/step-10-export.md +48 -36
  213. package/src/skf-campaign/references/step-11-maintenance.md +31 -34
  214. package/src/skf-campaign/references/step-resume.md +37 -68
  215. package/src/skf-campaign/scripts/campaign-deps.py +57 -18
  216. package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
  217. package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
  218. package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
  219. package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
  220. package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
  221. package/src/skf-campaign/scripts/campaign-report.py +204 -37
  222. package/src/skf-campaign/scripts/campaign-state.py +1128 -0
  223. package/src/skf-campaign/scripts/campaign-status.py +10 -74
  224. package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
  225. package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
  226. package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
  227. package/src/skf-campaign/templates/campaign-report-template.md +5 -1
  228. package/src/skf-campaign/templates/kickoff-template.md +0 -14
  229. package/src/skf-create-skill/SKILL.md +27 -20
  230. package/src/skf-create-skill/assets/compile-assembly-rules.md +11 -8
  231. package/src/skf-create-skill/assets/skill-sections.md +21 -11
  232. package/src/skf-create-skill/customize.toml +20 -8
  233. package/src/skf-create-skill/references/authoritative-files-protocol.md +36 -58
  234. package/src/skf-create-skill/references/batch-mode.md +81 -0
  235. package/src/skf-create-skill/references/ccc-index-check.md +27 -0
  236. package/src/skf-create-skill/references/compile.md +119 -70
  237. package/src/skf-create-skill/references/component-extraction.md +113 -91
  238. package/src/skf-create-skill/references/enrich.md +41 -23
  239. package/src/skf-create-skill/references/entry-points-by-hand.md +37 -0
  240. package/src/skf-create-skill/references/extract.md +204 -162
  241. package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
  242. package/src/skf-create-skill/references/extraction-patterns-tracing.md +11 -11
  243. package/src/skf-create-skill/references/extraction-patterns.md +588 -201
  244. package/src/skf-create-skill/references/generate-artifacts.md +145 -93
  245. package/src/skf-create-skill/references/health-check.md +1 -1
  246. package/src/skf-create-skill/references/load-brief.md +53 -36
  247. package/src/skf-create-skill/references/relabel-rule.md +12 -0
  248. package/src/skf-create-skill/references/report.md +37 -57
  249. package/src/skf-create-skill/references/source-resolution-protocols.md +58 -169
  250. package/src/skf-create-skill/references/step-auto-shard.md +20 -93
  251. package/src/skf-create-skill/references/step-doc-rot.md +44 -60
  252. package/src/skf-create-skill/references/step-doc-sources.md +28 -19
  253. package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
  254. package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
  255. package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
  256. package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
  257. package/src/skf-create-skill/references/validate.md +173 -99
  258. package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
  259. package/src/skf-create-stack-skill/SKILL.md +29 -56
  260. package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
  261. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
  262. package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
  263. package/src/skf-create-stack-skill/customize.toml +23 -12
  264. package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
  265. package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
  266. package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
  267. package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
  268. package/src/skf-create-stack-skill/references/generate-output.md +138 -128
  269. package/src/skf-create-stack-skill/references/init.md +90 -32
  270. package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
  271. package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
  272. package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
  273. package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
  274. package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
  275. package/src/skf-create-stack-skill/references/report.md +39 -33
  276. package/src/skf-create-stack-skill/references/validate.md +20 -39
  277. package/src/skf-drop-skill/SKILL.md +28 -51
  278. package/src/skf-drop-skill/customize.toml +44 -30
  279. package/src/skf-drop-skill/references/execute.md +83 -146
  280. package/src/skf-drop-skill/references/health-check.md +2 -0
  281. package/src/skf-drop-skill/references/invocation-contract.md +64 -0
  282. package/src/skf-drop-skill/references/report.md +46 -30
  283. package/src/skf-drop-skill/references/select.md +122 -124
  284. package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
  285. package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
  286. package/src/skf-export-skill/SKILL.md +40 -62
  287. package/src/skf-export-skill/assets/managed-section-format.md +14 -50
  288. package/src/skf-export-skill/assets/snippet-format.md +3 -1
  289. package/src/skf-export-skill/customize.toml +42 -13
  290. package/src/skf-export-skill/references/generate-snippet.md +54 -46
  291. package/src/skf-export-skill/references/health-check.md +2 -0
  292. package/src/skf-export-skill/references/invocation-contract.md +15 -0
  293. package/src/skf-export-skill/references/load-skill.md +88 -62
  294. package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
  295. package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
  296. package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
  297. package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
  298. package/src/skf-export-skill/references/package.md +15 -73
  299. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
  300. package/src/skf-export-skill/references/result-envelope.md +31 -16
  301. package/src/skf-export-skill/references/summary.md +41 -17
  302. package/src/skf-export-skill/references/token-report.md +4 -12
  303. package/src/skf-export-skill/references/update-context.md +118 -237
  304. package/src/skf-forger/SKILL.md +61 -37
  305. package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
  306. package/src/skf-forger/customize.toml +43 -0
  307. package/src/skf-forger/references/pipeline-mode.md +73 -18
  308. package/src/skf-forger/scripts/forge-status.py +299 -0
  309. package/src/skf-forger/scripts/parse-pipeline.py +322 -54
  310. package/src/skf-forger/scripts/pipeline-gate.py +428 -0
  311. package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
  312. package/src/skf-quick-skill/SKILL.md +54 -53
  313. package/src/skf-quick-skill/assets/skill-template.md +4 -2
  314. package/src/skf-quick-skill/customize.toml +51 -20
  315. package/src/skf-quick-skill/references/batch-mode.md +74 -21
  316. package/src/skf-quick-skill/references/compile.md +30 -31
  317. package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
  318. package/src/skf-quick-skill/references/finalize.md +69 -18
  319. package/src/skf-quick-skill/references/halt-contract.md +41 -29
  320. package/src/skf-quick-skill/references/health-check.md +5 -3
  321. package/src/skf-quick-skill/references/quick-extract.md +114 -45
  322. package/src/skf-quick-skill/references/registry-resolution.md +9 -64
  323. package/src/skf-quick-skill/references/resolve-target.md +116 -66
  324. package/src/skf-quick-skill/references/write-and-validate.md +79 -40
  325. package/src/skf-refine-architecture/SKILL.md +43 -53
  326. package/src/skf-refine-architecture/customize.toml +49 -20
  327. package/src/skf-refine-architecture/references/compile.md +97 -54
  328. package/src/skf-refine-architecture/references/exit-codes.md +30 -0
  329. package/src/skf-refine-architecture/references/finding-storage.md +21 -0
  330. package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
  331. package/src/skf-refine-architecture/references/improvements.md +14 -20
  332. package/src/skf-refine-architecture/references/init.md +98 -57
  333. package/src/skf-refine-architecture/references/issue-detection.md +32 -39
  334. package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
  335. package/src/skf-refine-architecture/references/report.md +65 -54
  336. package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
  337. package/src/skf-rename-skill/SKILL.md +19 -54
  338. package/src/skf-rename-skill/customize.toml +32 -26
  339. package/src/skf-rename-skill/references/execute.md +230 -184
  340. package/src/skf-rename-skill/references/exit-codes.md +7 -7
  341. package/src/skf-rename-skill/references/health-check.md +1 -7
  342. package/src/skf-rename-skill/references/invocation-contract.md +25 -0
  343. package/src/skf-rename-skill/references/report.md +63 -52
  344. package/src/skf-rename-skill/references/select.md +197 -115
  345. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
  346. package/src/skf-setup/SKILL.md +26 -26
  347. package/src/skf-setup/customize.toml +14 -10
  348. package/src/skf-setup/references/auto-index.md +50 -63
  349. package/src/skf-setup/references/ccc-index.md +29 -90
  350. package/src/skf-setup/references/detect-and-tier.md +58 -67
  351. package/src/skf-setup/references/health-check.md +2 -0
  352. package/src/skf-setup/references/invocation-contract.md +14 -0
  353. package/src/skf-setup/references/report.md +43 -166
  354. package/src/skf-setup/references/tier-rules.md +5 -5
  355. package/src/skf-setup/references/write-config.md +34 -57
  356. package/src/skf-test-skill/SKILL.md +17 -57
  357. package/src/skf-test-skill/assets/output-section-formats.md +33 -34
  358. package/src/skf-test-skill/customize.toml +27 -13
  359. package/src/skf-test-skill/references/coherence-check.md +134 -62
  360. package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
  361. package/src/skf-test-skill/references/coverage-check.md +209 -264
  362. package/src/skf-test-skill/references/detect-mode.md +5 -5
  363. package/src/skf-test-skill/references/external-validators.md +94 -82
  364. package/src/skf-test-skill/references/health-check.md +6 -7
  365. package/src/skf-test-skill/references/init.md +164 -91
  366. package/src/skf-test-skill/references/invocation-contract.md +66 -0
  367. package/src/skf-test-skill/references/migration-section-rules.md +5 -2
  368. package/src/skf-test-skill/references/report.md +164 -142
  369. package/src/skf-test-skill/references/score.md +80 -107
  370. package/src/skf-test-skill/references/scoring-rules.md +42 -155
  371. package/src/skf-test-skill/references/source-access-protocol.md +18 -94
  372. package/src/skf-test-skill/references/step-hard-gate.md +51 -33
  373. package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
  374. package/src/skf-test-skill/scripts/build-result-context.py +380 -0
  375. package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
  376. package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
  377. package/src/skf-test-skill/scripts/compute-score.py +223 -45
  378. package/src/skf-test-skill/scripts/gap-ledger.py +1666 -0
  379. package/src/skf-test-skill/scripts/hard-gate.py +203 -0
  380. package/src/skf-test-skill/scripts/load-coverage-inputs.py +1198 -0
  381. package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
  382. package/src/skf-test-skill/scripts/reconcile-coverage.py +547 -79
  383. package/src/skf-test-skill/scripts/score-signatures.py +479 -0
  384. package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
  385. package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
  386. package/src/skf-test-skill/scripts/verify-declared-numerator.py +133 -56
  387. package/src/skf-test-skill/templates/test-report-template.md +21 -15
  388. package/src/skf-update-skill/SKILL.md +38 -25
  389. package/src/skf-update-skill/customize.toml +27 -12
  390. package/src/skf-update-skill/references/detect-changes.md +253 -223
  391. package/src/skf-update-skill/references/gap-driven.md +336 -0
  392. package/src/skf-update-skill/references/health-check.md +31 -5
  393. package/src/skf-update-skill/references/init.md +254 -84
  394. package/src/skf-update-skill/references/invocation-contract.md +12 -0
  395. package/src/skf-update-skill/references/manual-section-rules.md +6 -23
  396. package/src/skf-update-skill/references/merge.md +182 -49
  397. package/src/skf-update-skill/references/re-extract.md +53 -262
  398. package/src/skf-update-skill/references/report.md +102 -35
  399. package/src/skf-update-skill/references/write.md +357 -155
  400. package/src/skf-update-skill/scripts/skf-new-file-diff.py +183 -46
  401. package/src/skf-verify-stack/SKILL.md +23 -42
  402. package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
  403. package/src/skf-verify-stack/customize.toml +55 -22
  404. package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
  405. package/src/skf-verify-stack/references/coverage.md +67 -64
  406. package/src/skf-verify-stack/references/exit-codes.md +21 -8
  407. package/src/skf-verify-stack/references/init.md +107 -62
  408. package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
  409. package/src/skf-verify-stack/references/integrations.md +106 -65
  410. package/src/skf-verify-stack/references/report.md +59 -42
  411. package/src/skf-verify-stack/references/requirements.md +32 -9
  412. package/src/skf-verify-stack/references/synthesize.md +53 -39
  413. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
  414. package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
  415. package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
  416. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
  417. package/tools/cli/commands/install.js +11 -4
  418. package/tools/cli/commands/status.js +15 -3
  419. package/tools/cli/commands/update.js +11 -4
  420. package/tools/cli/lib/tool-check.js +320 -0
  421. package/tools/cli/lib/version-check.js +22 -9
  422. package/tools/cli/skf-cli.js +4 -1
  423. package/.gitattributes +0 -16
  424. package/.nvmrc +0 -1
  425. package/docs/_internal/RELEASING.md +0 -679
  426. package/release-audits/v1.0.0-launch-audit.md +0 -728
  427. package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
  428. package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
  429. package/src/skf-campaign/manifest.yaml +0 -15
  430. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
  431. package/src/skf-create-skill/references/ecosystem-check.md +0 -74
  432. package/src/skf-drop-skill/references/headless-contract.md +0 -18
  433. package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
  434. package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
  435. package/src/skf-update-skill/references/validate.md +0 -124
  436. package/tools/validate-docs-drift.js +0 -233
  437. package/tools/validate-docs-links.js +0 -665
@@ -0,0 +1,700 @@
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF GitHub Probe: can this GitHub repository be read, and if not, why.
6
+
7
+ brief-skill checks a GitHub URL before the user spends time on a brief,
8
+ and quick-skill checks a version tag and lists the files it reads. One
9
+ `gh api` call cannot tell a missing gh, a logged-out gh, a repository that
10
+ does not exist and one the account may not read apart, and an
11
+ unauthenticated `curl` answers 404 for every private repository. This
12
+ helper runs the probes in order, stops at the first that reads the
13
+ repository, and otherwise names the cause.
14
+
15
+ CLI:
16
+ uv run skf-github-probe.py repo --repo <repo> [--timeout <seconds>]
17
+ uv run skf-github-probe.py tags --repo <repo> [--want <tag>]... \\
18
+ [--version <version> [--name <name>]] [--limit <n>] [--ref <ref>] \\
19
+ [--timeout <seconds>]
20
+ uv run skf-github-probe.py tree --repo <repo> [--ref <ref>] \\
21
+ [--out <file>] [--timeout <seconds>]
22
+
23
+ --repo takes a github.com repository as skf-resolve-package.py (the
24
+ sibling in this folder, which must sit beside this script) reads one:
25
+ `owner/repo`, `github.com/owner/repo`, `github:owner/repo`, an https,
26
+ http, git, ssh or git+ URL or `git@github.com:owner/repo`, with or
27
+ without `.git`, a query, a fragment and a `/tree/<ref>/...` tail. Only
28
+ github.com repositories are probed.
29
+
30
+ Probes, in order:
31
+ repo `gh api repos/{owner}/{repo}`; the unauthenticated REST API
32
+ (https://api.github.com/repos/{owner}/{repo}), unless gh answered
33
+ 404 (a repository an account cannot see is not public either);
34
+ `git ls-remote --symref
35
+ https://github.com/{owner}/{repo} HEAD`, which uses the user's git
36
+ credentials (never a prompt).
37
+ tags `gh api --paginate repos/{owner}/{repo}/tags`; `git ls-remote
38
+ --tags https://github.com/{owner}/{repo}`.
39
+ tree `gh api repos/{owner}/{repo}/git/trees/{ref}?recursive=1`; the
40
+ same path on the unauthenticated REST API, unless gh answered 404
41
+ for the repository. When the tree is not found, the same client
42
+ reads the repository, to tell a missing ref from a missing
43
+ repository: GitHub answers 404 for both, so a tree's 404 alone
44
+ never counts as a missing repository.
45
+ When a probe finds nothing, the owner is looked up (`users/{owner}`), so
46
+ a misspelled user or organization is reported as such.
47
+
48
+ Output (one ASCII JSON line; every key of the command is always present):
49
+ every command:
50
+ status "ok" | "unavailable"
51
+ cause null | "repo-not-found" | "no-access" | "gh-missing" |
52
+ "gh-unauthenticated" | "ref-not-found" | "unreachable" |
53
+ "invalid-repo" (CAUSES)
54
+ message one line for the user naming the cause and the fix, else null
55
+ repo "owner/repo", or null for invalid-repo
56
+ gh "ok" | "missing" | "unauthenticated" | "failed" | "not-run"
57
+ (GH_STATES): gh answered (an HTTP error is an answer), is not
58
+ installed, is not logged in or its token is rejected, failed
59
+ on the network or a time limit, or was not needed
60
+ account the login gh uses, when a message names it, else null
61
+ via "gh" | "api" | "git" | null: the probe that read the repository
62
+ probes [{"probe": "gh"|"api"|"git", "target": "repo"|"tags"|"tree"|
63
+ "owner", "result": ..., "detail": ...}] in the order run;
64
+ result is "ok" | "missing" | "unauthenticated" | "not-found" |
65
+ "forbidden" | "auth-required" | "rate-limited" | "network" |
66
+ "timeout" (PROBE_RESULTS)
67
+ repo: private (true | false | null), default_branch (name or null)
68
+ tags: tags (names, newest version first, then the others by name, at
69
+ most --limit, 20 by default, 0 for all), count (every tag),
70
+ match (the first --want tag that exists, else the first tag
71
+ that names --version, else null), found ({tag: commit} for the
72
+ --want tags that exist), missing (the --want tags the listing
73
+ lacks), nearest (with --version, up to five tags that sort next
74
+ to it by version, newest first, else empty), release (the tag a
75
+ version is read from, {"tag", "version"}, the version being the
76
+ tag's name less a leading `v`, else null: at --ref HEAD, the
77
+ default, the newest tag whose name is a release version, X.Y.Z
78
+ with no pre-release or build; at another --ref, that ref when the
79
+ listing holds it as a tag whose name is a version, a pre-release
80
+ included, so a branch or a commit names none). When the listing
81
+ could not be read, found, missing and nearest are empty and
82
+ release is null: only a listing says a tag is absent.
83
+ A tag names --version in the forms skf-source-tree.py's
84
+ match_tags tries, in its order: the version as given,
85
+ `v<version>` and, with --name, `<name>@<v>`,
86
+ `@<scope>/<name>@<v>` (any scope), `<name>/v<v>`, `<name>/<v>`
87
+ and `<name>-v<v>`, where <v> drops a leading `v` of the version.
88
+ A monorepo's package tags (`astro@4.16.0`) match through --name.
89
+ tree: ref (as given; "HEAD", the default branch, for an empty, `null`,
90
+ `none` or `head` --ref in any letter case), tree (file paths;
91
+ hand the listing to skf-detect-language.py or another --tree-file
92
+ reader as a file, through --out, never through echo), count,
93
+ truncated (GitHub cut the list short). With --out <file>, the
94
+ whole line goes to <file>, which every --tree-file reader takes,
95
+ and stdout carries it without `tree`, so a caller reads the
96
+ status, count and truncated of a listing of any size.
97
+
98
+ cause:
99
+ repo-not-found an authenticated probe (gh, or git with the user's
100
+ credentials) found no such repository: it does not
101
+ exist, or it is private and the account cannot see
102
+ it (GitHub answers 404 then), or the owner does not
103
+ exist
104
+ no-access an authenticated probe was refused (HTTP 403: an
105
+ organization's SAML or IP rules, a token without the
106
+ scope)
107
+ gh-missing gh is not installed, and nothing else could read the
108
+ repository: it is private or does not exist
109
+ gh-unauthenticated gh is not logged in (or its token is rejected), and
110
+ nothing else could read the repository
111
+ ref-not-found tree: the repository was read, the ref was not found
112
+ unreachable the probes failed on the network, a time limit or
113
+ the API rate limit
114
+ invalid-repo --repo names no github.com repository
115
+
116
+ The whole call finishes within about --timeout seconds (60 by default);
117
+ one probe gets at most 20 of them, so a slow probe leaves time for the
118
+ next. gh and git run through skf-source-tree.py (the sibling in this
119
+ folder, which must sit beside this script): a call the limit stops is
120
+ stopped with everything it started, and a gh or git in the current
121
+ folder is never run. gh runs with `--hostname github.com`; git runs from
122
+ the system temp folder with LC_ALL=C, no hooks, no terminal or
123
+ credential-manager prompt and the git location variables removed.
124
+
125
+ Exit codes:
126
+ 0 status ok
127
+ 3 status unavailable (JSON on stdout; the caller decides what to do)
128
+ 1 unexpected error: {"status": "error", "message": ...} on stderr
129
+ 2 usage error (argparse)
130
+ """
131
+
132
+ from __future__ import annotations
133
+
134
+ import argparse
135
+ import importlib.util
136
+ import json
137
+ import os
138
+ import re
139
+ import socket
140
+ import sys
141
+ import tempfile
142
+ import time
143
+ import urllib.error
144
+ import urllib.parse
145
+ import urllib.request
146
+ from pathlib import Path
147
+
148
+ CAUSES = ("repo-not-found", "no-access", "gh-missing", "gh-unauthenticated", "ref-not-found", "unreachable",
149
+ "invalid-repo")
150
+ GH_STATES = ("ok", "missing", "unauthenticated", "failed", "not-run")
151
+ PROBE_RESULTS = ("ok", "missing", "unauthenticated", "not-found", "forbidden", "auth-required", "rate-limited",
152
+ "network", "timeout")
153
+ API_ROOT = "https://api.github.com"
154
+ WEB_ROOT = "https://github.com"
155
+ USER_AGENT = "skf-github-probe/1.0 (+https://github.com/armelhbobdad/bmad-module-skill-forge)"
156
+ DEFAULT_TIMEOUT_SEC = 60.0
157
+ PROBE_TIMEOUT_SEC = 20.0
158
+ DEFAULT_TAG_LIMIT = 20
159
+ _HTTP_CODE_RE = re.compile(r"\(HTTP (\d{3})\)")
160
+
161
+ _SIBLINGS: dict[str, object] = {}
162
+
163
+
164
+ def _load(filename: str):
165
+ """A script of this folder, loaded once."""
166
+ if filename not in _SIBLINGS:
167
+ path = Path(__file__).resolve().parent / filename
168
+ spec = importlib.util.spec_from_file_location(filename[:-3].replace("-", "_"), path)
169
+ if spec is None or spec.loader is None:
170
+ raise ImportError(f"cannot load {path}")
171
+ module = importlib.util.module_from_spec(spec)
172
+ spec.loader.exec_module(module)
173
+ _SIBLINGS[filename] = module
174
+ return _SIBLINGS[filename]
175
+
176
+
177
+ def _sibling():
178
+ """skf-source-tree.py from this folder, loaded once: gh and git run
179
+ through its runner (_run stops a call and all it started at the time
180
+ limit; _resolve_outside_cwd never takes a gh or git planted in the
181
+ current folder), tags sort with its split_version, a version's tags
182
+ are found with its match_tags and nearest_tags, and refs are checked
183
+ with its REF_RE."""
184
+ return _load("skf-source-tree.py")
185
+
186
+
187
+ # --------------------------------------------------------------------------
188
+ # Repository names
189
+ # --------------------------------------------------------------------------
190
+
191
+
192
+ def parse_repo(value: str | None) -> tuple[str, str] | None:
193
+ """(owner, repo) of a github.com repository, else None: read by
194
+ skf-resolve-package.py's github_target, the one GitHub URL grammar
195
+ quick-skill parses targets and registry answers with."""
196
+ loc = _load("skf-resolve-package.py").github_target((value or "").strip())
197
+ return (loc["owner"], loc["repo"]) if loc else None
198
+
199
+
200
+ # --------------------------------------------------------------------------
201
+ # Probes
202
+ # --------------------------------------------------------------------------
203
+
204
+
205
+ _deadline: float | None = None
206
+
207
+
208
+ def _start_clock(seconds: float | None) -> None:
209
+ global _deadline
210
+ _deadline = None if seconds is None else time.monotonic() + max(0.0, seconds)
211
+
212
+
213
+ def _budget() -> float | None:
214
+ """Seconds the next probe may take: PROBE_TIMEOUT_SEC or what is left; None when nothing is."""
215
+ if _deadline is None:
216
+ return PROBE_TIMEOUT_SEC
217
+ left = _deadline - time.monotonic()
218
+ return min(PROBE_TIMEOUT_SEC, left) if left > 0.5 else None
219
+
220
+
221
+ def _last_line(text: str) -> str:
222
+ lines = [line.strip() for line in (text or "").splitlines() if line.strip()]
223
+ for line in reversed(lines):
224
+ if line.startswith(("fatal:", "error:", "gh:")):
225
+ return line
226
+ return lines[-1] if lines else ""
227
+
228
+
229
+ def _gh(*args: str) -> tuple[str, str, str]:
230
+ """`gh api --hostname github.com <args>`: (result, stdout, detail)."""
231
+ tree = _sibling()
232
+ exe = tree._resolve_outside_cwd("gh")
233
+ if exe is None:
234
+ return "missing", "", "gh is not installed"
235
+ timeout = _budget()
236
+ if timeout is None:
237
+ return "timeout", "", "no time was left to run gh"
238
+ try:
239
+ rc, out, err, _killed = tree._run([exe, "api", "--hostname", "github.com", *args], timeout)
240
+ except (OSError, ValueError) as e:
241
+ return "missing", "", f"gh could not run: {e}"
242
+ if rc is None:
243
+ return "timeout", "", f"gh did not answer within {timeout:g} seconds"
244
+ stdout = out.decode("utf-8", errors="replace")
245
+ detail = _last_line(err.decode("utf-8", errors="replace"))
246
+ if rc == 0:
247
+ return "ok", stdout, ""
248
+ if rc == 4: # gh: authentication required
249
+ return "unauthenticated", "", "gh is not logged in"
250
+ m = _HTTP_CODE_RE.search(detail)
251
+ code = int(m.group(1)) if m else None
252
+ if code == 401:
253
+ return "unauthenticated", "", detail
254
+ if code == 404:
255
+ return "not-found", "", detail
256
+ if code == 429 or (code == 403 and "rate limit" in (detail + stdout).lower()):
257
+ return "rate-limited", "", detail
258
+ if code == 403:
259
+ return "forbidden", "", detail
260
+ return "network", "", detail or f"gh exited {rc}"
261
+
262
+
263
+ def _api(path: str) -> tuple[str, str, str]:
264
+ """GET {API_ROOT}/{path} without credentials: (result, body, detail)."""
265
+ timeout = _budget()
266
+ if timeout is None:
267
+ return "timeout", "", "no time was left to call the GitHub API"
268
+ req = urllib.request.Request(f"{API_ROOT}/{path}", headers={
269
+ "Accept": "application/vnd.github+json", "User-Agent": USER_AGENT})
270
+ try:
271
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
272
+ return "ok", resp.read().decode("utf-8", errors="replace"), ""
273
+ except urllib.error.HTTPError as e:
274
+ try:
275
+ body = e.read().decode("utf-8", errors="replace")
276
+ except OSError:
277
+ body = ""
278
+ detail = f"HTTP {e.code} from {API_ROOT}/{path}"
279
+ if e.code == 404:
280
+ return "not-found", "", detail
281
+ if e.code in (403, 429):
282
+ limited = "rate limit" in body.lower() or e.headers.get("X-RateLimit-Remaining") == "0"
283
+ return ("rate-limited" if limited or e.code == 429 else "forbidden"), "", detail
284
+ if e.code == 451:
285
+ return "forbidden", "", detail
286
+ return "network", "", detail
287
+ except (TimeoutError, socket.timeout):
288
+ return "timeout", "", f"{API_ROOT} did not answer within {timeout:g} seconds"
289
+ except (urllib.error.URLError, OSError, ValueError) as e:
290
+ return "network", "", f"{API_ROOT}: {getattr(e, 'reason', e)}"
291
+
292
+
293
+ def _classify_git(stderr: str) -> str:
294
+ """The probe result a failed `git ls-remote` of a github.com URL reports."""
295
+ text = stderr.lower()
296
+ if "returned error: 403" in text or "saml" in text or ("permission to" in text and "denied" in text):
297
+ return "forbidden"
298
+ if ("repository not found" in text or "returned error: 404" in text
299
+ or ("repository '" in text and "not found" in text)):
300
+ return "not-found"
301
+ if any(sign in text for sign in ("could not read username", "could not read password",
302
+ "terminal prompts disabled", "returned error: 401",
303
+ "authentication failed", "invalid username or password")):
304
+ return "auth-required"
305
+ return "network"
306
+
307
+
308
+ def _git(*args: str) -> tuple[str, str, str]:
309
+ """`git <args>` from the system temp folder: (result, stdout, detail)."""
310
+ tree = _sibling()
311
+ exe = tree._resolve_outside_cwd("git")
312
+ if exe is None:
313
+ return "missing", "", "git is not installed"
314
+ timeout = _budget()
315
+ if timeout is None:
316
+ return "timeout", "", "no time was left to run git"
317
+ try:
318
+ rc, out, err, _killed = tree._run([exe, "-c", f"core.hooksPath={os.devnull}", "-C",
319
+ tempfile.gettempdir(), *args], timeout)
320
+ except (OSError, ValueError) as e:
321
+ return "missing", "", f"git could not run: {e}"
322
+ if rc is None:
323
+ return "timeout", "", f"git did not answer within {timeout:g} seconds"
324
+ if rc == 0:
325
+ return "ok", out.decode("utf-8", errors="replace"), ""
326
+ stderr = err.decode("utf-8", errors="replace")
327
+ return _classify_git(stderr), "", _last_line(stderr) or f"git exited {rc}"
328
+
329
+
330
+ def _json(text: str):
331
+ """A JSON document, or the items of several arrays `gh --paginate` printed one after another."""
332
+ decoder = json.JSONDecoder()
333
+ docs, i, text = [], 0, text.strip()
334
+ while i < len(text):
335
+ doc, end = decoder.raw_decode(text, i)
336
+ docs.append(doc)
337
+ i = end
338
+ while i < len(text) and text[i].isspace():
339
+ i += 1
340
+ if len(docs) == 1:
341
+ return docs[0]
342
+ return [item for doc in docs if isinstance(doc, list) for item in doc]
343
+
344
+
345
+ # --------------------------------------------------------------------------
346
+ # Causes
347
+ # --------------------------------------------------------------------------
348
+
349
+
350
+ class Probe:
351
+ """One command's probes, their records and the state of gh."""
352
+
353
+ def __init__(self, owner: str, repo: str):
354
+ self.owner, self.repo = owner, repo
355
+ self.records: list[dict] = []
356
+ self.gh = "not-run"
357
+
358
+ def record(self, probe: str, target: str, result: str, detail: str) -> str:
359
+ self.records.append({"probe": probe, "target": target, "result": result, "detail": detail or None})
360
+ if probe == "gh":
361
+ if result in ("missing", "unauthenticated"):
362
+ self.gh = result
363
+ elif result in ("ok", "not-found", "forbidden"):
364
+ self.gh = "ok"
365
+ elif self.gh == "not-run":
366
+ self.gh = "failed"
367
+ return result
368
+
369
+ def gh_call(self, target: str, *args: str) -> tuple[str, str]:
370
+ result, out, detail = _gh(*args)
371
+ return self.record("gh", target, result, detail), out
372
+
373
+ def api_call(self, target: str, path: str) -> tuple[str, str]:
374
+ result, out, detail = _api(path)
375
+ return self.record("api", target, result, detail), out
376
+
377
+ def git_call(self, target: str, *args: str) -> tuple[str, str]:
378
+ result, out, detail = _git(*args)
379
+ return self.record("git", target, result, detail), out
380
+
381
+ def results(self, probe: str) -> list[str]:
382
+ """This probe's results, without the owner lookup and without a tree's 404: GitHub
383
+ answers 404 for a missing ref as for a missing repository."""
384
+ return [r["result"] for r in self.records if r["probe"] == probe and r["target"] != "owner"
385
+ and not (r["target"] == "tree" and r["result"] == "not-found")]
386
+
387
+ def owner_missing(self) -> bool:
388
+ """Look the owner up with gh when it answers, else the API; True only on a 404."""
389
+ if self.gh == "ok":
390
+ result, _out = self.gh_call("owner", f"users/{self.owner}")
391
+ else:
392
+ result, _out = self.api_call("owner", f"users/{self.owner}")
393
+ return result == "not-found"
394
+
395
+ def cause(self) -> str:
396
+ gh, git, api = self.results("gh"), self.results("git"), self.results("api")
397
+ if "forbidden" in gh or "forbidden" in git:
398
+ return "no-access"
399
+ found_nothing = "not-found" in gh or "not-found" in git
400
+ hidden = "not-found" in api or "auth-required" in git
401
+ if (found_nothing or hidden) and self.owner_missing():
402
+ return "repo-not-found"
403
+ if found_nothing:
404
+ return "repo-not-found"
405
+ flaky = any(r in ("network", "timeout", "rate-limited") for r in api + git)
406
+ if self.gh in ("missing", "unauthenticated") and (hidden or not flaky):
407
+ return f"gh-{self.gh}"
408
+ return "unreachable"
409
+
410
+ def account(self) -> str | None:
411
+ if self.gh != "ok":
412
+ return None
413
+ result, out, _detail = _gh("user")
414
+ try:
415
+ login = json.loads(out).get("login") if result == "ok" else None
416
+ except (ValueError, AttributeError):
417
+ login = None
418
+ return login if isinstance(login, str) else None
419
+
420
+ def message(self, cause: str, account: str | None) -> str:
421
+ name = f"{self.owner}/{self.repo}"
422
+ details = "; ".join(dict.fromkeys(r["detail"] for r in self.records
423
+ if r["detail"] and r["result"] != "ok"))
424
+ if cause == "repo-not-found":
425
+ if any(r["target"] == "owner" and r["result"] == "not-found" for r in self.records):
426
+ return f"GitHub has no user or organization named {self.owner}; check the URL."
427
+ who = f"the GitHub account {account}" if account else "your GitHub credentials"
428
+ return (f"{name} was not found with {who}: it does not exist, or it is private and that account "
429
+ "cannot see it. Check the URL, or log in with an account that can read it (gh auth login).")
430
+ if cause == "no-access":
431
+ return (f"GitHub refused access to {name} ({details}). Authorize your token for the organization "
432
+ "(gh auth refresh), or use an account that can read it.")
433
+ if cause == "gh-missing":
434
+ return (f"{name} is private or does not exist: nothing could read it without a GitHub login, and "
435
+ "the GitHub CLI (gh) is not installed. Install gh and run gh auth login, or check the URL.")
436
+ if cause == "gh-unauthenticated":
437
+ return (f"{name} is private or does not exist: nothing could read it without a GitHub login, and "
438
+ "the GitHub CLI (gh) is not logged in. Run gh auth login, or check the URL.")
439
+ return f"Could not reach GitHub to read {name} ({details or 'no probe could run'}); try again later."
440
+
441
+ def output(self, status: str, via: str | None, cause: str | None = None, message: str | None = None,
442
+ account: str | None = None, **extra) -> dict:
443
+ return {"status": status, "cause": cause, "message": message, "repo": f"{self.owner}/{self.repo}",
444
+ "gh": self.gh, "account": account, "via": via, "probes": self.records, **extra}
445
+
446
+ def unavailable(self, **extra) -> dict:
447
+ cause = self.cause()
448
+ owner_gone = any(r["target"] == "owner" and r["result"] == "not-found" for r in self.records)
449
+ account = self.account() if cause == "repo-not-found" and not owner_gone else None
450
+ return self.output("unavailable", None, cause, self.message(cause, account), account, **extra)
451
+
452
+
453
+ # --------------------------------------------------------------------------
454
+ # Commands
455
+ # --------------------------------------------------------------------------
456
+
457
+
458
+ # What each command reports besides the common keys when it read nothing;
459
+ # found and missing stay empty then, since no listing says a tag is absent.
460
+ _EMPTY = {
461
+ "repo": {"private": None, "default_branch": None},
462
+ "tags": {"tags": [], "count": 0, "match": None, "found": {}, "missing": [], "nearest": [], "release": None},
463
+ "tree": {"ref": "HEAD", "tree": [], "count": 0, "truncated": False},
464
+ }
465
+
466
+
467
+ def probe_repo(owner: str, repo: str) -> dict:
468
+ p = Probe(owner, repo)
469
+
470
+ def read(body: str, via: str) -> dict | None:
471
+ try:
472
+ data = json.loads(body)
473
+ except ValueError:
474
+ return None
475
+ if not isinstance(data, dict):
476
+ return None
477
+ private = data.get("private")
478
+ branch = data.get("default_branch")
479
+ return p.output("ok", via, private=private if isinstance(private, bool) else None,
480
+ default_branch=branch if isinstance(branch, str) else None)
481
+
482
+ result, body = p.gh_call("repo", f"repos/{owner}/{repo}")
483
+ if result == "ok" and (out := read(body, "gh")):
484
+ return out
485
+ if result != "not-found":
486
+ result, body = p.api_call("repo", f"repos/{owner}/{repo}")
487
+ if result == "ok" and (out := read(body, "api")):
488
+ return out
489
+ result, text = p.git_call("repo", "ls-remote", "--symref", "--", f"{WEB_ROOT}/{owner}/{repo}", "HEAD")
490
+ if result == "ok":
491
+ branch = None
492
+ for line in text.splitlines():
493
+ left, _, name = line.partition("\t")
494
+ if left.startswith("ref: refs/heads/") and name.strip() == "HEAD":
495
+ branch = left[len("ref: refs/heads/"):].strip()
496
+ private = True if "not-found" in p.results("api") else None
497
+ return p.output("ok", "git", private=private, default_branch=branch)
498
+ return p.unavailable(**_EMPTY["repo"])
499
+
500
+
501
+ def _sorted_tags(names) -> list[str]:
502
+ versioned, other = [], []
503
+ for name in names:
504
+ split = _sibling().split_version(name)
505
+ (versioned if split else other).append((split[1] if split else None, name))
506
+ versioned.sort(key=lambda item: (item[0], item[1]), reverse=True)
507
+ return [name for _key, name in versioned] + sorted(name for _key, name in other)
508
+
509
+
510
+ # A tag whose name is a version (`v1.2.3`, `1.2.3-rc.1`), and one whose name
511
+ # is a release version (X.Y.Z, no pre-release or build); a leading v is not
512
+ # part of the version.
513
+ _VERSION_TAG_RE = re.compile(r"v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)")
514
+ _RELEASE_TAG_RE = re.compile(r"v?(\d+\.\d+\.\d+)")
515
+
516
+
517
+ def tag_version(name: str, release: bool = False) -> str | None:
518
+ """The version a tag's name is, less a leading `v` (`v1.2.3` gives
519
+ 1.2.3), else None; with `release`, a pre-release or a build is none."""
520
+ m = (_RELEASE_TAG_RE if release else _VERSION_TAG_RE).fullmatch(name)
521
+ return m.group(1) if m else None
522
+
523
+
524
+ def release_of(ordered: list[str], ref: str | None) -> dict | None:
525
+ """The tag a version is read from at `ref`, as {"tag", "version"}: at
526
+ HEAD the newest of `ordered` (newest version first) whose name is a
527
+ release version; at another ref, that ref when `ordered` holds it and
528
+ its name is a version; else None."""
529
+ ref = _tree_ref(ref)
530
+ if ref != "HEAD":
531
+ version = tag_version(ref) if ref in ordered else None
532
+ return {"tag": ref, "version": version} if version else None
533
+ for name in ordered:
534
+ version = tag_version(name, release=True)
535
+ if version:
536
+ return {"tag": name, "version": version}
537
+ return None
538
+
539
+
540
+ def probe_tags(owner: str, repo: str, wants: list[str], limit: int, version: str | None = None,
541
+ package: str | None = None, ref: str | None = None) -> dict:
542
+ p = Probe(owner, repo)
543
+ tags: dict[str, str | None] | None = None
544
+ via = None
545
+ result, body = p.gh_call("tags", "--paginate", f"repos/{owner}/{repo}/tags?per_page=100")
546
+ if result == "ok":
547
+ try:
548
+ items = _json(body)
549
+ if isinstance(items, list):
550
+ tags = {}
551
+ for item in items:
552
+ if isinstance(item, dict) and isinstance(item.get("name"), str):
553
+ commit = item.get("commit")
554
+ sha = commit.get("sha") if isinstance(commit, dict) else None
555
+ tags[item["name"]] = sha if isinstance(sha, str) else None
556
+ via = "gh"
557
+ except ValueError:
558
+ tags = None
559
+ if tags is None:
560
+ result, text = p.git_call("tags", "ls-remote", "--tags", "--", f"{WEB_ROOT}/{owner}/{repo}")
561
+ if result == "ok":
562
+ tags = {}
563
+ for line in text.splitlines():
564
+ sha, _, name = line.partition("\t")
565
+ name = name.strip()
566
+ if not name.startswith("refs/tags/"):
567
+ continue
568
+ name = name[len("refs/tags/"):]
569
+ if name.endswith("^{}"):
570
+ tags[name[:-3]] = sha.strip()
571
+ else:
572
+ tags.setdefault(name, sha.strip())
573
+ via = "git"
574
+ if tags is None:
575
+ return p.unavailable(**_EMPTY["tags"])
576
+ ordered = _sorted_tags(tags)
577
+ wanted = list(dict.fromkeys(wants))
578
+ found = {tag: tags[tag] for tag in wanted if tag in tags}
579
+ named = _sibling().match_tags(tags, version, package or None) if version else []
580
+ return p.output("ok", via, tags=ordered[:limit] if limit > 0 else ordered, count=len(ordered),
581
+ match=next((tag for tag in (*wanted, *named) if tag in tags), None), found=found,
582
+ missing=[tag for tag in wanted if tag not in tags],
583
+ nearest=_sibling().nearest_tags(tags, version) if version else [],
584
+ release=release_of(ordered, ref))
585
+
586
+
587
+ def _tree_ref(value: str | None) -> str:
588
+ """The ref tree reads: "HEAD" for an empty, `null`, `none` or `head` value (any letter case)."""
589
+ ref = (value or "").strip()
590
+ return "HEAD" if ref.lower() in ("", "null", "none", "head") else ref
591
+
592
+
593
+ def probe_tree(owner: str, repo: str, ref: str) -> dict:
594
+ p = Probe(owner, repo)
595
+ ref = _tree_ref(ref)
596
+ empty = {**_EMPTY["tree"], "ref": ref}
597
+ path = f"repos/{owner}/{repo}/git/trees/{urllib.parse.quote(ref, safe='')}?recursive=1"
598
+
599
+ def read(body: str, via: str) -> dict | None:
600
+ try:
601
+ data = json.loads(body)
602
+ except ValueError:
603
+ return None
604
+ if not isinstance(data, dict) or not isinstance(data.get("tree"), list):
605
+ return None
606
+ files = [e["path"] for e in data["tree"]
607
+ if isinstance(e, dict) and e.get("type") == "blob" and isinstance(e.get("path"), str)]
608
+ return p.output("ok", via, ref=ref, tree=files, count=len(files),
609
+ truncated=data.get("truncated") is True)
610
+
611
+ if ref != "HEAD" and not _sibling().REF_RE.fullmatch(ref):
612
+ return p.output("unavailable", None, "ref-not-found",
613
+ f"{ref} is not a valid branch, tag or commit name.", **empty)
614
+ result, body = p.gh_call("tree", path)
615
+ if result == "ok" and (out := read(body, "gh")):
616
+ return out
617
+ repo_result = None
618
+ if result == "not-found":
619
+ repo_result, _body = p.gh_call("repo", f"repos/{owner}/{repo}")
620
+ if repo_result == "ok":
621
+ return p.output("unavailable", None, "ref-not-found",
622
+ f"{owner}/{repo} has no branch, tag or commit named {ref}.", **empty)
623
+ # Only gh's 404 for the repository itself says the anonymous API cannot read it either.
624
+ if repo_result != "not-found":
625
+ result, body = p.api_call("tree", path)
626
+ if result == "ok" and (out := read(body, "api")):
627
+ return out
628
+ if result == "not-found":
629
+ repo_result, _body = p.api_call("repo", f"repos/{owner}/{repo}")
630
+ if repo_result == "ok":
631
+ return p.output("unavailable", None, "ref-not-found",
632
+ f"{owner}/{repo} has no branch, tag or commit named {ref}.", **empty)
633
+ return p.unavailable(**empty)
634
+
635
+
636
+ # --------------------------------------------------------------------------
637
+ # CLI
638
+ # --------------------------------------------------------------------------
639
+
640
+
641
+ def _build_parser() -> argparse.ArgumentParser:
642
+ parser = argparse.ArgumentParser(
643
+ prog="skf-github-probe",
644
+ description="Tell whether a GitHub repository can be read and why not; list its tags or files.",
645
+ )
646
+ sub = parser.add_subparsers(dest="cmd", required=True)
647
+
648
+ def common(p: argparse.ArgumentParser) -> None:
649
+ p.add_argument("--repo", required=True, help="owner/repo or a github.com URL")
650
+ p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_SEC,
651
+ help="seconds the whole call may take (default 60)")
652
+
653
+ common(sub.add_parser("repo", help="can the repository be read"))
654
+ p_tags = sub.add_parser("tags", help="list the repository's tags")
655
+ common(p_tags)
656
+ p_tags.add_argument("--want", action="append", default=[], help="a tag to look for (repeatable)")
657
+ p_tags.add_argument("--version", help="a version whose tag to look for, in every form a tag writes it in")
658
+ p_tags.add_argument("--name", help="with --version: the package name a monorepo's tags carry")
659
+ p_tags.add_argument("--limit", type=int, default=DEFAULT_TAG_LIMIT,
660
+ help="tags to list, newest first (default 20; 0 for all)")
661
+ p_tags.add_argument("--ref", default="HEAD",
662
+ help="the ref `release` reads a version at (default HEAD: the newest release tag)")
663
+ p_tree = sub.add_parser("tree", help="list the repository's files at a ref")
664
+ common(p_tree)
665
+ p_tree.add_argument("--ref", default="HEAD", help="branch, tag or commit (default: the default branch)")
666
+ p_tree.add_argument("--out", metavar="FILE",
667
+ help="write the whole listing to FILE and print it without `tree`")
668
+ return parser
669
+
670
+
671
+ def main(argv: list[str] | None = None) -> int:
672
+ args = _build_parser().parse_args(argv)
673
+ try:
674
+ _start_clock(args.timeout)
675
+ parsed = parse_repo(args.repo)
676
+ if parsed is None:
677
+ out = {"status": "unavailable", "cause": "invalid-repo",
678
+ "message": f"{args.repo} is not a github.com repository (expected owner/repo or a URL).",
679
+ "repo": None, "gh": "not-run", "account": None, "via": None, "probes": [],
680
+ **_EMPTY[args.cmd]}
681
+ if args.cmd == "tree":
682
+ out["ref"] = _tree_ref(args.ref)
683
+ elif args.cmd == "repo":
684
+ out = probe_repo(*parsed)
685
+ elif args.cmd == "tags":
686
+ out = probe_tags(*parsed, args.want, args.limit, args.version, args.name, args.ref)
687
+ else:
688
+ out = probe_tree(*parsed, args.ref)
689
+ if args.cmd == "tree" and args.out:
690
+ Path(args.out).write_bytes((json.dumps(out) + "\n").encode("utf-8"))
691
+ out = {key: value for key, value in out.items() if key != "tree"}
692
+ except Exception as e: # noqa: BLE001 - one JSON error line, never a traceback
693
+ print(json.dumps({"status": "error", "message": f"{type(e).__name__}: {e}"}), file=sys.stderr)
694
+ return 1
695
+ print(json.dumps(out))
696
+ return 0 if out["status"] == "ok" else 3
697
+
698
+
699
+ if __name__ == "__main__":
700
+ raise SystemExit(main())