bmad-module-skill-forge 2.1.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 +51 -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 +14 -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 +117 -9
  75. package/src/shared/scripts/skf-detect-docs.py +892 -4
  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 +215 -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 +17 -6
  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 +55 -18
  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 -57
  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 -89
  348. package/src/skf-setup/references/detect-and-tier.md +58 -66
  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 -645
  424. package/release-audits/v1.0.0-launch-audit.md +0 -728
  425. package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
  426. package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
  427. package/src/skf-campaign/manifest.yaml +0 -15
  428. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
  429. package/src/skf-create-skill/references/ecosystem-check.md +0 -74
  430. package/src/skf-drop-skill/references/headless-contract.md +0 -18
  431. package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
  432. package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
  433. package/src/skf-update-skill/references/validate.md +0 -124
  434. package/tools/validate-docs-drift.js +0 -233
  435. package/tools/validate-docs-links.js +0 -665
@@ -0,0 +1,661 @@
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>] \\
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). When the listing
75
+ could not be read, found, missing and nearest are empty: only a
76
+ listing says a tag is absent.
77
+ A tag names --version in the forms skf-source-tree.py's
78
+ match_tags tries, in its order: the version as given,
79
+ `v<version>` and, with --name, `<name>@<v>`,
80
+ `@<scope>/<name>@<v>` (any scope), `<name>/v<v>`, `<name>/<v>`
81
+ and `<name>-v<v>`, where <v> drops a leading `v` of the version.
82
+ A monorepo's package tags (`astro@4.16.0`) match through --name.
83
+ tree: ref (as given; "HEAD", the default branch, for an empty, `null`,
84
+ `none` or `head` --ref in any letter case), tree (file paths;
85
+ hand the listing to skf-detect-language.py or another --tree-file
86
+ reader as a file, through --out, never through echo), count,
87
+ truncated (GitHub cut the list short). With --out <file>, the
88
+ whole line goes to <file>, which every --tree-file reader takes,
89
+ and stdout carries it without `tree`, so a caller reads the
90
+ status, count and truncated of a listing of any size.
91
+
92
+ cause:
93
+ repo-not-found an authenticated probe (gh, or git with the user's
94
+ credentials) found no such repository: it does not
95
+ exist, or it is private and the account cannot see
96
+ it (GitHub answers 404 then), or the owner does not
97
+ exist
98
+ no-access an authenticated probe was refused (HTTP 403: an
99
+ organization's SAML or IP rules, a token without the
100
+ scope)
101
+ gh-missing gh is not installed, and nothing else could read the
102
+ repository: it is private or does not exist
103
+ gh-unauthenticated gh is not logged in (or its token is rejected), and
104
+ nothing else could read the repository
105
+ ref-not-found tree: the repository was read, the ref was not found
106
+ unreachable the probes failed on the network, a time limit or
107
+ the API rate limit
108
+ invalid-repo --repo names no github.com repository
109
+
110
+ The whole call finishes within about --timeout seconds (60 by default);
111
+ one probe gets at most 20 of them, so a slow probe leaves time for the
112
+ next. gh and git run through skf-source-tree.py (the sibling in this
113
+ folder, which must sit beside this script): a call the limit stops is
114
+ stopped with everything it started, and a gh or git in the current
115
+ folder is never run. gh runs with `--hostname github.com`; git runs from
116
+ the system temp folder with LC_ALL=C, no hooks, no terminal or
117
+ credential-manager prompt and the git location variables removed.
118
+
119
+ Exit codes:
120
+ 0 status ok
121
+ 3 status unavailable (JSON on stdout; the caller decides what to do)
122
+ 1 unexpected error: {"status": "error", "message": ...} on stderr
123
+ 2 usage error (argparse)
124
+ """
125
+
126
+ from __future__ import annotations
127
+
128
+ import argparse
129
+ import importlib.util
130
+ import json
131
+ import os
132
+ import re
133
+ import socket
134
+ import sys
135
+ import tempfile
136
+ import time
137
+ import urllib.error
138
+ import urllib.parse
139
+ import urllib.request
140
+ from pathlib import Path
141
+
142
+ CAUSES = ("repo-not-found", "no-access", "gh-missing", "gh-unauthenticated", "ref-not-found", "unreachable",
143
+ "invalid-repo")
144
+ GH_STATES = ("ok", "missing", "unauthenticated", "failed", "not-run")
145
+ PROBE_RESULTS = ("ok", "missing", "unauthenticated", "not-found", "forbidden", "auth-required", "rate-limited",
146
+ "network", "timeout")
147
+ API_ROOT = "https://api.github.com"
148
+ WEB_ROOT = "https://github.com"
149
+ USER_AGENT = "skf-github-probe/1.0 (+https://github.com/armelhbobdad/bmad-module-skill-forge)"
150
+ DEFAULT_TIMEOUT_SEC = 60.0
151
+ PROBE_TIMEOUT_SEC = 20.0
152
+ DEFAULT_TAG_LIMIT = 20
153
+ _HTTP_CODE_RE = re.compile(r"\(HTTP (\d{3})\)")
154
+
155
+ _SIBLINGS: dict[str, object] = {}
156
+
157
+
158
+ def _load(filename: str):
159
+ """A script of this folder, loaded once."""
160
+ if filename not in _SIBLINGS:
161
+ path = Path(__file__).resolve().parent / filename
162
+ spec = importlib.util.spec_from_file_location(filename[:-3].replace("-", "_"), path)
163
+ if spec is None or spec.loader is None:
164
+ raise ImportError(f"cannot load {path}")
165
+ module = importlib.util.module_from_spec(spec)
166
+ spec.loader.exec_module(module)
167
+ _SIBLINGS[filename] = module
168
+ return _SIBLINGS[filename]
169
+
170
+
171
+ def _sibling():
172
+ """skf-source-tree.py from this folder, loaded once: gh and git run
173
+ through its runner (_run stops a call and all it started at the time
174
+ limit; _resolve_outside_cwd never takes a gh or git planted in the
175
+ current folder), tags sort with its split_version, a version's tags
176
+ are found with its match_tags and nearest_tags, and refs are checked
177
+ with its REF_RE."""
178
+ return _load("skf-source-tree.py")
179
+
180
+
181
+ # --------------------------------------------------------------------------
182
+ # Repository names
183
+ # --------------------------------------------------------------------------
184
+
185
+
186
+ def parse_repo(value: str | None) -> tuple[str, str] | None:
187
+ """(owner, repo) of a github.com repository, else None: read by
188
+ skf-resolve-package.py's github_target, the one GitHub URL grammar
189
+ quick-skill parses targets and registry answers with."""
190
+ loc = _load("skf-resolve-package.py").github_target((value or "").strip())
191
+ return (loc["owner"], loc["repo"]) if loc else None
192
+
193
+
194
+ # --------------------------------------------------------------------------
195
+ # Probes
196
+ # --------------------------------------------------------------------------
197
+
198
+
199
+ _deadline: float | None = None
200
+
201
+
202
+ def _start_clock(seconds: float | None) -> None:
203
+ global _deadline
204
+ _deadline = None if seconds is None else time.monotonic() + max(0.0, seconds)
205
+
206
+
207
+ def _budget() -> float | None:
208
+ """Seconds the next probe may take: PROBE_TIMEOUT_SEC or what is left; None when nothing is."""
209
+ if _deadline is None:
210
+ return PROBE_TIMEOUT_SEC
211
+ left = _deadline - time.monotonic()
212
+ return min(PROBE_TIMEOUT_SEC, left) if left > 0.5 else None
213
+
214
+
215
+ def _last_line(text: str) -> str:
216
+ lines = [line.strip() for line in (text or "").splitlines() if line.strip()]
217
+ for line in reversed(lines):
218
+ if line.startswith(("fatal:", "error:", "gh:")):
219
+ return line
220
+ return lines[-1] if lines else ""
221
+
222
+
223
+ def _gh(*args: str) -> tuple[str, str, str]:
224
+ """`gh api --hostname github.com <args>`: (result, stdout, detail)."""
225
+ tree = _sibling()
226
+ exe = tree._resolve_outside_cwd("gh")
227
+ if exe is None:
228
+ return "missing", "", "gh is not installed"
229
+ timeout = _budget()
230
+ if timeout is None:
231
+ return "timeout", "", "no time was left to run gh"
232
+ try:
233
+ rc, out, err, _killed = tree._run([exe, "api", "--hostname", "github.com", *args], timeout)
234
+ except (OSError, ValueError) as e:
235
+ return "missing", "", f"gh could not run: {e}"
236
+ if rc is None:
237
+ return "timeout", "", f"gh did not answer within {timeout:g} seconds"
238
+ stdout = out.decode("utf-8", errors="replace")
239
+ detail = _last_line(err.decode("utf-8", errors="replace"))
240
+ if rc == 0:
241
+ return "ok", stdout, ""
242
+ if rc == 4: # gh: authentication required
243
+ return "unauthenticated", "", "gh is not logged in"
244
+ m = _HTTP_CODE_RE.search(detail)
245
+ code = int(m.group(1)) if m else None
246
+ if code == 401:
247
+ return "unauthenticated", "", detail
248
+ if code == 404:
249
+ return "not-found", "", detail
250
+ if code == 429 or (code == 403 and "rate limit" in (detail + stdout).lower()):
251
+ return "rate-limited", "", detail
252
+ if code == 403:
253
+ return "forbidden", "", detail
254
+ return "network", "", detail or f"gh exited {rc}"
255
+
256
+
257
+ def _api(path: str) -> tuple[str, str, str]:
258
+ """GET {API_ROOT}/{path} without credentials: (result, body, detail)."""
259
+ timeout = _budget()
260
+ if timeout is None:
261
+ return "timeout", "", "no time was left to call the GitHub API"
262
+ req = urllib.request.Request(f"{API_ROOT}/{path}", headers={
263
+ "Accept": "application/vnd.github+json", "User-Agent": USER_AGENT})
264
+ try:
265
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
266
+ return "ok", resp.read().decode("utf-8", errors="replace"), ""
267
+ except urllib.error.HTTPError as e:
268
+ try:
269
+ body = e.read().decode("utf-8", errors="replace")
270
+ except OSError:
271
+ body = ""
272
+ detail = f"HTTP {e.code} from {API_ROOT}/{path}"
273
+ if e.code == 404:
274
+ return "not-found", "", detail
275
+ if e.code in (403, 429):
276
+ limited = "rate limit" in body.lower() or e.headers.get("X-RateLimit-Remaining") == "0"
277
+ return ("rate-limited" if limited or e.code == 429 else "forbidden"), "", detail
278
+ if e.code == 451:
279
+ return "forbidden", "", detail
280
+ return "network", "", detail
281
+ except (TimeoutError, socket.timeout):
282
+ return "timeout", "", f"{API_ROOT} did not answer within {timeout:g} seconds"
283
+ except (urllib.error.URLError, OSError, ValueError) as e:
284
+ return "network", "", f"{API_ROOT}: {getattr(e, 'reason', e)}"
285
+
286
+
287
+ def _classify_git(stderr: str) -> str:
288
+ """The probe result a failed `git ls-remote` of a github.com URL reports."""
289
+ text = stderr.lower()
290
+ if "returned error: 403" in text or "saml" in text or ("permission to" in text and "denied" in text):
291
+ return "forbidden"
292
+ if ("repository not found" in text or "returned error: 404" in text
293
+ or ("repository '" in text and "not found" in text)):
294
+ return "not-found"
295
+ if any(sign in text for sign in ("could not read username", "could not read password",
296
+ "terminal prompts disabled", "returned error: 401",
297
+ "authentication failed", "invalid username or password")):
298
+ return "auth-required"
299
+ return "network"
300
+
301
+
302
+ def _git(*args: str) -> tuple[str, str, str]:
303
+ """`git <args>` from the system temp folder: (result, stdout, detail)."""
304
+ tree = _sibling()
305
+ exe = tree._resolve_outside_cwd("git")
306
+ if exe is None:
307
+ return "missing", "", "git is not installed"
308
+ timeout = _budget()
309
+ if timeout is None:
310
+ return "timeout", "", "no time was left to run git"
311
+ try:
312
+ rc, out, err, _killed = tree._run([exe, "-c", f"core.hooksPath={os.devnull}", "-C",
313
+ tempfile.gettempdir(), *args], timeout)
314
+ except (OSError, ValueError) as e:
315
+ return "missing", "", f"git could not run: {e}"
316
+ if rc is None:
317
+ return "timeout", "", f"git did not answer within {timeout:g} seconds"
318
+ if rc == 0:
319
+ return "ok", out.decode("utf-8", errors="replace"), ""
320
+ stderr = err.decode("utf-8", errors="replace")
321
+ return _classify_git(stderr), "", _last_line(stderr) or f"git exited {rc}"
322
+
323
+
324
+ def _json(text: str):
325
+ """A JSON document, or the items of several arrays `gh --paginate` printed one after another."""
326
+ decoder = json.JSONDecoder()
327
+ docs, i, text = [], 0, text.strip()
328
+ while i < len(text):
329
+ doc, end = decoder.raw_decode(text, i)
330
+ docs.append(doc)
331
+ i = end
332
+ while i < len(text) and text[i].isspace():
333
+ i += 1
334
+ if len(docs) == 1:
335
+ return docs[0]
336
+ return [item for doc in docs if isinstance(doc, list) for item in doc]
337
+
338
+
339
+ # --------------------------------------------------------------------------
340
+ # Causes
341
+ # --------------------------------------------------------------------------
342
+
343
+
344
+ class Probe:
345
+ """One command's probes, their records and the state of gh."""
346
+
347
+ def __init__(self, owner: str, repo: str):
348
+ self.owner, self.repo = owner, repo
349
+ self.records: list[dict] = []
350
+ self.gh = "not-run"
351
+
352
+ def record(self, probe: str, target: str, result: str, detail: str) -> str:
353
+ self.records.append({"probe": probe, "target": target, "result": result, "detail": detail or None})
354
+ if probe == "gh":
355
+ if result in ("missing", "unauthenticated"):
356
+ self.gh = result
357
+ elif result in ("ok", "not-found", "forbidden"):
358
+ self.gh = "ok"
359
+ elif self.gh == "not-run":
360
+ self.gh = "failed"
361
+ return result
362
+
363
+ def gh_call(self, target: str, *args: str) -> tuple[str, str]:
364
+ result, out, detail = _gh(*args)
365
+ return self.record("gh", target, result, detail), out
366
+
367
+ def api_call(self, target: str, path: str) -> tuple[str, str]:
368
+ result, out, detail = _api(path)
369
+ return self.record("api", target, result, detail), out
370
+
371
+ def git_call(self, target: str, *args: str) -> tuple[str, str]:
372
+ result, out, detail = _git(*args)
373
+ return self.record("git", target, result, detail), out
374
+
375
+ def results(self, probe: str) -> list[str]:
376
+ """This probe's results, without the owner lookup and without a tree's 404: GitHub
377
+ answers 404 for a missing ref as for a missing repository."""
378
+ return [r["result"] for r in self.records if r["probe"] == probe and r["target"] != "owner"
379
+ and not (r["target"] == "tree" and r["result"] == "not-found")]
380
+
381
+ def owner_missing(self) -> bool:
382
+ """Look the owner up with gh when it answers, else the API; True only on a 404."""
383
+ if self.gh == "ok":
384
+ result, _out = self.gh_call("owner", f"users/{self.owner}")
385
+ else:
386
+ result, _out = self.api_call("owner", f"users/{self.owner}")
387
+ return result == "not-found"
388
+
389
+ def cause(self) -> str:
390
+ gh, git, api = self.results("gh"), self.results("git"), self.results("api")
391
+ if "forbidden" in gh or "forbidden" in git:
392
+ return "no-access"
393
+ found_nothing = "not-found" in gh or "not-found" in git
394
+ hidden = "not-found" in api or "auth-required" in git
395
+ if (found_nothing or hidden) and self.owner_missing():
396
+ return "repo-not-found"
397
+ if found_nothing:
398
+ return "repo-not-found"
399
+ flaky = any(r in ("network", "timeout", "rate-limited") for r in api + git)
400
+ if self.gh in ("missing", "unauthenticated") and (hidden or not flaky):
401
+ return f"gh-{self.gh}"
402
+ return "unreachable"
403
+
404
+ def account(self) -> str | None:
405
+ if self.gh != "ok":
406
+ return None
407
+ result, out, _detail = _gh("user")
408
+ try:
409
+ login = json.loads(out).get("login") if result == "ok" else None
410
+ except (ValueError, AttributeError):
411
+ login = None
412
+ return login if isinstance(login, str) else None
413
+
414
+ def message(self, cause: str, account: str | None) -> str:
415
+ name = f"{self.owner}/{self.repo}"
416
+ details = "; ".join(dict.fromkeys(r["detail"] for r in self.records
417
+ if r["detail"] and r["result"] != "ok"))
418
+ if cause == "repo-not-found":
419
+ if any(r["target"] == "owner" and r["result"] == "not-found" for r in self.records):
420
+ return f"GitHub has no user or organization named {self.owner}; check the URL."
421
+ who = f"the GitHub account {account}" if account else "your GitHub credentials"
422
+ return (f"{name} was not found with {who}: it does not exist, or it is private and that account "
423
+ "cannot see it. Check the URL, or log in with an account that can read it (gh auth login).")
424
+ if cause == "no-access":
425
+ return (f"GitHub refused access to {name} ({details}). Authorize your token for the organization "
426
+ "(gh auth refresh), or use an account that can read it.")
427
+ if cause == "gh-missing":
428
+ return (f"{name} is private or does not exist: nothing could read it without a GitHub login, and "
429
+ "the GitHub CLI (gh) is not installed. Install gh and run gh auth login, or check the URL.")
430
+ if cause == "gh-unauthenticated":
431
+ return (f"{name} is private or does not exist: nothing could read it without a GitHub login, and "
432
+ "the GitHub CLI (gh) is not logged in. Run gh auth login, or check the URL.")
433
+ return f"Could not reach GitHub to read {name} ({details or 'no probe could run'}); try again later."
434
+
435
+ def output(self, status: str, via: str | None, cause: str | None = None, message: str | None = None,
436
+ account: str | None = None, **extra) -> dict:
437
+ return {"status": status, "cause": cause, "message": message, "repo": f"{self.owner}/{self.repo}",
438
+ "gh": self.gh, "account": account, "via": via, "probes": self.records, **extra}
439
+
440
+ def unavailable(self, **extra) -> dict:
441
+ cause = self.cause()
442
+ owner_gone = any(r["target"] == "owner" and r["result"] == "not-found" for r in self.records)
443
+ account = self.account() if cause == "repo-not-found" and not owner_gone else None
444
+ return self.output("unavailable", None, cause, self.message(cause, account), account, **extra)
445
+
446
+
447
+ # --------------------------------------------------------------------------
448
+ # Commands
449
+ # --------------------------------------------------------------------------
450
+
451
+
452
+ # What each command reports besides the common keys when it read nothing;
453
+ # found and missing stay empty then, since no listing says a tag is absent.
454
+ _EMPTY = {
455
+ "repo": {"private": None, "default_branch": None},
456
+ "tags": {"tags": [], "count": 0, "match": None, "found": {}, "missing": [], "nearest": []},
457
+ "tree": {"ref": "HEAD", "tree": [], "count": 0, "truncated": False},
458
+ }
459
+
460
+
461
+ def probe_repo(owner: str, repo: str) -> dict:
462
+ p = Probe(owner, repo)
463
+
464
+ def read(body: str, via: str) -> dict | None:
465
+ try:
466
+ data = json.loads(body)
467
+ except ValueError:
468
+ return None
469
+ if not isinstance(data, dict):
470
+ return None
471
+ private = data.get("private")
472
+ branch = data.get("default_branch")
473
+ return p.output("ok", via, private=private if isinstance(private, bool) else None,
474
+ default_branch=branch if isinstance(branch, str) else None)
475
+
476
+ result, body = p.gh_call("repo", f"repos/{owner}/{repo}")
477
+ if result == "ok" and (out := read(body, "gh")):
478
+ return out
479
+ if result != "not-found":
480
+ result, body = p.api_call("repo", f"repos/{owner}/{repo}")
481
+ if result == "ok" and (out := read(body, "api")):
482
+ return out
483
+ result, text = p.git_call("repo", "ls-remote", "--symref", "--", f"{WEB_ROOT}/{owner}/{repo}", "HEAD")
484
+ if result == "ok":
485
+ branch = None
486
+ for line in text.splitlines():
487
+ left, _, name = line.partition("\t")
488
+ if left.startswith("ref: refs/heads/") and name.strip() == "HEAD":
489
+ branch = left[len("ref: refs/heads/"):].strip()
490
+ private = True if "not-found" in p.results("api") else None
491
+ return p.output("ok", "git", private=private, default_branch=branch)
492
+ return p.unavailable(**_EMPTY["repo"])
493
+
494
+
495
+ def _sorted_tags(names) -> list[str]:
496
+ versioned, other = [], []
497
+ for name in names:
498
+ split = _sibling().split_version(name)
499
+ (versioned if split else other).append((split[1] if split else None, name))
500
+ versioned.sort(key=lambda item: (item[0], item[1]), reverse=True)
501
+ return [name for _key, name in versioned] + sorted(name for _key, name in other)
502
+
503
+
504
+ def probe_tags(owner: str, repo: str, wants: list[str], limit: int, version: str | None = None,
505
+ package: str | None = None) -> dict:
506
+ p = Probe(owner, repo)
507
+ tags: dict[str, str | None] | None = None
508
+ via = None
509
+ result, body = p.gh_call("tags", "--paginate", f"repos/{owner}/{repo}/tags?per_page=100")
510
+ if result == "ok":
511
+ try:
512
+ items = _json(body)
513
+ if isinstance(items, list):
514
+ tags = {}
515
+ for item in items:
516
+ if isinstance(item, dict) and isinstance(item.get("name"), str):
517
+ commit = item.get("commit")
518
+ sha = commit.get("sha") if isinstance(commit, dict) else None
519
+ tags[item["name"]] = sha if isinstance(sha, str) else None
520
+ via = "gh"
521
+ except ValueError:
522
+ tags = None
523
+ if tags is None:
524
+ result, text = p.git_call("tags", "ls-remote", "--tags", "--", f"{WEB_ROOT}/{owner}/{repo}")
525
+ if result == "ok":
526
+ tags = {}
527
+ for line in text.splitlines():
528
+ sha, _, name = line.partition("\t")
529
+ name = name.strip()
530
+ if not name.startswith("refs/tags/"):
531
+ continue
532
+ name = name[len("refs/tags/"):]
533
+ if name.endswith("^{}"):
534
+ tags[name[:-3]] = sha.strip()
535
+ else:
536
+ tags.setdefault(name, sha.strip())
537
+ via = "git"
538
+ if tags is None:
539
+ return p.unavailable(**_EMPTY["tags"])
540
+ ordered = _sorted_tags(tags)
541
+ wanted = list(dict.fromkeys(wants))
542
+ found = {tag: tags[tag] for tag in wanted if tag in tags}
543
+ named = _sibling().match_tags(tags, version, package or None) if version else []
544
+ return p.output("ok", via, tags=ordered[:limit] if limit > 0 else ordered, count=len(ordered),
545
+ match=next((tag for tag in (*wanted, *named) if tag in tags), None), found=found,
546
+ missing=[tag for tag in wanted if tag not in tags],
547
+ nearest=_sibling().nearest_tags(tags, version) if version else [])
548
+
549
+
550
+ def _tree_ref(value: str | None) -> str:
551
+ """The ref tree reads: "HEAD" for an empty, `null`, `none` or `head` value (any letter case)."""
552
+ ref = (value or "").strip()
553
+ return "HEAD" if ref.lower() in ("", "null", "none", "head") else ref
554
+
555
+
556
+ def probe_tree(owner: str, repo: str, ref: str) -> dict:
557
+ p = Probe(owner, repo)
558
+ ref = _tree_ref(ref)
559
+ empty = {**_EMPTY["tree"], "ref": ref}
560
+ path = f"repos/{owner}/{repo}/git/trees/{urllib.parse.quote(ref, safe='')}?recursive=1"
561
+
562
+ def read(body: str, via: str) -> dict | None:
563
+ try:
564
+ data = json.loads(body)
565
+ except ValueError:
566
+ return None
567
+ if not isinstance(data, dict) or not isinstance(data.get("tree"), list):
568
+ return None
569
+ files = [e["path"] for e in data["tree"]
570
+ if isinstance(e, dict) and e.get("type") == "blob" and isinstance(e.get("path"), str)]
571
+ return p.output("ok", via, ref=ref, tree=files, count=len(files),
572
+ truncated=data.get("truncated") is True)
573
+
574
+ if ref != "HEAD" and not _sibling().REF_RE.fullmatch(ref):
575
+ return p.output("unavailable", None, "ref-not-found",
576
+ f"{ref} is not a valid branch, tag or commit name.", **empty)
577
+ result, body = p.gh_call("tree", path)
578
+ if result == "ok" and (out := read(body, "gh")):
579
+ return out
580
+ repo_result = None
581
+ if result == "not-found":
582
+ repo_result, _body = p.gh_call("repo", f"repos/{owner}/{repo}")
583
+ if repo_result == "ok":
584
+ return p.output("unavailable", None, "ref-not-found",
585
+ f"{owner}/{repo} has no branch, tag or commit named {ref}.", **empty)
586
+ # Only gh's 404 for the repository itself says the anonymous API cannot read it either.
587
+ if repo_result != "not-found":
588
+ result, body = p.api_call("tree", path)
589
+ if result == "ok" and (out := read(body, "api")):
590
+ return out
591
+ if result == "not-found":
592
+ repo_result, _body = p.api_call("repo", f"repos/{owner}/{repo}")
593
+ if repo_result == "ok":
594
+ return p.output("unavailable", None, "ref-not-found",
595
+ f"{owner}/{repo} has no branch, tag or commit named {ref}.", **empty)
596
+ return p.unavailable(**empty)
597
+
598
+
599
+ # --------------------------------------------------------------------------
600
+ # CLI
601
+ # --------------------------------------------------------------------------
602
+
603
+
604
+ def _build_parser() -> argparse.ArgumentParser:
605
+ parser = argparse.ArgumentParser(
606
+ prog="skf-github-probe",
607
+ description="Tell whether a GitHub repository can be read and why not; list its tags or files.",
608
+ )
609
+ sub = parser.add_subparsers(dest="cmd", required=True)
610
+
611
+ def common(p: argparse.ArgumentParser) -> None:
612
+ p.add_argument("--repo", required=True, help="owner/repo or a github.com URL")
613
+ p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_SEC,
614
+ help="seconds the whole call may take (default 60)")
615
+
616
+ common(sub.add_parser("repo", help="can the repository be read"))
617
+ p_tags = sub.add_parser("tags", help="list the repository's tags")
618
+ common(p_tags)
619
+ p_tags.add_argument("--want", action="append", default=[], help="a tag to look for (repeatable)")
620
+ p_tags.add_argument("--version", help="a version whose tag to look for, in every form a tag writes it in")
621
+ p_tags.add_argument("--name", help="with --version: the package name a monorepo's tags carry")
622
+ p_tags.add_argument("--limit", type=int, default=DEFAULT_TAG_LIMIT,
623
+ help="tags to list, newest first (default 20; 0 for all)")
624
+ p_tree = sub.add_parser("tree", help="list the repository's files at a ref")
625
+ common(p_tree)
626
+ p_tree.add_argument("--ref", default="HEAD", help="branch, tag or commit (default: the default branch)")
627
+ p_tree.add_argument("--out", metavar="FILE",
628
+ help="write the whole listing to FILE and print it without `tree`")
629
+ return parser
630
+
631
+
632
+ def main(argv: list[str] | None = None) -> int:
633
+ args = _build_parser().parse_args(argv)
634
+ try:
635
+ _start_clock(args.timeout)
636
+ parsed = parse_repo(args.repo)
637
+ if parsed is None:
638
+ out = {"status": "unavailable", "cause": "invalid-repo",
639
+ "message": f"{args.repo} is not a github.com repository (expected owner/repo or a URL).",
640
+ "repo": None, "gh": "not-run", "account": None, "via": None, "probes": [],
641
+ **_EMPTY[args.cmd]}
642
+ if args.cmd == "tree":
643
+ out["ref"] = _tree_ref(args.ref)
644
+ elif args.cmd == "repo":
645
+ out = probe_repo(*parsed)
646
+ elif args.cmd == "tags":
647
+ out = probe_tags(*parsed, args.want, args.limit, args.version, args.name)
648
+ else:
649
+ out = probe_tree(*parsed, args.ref)
650
+ if args.cmd == "tree" and args.out:
651
+ Path(args.out).write_bytes((json.dumps(out) + "\n").encode("utf-8"))
652
+ out = {key: value for key, value in out.items() if key != "tree"}
653
+ except Exception as e: # noqa: BLE001 - one JSON error line, never a traceback
654
+ print(json.dumps({"status": "error", "message": f"{type(e).__name__}: {e}"}), file=sys.stderr)
655
+ return 1
656
+ print(json.dumps(out))
657
+ return 0 if out["status"] == "ok" else 3
658
+
659
+
660
+ if __name__ == "__main__":
661
+ raise SystemExit(main())