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
@@ -1,5 +1,5 @@
1
1
  # /// script
2
- # requires-python = ">=3.9"
2
+ # requires-python = ">=3.11"
3
3
  # dependencies = []
4
4
  # ///
5
5
  """SKF Detect Docs — discover documentation URLs for a GitHub repository.
@@ -48,7 +48,7 @@ Doc-drift detection for audit-skill. Given a set of tracked doc sources
48
48
  the SAME fetch+sha256 primitive the compile side used, and classify each entry
49
49
  as changed / unchanged / fetch_failed / skipped_null_hash. This replaces the
50
50
  per-URL fetch/hash/compare/count prose at
51
- `src/skf-audit-skill/references/step-doc-drift.md` §2-3 — the model no longer
51
+ `src/skf-audit-skill/references/doc-drift.md` §2-3: the model no longer
52
52
  orchestrates HTTP GETs or computes sha256 by hand, and correctness no longer
53
53
  depends on whether a WebFetch tool is wired.
54
54
 
@@ -74,20 +74,219 @@ Each list is stably sorted by url. Stored hashes are normalized (leading
74
74
  `algo:` prefix stripped) before comparison so a bare-hex writer form still
75
75
  matches. Exit 0 on any well-formed input (never blocks the informational
76
76
  audit); exit 2 on malformed args or JSON.
77
+
78
+ ---------------------------------------------------------------------------
79
+ Subcommand: refresh-hashes
80
+ ---------------------------------------------------------------------------
81
+
82
+ Records the new hashes of the documents a `compare-hashes` run found
83
+ changed, once update-skill has re-read them (update-skill write.md §2), so
84
+ the next comparison starts from the documents this update used.
85
+
86
+ uv run src/shared/scripts/skf-detect-docs.py refresh-hashes \\
87
+ <metadata.json> --compare <compare-hashes output>
88
+
89
+ Each `doc_sources[]` entry whose url is in the comparison's `changed[]`
90
+ takes that entry's `new_hash` as its `content_hash` and the current UTC
91
+ time as its `recorded_at`; every other entry and every other key of the
92
+ file stays as it was. The file is rewritten through a temporary file and
93
+ one rename.
94
+
95
+ Output (JSON object on stdout):
96
+ {"metadata": <path>, "refreshed": [urls], "not_found": [changed urls
97
+ that metadata.json does not track]}
98
+
99
+ Exit 0 once written; exit 2 on malformed args, JSON that is not the shape
100
+ named, or a file that cannot be read or written.
101
+
102
+ ---------------------------------------------------------------------------
103
+ Subcommand: hash-urls
104
+ ---------------------------------------------------------------------------
105
+
106
+ Compile-time `doc_sources[]` for docs-only skills (create-skill step 5a).
107
+ The detect path above needs a GitHub repository; a docs-only brief has none
108
+ — its `source_repo` is the documentation site and its corpus is the brief's
109
+ `doc_urls`. This subcommand fetches each URL and hashes the raw response
110
+ bytes with the SAME primitive `compare-hashes` uses, so a hash recorded here
111
+ compares byte-for-byte at audit time. (Hashing the markdown a web-fetch tool
112
+ rendered would not: the audit re-fetches the raw bytes.)
113
+
114
+ uv run src/shared/scripts/skf-detect-docs.py hash-urls <source>
115
+
116
+ Input `<source>`:
117
+ - a path to a JSON file that is EITHER an array whose items are URL
118
+ strings or `{url, ...}` objects (the brief's `doc_urls[]` entry shape)
119
+ OR an object with a `doc_urls` array (the brief block itself), OR
120
+ - `-` to read that JSON from stdin.
121
+
122
+ Output (JSON object on stdout):
123
+ {
124
+ "doc_sources": [{"url", "detected_via": "brief_doc_urls",
125
+ "content_hash": "sha256:..."|null, "recorded_at"}],
126
+ "fetch_failed": [{"url", "reason"}],
127
+ "stats": {"total", "hashed", "fetch_failed", "invalid"}
128
+ }
129
+
130
+ URLs keep their input order and are deduplicated on first occurrence; an
131
+ entry without a usable url string is dropped and counted under
132
+ `stats.invalid`. A URL that cannot be fetched still gets an entry with
133
+ `content_hash: null` — the audit side files it under `skipped_null_hash`
134
+ instead of reporting drift. `doc_sources` is already in the metadata.json
135
+ schema, and the whole object is valid `compare-hashes` input. Exit 0 on any
136
+ well-formed input; exit 2 on malformed args or JSON.
137
+
138
+ ---------------------------------------------------------------------------
139
+ Subcommand: readme-entry
140
+ ---------------------------------------------------------------------------
141
+
142
+ The README entry create-skill step 5a adds to `doc_sources[]` when detection
143
+ found none. The helper picks the README, builds its URL and hashes it with
144
+ the `hash-urls` primitive, so the model never parses the repository or
145
+ builds the URL by hand.
146
+
147
+ uv run src/shared/scripts/skf-detect-docs.py readme-entry \\
148
+ --source-repo <repo> --ref <ref> [--local-root <path>]
149
+
150
+ Input:
151
+ --source-repo the brief's source_repo
152
+ --ref the ref the skill was built from: a tag, branch or commit,
153
+ `HEAD` for the default branch (also for an empty, `null` or
154
+ `none` value), `local` for a local source
155
+ --local-root the folder the source was read from, when it is a local
156
+ folder (a local source, or the tree create-skill read a
157
+ remote one into); the README is picked from its files
158
+
159
+ A local source (`--ref local`) records `file://` and the absolute path of
160
+ its README, with `/` separators and no percent-encoding, as
161
+ `_fetch_and_hash` reads it back; its folder is --local-root, else
162
+ --source-repo. A GitHub source (an https, ssh or git URL, the scp-like
163
+ `git@github.com:owner/repo`, or the `owner/repo` shorthand; a trailing
164
+ `.git` is dropped) records the raw file at --ref:
165
+ `https://raw.githubusercontent.com/<owner>/<repo>/<ref>/<README>`. The
166
+ README is picked from the top-level files of --local-root, or, without a
167
+ local folder, from the repository's top level at --ref through the GitHub
168
+ API: `README.md` in any letter case first, then a README with no language
169
+ suffix (`README.rst`, `README.markdown`, `README`), then the first other
170
+ README in name order (a translation such as `README-ja.md`, which sorts
171
+ before `README.md`). With none listed it is `README.md`. Any other source
172
+ gets no entry.
173
+
174
+ Output (JSON object on stdout): the `hash-urls` object for the one URL,
175
+ with `detected_via: "readme_always"` on its entry, plus
176
+ "skip_reason": null | "not-github" (no entry: a source on another host)
177
+
178
+ Exit 0 once JSON is printed; exit 2 on malformed args.
179
+
180
+ ---------------------------------------------------------------------------
181
+ Subcommand: page-metrics
182
+ ---------------------------------------------------------------------------
183
+
184
+ create-skill step 3c fetches each `doc_urls` page and looks for its
185
+ subpages when the page is a documentation root with little API content of
186
+ its own (a Mintlify, Docusaurus, ReadTheDocs or GitBook landing page). This
187
+ subcommand measures a fetched page, saved as markdown, so the step reads the
188
+ decision instead of counting words and link lines by eye.
189
+
190
+ uv run src/shared/scripts/skf-detect-docs.py page-metrics \\
191
+ --url <url> <file>
192
+
193
+ Input: --url is the page's URL; <file> is its markdown, or `-` for stdin.
194
+
195
+ Output (JSON object on stdout):
196
+ {
197
+ "url": "<url>",
198
+ "path_segments": N, # non-empty segments of the URL path
199
+ "root_like": bool, # N <= 1, or the path ends in `/`, `/index`
200
+ # or `/index.html`
201
+ "code_fences": N, # fenced code blocks (``` or ~~~)
202
+ "table_rows": N, # table separator rows (`|---|`), one a table
203
+ "signature_hits": N, # `def `, `function `, `fn `, `func `,
204
+ # `export ` as words
205
+ "link_lines": N, # non-empty lines holding only markdown links
206
+ "non_empty_lines": N,
207
+ "link_line_ratio": 0.0-1.0, # link_lines / non_empty_lines, 4 places
208
+ "word_count": N, # words, link targets left out
209
+ "trigger1": bool, # root_like, no code fence, table row or
210
+ # signature, and link_line_ratio > 0.7
211
+ "trigger2": bool, # root_like and word_count < 2000
212
+ "discover_subpages": bool # trigger1 or trigger2
213
+ }
214
+
215
+ A link line is a line whose text, once its list marker, quote marker or
216
+ heading marks and its `[text](url)` links are taken out, holds no letter or
217
+ digit. Exit 0 once JSON is printed; exit 2 on malformed args or an input
218
+ that cannot be read or decoded as UTF-8.
219
+
220
+ ---------------------------------------------------------------------------
221
+ Subcommand: filter-urls
222
+ ---------------------------------------------------------------------------
223
+
224
+ The subpages step 3c may fetch for a root page: the URLs a site map, a
225
+ sitemap.xml or a crawl found, cut to the root page's own site.
226
+
227
+ uv run src/shared/scripts/skf-detect-docs.py filter-urls \\
228
+ --root <url> <source>
229
+
230
+ Input `<source>` (a file, or `-` for stdin), read as UTF-8 and gunzipped
231
+ first when it is gzip compressed (a `.xml.gz` sitemap):
232
+ - a sitemap.xml (its `<loc>` values), or
233
+ - a JSON array of URL strings or `{url, title?}` objects, or an object
234
+ with such an array under `links` or `urls` (a map tool's result), or
235
+ - one URL per line.
236
+ A URL given as a path from the site root (`/docs/api`) is read against
237
+ the root URL.
238
+
239
+ A URL is kept when it is http or https, on the root URL's registrable
240
+ domain (any subdomain of it), not the root page itself, not a repeat once
241
+ its `#fragment` is dropped (scheme and host compared in any letter case),
242
+ and has no path segment, less its file extension, that is one of
243
+ SUBPAGE_EXCLUDED_WORDS (`blog`, `changelog`, `pricing`, `about`,
244
+ `careers`): such a segment names a section with no API content, while a
245
+ longer one (`about-the-rest-api`) can head a section of API pages. The
246
+ registrable domain is worked out without a public suffix list: the last two
247
+ labels of the host, or the last three when the last two are a hosting
248
+ suffix (HOSTED_SUFFIXES: `github.io`, `readthedocs.io` and the like, where
249
+ each subdomain is another owner's site) or a country-code second level
250
+ (`co.uk`, `com.au`). An IP address or a one-label host is its own domain.
251
+
252
+ Output (JSON object on stdout):
253
+ {
254
+ "root": "<url>",
255
+ "registrable_domain": "example.com",
256
+ "kept": [{"url", "title": "..." | null,
257
+ "terms": ["api", "reference", ...]}], # input order
258
+ "sitemaps": ["<url>", ...], # same-site .xml / .xml.gz URLs
259
+ "stats": {"input", "kept", "other_domain", "excluded_path",
260
+ "not_http", "duplicate", "root", "sitemap"}
261
+ }
262
+
263
+ `terms` lists the API words (API_TERMS) the URL path or title holds, to
264
+ help choose the most relevant subpages; choosing them is the caller's call.
265
+ A same-site URL ending in `.xml` or `.xml.gz` is a sitemap, not a page (a
266
+ sitemap index lists them): it goes to `sitemaps`, for the caller to fetch
267
+ and filter in turn.
268
+ Exit 0 once JSON is printed; exit 2 on malformed args, an input that
269
+ cannot be read or decoded as UTF-8, malformed JSON or a root URL that is
270
+ not http or https.
77
271
  """
78
272
 
79
273
  from __future__ import annotations
80
274
 
81
275
  import argparse
82
276
  import base64
277
+ import gzip
83
278
  import hashlib
84
279
  import json
280
+ import os
85
281
  import re
86
282
  import socket
87
283
  import subprocess
88
284
  import sys
89
285
  import urllib.error
286
+ import urllib.parse
90
287
  import urllib.request
288
+ import zlib
289
+ from datetime import datetime, timezone
91
290
  from pathlib import Path
92
291
  from typing import Any, Dict, List, Optional, Tuple
93
292
 
@@ -252,8 +451,10 @@ def _fetch_and_hash(url: str) -> Optional[str]:
252
451
  return "sha256:" + hashlib.sha256(content).hexdigest()
253
452
  except (OSError, IOError):
254
453
  return None
255
- req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
256
454
  try:
455
+ # Request() itself raises ValueError on a scheme-less or relative URL;
456
+ # it must sit inside the guard so a bad entry degrades to None.
457
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
257
458
  with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
258
459
  content = resp.read()
259
460
  return "sha256:" + hashlib.sha256(content).hexdigest()
@@ -294,7 +495,14 @@ def _fetch_and_hash_reason(url: str) -> Tuple[Optional[str], Optional[str]]:
294
495
  except OSError as exc:
295
496
  return None, f"local read failed: {exc}"
296
497
  return "sha256:" + hashlib.sha256(content).hexdigest(), None
297
- req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
498
+ try:
499
+ # Request() raises ValueError ("unknown url type") on a scheme-less or
500
+ # relative URL — a legal brief entry, since the brief schema only
501
+ # requires a non-empty string. Keep it inside the guard so one bad
502
+ # entry lands in fetch_failed instead of aborting the whole run.
503
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
504
+ except ValueError as exc:
505
+ return None, f"invalid URL: {exc}"
298
506
  try:
299
507
  with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
300
508
  content = resp.read()
@@ -601,16 +809,696 @@ def _cmd_compare_hashes(argv: List[str]) -> int:
601
809
  return 0
602
810
 
603
811
 
812
+ # ---------------------------------------------------------------------------
813
+ # Subcommand: refresh-hashes: record the hashes compare-hashes found changed
814
+ # ---------------------------------------------------------------------------
815
+
816
+ def refresh_doc_hashes(
817
+ metadata: Dict[str, Any], comparison: Dict[str, Any], recorded_at: Optional[str] = None
818
+ ) -> Tuple[Dict[str, Any], List[str], List[str]]:
819
+ """(metadata with the changed documents' new hashes, refreshed urls, urls it does not track).
820
+
821
+ Raises ValueError when `doc_sources` or `changed` is not an array.
822
+ """
823
+ entries = metadata.get("doc_sources")
824
+ if entries is None:
825
+ entries = []
826
+ if not isinstance(entries, list):
827
+ raise ValueError("`doc_sources` in the metadata is not an array")
828
+ changed = comparison.get("changed")
829
+ if not isinstance(changed, list):
830
+ raise ValueError("the comparison has no `changed` array: is it compare-hashes output?")
831
+ new_hashes = {
832
+ e["url"]: e["new_hash"] for e in changed
833
+ if isinstance(e, dict) and isinstance(e.get("url"), str) and isinstance(e.get("new_hash"), str)
834
+ }
835
+ stamp = recorded_at or datetime.now(timezone.utc).isoformat()
836
+ refreshed: List[str] = []
837
+ out: List[Any] = []
838
+ for entry in entries:
839
+ if isinstance(entry, dict) and entry.get("url") in new_hashes:
840
+ entry = {**entry, "content_hash": new_hashes[entry["url"]], "recorded_at": stamp}
841
+ refreshed.append(entry["url"])
842
+ out.append(entry)
843
+ not_found = sorted(url for url in new_hashes if url not in refreshed)
844
+ return {**metadata, "doc_sources": out}, refreshed, not_found
845
+
846
+
847
+ def _cmd_refresh_hashes(argv: List[str]) -> int:
848
+ parser = argparse.ArgumentParser(
849
+ prog="skf-detect-docs.py refresh-hashes",
850
+ description=(
851
+ "Record in metadata.json's doc_sources the new content_hash of each "
852
+ "document a compare-hashes run found changed."
853
+ ),
854
+ )
855
+ parser.add_argument("metadata", help="the skill's metadata.json, rewritten in place")
856
+ parser.add_argument("--compare", required=True, help="the compare-hashes output for that metadata.json")
857
+ args = parser.parse_args(argv)
858
+
859
+ path = Path(args.metadata)
860
+ tmp = path.with_name(f".{path.name}.skf-{os.getpid()}-tmp")
861
+ try:
862
+ metadata = json.loads(path.read_text(encoding="utf-8"))
863
+ comparison = json.loads(Path(args.compare).read_text(encoding="utf-8"))
864
+ if not isinstance(metadata, dict) or not isinstance(comparison, dict):
865
+ raise ValueError("metadata.json and the comparison must each hold a JSON object")
866
+ updated, refreshed, not_found = refresh_doc_hashes(metadata, comparison)
867
+ tmp.write_bytes((json.dumps(updated, indent=2, ensure_ascii=False) + "\n").encode("utf-8"))
868
+ os.replace(tmp, path)
869
+ except (OSError, ValueError) as exc:
870
+ if tmp.exists():
871
+ tmp.unlink()
872
+ json.dump({"error": str(exc), "code": "REFRESH_FAILED"}, sys.stderr)
873
+ sys.stderr.write("\n")
874
+ return 2
875
+ json.dump({"metadata": path.as_posix(), "refreshed": refreshed, "not_found": not_found}, sys.stdout)
876
+ sys.stdout.write("\n")
877
+ return 0
878
+
879
+
880
+ # ---------------------------------------------------------------------------
881
+ # Subcommand: hash-urls — docs-only doc_sources from the brief's doc_urls
882
+ # ---------------------------------------------------------------------------
883
+
884
+ # `doc_sources[].detected_via` value for entries that come from the brief's
885
+ # `doc_urls` (and the subpages step 3c fetched from them) rather than from
886
+ # repository detection. Listed in skf-create-skill/assets/skill-sections.md.
887
+ BRIEF_DOC_URLS_DETECTED_VIA = "brief_doc_urls"
888
+
889
+
890
+ def _load_url_list(raw_text: str, source_label: str) -> List[Any]:
891
+ """Parse a hash-urls input blob into a list of URL entries.
892
+
893
+ Accepts a top-level JSON array whose items are URL strings or objects
894
+ with a `url` key (the brief's `doc_urls[]` entry shape), or a top-level
895
+ object with a `doc_urls` array (the brief block itself). An object without
896
+ `doc_urls` yields an empty list (nothing to hash).
897
+
898
+ Raises ValueError on malformed JSON or an unexpected top-level shape.
899
+ """
900
+ try:
901
+ data = json.loads(raw_text)
902
+ except json.JSONDecodeError as exc:
903
+ raise ValueError(f"malformed JSON in {source_label}: {exc}") from exc
904
+ if isinstance(data, list):
905
+ return data
906
+ if isinstance(data, dict):
907
+ entries = data.get("doc_urls")
908
+ if entries is None:
909
+ return []
910
+ if not isinstance(entries, list):
911
+ raise ValueError(f"`doc_urls` in {source_label} is not an array")
912
+ return entries
913
+ raise ValueError(
914
+ f"{source_label} must be a JSON array or an object with a "
915
+ f"`doc_urls` array; got {type(data).__name__}"
916
+ )
917
+
918
+
919
+ def hash_urls(entries: List[Any], recorded_at: Optional[str] = None) -> Dict[str, Any]:
920
+ """Fetch and hash each URL into a compile-time `doc_sources[]` entry.
921
+
922
+ Byte-symmetric with `compare_doc_hashes`: both go through
923
+ `_fetch_and_hash_reason`, so a hash recorded here reads as `unchanged` at
924
+ audit time for unchanged bytes. URLs keep their input order (brief order,
925
+ then discovered subpages) and are deduplicated on first occurrence. A URL
926
+ that cannot be fetched still gets an entry with `content_hash: None`, which
927
+ the audit side skips rather than reporting as drift.
928
+ """
929
+ stamp = recorded_at or datetime.now(timezone.utc).isoformat()
930
+ doc_sources: List[Dict[str, Any]] = []
931
+ fetch_failed: List[Dict[str, Any]] = []
932
+ seen: set = set()
933
+ invalid = 0
934
+ for entry in entries:
935
+ url = entry.get("url") if isinstance(entry, dict) else entry
936
+ if not isinstance(url, str) or not url.strip():
937
+ invalid += 1
938
+ continue
939
+ if url in seen:
940
+ continue
941
+ seen.add(url)
942
+ content_hash, reason = _fetch_and_hash_reason(url)
943
+ doc_sources.append({
944
+ "url": url,
945
+ "detected_via": BRIEF_DOC_URLS_DETECTED_VIA,
946
+ "content_hash": content_hash,
947
+ "recorded_at": stamp,
948
+ })
949
+ if content_hash is None:
950
+ fetch_failed.append({"url": url, "reason": reason})
951
+ return {
952
+ "doc_sources": doc_sources,
953
+ "fetch_failed": fetch_failed,
954
+ "stats": {
955
+ "total": len(doc_sources),
956
+ "hashed": len(doc_sources) - len(fetch_failed),
957
+ "fetch_failed": len(fetch_failed),
958
+ "invalid": invalid,
959
+ },
960
+ }
961
+
962
+
963
+ def _cmd_hash_urls(argv: List[str]) -> int:
964
+ parser = argparse.ArgumentParser(
965
+ prog="skf-detect-docs.py hash-urls",
966
+ description=(
967
+ "Fetch each documentation URL, hash the response bytes, and emit "
968
+ "compile-time doc_sources entries (detected_via: brief_doc_urls) "
969
+ "for a docs-only skill."
970
+ ),
971
+ )
972
+ parser.add_argument(
973
+ "source",
974
+ help=(
975
+ "path to a JSON file (array of URL strings or {url, ...} objects, "
976
+ "or an object with a doc_urls array such as the brief's block), "
977
+ "or - to read that JSON from stdin"
978
+ ),
979
+ )
980
+ args = parser.parse_args(argv)
981
+
982
+ if args.source == "-":
983
+ raw = sys.stdin.read()
984
+ label = "<stdin>"
985
+ else:
986
+ path = Path(args.source)
987
+ try:
988
+ raw = path.read_text(encoding="utf-8")
989
+ except OSError as exc:
990
+ json.dump({"error": f"cannot read {path}: {exc}", "code": "READ_ERROR"}, sys.stderr)
991
+ sys.stderr.write("\n")
992
+ return 2
993
+ label = str(path)
994
+
995
+ try:
996
+ entries = _load_url_list(raw, label)
997
+ except ValueError as exc:
998
+ json.dump({"error": str(exc), "code": "INVALID_JSON"}, sys.stderr)
999
+ sys.stderr.write("\n")
1000
+ return 2
1001
+
1002
+ result = hash_urls(entries)
1003
+ json.dump(result, sys.stdout, separators=(",", ":"))
1004
+ sys.stdout.write("\n")
1005
+ return 0
1006
+
1007
+
1008
+ # ---------------------------------------------------------------------------
1009
+ # Subcommand: readme-entry, the README entry create-skill step 5a adds
1010
+ # ---------------------------------------------------------------------------
1011
+
1012
+ # `doc_sources[].detected_via` of that entry. Listed in
1013
+ # skf-create-skill/assets/skill-sections.md.
1014
+ README_ALWAYS_DETECTED_VIA = "readme_always"
1015
+
1016
+ # Serves a GitHub repository's files as they are, one path per ref.
1017
+ RAW_GITHUB = "https://raw.githubusercontent.com"
1018
+
1019
+ # A GitHub repository as a brief's source_repo names it: an https, ssh or git
1020
+ # URL, the scp-like `git@github.com:owner/repo`, `github.com/owner/repo`, or
1021
+ # the `owner/repo` shorthand. A trailing `.git` or `/` is not part of the
1022
+ # name, which may itself hold dots (`vercel/next.js`).
1023
+ _GITHUB_REPO_RE = re.compile(
1024
+ r"^(?:(?:https?|ssh|git)://(?:[^@/\s]+@)?(?:www\.)?github\.com(?::\d+)?/"
1025
+ r"|[^@/\s:]+@github\.com:"
1026
+ r"|(?:www\.)?github\.com/)?"
1027
+ r"([A-Za-z0-9][A-Za-z0-9-]*)/([A-Za-z0-9._-]+?)(?:\.git)?/?$",
1028
+ re.IGNORECASE,
1029
+ )
1030
+
1031
+ # The README a folder shows, in the order it is picked: README.md in any
1032
+ # letter case, then a README with no language suffix (README.rst,
1033
+ # README.markdown, README), then any other README, such as a translation
1034
+ # (README-ja.md), which sorts before README.md by name.
1035
+ _README_PREFERENCE = (
1036
+ re.compile(r"^readme\.md$", re.IGNORECASE),
1037
+ re.compile(r"^readme(?:\.[A-Za-z0-9]+)?$", re.IGNORECASE),
1038
+ re.compile(r"^readme", re.IGNORECASE),
1039
+ )
1040
+
1041
+
1042
+ def parse_github_repo(source_repo: str) -> Optional[Tuple[str, str]]:
1043
+ """(owner, repo) of the GitHub repository `source_repo` names, else None."""
1044
+ m = _GITHUB_REPO_RE.match((source_repo or "").strip())
1045
+ if not m or m.group(2) in (".", ".."):
1046
+ return None
1047
+ return m.group(1), m.group(2)
1048
+
1049
+
1050
+ def pick_readme(names: List[str]) -> Optional[str]:
1051
+ """The README among a folder's top-level file names, by _README_PREFERENCE."""
1052
+ ordered = sorted(names)
1053
+ for pattern in _README_PREFERENCE:
1054
+ for name in ordered:
1055
+ if pattern.match(name):
1056
+ return name
1057
+ return None
1058
+
1059
+
1060
+ def _local_files(folder: Path) -> List[str]:
1061
+ try:
1062
+ return [p.name for p in folder.iterdir() if p.is_file()]
1063
+ except OSError:
1064
+ return []
1065
+
1066
+
1067
+ def _github_files(owner: str, repo: str, ref: str) -> List[str]:
1068
+ """Names of the files at the top of the repository at `ref`; [] when gh cannot list them."""
1069
+ query = "" if ref == "HEAD" else "?ref=" + urllib.parse.quote(ref, safe="")
1070
+ raw = _run_gh(["api", f"repos/{owner}/{repo}/contents{query}",
1071
+ "--jq", '.[] | select(.type == "file") | .name'])
1072
+ return raw.splitlines() if raw else []
1073
+
1074
+
1075
+ def readme_url(source_repo: str, ref: str, local_root: Optional[str] = None) -> Optional[str]:
1076
+ """The URL of the README entry, or None for a source that is neither local nor on GitHub.
1077
+
1078
+ See the readme-entry section of the module docstring.
1079
+ """
1080
+ ref = (ref or "").strip()
1081
+ if ref.lower() in ("", "null", "none", "head"):
1082
+ ref = "HEAD"
1083
+ root = Path(local_root.strip()) if local_root and local_root.strip() else None
1084
+ if ref.lower() == "local":
1085
+ # Written as _fetch_and_hash reads it back: `file://` and the path, never percent-encoded.
1086
+ folder = Path(os.path.abspath(root if root is not None else (source_repo or "").strip()))
1087
+ return "file://" + (folder / (pick_readme(_local_files(folder)) or "README.md")).as_posix()
1088
+ parsed = parse_github_repo(source_repo)
1089
+ if parsed is None:
1090
+ return None
1091
+ owner, repo = parsed
1092
+ names = _local_files(root) if root is not None and root.is_dir() else _github_files(owner, repo, ref)
1093
+ name = pick_readme(names) or "README.md"
1094
+ return (f"{RAW_GITHUB}/{owner}/{repo}/{urllib.parse.quote(ref, safe='/@+')}/"
1095
+ f"{urllib.parse.quote(name, safe='')}")
1096
+
1097
+
1098
+ def readme_entry(source_repo: str, ref: str, local_root: Optional[str] = None,
1099
+ recorded_at: Optional[str] = None) -> Dict[str, Any]:
1100
+ """The readme-entry output: hash_urls for the README URL, its entry marked readme_always."""
1101
+ url = readme_url(source_repo, ref, local_root)
1102
+ if url is None:
1103
+ result = hash_urls([], recorded_at)
1104
+ result["skip_reason"] = "not-github"
1105
+ return result
1106
+ result = hash_urls([url], recorded_at)
1107
+ for entry in result["doc_sources"]:
1108
+ entry["detected_via"] = README_ALWAYS_DETECTED_VIA
1109
+ result["skip_reason"] = None
1110
+ return result
1111
+
1112
+
1113
+ def _cmd_readme_entry(argv: List[str]) -> int:
1114
+ parser = argparse.ArgumentParser(
1115
+ prog="skf-detect-docs.py readme-entry",
1116
+ description=(
1117
+ "Pick a skill source's README and emit its doc_sources entry "
1118
+ "(detected_via: readme_always): the raw GitHub file at --ref, or a "
1119
+ "file:// URL for a local source, hashed as compare-hashes hashes it."
1120
+ ),
1121
+ )
1122
+ parser.add_argument("--source-repo", required=True, help="the brief's source_repo")
1123
+ parser.add_argument(
1124
+ "--ref", required=True,
1125
+ help="the ref the skill was built from: a tag, branch or commit, HEAD "
1126
+ "for the default branch, local for a local source",
1127
+ )
1128
+ parser.add_argument(
1129
+ "--local-root", default=None,
1130
+ help="the folder the source was read from, when it is a local folder; "
1131
+ "the README is picked from its files",
1132
+ )
1133
+ args = parser.parse_args(argv)
1134
+
1135
+ result = readme_entry(args.source_repo, args.ref, args.local_root)
1136
+ json.dump(result, sys.stdout, separators=(",", ":"))
1137
+ sys.stdout.write("\n")
1138
+ return 0
1139
+
1140
+
1141
+ # ---------------------------------------------------------------------------
1142
+ # Subcommand: page-metrics, the root-page test create-skill step 3c runs
1143
+ # ---------------------------------------------------------------------------
1144
+
1145
+ # A page is a navigation hub when more than this share of its non-empty
1146
+ # lines are links (trigger 1), or a landing page when it has fewer words
1147
+ # than ROOT_WORD_LIMIT (trigger 2).
1148
+ LINK_RATIO_THRESHOLD = 0.7
1149
+ ROOT_WORD_LIMIT = 2000
1150
+
1151
+ _FENCE_OPEN_RE = re.compile(r"^\s*(`{3,}|~{3,})")
1152
+ _SIGNATURE_RE = re.compile(r"(?<![A-Za-z0-9_])(?:def|function|fn|func|export) ")
1153
+ # A markdown link that is not an image: `[text](target)`, the target
1154
+ # optionally followed by a quoted title.
1155
+ _LINK_RE = re.compile(r"(?<!!)\[([^\]]*)\]\(\s*[^)\s]*(?:\s+\"[^\"]*\")?\s*\)")
1156
+ _IMAGE_RE = re.compile(r"!\[([^\]]*)\]\([^)]*\)")
1157
+ _LINE_MARKER_RE = re.compile(r"^(?:>\s*)*(?:#{1,6}\s+|[-*+]\s+|\d+[.)]\s+)?")
1158
+ _WORD_RE = re.compile(r"[^\W_]+(?:['’-][^\W_]+)*")
1159
+
1160
+
1161
+ def _table_separator(line: str) -> bool:
1162
+ """A markdown table's separator row: pipes, dashes, colons and spaces
1163
+ only, with at least one pipe and three dashes in a row."""
1164
+ text = line.strip()
1165
+ return "|" in text and "---" in text and set(text) <= set("|-: \t")
1166
+
1167
+
1168
+ def _link_line(line: str) -> bool:
1169
+ """True for a line that holds markdown links and no other text."""
1170
+ text = _LINE_MARKER_RE.sub("", line.strip(), count=1)
1171
+ if not _LINK_RE.search(text):
1172
+ return False
1173
+ return not re.search(r"[^\W_]", _LINK_RE.sub("", text))
1174
+
1175
+
1176
+ def _root_like(path: str) -> Tuple[int, bool]:
1177
+ """(path segments, root-like) for a URL path."""
1178
+ segments = [s for s in path.split("/") if s]
1179
+ return len(segments), len(segments) <= 1 or path.endswith(("/", "/index", "/index.html"))
1180
+
1181
+
1182
+ def page_metrics(url: str, markdown: str) -> Dict[str, Any]:
1183
+ """Measure a fetched page for the step 3c subpage decision."""
1184
+ segments, root_like = _root_like(urllib.parse.urlsplit(url.strip()).path)
1185
+ lines = markdown.splitlines()
1186
+ fences = 0
1187
+ open_fence: Optional[str] = None
1188
+ for line in lines:
1189
+ m = _FENCE_OPEN_RE.match(line)
1190
+ if not m:
1191
+ continue
1192
+ mark = m.group(1)
1193
+ if open_fence is None:
1194
+ open_fence = mark
1195
+ fences += 1
1196
+ elif mark[0] == open_fence[0] and len(mark) >= len(open_fence):
1197
+ open_fence = None
1198
+ table_rows = sum(1 for line in lines if _table_separator(line))
1199
+ signature_hits = len(_SIGNATURE_RE.findall(markdown))
1200
+ non_empty = [line for line in lines if line.strip()]
1201
+ link_lines = sum(1 for line in non_empty if _link_line(line))
1202
+ ratio = link_lines / len(non_empty) if non_empty else 0.0
1203
+ visible = _LINK_RE.sub(lambda m: " " + m.group(1) + " ", _IMAGE_RE.sub(lambda m: " " + m.group(1) + " ", markdown))
1204
+ words = len(_WORD_RE.findall(visible))
1205
+ trigger1 = (root_like and fences == 0 and table_rows == 0 and signature_hits == 0
1206
+ and ratio > LINK_RATIO_THRESHOLD)
1207
+ trigger2 = root_like and words < ROOT_WORD_LIMIT
1208
+ return {
1209
+ "url": url,
1210
+ "path_segments": segments,
1211
+ "root_like": root_like,
1212
+ "code_fences": fences,
1213
+ "table_rows": table_rows,
1214
+ "signature_hits": signature_hits,
1215
+ "link_lines": link_lines,
1216
+ "non_empty_lines": len(non_empty),
1217
+ "link_line_ratio": round(ratio, 4),
1218
+ "word_count": words,
1219
+ "trigger1": trigger1,
1220
+ "trigger2": trigger2,
1221
+ "discover_subpages": trigger1 or trigger2,
1222
+ }
1223
+
1224
+
1225
+ def _read_source(source: str) -> Tuple[Optional[str], Optional[str]]:
1226
+ """(text, None) of a file or of stdin for `-`, gunzipped first when it is
1227
+ gzip compressed (a `.xml.gz` sitemap); (None, error) when it cannot be
1228
+ read or decoded as UTF-8."""
1229
+ label = "<stdin>" if source == "-" else source
1230
+ try:
1231
+ if source == "-":
1232
+ stream = getattr(sys.stdin, "buffer", None) # an in-process test double has none
1233
+ data = stream.read() if stream is not None else sys.stdin.read().encode("utf-8")
1234
+ else:
1235
+ data = Path(source).read_bytes()
1236
+ if data[:2] == b"\x1f\x8b":
1237
+ data = gzip.decompress(data)
1238
+ return data.decode("utf-8-sig"), None
1239
+ except (OSError, EOFError, zlib.error, UnicodeDecodeError) as exc:
1240
+ return None, f"cannot read {label}: {exc}"
1241
+
1242
+
1243
+ def _doc_section(name: str) -> str:
1244
+ """The module docstring's section on one subcommand, shown by its --help."""
1245
+ m = re.search(rf"^Subcommand: {re.escape(name)}\n-+\n(.*?)(?=^-{{10,}}\n|\Z)", __doc__ or "", re.S | re.M)
1246
+ return m.group(1).strip() if m else ""
1247
+
1248
+
1249
+ def _cmd_page_metrics(argv: List[str]) -> int:
1250
+ parser = argparse.ArgumentParser(
1251
+ prog="skf-detect-docs.py page-metrics",
1252
+ description=(
1253
+ "Measure a fetched documentation page (saved as markdown) and say "
1254
+ "whether create-skill step 3c should look for its subpages."
1255
+ ),
1256
+ epilog=_doc_section("page-metrics"),
1257
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1258
+ )
1259
+ parser.add_argument("--url", required=True, help="the URL the page was fetched from")
1260
+ parser.add_argument("source", help="the page's markdown file, or - to read it from stdin")
1261
+ args = parser.parse_args(argv)
1262
+
1263
+ text, error = _read_source(args.source)
1264
+ if text is None:
1265
+ json.dump({"error": error, "code": "READ_ERROR"}, sys.stderr)
1266
+ sys.stderr.write("\n")
1267
+ return 2
1268
+ json.dump(page_metrics(args.url, text), sys.stdout, separators=(",", ":"))
1269
+ sys.stdout.write("\n")
1270
+ return 0
1271
+
1272
+
1273
+ # ---------------------------------------------------------------------------
1274
+ # Subcommand: filter-urls, the subpages step 3c may fetch for a root page
1275
+ # ---------------------------------------------------------------------------
1276
+
1277
+ # Multi-label suffixes a documentation site is often hosted under: each name
1278
+ # directly below one of them is another owner's site
1279
+ # (`project.readthedocs.io` and `other.readthedocs.io` are two sites). A
1280
+ # small stand-in for the public suffix list, which the script does not ship.
1281
+ HOSTED_SUFFIXES = frozenset({
1282
+ "github.io", "gitlab.io", "codeberg.page", "readthedocs.io", "readthedocs-hosted.com",
1283
+ "netlify.app", "vercel.app", "pages.dev", "workers.dev", "web.app", "firebaseapp.com",
1284
+ "herokuapp.com", "gitbook.io", "mintlify.app", "azurewebsites.net", "cloudfront.net",
1285
+ "surge.sh", "fly.dev", "onrender.com", "blogspot.com", "appspot.com", "hf.space",
1286
+ "streamlit.app", "glitch.me", "sourceforge.io",
1287
+ })
1288
+ # Second-level labels country-code domains register names under (`co.uk`,
1289
+ # `com.au`): the registrable name is one label further left.
1290
+ _CCTLD_SECOND_LEVELS = frozenset({
1291
+ "ac", "co", "com", "edu", "go", "gob", "gov", "ltd", "mil", "ne", "net", "nom", "or", "org", "plc",
1292
+ })
1293
+ # Path segments that name a page with no API content.
1294
+ SUBPAGE_EXCLUDED_WORDS = ("blog", "changelog", "pricing", "about", "careers")
1295
+ # Words in a URL path or title that point at API documentation. A word
1296
+ # matches when it starts with one (`configuration`, `methods`).
1297
+ API_TERMS = ("api", "reference", "quickstart", "setup", "config", "getting-started", "guide", "sdk",
1298
+ "method", "function")
1299
+
1300
+ _LOC_RE = re.compile(r"<loc>\s*([^<\s]+)\s*</loc>", re.IGNORECASE)
1301
+ _IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
1302
+
1303
+
1304
+ def registrable_domain(host: str) -> str:
1305
+ """The registrable domain of a host name, by the heuristic in the
1306
+ filter-urls section of the module docstring."""
1307
+ host = (host or "").strip().lower().rstrip(".")
1308
+ labels = host.split(".")
1309
+ if ":" in host or _IPV4_RE.match(host) or len(labels) <= 2:
1310
+ return host
1311
+ last_two = ".".join(labels[-2:])
1312
+ if last_two in HOSTED_SUFFIXES or (len(labels[-1]) == 2 and labels[-2] in _CCTLD_SECOND_LEVELS):
1313
+ return ".".join(labels[-3:])
1314
+ return last_two
1315
+
1316
+
1317
+ def _excluded_path(path: str) -> bool:
1318
+ """True when a path segment, less its file extension, is one of SUBPAGE_EXCLUDED_WORDS."""
1319
+ for segment in path.lower().split("/"):
1320
+ stem = segment.rsplit(".", 1)[0] if "." in segment else segment
1321
+ if stem in SUBPAGE_EXCLUDED_WORDS:
1322
+ return True
1323
+ return False
1324
+
1325
+
1326
+ def _api_terms(path: str, title: str) -> List[str]:
1327
+ """The API_TERMS a URL path or title holds, as whole words (a word may go on: `configuration`)."""
1328
+ words = re.findall(r"[a-z0-9]+", (path + " " + title).lower())
1329
+ found = []
1330
+ for term in API_TERMS:
1331
+ parts = term.split("-")
1332
+ for i in range(len(words) - len(parts) + 1):
1333
+ head = words[i:i + len(parts)]
1334
+ if head[:-1] == parts[:-1] and head[-1].startswith(parts[-1]):
1335
+ found.append(term)
1336
+ break
1337
+ return found
1338
+
1339
+
1340
+ def _candidate_urls(text: str, source_label: str) -> List[Tuple[str, Optional[str]]]:
1341
+ """(url, title) pairs from a sitemap, a JSON list or map result, or one URL per line.
1342
+
1343
+ Raises ValueError on malformed JSON or a JSON shape with no URL list.
1344
+ """
1345
+ stripped = text.lstrip()
1346
+ if stripped.startswith("<"):
1347
+ return [(m.group(1).replace("&amp;", "&"), None) for m in _LOC_RE.finditer(text)]
1348
+ if stripped.startswith(("[", "{")):
1349
+ try:
1350
+ data = json.loads(text)
1351
+ except json.JSONDecodeError as exc:
1352
+ raise ValueError(f"malformed JSON in {source_label}: {exc}") from exc
1353
+ if isinstance(data, dict):
1354
+ items = next((data[k] for k in ("links", "urls") if isinstance(data.get(k), list)), None)
1355
+ if items is None:
1356
+ raise ValueError(f"{source_label} holds no `links` or `urls` array")
1357
+ data = items
1358
+ if not isinstance(data, list):
1359
+ raise ValueError(f"{source_label} must be a JSON array of URLs")
1360
+ out: List[Tuple[str, Optional[str]]] = []
1361
+ for item in data:
1362
+ if isinstance(item, str):
1363
+ out.append((item, None))
1364
+ elif isinstance(item, dict) and isinstance(item.get("url"), str):
1365
+ title = item.get("title")
1366
+ out.append((item["url"], title if isinstance(title, str) else None))
1367
+ return out
1368
+ return [(line.strip(), None) for line in text.splitlines() if line.strip()]
1369
+
1370
+
1371
+ def _url_key(parts: urllib.parse.SplitResult) -> str:
1372
+ """A URL as filter-urls compares it: scheme and host in lower case, no `#fragment`."""
1373
+ return parts._replace(scheme=parts.scheme.lower(), netloc=parts.netloc.lower(), fragment="").geturl()
1374
+
1375
+
1376
+ def filter_urls(root: str, candidates: List[Tuple[str, Optional[str]]]) -> Dict[str, Any]:
1377
+ """Cut subpage candidates to the root page's site and drop non-API pages."""
1378
+ root_parts = urllib.parse.urlsplit(root.strip())
1379
+ if root_parts.scheme.lower() not in ("http", "https") or not root_parts.hostname:
1380
+ raise ValueError(f"root is not an http or https URL: {root!r}")
1381
+ domain = registrable_domain(root_parts.hostname)
1382
+ root_key = _url_key(root_parts).rstrip("/")
1383
+ stats = {"input": len(candidates), "kept": 0, "other_domain": 0, "excluded_path": 0,
1384
+ "not_http": 0, "duplicate": 0, "root": 0, "sitemap": 0}
1385
+ kept: List[Dict[str, Any]] = []
1386
+ sitemaps: List[str] = []
1387
+ seen: set = set()
1388
+ for url, title in candidates:
1389
+ url = url.strip()
1390
+ if url.startswith("/"):
1391
+ # A crawl may list a page by its path: read it against the root.
1392
+ url = urllib.parse.urljoin(root.strip(), url)
1393
+ try:
1394
+ parts = urllib.parse.urlsplit(url)
1395
+ host = parts.hostname
1396
+ except ValueError:
1397
+ parts, host = None, None
1398
+ if parts is None or parts.scheme.lower() not in ("http", "https") or not host:
1399
+ stats["not_http"] += 1
1400
+ continue
1401
+ key = _url_key(parts)
1402
+ if key.rstrip("/") == root_key:
1403
+ stats["root"] += 1
1404
+ continue
1405
+ if key in seen:
1406
+ stats["duplicate"] += 1
1407
+ continue
1408
+ seen.add(key)
1409
+ if registrable_domain(host) != domain:
1410
+ stats["other_domain"] += 1
1411
+ continue
1412
+ if parts.path.lower().endswith((".xml", ".xml.gz")):
1413
+ # A sitemap index lists sitemaps, not pages.
1414
+ sitemaps.append(key)
1415
+ stats["sitemap"] += 1
1416
+ continue
1417
+ if _excluded_path(parts.path):
1418
+ stats["excluded_path"] += 1
1419
+ continue
1420
+ kept.append({"url": key, "title": title, "terms": _api_terms(parts.path, title or "")})
1421
+ stats["kept"] = len(kept)
1422
+ return {"root": root, "registrable_domain": domain, "kept": kept, "sitemaps": sitemaps, "stats": stats}
1423
+
1424
+
1425
+ def _cmd_filter_urls(argv: List[str]) -> int:
1426
+ parser = argparse.ArgumentParser(
1427
+ prog="skf-detect-docs.py filter-urls",
1428
+ description=(
1429
+ "Keep the subpage URLs on a documentation root page's own site "
1430
+ "(its registrable domain) and drop pages with no API content."
1431
+ ),
1432
+ epilog=_doc_section("filter-urls"),
1433
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1434
+ )
1435
+ parser.add_argument("--root", required=True, help="the root page's URL")
1436
+ parser.add_argument(
1437
+ "source",
1438
+ help=(
1439
+ "a sitemap.xml, a JSON array of URLs or {url, title} objects (or an "
1440
+ "object with a links or urls array), or one URL per line; - for stdin"
1441
+ ),
1442
+ )
1443
+ args = parser.parse_args(argv)
1444
+
1445
+ text, error = _read_source(args.source)
1446
+ if text is None:
1447
+ json.dump({"error": error, "code": "READ_ERROR"}, sys.stderr)
1448
+ sys.stderr.write("\n")
1449
+ return 2
1450
+ label = "<stdin>" if args.source == "-" else args.source
1451
+ try:
1452
+ result = filter_urls(args.root, _candidate_urls(text, label))
1453
+ except ValueError as exc:
1454
+ json.dump({"error": str(exc), "code": "INVALID_INPUT"}, sys.stderr)
1455
+ sys.stderr.write("\n")
1456
+ return 2
1457
+ json.dump(result, sys.stdout, separators=(",", ":"))
1458
+ sys.stdout.write("\n")
1459
+ return 0
1460
+
1461
+
604
1462
  # ---------------------------------------------------------------------------
605
1463
  # CLI entry point
606
1464
  # ---------------------------------------------------------------------------
607
1465
 
1466
+ def _force_utf8(*streams) -> None:
1467
+ """Reconfigure JSON-carrying streams to UTF-8 (issue #465).
1468
+
1469
+ A default Windows console decodes stdio as cp1252, which cannot carry
1470
+ non-ASCII JSON (raw UTF-8 input on stdin for `compare-hashes -` and
1471
+ `hash-urls -`). Preserves each stream's existing error handler —
1472
+ reconfigure(encoding=...) alone would reset it to 'strict', downgrading
1473
+ e.g. an already-UTF-8 stderr on Linux. For stdin this must run before the
1474
+ first read. Skips in-process test doubles without reconfigure().
1475
+ """
1476
+ for stream in streams:
1477
+ if hasattr(stream, "reconfigure"):
1478
+ errors = getattr(stream, "errors", None)
1479
+ if errors is None:
1480
+ stream.reconfigure(encoding="utf-8")
1481
+ else:
1482
+ stream.reconfigure(encoding="utf-8", errors=errors)
1483
+
1484
+
608
1485
  def main() -> int:
1486
+ _force_utf8(sys.stdin, sys.stdout)
609
1487
  # Additive subcommand: route `compare-hashes ...` to the doc-drift path
610
1488
  # before the existing detect parser runs. The default (no subcommand)
611
1489
  # invocation `--repo-url <url> ...` is unchanged.
612
1490
  if len(sys.argv) > 1 and sys.argv[1] == "compare-hashes":
613
1491
  return _cmd_compare_hashes(sys.argv[2:])
1492
+ if len(sys.argv) > 1 and sys.argv[1] == "refresh-hashes":
1493
+ return _cmd_refresh_hashes(sys.argv[2:])
1494
+ if len(sys.argv) > 1 and sys.argv[1] == "hash-urls":
1495
+ return _cmd_hash_urls(sys.argv[2:])
1496
+ if len(sys.argv) > 1 and sys.argv[1] == "readme-entry":
1497
+ return _cmd_readme_entry(sys.argv[2:])
1498
+ if len(sys.argv) > 1 and sys.argv[1] == "page-metrics":
1499
+ return _cmd_page_metrics(sys.argv[2:])
1500
+ if len(sys.argv) > 1 and sys.argv[1] == "filter-urls":
1501
+ return _cmd_filter_urls(sys.argv[2:])
614
1502
 
615
1503
  parser = argparse.ArgumentParser(
616
1504
  description="Detect documentation URLs for a GitHub repository.",