bmad-module-skill-forge 2.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (435) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +49 -49
  3. package/docs/404.md +10 -8
  4. package/docs/_data/pinned.yaml +22 -13
  5. package/docs/_internal/STABILITY.md +58 -35
  6. package/docs/agents.md +39 -53
  7. package/docs/architecture.md +65 -51
  8. package/docs/bmad-synergy.md +53 -51
  9. package/docs/campaign.md +127 -75
  10. package/docs/concepts.md +34 -28
  11. package/docs/examples.md +70 -68
  12. package/docs/forge-auto.md +34 -31
  13. package/docs/getting-started.md +118 -57
  14. package/docs/how-it-works.md +25 -21
  15. package/docs/index.md +15 -15
  16. package/docs/skill-model.md +70 -59
  17. package/docs/troubleshooting.md +135 -18
  18. package/docs/verifying-a-skill.md +83 -57
  19. package/docs/why-skf.md +24 -22
  20. package/docs/workflows.md +274 -110
  21. package/package.json +18 -10
  22. package/src/forger/preferences.yaml +8 -1
  23. package/src/knowledge/agentskills-spec.md +2 -0
  24. package/src/knowledge/ccc-bridge.md +40 -24
  25. package/src/knowledge/confidence-tiers.md +9 -5
  26. package/src/knowledge/overview.md +3 -3
  27. package/src/knowledge/progressive-capability.md +8 -8
  28. package/src/knowledge/provenance-tracking.md +9 -5
  29. package/src/knowledge/qmd-registry.md +2 -2
  30. package/src/knowledge/skf-knowledge-index.csv +2 -2
  31. package/src/knowledge/skill-lifecycle.md +18 -11
  32. package/src/knowledge/split-body-strategy.md +0 -3
  33. package/src/knowledge/tool-resolution.md +4 -2
  34. package/src/knowledge/version-paths.md +101 -15
  35. package/src/module-help.csv +3 -3
  36. package/src/module.yaml +10 -0
  37. package/src/shared/data/ast-grep-recipes.yaml +608 -0
  38. package/src/shared/data/ide-context-files.json +30 -0
  39. package/src/shared/health-check.md +32 -12
  40. package/src/shared/references/description-guard-protocol.md +12 -9
  41. package/src/shared/references/feasibility-report-schema.md +19 -13
  42. package/src/shared/references/headless-gate-convention.md +55 -3
  43. package/src/shared/references/output-contract-schema.md +19 -8
  44. package/src/shared/references/pipeline-contracts.md +94 -29
  45. package/src/shared/references/tessl-review.md +77 -0
  46. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
  47. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
  48. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
  49. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
  50. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
  51. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
  52. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
  53. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
  54. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
  55. package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
  56. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
  57. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
  58. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
  59. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
  60. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
  61. package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
  62. package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
  63. package/src/shared/scripts/skf-atomic-write.py +30 -14
  64. package/src/shared/scripts/skf-build-change-manifest.py +2203 -24
  65. package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
  66. package/src/shared/scripts/skf-check-unit-records.py +184 -0
  67. package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
  68. package/src/shared/scripts/skf-classify-changed-files.py +694 -0
  69. package/src/shared/scripts/skf-comention-pairs.py +1333 -123
  70. package/src/shared/scripts/skf-compare-file-hashes.py +1 -1
  71. package/src/shared/scripts/skf-count-imports.py +1867 -0
  72. package/src/shared/scripts/skf-count-tokens.py +1 -1
  73. package/src/shared/scripts/skf-derive-assembly-shape.py +3 -2
  74. package/src/shared/scripts/skf-description-guard.py +99 -11
  75. package/src/shared/scripts/skf-detect-docs.py +694 -2
  76. package/src/shared/scripts/skf-detect-language.py +486 -107
  77. package/src/shared/scripts/skf-detect-registry.py +887 -0
  78. package/src/shared/scripts/skf-detect-scripts-assets.py +1 -1
  79. package/src/shared/scripts/skf-detect-tools.py +209 -27
  80. package/src/shared/scripts/skf-detect-workspaces.py +506 -30
  81. package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
  82. package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
  83. package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
  84. package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
  85. package/src/shared/scripts/skf-extract-public-api.py +4084 -85
  86. package/src/shared/scripts/skf-extraction-inventory.py +746 -0
  87. package/src/shared/scripts/skf-extraction-snapshot.py +539 -0
  88. package/src/shared/scripts/skf-fetch-temporal.py +674 -0
  89. package/src/shared/scripts/skf-find-cycles.py +97 -29
  90. package/src/shared/scripts/skf-find-test-report.py +476 -0
  91. package/src/shared/scripts/skf-forge-tier-rw.py +444 -203
  92. package/src/shared/scripts/skf-github-fetch.py +365 -0
  93. package/src/shared/scripts/skf-github-probe.py +661 -0
  94. package/src/shared/scripts/skf-hash-content.py +166 -12
  95. package/src/shared/scripts/skf-language-corpora.py +1 -1
  96. package/src/shared/scripts/skf-load-provenance.py +159 -27
  97. package/src/shared/scripts/skf-manifest-ops.py +1 -1
  98. package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
  99. package/src/shared/scripts/skf-merge-doc-urls.py +1 -1
  100. package/src/shared/scripts/skf-names-present.py +221 -0
  101. package/src/shared/scripts/skf-pair-intersect.py +119 -28
  102. package/src/shared/scripts/skf-parse-gaps.py +1384 -0
  103. package/src/shared/scripts/skf-preapply.py +1 -1
  104. package/src/shared/scripts/skf-preflight.py +161 -27
  105. package/src/shared/scripts/skf-promote-staged.py +430 -0
  106. package/src/shared/scripts/skf-provenance-gap-dispatch.py +39 -7
  107. package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
  108. package/src/shared/scripts/skf-quick-batch.py +774 -0
  109. package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
  110. package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
  111. package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
  112. package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
  113. package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
  114. package/src/shared/scripts/skf-resolve-authoritative-files.py +128 -13
  115. package/src/shared/scripts/skf-resolve-package.py +523 -106
  116. package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
  117. package/src/shared/scripts/skf-run-lock.py +577 -0
  118. package/src/shared/scripts/skf-scan-manifests.py +998 -57
  119. package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
  120. package/src/shared/scripts/skf-severity-classify.py +712 -108
  121. package/src/shared/scripts/skf-shape-detect.py +298 -204
  122. package/src/shared/scripts/skf-shard-body.py +9 -8
  123. package/src/shared/scripts/skf-skill-inventory.py +2412 -89
  124. package/src/shared/scripts/skf-skills-module.py +472 -0
  125. package/src/shared/scripts/skf-source-tree.py +1796 -0
  126. package/src/shared/scripts/skf-structural-diff.py +670 -119
  127. package/src/shared/scripts/skf-tessl-review.py +706 -0
  128. package/src/shared/scripts/skf-update-active-symlink.py +5 -5
  129. package/src/shared/scripts/skf-update-run-state.py +624 -0
  130. package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
  131. package/src/shared/scripts/skf-validate-brief-schema.py +4 -1
  132. package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
  133. package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
  134. package/src/shared/scripts/skf-validate-output.py +712 -64
  135. package/src/shared/scripts/skf-validate-pins.py +1 -1
  136. package/src/shared/scripts/skf-verify-no-trace.py +116 -10
  137. package/src/shared/scripts/skf-verify-provenance-completeness.py +2919 -54
  138. package/src/shared/scripts/skf-write-skill-brief.py +367 -19
  139. package/src/shared/tool-requirements.yaml +201 -0
  140. package/src/skf-analyze-source/SKILL.md +41 -36
  141. package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
  142. package/src/skf-analyze-source/customize.toml +36 -24
  143. package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
  144. package/src/skf-analyze-source/references/continue.md +2 -13
  145. package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
  146. package/src/skf-analyze-source/references/generate-briefs.md +121 -55
  147. package/src/skf-analyze-source/references/headless-contract.md +42 -15
  148. package/src/skf-analyze-source/references/identify-units.md +96 -110
  149. package/src/skf-analyze-source/references/init.md +73 -71
  150. package/src/skf-analyze-source/references/map-and-detect.md +123 -105
  151. package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
  152. package/src/skf-analyze-source/references/recommend.md +51 -70
  153. package/src/skf-analyze-source/references/scan-project.md +60 -84
  154. package/src/skf-analyze-source/references/scan-root.md +39 -0
  155. package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
  156. package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
  157. package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
  158. package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
  159. package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
  160. package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
  161. package/src/skf-audit-skill/SKILL.md +43 -51
  162. package/src/skf-audit-skill/assets/drift-report-template.md +27 -3
  163. package/src/skf-audit-skill/customize.toml +41 -20
  164. package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
  165. package/src/skf-audit-skill/references/doc-drift.md +141 -0
  166. package/src/skf-audit-skill/references/headless-contract.md +29 -0
  167. package/src/skf-audit-skill/references/init.md +148 -210
  168. package/src/skf-audit-skill/references/re-index.md +96 -108
  169. package/src/skf-audit-skill/references/report.md +96 -33
  170. package/src/skf-audit-skill/references/semantic-diff.md +27 -18
  171. package/src/skf-audit-skill/references/severity-classify.md +79 -65
  172. package/src/skf-audit-skill/references/severity-rules.md +15 -6
  173. package/src/skf-audit-skill/references/structural-diff.md +75 -90
  174. package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
  175. package/src/skf-audit-skill/scripts/render-drift-tables.py +685 -0
  176. package/src/skf-brief-skill/SKILL.md +36 -25
  177. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  178. package/src/skf-brief-skill/assets/scope-templates.md +17 -24
  179. package/src/skf-brief-skill/assets/skill-brief-schema.md +29 -15
  180. package/src/skf-brief-skill/customize.toml +48 -20
  181. package/src/skf-brief-skill/references/analyze-target.md +142 -125
  182. package/src/skf-brief-skill/references/confirm-brief.md +10 -8
  183. package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
  184. package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
  185. package/src/skf-brief-skill/references/gather-intent.md +87 -182
  186. package/src/skf-brief-skill/references/headless-args.md +84 -16
  187. package/src/skf-brief-skill/references/health-check.md +1 -1
  188. package/src/skf-brief-skill/references/invocation-contract.md +18 -13
  189. package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
  190. package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
  191. package/src/skf-brief-skill/references/scope-definition.md +123 -47
  192. package/src/skf-brief-skill/references/step-auto-brief.md +24 -80
  193. package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
  194. package/src/skf-brief-skill/references/version-resolution.md +3 -2
  195. package/src/skf-brief-skill/references/write-brief.md +66 -57
  196. package/src/skf-campaign/SKILL.md +31 -34
  197. package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
  198. package/src/skf-campaign/customize.toml +47 -16
  199. package/src/skf-campaign/references/campaign-contracts.md +39 -29
  200. package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
  201. package/src/skf-campaign/references/health-check.md +7 -16
  202. package/src/skf-campaign/references/step-01-setup.md +46 -84
  203. package/src/skf-campaign/references/step-02-strategy.md +31 -30
  204. package/src/skf-campaign/references/step-03-pins.md +26 -18
  205. package/src/skf-campaign/references/step-04-provenance.md +24 -23
  206. package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
  207. package/src/skf-campaign/references/step-06-batch.md +41 -34
  208. package/src/skf-campaign/references/step-07-capstone.md +40 -25
  209. package/src/skf-campaign/references/step-08-verify.md +28 -29
  210. package/src/skf-campaign/references/step-09-refine.md +29 -30
  211. package/src/skf-campaign/references/step-10-export.md +48 -36
  212. package/src/skf-campaign/references/step-11-maintenance.md +31 -34
  213. package/src/skf-campaign/references/step-resume.md +37 -68
  214. package/src/skf-campaign/scripts/campaign-deps.py +57 -18
  215. package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
  216. package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
  217. package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
  218. package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
  219. package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
  220. package/src/skf-campaign/scripts/campaign-report.py +204 -37
  221. package/src/skf-campaign/scripts/campaign-state.py +1128 -0
  222. package/src/skf-campaign/scripts/campaign-status.py +10 -74
  223. package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
  224. package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
  225. package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
  226. package/src/skf-campaign/templates/campaign-report-template.md +5 -1
  227. package/src/skf-campaign/templates/kickoff-template.md +0 -14
  228. package/src/skf-create-skill/SKILL.md +27 -20
  229. package/src/skf-create-skill/assets/compile-assembly-rules.md +6 -5
  230. package/src/skf-create-skill/assets/skill-sections.md +15 -5
  231. package/src/skf-create-skill/customize.toml +20 -8
  232. package/src/skf-create-skill/references/authoritative-files-protocol.md +41 -42
  233. package/src/skf-create-skill/references/batch-mode.md +81 -0
  234. package/src/skf-create-skill/references/ccc-index-check.md +27 -0
  235. package/src/skf-create-skill/references/compile.md +107 -29
  236. package/src/skf-create-skill/references/component-extraction.md +113 -91
  237. package/src/skf-create-skill/references/enrich.md +41 -23
  238. package/src/skf-create-skill/references/entry-points-by-hand.md +20 -0
  239. package/src/skf-create-skill/references/extract.md +200 -161
  240. package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
  241. package/src/skf-create-skill/references/extraction-patterns-tracing.md +2 -2
  242. package/src/skf-create-skill/references/extraction-patterns.md +584 -196
  243. package/src/skf-create-skill/references/generate-artifacts.md +145 -93
  244. package/src/skf-create-skill/references/health-check.md +1 -1
  245. package/src/skf-create-skill/references/load-brief.md +53 -36
  246. package/src/skf-create-skill/references/relabel-rule.md +12 -0
  247. package/src/skf-create-skill/references/report.md +28 -59
  248. package/src/skf-create-skill/references/source-resolution-protocols.md +49 -155
  249. package/src/skf-create-skill/references/step-auto-shard.md +20 -93
  250. package/src/skf-create-skill/references/step-doc-rot.md +44 -60
  251. package/src/skf-create-skill/references/step-doc-sources.md +28 -19
  252. package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
  253. package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
  254. package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
  255. package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
  256. package/src/skf-create-skill/references/validate.md +173 -99
  257. package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
  258. package/src/skf-create-stack-skill/SKILL.md +29 -56
  259. package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
  260. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
  261. package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
  262. package/src/skf-create-stack-skill/customize.toml +23 -12
  263. package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
  264. package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
  265. package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
  266. package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
  267. package/src/skf-create-stack-skill/references/generate-output.md +138 -128
  268. package/src/skf-create-stack-skill/references/init.md +90 -32
  269. package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
  270. package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
  271. package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
  272. package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
  273. package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
  274. package/src/skf-create-stack-skill/references/report.md +39 -33
  275. package/src/skf-create-stack-skill/references/validate.md +20 -39
  276. package/src/skf-drop-skill/SKILL.md +28 -51
  277. package/src/skf-drop-skill/customize.toml +44 -30
  278. package/src/skf-drop-skill/references/execute.md +79 -146
  279. package/src/skf-drop-skill/references/invocation-contract.md +64 -0
  280. package/src/skf-drop-skill/references/report.md +46 -30
  281. package/src/skf-drop-skill/references/select.md +118 -124
  282. package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
  283. package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
  284. package/src/skf-export-skill/SKILL.md +40 -62
  285. package/src/skf-export-skill/assets/managed-section-format.md +14 -50
  286. package/src/skf-export-skill/assets/snippet-format.md +3 -1
  287. package/src/skf-export-skill/customize.toml +42 -13
  288. package/src/skf-export-skill/references/generate-snippet.md +54 -46
  289. package/src/skf-export-skill/references/health-check.md +2 -0
  290. package/src/skf-export-skill/references/invocation-contract.md +15 -0
  291. package/src/skf-export-skill/references/load-skill.md +88 -62
  292. package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
  293. package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
  294. package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
  295. package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
  296. package/src/skf-export-skill/references/package.md +15 -73
  297. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
  298. package/src/skf-export-skill/references/result-envelope.md +31 -16
  299. package/src/skf-export-skill/references/summary.md +41 -17
  300. package/src/skf-export-skill/references/token-report.md +4 -12
  301. package/src/skf-export-skill/references/update-context.md +118 -237
  302. package/src/skf-forger/SKILL.md +61 -37
  303. package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
  304. package/src/skf-forger/customize.toml +43 -0
  305. package/src/skf-forger/references/pipeline-mode.md +73 -18
  306. package/src/skf-forger/scripts/forge-status.py +299 -0
  307. package/src/skf-forger/scripts/parse-pipeline.py +322 -54
  308. package/src/skf-forger/scripts/pipeline-gate.py +428 -0
  309. package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
  310. package/src/skf-quick-skill/SKILL.md +54 -53
  311. package/src/skf-quick-skill/assets/skill-template.md +4 -2
  312. package/src/skf-quick-skill/customize.toml +51 -20
  313. package/src/skf-quick-skill/references/batch-mode.md +74 -21
  314. package/src/skf-quick-skill/references/compile.md +30 -31
  315. package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
  316. package/src/skf-quick-skill/references/finalize.md +69 -18
  317. package/src/skf-quick-skill/references/halt-contract.md +41 -29
  318. package/src/skf-quick-skill/references/health-check.md +5 -3
  319. package/src/skf-quick-skill/references/quick-extract.md +114 -45
  320. package/src/skf-quick-skill/references/registry-resolution.md +9 -64
  321. package/src/skf-quick-skill/references/resolve-target.md +116 -66
  322. package/src/skf-quick-skill/references/write-and-validate.md +79 -40
  323. package/src/skf-refine-architecture/SKILL.md +43 -53
  324. package/src/skf-refine-architecture/customize.toml +49 -20
  325. package/src/skf-refine-architecture/references/compile.md +97 -54
  326. package/src/skf-refine-architecture/references/exit-codes.md +30 -0
  327. package/src/skf-refine-architecture/references/finding-storage.md +21 -0
  328. package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
  329. package/src/skf-refine-architecture/references/improvements.md +14 -20
  330. package/src/skf-refine-architecture/references/init.md +98 -57
  331. package/src/skf-refine-architecture/references/issue-detection.md +32 -39
  332. package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
  333. package/src/skf-refine-architecture/references/report.md +65 -54
  334. package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
  335. package/src/skf-rename-skill/SKILL.md +19 -54
  336. package/src/skf-rename-skill/customize.toml +32 -26
  337. package/src/skf-rename-skill/references/execute.md +230 -184
  338. package/src/skf-rename-skill/references/exit-codes.md +7 -7
  339. package/src/skf-rename-skill/references/health-check.md +1 -7
  340. package/src/skf-rename-skill/references/invocation-contract.md +25 -0
  341. package/src/skf-rename-skill/references/report.md +63 -52
  342. package/src/skf-rename-skill/references/select.md +197 -115
  343. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
  344. package/src/skf-setup/SKILL.md +26 -26
  345. package/src/skf-setup/customize.toml +14 -10
  346. package/src/skf-setup/references/auto-index.md +50 -63
  347. package/src/skf-setup/references/ccc-index.md +29 -90
  348. package/src/skf-setup/references/detect-and-tier.md +58 -67
  349. package/src/skf-setup/references/health-check.md +2 -0
  350. package/src/skf-setup/references/invocation-contract.md +14 -0
  351. package/src/skf-setup/references/report.md +43 -166
  352. package/src/skf-setup/references/tier-rules.md +5 -5
  353. package/src/skf-setup/references/write-config.md +34 -57
  354. package/src/skf-test-skill/SKILL.md +17 -57
  355. package/src/skf-test-skill/assets/output-section-formats.md +33 -34
  356. package/src/skf-test-skill/customize.toml +27 -13
  357. package/src/skf-test-skill/references/coherence-check.md +134 -62
  358. package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
  359. package/src/skf-test-skill/references/coverage-check.md +217 -254
  360. package/src/skf-test-skill/references/detect-mode.md +5 -5
  361. package/src/skf-test-skill/references/external-validators.md +94 -82
  362. package/src/skf-test-skill/references/health-check.md +6 -7
  363. package/src/skf-test-skill/references/init.md +163 -88
  364. package/src/skf-test-skill/references/invocation-contract.md +66 -0
  365. package/src/skf-test-skill/references/migration-section-rules.md +5 -2
  366. package/src/skf-test-skill/references/report.md +162 -142
  367. package/src/skf-test-skill/references/score.md +80 -107
  368. package/src/skf-test-skill/references/scoring-rules.md +41 -155
  369. package/src/skf-test-skill/references/source-access-protocol.md +19 -92
  370. package/src/skf-test-skill/references/step-hard-gate.md +51 -33
  371. package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
  372. package/src/skf-test-skill/scripts/build-result-context.py +287 -0
  373. package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
  374. package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
  375. package/src/skf-test-skill/scripts/compute-score.py +223 -45
  376. package/src/skf-test-skill/scripts/gap-ledger.py +1381 -0
  377. package/src/skf-test-skill/scripts/hard-gate.py +203 -0
  378. package/src/skf-test-skill/scripts/load-coverage-inputs.py +760 -0
  379. package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
  380. package/src/skf-test-skill/scripts/reconcile-coverage.py +273 -79
  381. package/src/skf-test-skill/scripts/score-signatures.py +459 -0
  382. package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
  383. package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
  384. package/src/skf-test-skill/scripts/verify-declared-numerator.py +127 -52
  385. package/src/skf-test-skill/templates/test-report-template.md +21 -15
  386. package/src/skf-update-skill/SKILL.md +38 -25
  387. package/src/skf-update-skill/customize.toml +27 -12
  388. package/src/skf-update-skill/references/detect-changes.md +233 -222
  389. package/src/skf-update-skill/references/gap-driven.md +336 -0
  390. package/src/skf-update-skill/references/health-check.md +31 -5
  391. package/src/skf-update-skill/references/init.md +250 -84
  392. package/src/skf-update-skill/references/invocation-contract.md +12 -0
  393. package/src/skf-update-skill/references/manual-section-rules.md +6 -23
  394. package/src/skf-update-skill/references/merge.md +180 -47
  395. package/src/skf-update-skill/references/re-extract.md +53 -262
  396. package/src/skf-update-skill/references/report.md +100 -35
  397. package/src/skf-update-skill/references/write.md +331 -149
  398. package/src/skf-update-skill/scripts/skf-new-file-diff.py +29 -7
  399. package/src/skf-verify-stack/SKILL.md +23 -42
  400. package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
  401. package/src/skf-verify-stack/customize.toml +55 -22
  402. package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
  403. package/src/skf-verify-stack/references/coverage.md +67 -64
  404. package/src/skf-verify-stack/references/exit-codes.md +21 -8
  405. package/src/skf-verify-stack/references/init.md +107 -62
  406. package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
  407. package/src/skf-verify-stack/references/integrations.md +106 -65
  408. package/src/skf-verify-stack/references/report.md +59 -42
  409. package/src/skf-verify-stack/references/requirements.md +32 -9
  410. package/src/skf-verify-stack/references/synthesize.md +53 -39
  411. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
  412. package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
  413. package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
  414. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
  415. package/tools/cli/commands/install.js +11 -4
  416. package/tools/cli/commands/status.js +15 -3
  417. package/tools/cli/commands/update.js +11 -4
  418. package/tools/cli/lib/tool-check.js +320 -0
  419. package/tools/cli/lib/version-check.js +22 -9
  420. package/tools/cli/skf-cli.js +4 -1
  421. package/.gitattributes +0 -16
  422. package/.nvmrc +0 -1
  423. package/docs/_internal/RELEASING.md +0 -679
  424. package/release-audits/v1.0.0-launch-audit.md +0 -728
  425. package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
  426. package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
  427. package/src/skf-campaign/manifest.yaml +0 -15
  428. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
  429. package/src/skf-create-skill/references/ecosystem-check.md +0 -74
  430. package/src/skf-drop-skill/references/headless-contract.md +0 -18
  431. package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
  432. package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
  433. package/src/skf-update-skill/references/validate.md +0 -124
  434. package/tools/validate-docs-drift.js +0 -233
  435. package/tools/validate-docs-links.js +0 -665
@@ -1,5 +1,5 @@
1
1
  # /// script
2
- # requires-python = ">=3.10"
2
+ # requires-python = ">=3.11"
3
3
  # dependencies = []
4
4
  # ///
5
5
  """SKF Scan Skill.md Structure — deterministic structural checks for SKILL.md.
@@ -8,23 +8,34 @@ Replaces the synonym-grep loops, bash fence recipes, and inline Python
8
8
  table-parser in `references/coherence-check.md` §§2.1 / 2.2 / 2.3 / 2.6 with
9
9
  a single subprocess invocation that emits JSON.
10
10
 
11
+ The usage-scope, cross-reference and reference-check subcommands emit the
12
+ facts that test-skill's coherence check (§2.4 zero-usage exports, §§3-4
13
+ file, script and asset references) and verify-stack's Check 4 (literal
14
+ citations between skills) would otherwise count, grep and compare by hand,
15
+ so two runs over the same files always agree.
16
+
11
17
  Subcommands:
12
18
  scan <skill-md>
13
19
  Emit JSON describing fence balance, bare opening fences (no language
14
- tag), and table column-count drift:
20
+ tag), table column-count drift and the Scripts & Assets section (see
21
+ "Scripts & Assets section" below):
15
22
  {
16
23
  "unbalanced_fences": <bool>,
17
24
  "fence_count": N,
18
25
  "bare_opening_fences": [{"line": N, "text": "..."}, ...],
19
26
  "table_drift": [{"line": N, "section": "<heading>",
20
27
  "expected_cols": N, "actual_cols": N,
21
- "row": "..."}]
28
+ "row": "..."}],
29
+ "scripts_assets": {"folders": ["assets", "scripts"],
30
+ "section": {"heading": "...", "line": N} | null,
31
+ "missing": <bool>}
22
32
  }
23
33
 
24
34
  scan <skill-md> --required-sections
25
35
  Emit JSON describing which of the three required section families
26
36
  (description / usage / api_surface) are present, and which synonym
27
- satisfied the requirement (case-insensitive, `##`/`###` tolerated):
37
+ satisfied the requirement (see "Heading match rule" below), with the
38
+ outline a caller reads when a family is not satisfied:
28
39
  {
29
40
  "description": {"satisfied": <bool>,
30
41
  "matched_synonym": "<heading>" | null,
@@ -34,14 +45,129 @@ Subcommands:
34
45
  "tried": [...]},
35
46
  "api_surface": {"satisfied": <bool>,
36
47
  "matched_synonym": "..." | null,
37
- "tried": [...]}
48
+ "tried": [...]},
49
+ "frontmatter_description": <bool>,
50
+ "outline": [{"line": N, "level": N, "text": "<heading>"}, ...]
51
+ }
52
+ `outline` lists every heading of the body in document order (the
53
+ Line model's headings: outside the frontmatter and fenced code).
54
+ `frontmatter_description` is true when the frontmatter has a
55
+ `description` key with a non-empty value: inline, or on the
56
+ indented lines under it (a `>` or `|` block, say).
57
+
58
+ usage-scope <skill-md> --exports <json-file-or-'-'> [--kinds <k1,k2,...>]
59
+ [--body auto|single|split]
60
+ Emit JSON counting, for each export, the lines that use its name in
61
+ the usage scope (see "Usage scope" and "Export match" below):
62
+ {
63
+ "scope": {
64
+ "body": "single" | "split",
65
+ "sections": [{"heading": "<text>", "line": N,
66
+ "end_line": N}, ...],
67
+ "reference_files": ["references/<file>.md", ...]
68
+ },
69
+ "exports": [{"name": "...", "kind": "..." | null, "count": N,
70
+ "first_hit": {"file": "...", "line": N} | null},
71
+ ...],
72
+ "zero_usage": [{"name": "...", "kind": "..." | null}, ...],
73
+ "warnings": ["references/<file>.md: cannot read: ...", ...]
74
+ }
75
+ --exports holds a JSON array of names or {"name", "kind"} objects,
76
+ an object with an `exports` array (test-skill's step 3 inventory),
77
+ or the validate-inventory.py result (its `inventory.exports`).
78
+ --kinds counts only the exports whose kind it lists; an export
79
+ given without a kind is always counted. --body picks the scope
80
+ (default auto; see "Usage scope"). `exports[]` is sorted by name,
81
+ then kind, with duplicates dropped; `zero_usage[]` lists the
82
+ exports whose count is 0.
83
+
84
+ cross-reference --skills <json-file-or-'-'> [--skills-root <dir>]
85
+ [--pairs <json-file-or-'-'>]
86
+ Emit JSON listing where one skill's SKILL.md literally cites another
87
+ skill (see "Citation match" below):
88
+ {
89
+ "citations": [{"from": "<a>", "to": "<b>",
90
+ "substring": "<as written>", "line": N,
91
+ "count": N, "excerpt": "...",
92
+ "hits": [{"line": N, "substring": "...",
93
+ "excerpt": "..."}, ...]}, ...],
94
+ "edges": [["<a>", "<b>"], ...],
95
+ "pairs": [{"library_a": "<a>", "library_b": "<b>",
96
+ "a_cites_b": <bool>, "b_cites_a": <bool>,
97
+ "cited": <bool>}, ...],
98
+ "warnings": ["<name>: ...", ...]
99
+ }
100
+ --skills holds a JSON array of {"name", "skill_md" | "path",
101
+ "aliases"} entries (`aliases` optional), or an object with a
102
+ `skills` array: the skf-enumerate-stack-skills.py inventory, whose
103
+ `path` is relative to the skills folder, so pass that folder as
104
+ --skills-root. An entry's `source_repo_basename` and
105
+ `source_root_basename` (the inventory gives both) are aliases too,
106
+ unless another entry has the same term as its name, an alias or a
107
+ source basename, since one word would then cite two skills.
108
+ `path` names the skill folder, `skill_md` the file;
109
+ a relative one resolves against --skills-root, else the working
110
+ directory, and a --skills-root that is not a directory is an
111
+ error. `citations[]` holds one entry per citing direction, sorted
112
+ by (from, to): `hits[]` lists every citing line in line order,
113
+ `count` is their number, and `substring`, `line` and `excerpt`
114
+ repeat the first hit. `edges[]` is the same set in the shape
115
+ skf-find-cycles.py reads ({"edges": [[from, to], ...]}), so this
116
+ output pipes into `find --edges -` as is. --pairs (a JSON array of
117
+ [a, b] or {"library_a", "library_b"} entries, or an object with a
118
+ `pairs` array) keeps only the citations between those pairs and
119
+ adds `pairs[]`, sorted, where `cited` means either direction
120
+ cites. A SKILL.md that cannot be read is a warning (its skill can
121
+ still be cited), and so is a pair that names a skill --skills
122
+ lacks.
123
+
124
+ reference-check <skill-md> [--source-root <dir>] [--skills-root <dir>]
125
+ Emit JSON checking every file, script and asset reference in the
126
+ SKILL.md body (see "Reference extraction" below):
127
+ {
128
+ "roots": {"skill": "<dir>", "source": "<dir>" | null,
129
+ "skills": "<dir>" | null},
130
+ "references": [{"line": N,
131
+ "type": "file-path" | "script-asset",
132
+ "form": "link" | "mention",
133
+ "target": "<as written>",
134
+ "canonical": "<realpath>",
135
+ "status": "ok" | "missing" | "escapes",
136
+ "root": "skill" | "source" | "skills" | null},
137
+ ...],
138
+ "counts": {"total": N, "ok": N, "missing": N, "escapes": N},
139
+ "scripts_assets": {...},
140
+ "warnings": ["--source-root is not a directory: <dir>", ...]
38
141
  }
142
+ A target resolves against the SKILL.md folder and through every
143
+ symlink (os.path.realpath) before the root check. The roots are
144
+ the SKILL.md folder, --source-root (the extraction tree,
145
+ metadata.json `source_root`) and --skills-root (the skills output
146
+ folder, for a stack skill whose references reach its constituent
147
+ skills); `root` names the first one that holds the target.
148
+ `escapes`: outside every root (whether it exists is not checked,
149
+ the escape is the finding); `missing`: inside a root, nothing
150
+ there; `ok`: inside a root and present. `references[]` is in
151
+ document order; the same canonical target twice on one line is one
152
+ reference. A --source-root or --skills-root that is not a
153
+ directory (a stale metadata.json `source_root`, say) is a warning,
154
+ not an error.
155
+
156
+ Scripts & Assets section:
157
+ `folders` lists the `scripts` and `assets` folders beside SKILL.md,
158
+ sorted; `section` is the first heading outside the frontmatter and
159
+ fenced code whose text starts with `Scripts` (case-insensitive, any
160
+ level: the section the reference extraction below also reads), with its
161
+ text and line, or null; `missing` is true when a folder exists and no
162
+ such section does, the gap test-skill's coherence check records.
39
163
 
40
164
  Heading match rule:
41
- Match the first `^#+\\s+<heading>$` (any number of `#`, case-insensitive,
42
- surrounding whitespace trimmed) that matches any synonym in a family.
43
- The reported `matched_synonym` is the canonical synonym from the list,
44
- not the heading text as it appears in the file.
165
+ Match the first heading of the body (the Line model's headings: any
166
+ level, a closing `#` sequence dropped, never a `#` line inside fenced or
167
+ indented code or the frontmatter) whose full text, case-insensitive,
168
+ is a synonym of a family. The reported `matched_synonym` is the
169
+ canonical synonym from the list, not the heading text as it appears in
170
+ the file.
45
171
 
46
172
  Fence balance:
47
173
  Count triple-backtick (```) fence lines (`^```` at start of line, ignoring
@@ -74,20 +200,107 @@ Empty SKILL.md:
74
200
  An empty file yields `fence_count: 0`, `unbalanced_fences: false`,
75
201
  empty `bare_opening_fences`, empty `table_drift`; for
76
202
  `--required-sections`, all three families have `satisfied: false`,
77
- `matched_synonym: null`.
203
+ `matched_synonym: null`, with `frontmatter_description: false` and an
204
+ empty `outline`.
205
+
206
+ Line model (scan --required-sections, usage-scope, cross-reference,
207
+ reference-check):
208
+ A file is read as UTF-8, a leading byte order mark dropped, and split
209
+ on `\\n` alone (a `\\r\\n` pair loses its `\\r`, a lone `\\r` stays in
210
+ its line), so every `line` matches `grep -n`. The frontmatter is a
211
+ leading `---` ... `---` block. A fence opens on a line of three or more
212
+ backticks or tildes (any indent) and closes on the same character
213
+ repeated at least as often with nothing after it; an unclosed fence
214
+ runs to the end of the file. A heading is an ATX heading outside the
215
+ frontmatter and fenced code: at most three spaces of indent (four make
216
+ indented code), one to six `#`, then a space, a tab or the end of the
217
+ line, with a closing `#` sequence dropped from its text. Its section
218
+ runs to the line before the next heading of the same or a higher level.
219
+
220
+ Usage scope:
221
+ Single-body (no `*.md` file under `references/`): the section of the
222
+ first heading whose text is a usage synonym (REQUIRED_SYNONYMS["usage"],
223
+ the anchor coherence-check.md §2.1 matches), fenced code inside it
224
+ included. The Key Exports list is outside it, so listing an export
225
+ there does not count as using it. With no such heading the scope is
226
+ empty and every export is zero-usage.
227
+ Split-body (a `*.md` file under `references/`): every usage-family
228
+ section of SKILL.md (the headings in USAGE_FAMILY_HEADINGS: every usage
229
+ synonym plus `Key API Summary`, `Pattern Surface` and `Key Exports`,
230
+ full text, case-insensitive, any level), fenced code inside them
231
+ included, plus every `*.md` file under `references/` at any depth, in
232
+ path order. Overlapping sections count each line once.
233
+ --body auto (the default) decides by the `references/` test alone.
234
+ coherence-check.md §2.4 also calls a package with `references/`
235
+ single-body when its `## Full*` sections carry real content rather than
236
+ stubs: that is the caller's judgment, passed as --body single.
237
+
238
+ Export match:
239
+ The name as a fixed string, case-sensitive, never inside a longer
240
+ identifier: on a side where the name ends in a letter, digit, `_` or
241
+ `$`, the next character may not be one of those. So `$state` and `a.b`
242
+ match only themselves (no shell expansion, no regex wildcard), `get`
243
+ does not match `getAll`, and `.then` still matches `promise.then`.
244
+ `count` is the number of matching lines (the `grep -c` count).
245
+
246
+ Citation match:
247
+ A skill's name and aliases, case-insensitive, never inside a longer
248
+ name: on a side where the term ends in a letter, digit, `_` or `-`, the
249
+ next character may not be one of those. So `React` and `react/client`
250
+ cite `react`, while `react-dom`, `preact` and `reactive` do not. The
251
+ whole SKILL.md is searched, frontmatter and code included, and a skill
252
+ never cites itself. A hit's `substring` is the match as written (the
253
+ longest term at the leftmost match of its line) and its `excerpt` that
254
+ line around all of its matches. A match is literal, not a judgment: a
255
+ skill named after a common word (`next`, `requests`) also matches
256
+ prose such as `the next step` or a `sending requests` description, so
257
+ the caller picks from `hits[]` the line it quotes as the citation.
258
+
259
+ Reference extraction:
260
+ Outside the frontmatter and fenced code, two forms count:
261
+ - link: a markdown link, image or reference definition (`[id]: target`,
262
+ footnotes aside) outside inline code, unless its target is a URL
263
+ (`scheme:` or `//`), a bare `#anchor`, or holds a placeholder or glob
264
+ character (`{}<>*?$`). The target may hold one level of balanced
265
+ parentheses; its backslash escapes and entity references are
266
+ resolved, the `#fragment` and `?query` dropped and `%XX` escapes
267
+ decoded.
268
+ - mention: a `references/`, `scripts/` or `assets/` path, optionally
269
+ `./`-prefixed, in prose or inline code, not preceded by a letter,
270
+ digit, `_`, `-`, `.`, `/`, `@` or `:` (so `src/assets/x`, URLs and
271
+ `[SRC:scripts/x.sh:L1]` provenance citations are not references).
272
+ Trailing punctuation, a `#fragment` and a `:L12` line suffix are
273
+ dropped; a bare folder or a placeholder is not a reference. A
274
+ `scripts/` or `assets/` mention counts only when the package has that
275
+ folder or it sits in a section whose heading starts with `Scripts`
276
+ (the Scripts & Assets section): elsewhere it can name a path in the
277
+ reader's own project, as framework docs do.
278
+ A target under `scripts/` or `assets/` is `script-asset`; any other
279
+ target is `file-path`.
280
+ Known limits: a mention is taken as written (no escape is resolved) and
281
+ ends at whitespace, so `references/my file.md` in inline code reads as
282
+ `references/my`; parentheses nest one level deep in a link target; and
283
+ a one-word line such as `[Note]: see-below` is a reference definition,
284
+ as CommonMark reads it.
78
285
 
79
286
  Exit codes:
80
- 0 — operation succeeded
81
- 1 — user error (file not found, can't read)
287
+ 0: operation succeeded
288
+ 1: user error (file not found or unreadable, malformed JSON input, both
289
+ --skills and --pairs on stdin, a cross-reference --skills-root that
290
+ is not a directory)
291
+ 2: usage error (argparse: missing or unknown argument or subcommand)
82
292
  """
83
293
 
84
294
  from __future__ import annotations
85
295
 
86
296
  import argparse
297
+ import html
87
298
  import json
299
+ import os
88
300
  import re
89
301
  import sys
90
302
  from pathlib import Path
303
+ from urllib.parse import unquote
91
304
 
92
305
 
93
306
  # --------------------------------------------------------------------------
@@ -95,17 +308,19 @@ from pathlib import Path
95
308
  # --------------------------------------------------------------------------
96
309
 
97
310
 
98
- # These mirror the canonical synonyms documented in
99
- # `src/skf-test-skill/references/coherence-check.md` §2.1, with the
100
- # SKF-template-specific headings folded in so they are first-class
101
- # matches rather than literal-name misses (per the §2.1 "Note"
102
- # paragraph). The set covers the Deep/create-skill template
103
- # (`Quick Start`, `Common Workflows`, `Key API Summary`, `Key Types`),
104
- # the quick-skill template (`Usage Patterns`, `Key Exports`), and the
105
- # reference-app assembly overrides (`Adoption Steps` replaces Common
106
- # Workflows for usage; `Pattern Surface` replaces Key API Summary for
107
- # api_surface), since headings are matched on the full heading text,
108
- # not a substring.
311
+ # The synonyms test-skill's coherence check (coherence-check.md §2.1)
312
+ # matches, with the SKF-template headings folded in so they are
313
+ # first-class matches rather than literal-name misses: the Deep/create-skill
314
+ # template (`Overview`, `Quick Start`, `Common Workflows`, `Key API
315
+ # Summary`), the quick-skill template (`Usage Patterns`, `Key Exports`),
316
+ # the component-library override (`Component Catalog` replaces Key API
317
+ # Summary for api_surface) and the reference-app overrides (`Adoption
318
+ # Steps` replaces Common Workflows for usage; `Pattern Surface` replaces
319
+ # Key API Summary for api_surface), since headings are matched on the full
320
+ # heading text, not a substring. A template heading is never renamed: a
321
+ # new one widens this list (test-skf-scan-skill-md-structure.py checks the
322
+ # templates against it). A heading outside the list leaves its family
323
+ # unsatisfied, and §2.1 then judges the `outline`.
109
324
  REQUIRED_SYNONYMS: dict[str, list[str]] = {
110
325
  "description": ["Description", "Overview", "Purpose", "Summary"],
111
326
  "usage": [
@@ -129,6 +344,7 @@ REQUIRED_SYNONYMS: dict[str, list[str]] = {
129
344
  "Reference",
130
345
  "Key API Summary",
131
346
  "Pattern Surface",
347
+ "Component Catalog",
132
348
  ],
133
349
  }
134
350
 
@@ -138,13 +354,39 @@ REQUIRED_SYNONYMS: dict[str, list[str]] = {
138
354
  # --------------------------------------------------------------------------
139
355
 
140
356
 
357
+ # `scan` (table drift) and skf-shard-body.py read headings with this one.
141
358
  _HEADING_RE = re.compile(r"^\s*(#+)\s+(.*?)\s*$")
359
+ _FRONTMATTER_DESCRIPTION_RE = re.compile(r"^description[ \t]*:(.*)$")
360
+ # A YAML block scalar header (`>`, `|-`, `>2` ...) holds no text itself.
361
+ _BLOCK_SCALAR_RE = re.compile(r"^[|>](?:[1-9][+-]?|[+-][1-9]?)?$")
362
+ _EMPTY_SCALARS = frozenset({"", '""', "''", "~", "null"})
363
+
364
+
365
+ def frontmatter_description(lines: list[str], start: int) -> bool:
366
+ """True when the frontmatter (`lines[:start]`) has a `description` key
367
+ with a non-empty value: inline, or on the indented lines under it."""
368
+ for i in range(1, max(start - 1, 1)):
369
+ m = _FRONTMATTER_DESCRIPTION_RE.match(lines[i])
370
+ if not m:
371
+ continue
372
+ value = m.group(1).split(" #", 1)[0].strip()
373
+ if value.lower() not in _EMPTY_SCALARS and not _BLOCK_SCALAR_RE.match(value):
374
+ return True
375
+ for nxt in lines[i + 1:start - 1]:
376
+ if not nxt.strip():
377
+ continue
378
+ if nxt[:1] not in (" ", "\t"):
379
+ break
380
+ return True
381
+ return False
382
+ return False
142
383
 
143
384
 
144
- def find_required_sections(text: str) -> dict[str, dict]:
385
+ def find_required_sections(text: str) -> dict[str, object]:
145
386
  """For each family, find the first matching heading.
146
387
 
147
- Walks every line once, lower-cases the heading text, and looks it up
388
+ Reads the body's headings once (the Line model: outside the frontmatter
389
+ and fenced code), lower-cases each heading text, and looks it up
148
390
  against pre-lowered synonym sets. Returns the structure described in
149
391
  the module docstring.
150
392
  """
@@ -155,13 +397,13 @@ def find_required_sections(text: str) -> dict[str, dict]:
155
397
  for syn in synonyms:
156
398
  lookup[syn.lower()] = (family, syn)
157
399
 
400
+ lines = _split_lines(text)
401
+ start = _frontmatter_end(lines)
402
+ code = _code_lines(lines, start)
403
+ headings = _body_headings(lines, start, code)
158
404
  matched: dict[str, str] = {}
159
- for line in text.splitlines():
160
- m = _HEADING_RE.match(line)
161
- if not m:
162
- continue
163
- heading_text = m.group(2).strip().lower()
164
- hit = lookup.get(heading_text)
405
+ for _, _, heading in headings:
406
+ hit = lookup.get(heading.lower())
165
407
  if hit is None:
166
408
  continue
167
409
  family, canonical = hit
@@ -170,7 +412,7 @@ def find_required_sections(text: str) -> dict[str, dict]:
170
412
  continue
171
413
  matched[family] = canonical
172
414
 
173
- result: dict[str, dict] = {}
415
+ result: dict[str, object] = {}
174
416
  for family, synonyms in REQUIRED_SYNONYMS.items():
175
417
  if family in matched:
176
418
  result[family] = {
@@ -184,6 +426,11 @@ def find_required_sections(text: str) -> dict[str, dict]:
184
426
  "matched_synonym": None,
185
427
  "tried": list(synonyms),
186
428
  }
429
+ result["frontmatter_description"] = frontmatter_description(lines, start)
430
+ result["outline"] = [
431
+ {"line": idx + 1, "level": level, "text": heading}
432
+ for idx, level, heading in headings
433
+ ]
187
434
  return result
188
435
 
189
436
 
@@ -316,6 +563,636 @@ def find_table_drift(text: str) -> list[dict]:
316
563
  return findings
317
564
 
318
565
 
566
+ # --------------------------------------------------------------------------
567
+ # Line model shared by usage-scope, cross-reference, reference-check and
568
+ # scan --required-sections
569
+ # --------------------------------------------------------------------------
570
+
571
+
572
+ _FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})(.*)$")
573
+ # A CommonMark ATX heading. `_HEADING_RE` stays as it is for `scan` and
574
+ # skf-shard-body.py, which import it.
575
+ _BODY_HEADING_RE = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$")
576
+ _CLOSING_HASHES_RE = re.compile(r"(?:^|[ \t]+)#+$")
577
+
578
+
579
+ def _split_lines(text: str) -> list[str]:
580
+ """Split on newlines only, so line numbers match `grep -n`.
581
+
582
+ `str.splitlines()` also breaks on form feeds and U+2028, which would
583
+ shift every later line number away from what an editor or grep shows.
584
+ """
585
+ lines = text.replace("\r\n", "\n").split("\n")
586
+ if lines and lines[-1] == "":
587
+ lines.pop()
588
+ return lines
589
+
590
+
591
+ def _frontmatter_end(lines: list[str]) -> int:
592
+ """Index of the first body line, past a leading `---` ... `---` block."""
593
+ if lines and lines[0].strip() == "---":
594
+ for i in range(1, len(lines)):
595
+ if lines[i].strip() == "---":
596
+ return i + 1
597
+ return 0
598
+
599
+
600
+ def _code_lines(lines: list[str], start: int = 0) -> list[bool]:
601
+ """Flag every fence line and every line between a fence and its close.
602
+
603
+ A backtick line whose info string holds a backtick is inline code, not
604
+ a fence (CommonMark), and an unclosed fence runs to the end of the file.
605
+ """
606
+ code = [False] * len(lines)
607
+ fence: tuple[str, int] | None = None
608
+ for i in range(start, len(lines)):
609
+ m = _FENCE_RE.match(lines[i])
610
+ if fence is None:
611
+ if m and not (m.group(1)[0] == "`" and "`" in m.group(2)):
612
+ fence = (m.group(1)[0], len(m.group(1)))
613
+ code[i] = True
614
+ continue
615
+ code[i] = True
616
+ if (
617
+ m
618
+ and m.group(1)[0] == fence[0]
619
+ and len(m.group(1)) >= fence[1]
620
+ and not m.group(2).strip()
621
+ ):
622
+ fence = None
623
+ return code
624
+
625
+
626
+ def _body_headings(
627
+ lines: list[str], start: int, code: list[bool]
628
+ ) -> list[tuple[int, int, str]]:
629
+ """(index, level, text) of each heading outside frontmatter and code.
630
+
631
+ A `#` line indented four or more spaces is indented code, and a
632
+ closing sequence (`## Usage ##`) is not part of the text.
633
+ """
634
+ headings: list[tuple[int, int, str]] = []
635
+ for i in range(start, len(lines)):
636
+ if code[i]:
637
+ continue
638
+ m = _BODY_HEADING_RE.match(lines[i])
639
+ if m:
640
+ text = _CLOSING_HASHES_RE.sub("", m.group(2) or "").strip()
641
+ headings.append((i, len(m.group(1)), text))
642
+ return headings
643
+
644
+
645
+ def _section_spans(
646
+ headings: list[tuple[int, int, str]], total: int, wanted
647
+ ) -> list[tuple[int, int, str]]:
648
+ """(start, end, heading) of each section whose heading `wanted` accepts.
649
+
650
+ A section runs from its heading line to the line before the next
651
+ heading of the same or a higher level, or to the end of the file;
652
+ `end` is exclusive.
653
+ """
654
+ spans: list[tuple[int, int, str]] = []
655
+ for pos, (idx, level, text) in enumerate(headings):
656
+ if not wanted(text):
657
+ continue
658
+ end = total
659
+ for next_idx, next_level, _ in headings[pos + 1:]:
660
+ if next_level <= level:
661
+ end = next_idx
662
+ break
663
+ spans.append((idx, end, text))
664
+ return spans
665
+
666
+
667
+ def _bounded(term: str, word_chars: str) -> str:
668
+ """Regex for `term` as a fixed string that is never part of a longer word.
669
+
670
+ `word_chars` is a character-class body such as `\\w$`. The guard only
671
+ applies on a side where the term itself ends in such a character, so
672
+ `.then` still matches inside `promise.then`.
673
+ """
674
+ word = re.compile(f"[{word_chars}]")
675
+ body = re.escape(term)
676
+ if word.match(term[0]):
677
+ body = f"(?<![{word_chars}])" + body
678
+ if word.match(term[-1]):
679
+ body += f"(?![{word_chars}])"
680
+ return body
681
+
682
+
683
+ # --------------------------------------------------------------------------
684
+ # Usage scope: per-export usage counts
685
+ # --------------------------------------------------------------------------
686
+
687
+
688
+ # coherence-check.md §2.4 counts a split-body skill over every usage-family
689
+ # heading: the usage synonyms above plus the api-surface headings that carry
690
+ # call examples in the SKF templates. A single-body skill counts over its
691
+ # first usage-synonym section alone, so an export does not count as used
692
+ # because Key Exports lists it. Derived from REQUIRED_SYNONYMS so a usage
693
+ # synonym added there joins both scopes.
694
+ USAGE_FAMILY_HEADINGS: list[str] = [
695
+ *REQUIRED_SYNONYMS["usage"],
696
+ "Key API Summary",
697
+ "Pattern Surface",
698
+ "Key Exports",
699
+ ]
700
+
701
+ # The --body choices after `auto` is resolved.
702
+ _BODIES = ("single", "split")
703
+
704
+ # Characters that continue an identifier in the languages SKF documents
705
+ # (`$` for JS/TS names such as `$state`).
706
+ _IDENT_CHARS = r"\w$"
707
+
708
+
709
+ def usage_scope(
710
+ skill_text: str,
711
+ reference_texts: list[tuple[str, str]],
712
+ exports: list[dict],
713
+ kinds: set[str] | None = None,
714
+ skill_label: str = "SKILL.md",
715
+ body: str | None = None,
716
+ ) -> dict:
717
+ """Count the lines that use each export over the usage scope.
718
+
719
+ `reference_texts` holds (relative path, text) for each reference file,
720
+ in path order. `exports` holds {"name", "kind"} dicts (kind may be
721
+ None); `kinds`, when given, drops the exports whose kind is known and
722
+ not listed. `body` is "single" or "split"; None picks "split" when
723
+ there is a reference text and "single" otherwise, and a single-body
724
+ scope reads no reference text. Returns the structure described in the
725
+ module docstring, without `warnings` (the caller owns file reading).
726
+ """
727
+ if body is None:
728
+ body = "split" if reference_texts else "single"
729
+ if body not in _BODIES:
730
+ raise ValueError(f"body must be one of {_BODIES}, not {body!r}")
731
+ lines = _split_lines(skill_text)
732
+ start = _frontmatter_end(lines)
733
+ code = _code_lines(lines, start)
734
+ headings = _body_headings(lines, start, code)
735
+ if body == "single":
736
+ usage = {h.lower() for h in REQUIRED_SYNONYMS["usage"]}
737
+ spans = _section_spans(
738
+ headings, len(lines), lambda text: text.lower() in usage
739
+ )[:1]
740
+ reference_texts = []
741
+ else:
742
+ family = {h.lower() for h in USAGE_FAMILY_HEADINGS}
743
+ spans = _section_spans(
744
+ headings, len(lines), lambda text: text.lower() in family
745
+ )
746
+ in_scope = sorted({i for s, e, _ in spans for i in range(s, e)})
747
+
748
+ # (file, 1-based line, text) in scope order: SKILL.md first, then the
749
+ # reference files, so `first_hit` is deterministic.
750
+ corpus: list[tuple[str, int, str]] = [
751
+ (skill_label, i + 1, lines[i]) for i in in_scope
752
+ ]
753
+ for rel, text in reference_texts:
754
+ corpus.extend(
755
+ (rel, n, line) for n, line in enumerate(_split_lines(text), start=1)
756
+ )
757
+
758
+ selected = {
759
+ (e["name"], e["kind"])
760
+ for e in exports
761
+ if kinds is None or e["kind"] is None or e["kind"] in kinds
762
+ }
763
+ results: list[dict] = []
764
+ for name, kind in sorted(selected, key=lambda k: (k[0], k[1] or "")):
765
+ pattern = re.compile(_bounded(name, _IDENT_CHARS))
766
+ count = 0
767
+ first_hit = None
768
+ for label, lineno, line in corpus:
769
+ # the substring test is a cheap pre-filter; the regex decides
770
+ if name in line and pattern.search(line):
771
+ count += 1
772
+ if first_hit is None:
773
+ first_hit = {"file": label, "line": lineno}
774
+ results.append({
775
+ "name": name,
776
+ "kind": kind,
777
+ "count": count,
778
+ "first_hit": first_hit,
779
+ })
780
+
781
+ return {
782
+ "scope": {
783
+ "body": body,
784
+ "sections": [
785
+ {"heading": text, "line": s + 1, "end_line": e}
786
+ for s, e, text in spans
787
+ ],
788
+ "reference_files": [rel for rel, _ in reference_texts],
789
+ },
790
+ "exports": results,
791
+ "zero_usage": [
792
+ {"name": r["name"], "kind": r["kind"]}
793
+ for r in results
794
+ if r["count"] == 0
795
+ ],
796
+ }
797
+
798
+
799
+ # --------------------------------------------------------------------------
800
+ # Cross-reference: literal citations between skills
801
+ # --------------------------------------------------------------------------
802
+
803
+
804
+ # Characters that continue a package or skill name (`react-dom` is not
805
+ # `react`).
806
+ _NAME_CHARS = r"\w-"
807
+ _EXCERPT_CONTEXT = 60
808
+
809
+
810
+ def _citation_pattern(terms: list[str]) -> re.Pattern[str]:
811
+ """One case-insensitive regex for a skill's terms, longest term first."""
812
+ ordered = sorted(terms, key=lambda t: (-len(t), t.lower()))
813
+ return re.compile(
814
+ "|".join(_bounded(t, _NAME_CHARS) for t in ordered), re.IGNORECASE
815
+ )
816
+
817
+
818
+ def _excerpt(line: str, start: int, end: int) -> str:
819
+ """The line around [start, end), whitespace collapsed, `...` at a cut.
820
+
821
+ [start, end) runs from a line's first match to its last, so every
822
+ match on the line shows.
823
+ """
824
+ lo = max(0, start - _EXCERPT_CONTEXT)
825
+ hi = min(len(line), end + _EXCERPT_CONTEXT)
826
+ text = " ".join(line[lo:hi].split())
827
+ if lo > 0:
828
+ text = "..." + text
829
+ if hi < len(line):
830
+ text += "..."
831
+ return text
832
+
833
+
834
+ def cross_references(
835
+ skills: list[dict], pairs: list[tuple[str, str]] | None = None
836
+ ) -> dict:
837
+ """Find where each skill's SKILL.md literally cites another skill.
838
+
839
+ `skills` holds {"name", "terms", "text"} dicts, `text` None when the
840
+ SKILL.md could not be read (the skill can still be cited). `pairs`,
841
+ when given, keeps only the citations between those unordered pairs and
842
+ adds `pairs[]`. Each citation lists every citing line in `hits`, since
843
+ the first one can be a common word rather than the citation. Returns
844
+ citations, edges, pairs and pair warnings.
845
+ """
846
+ patterns = {s["name"]: _citation_pattern(s["terms"]) for s in skills}
847
+ names = sorted(patterns)
848
+ wanted: set[tuple[str, str]] | None = None
849
+ if pairs is not None:
850
+ wanted = set()
851
+ for a, b in pairs:
852
+ wanted.update({(a, b), (b, a)})
853
+
854
+ citations: list[dict] = []
855
+ for source in sorted(skills, key=lambda s: s["name"]):
856
+ if source["text"] is None:
857
+ continue
858
+ lines = _split_lines(source["text"])
859
+ for target in names:
860
+ if target == source["name"]:
861
+ continue
862
+ if wanted is not None and (source["name"], target) not in wanted:
863
+ continue
864
+ pattern = patterns[target]
865
+ hits: list[dict] = []
866
+ for lineno, line in enumerate(lines, start=1):
867
+ matches = list(pattern.finditer(line))
868
+ if not matches:
869
+ continue
870
+ hits.append({
871
+ "line": lineno,
872
+ "substring": matches[0].group(0),
873
+ "excerpt": _excerpt(
874
+ line, matches[0].start(), matches[-1].end()
875
+ ),
876
+ })
877
+ if not hits:
878
+ continue
879
+ citations.append({
880
+ "from": source["name"],
881
+ "to": target,
882
+ "substring": hits[0]["substring"],
883
+ "line": hits[0]["line"],
884
+ "count": len(hits),
885
+ "excerpt": hits[0]["excerpt"],
886
+ "hits": hits,
887
+ })
888
+
889
+ result: dict = {
890
+ "citations": citations,
891
+ "edges": [[c["from"], c["to"]] for c in citations],
892
+ }
893
+ warnings: list[str] = []
894
+ if pairs is not None:
895
+ cited = {(c["from"], c["to"]) for c in citations}
896
+ rows: list[dict] = []
897
+ for a, b in sorted(pairs):
898
+ for name in (a, b):
899
+ if name not in patterns:
900
+ warnings.append(
901
+ f"{name}: named in --pairs but not in --skills"
902
+ )
903
+ rows.append({
904
+ "library_a": a,
905
+ "library_b": b,
906
+ "a_cites_b": (a, b) in cited,
907
+ "b_cites_a": (b, a) in cited,
908
+ "cited": (a, b) in cited or (b, a) in cited,
909
+ })
910
+ result["pairs"] = rows
911
+ # dict.fromkeys keeps the first of repeated warnings, in order
912
+ result["warnings"] = list(dict.fromkeys(warnings))
913
+ return result
914
+
915
+
916
+ # --------------------------------------------------------------------------
917
+ # Reference check: file, script and asset references
918
+ # --------------------------------------------------------------------------
919
+
920
+
921
+ # A link title is "...", '...' or (...); a reference definition must end
922
+ # after its title, so a prose line such as `[Note]: see below` is not one.
923
+ _LINK_TITLE = r"(?:\s+(?:\"[^\"\n]*\"|'[^'\n]*'|\([^()\n]*\)))?"
924
+ # A link destination is `<...>`, or a run with no whitespace whose
925
+ # parentheses are backslash-escaped or balanced one level deep
926
+ # (`references/a_(1).md`). A backslash always takes the next character.
927
+ _DEST_CHAR = r"(?:\\\S|[^\s()\\])"
928
+ _LINK_RE = re.compile(
929
+ rf"!?\[[^\]\n]*\]\(\s*(<[^>\n]*>|(?:{_DEST_CHAR}|\({_DEST_CHAR}*\))+)"
930
+ + _LINK_TITLE
931
+ + r"\s*\)"
932
+ )
933
+ _REF_DEFINITION_RE = re.compile(
934
+ r"^\s{0,3}\[([^\]\n]+)\]:\s*(<[^>\n]*>|\S+)" + _LINK_TITLE + r"\s*$"
935
+ )
936
+ _MENTION_RE = re.compile(
937
+ r"(?<![\w.:/@-])(?:\./)?(references|scripts|assets)/[^\s`'\"()|,;]+"
938
+ )
939
+ # Backslash-escaped ASCII punctuation, or an entity reference: CommonMark
940
+ # resolves both inside a link destination.
941
+ _ESCAPE_RE = re.compile(
942
+ r"\\([!-/:-@\[-`{-~])"
943
+ r"|&(?:#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[A-Za-z][A-Za-z0-9]*);"
944
+ )
945
+ _URL_SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]+:")
946
+ _LINE_SUFFIX_RE = re.compile(r":L?\d+(?:-L?\d+)?$")
947
+ _PLACEHOLDER_CHARS = frozenset("{}<>*?$")
948
+ _MENTION_TRAILING = ".,:;!?*_~>]}"
949
+ _PACKAGE_FOLDERS = ("scripts", "assets")
950
+
951
+
952
+ def _inline_code_spans(line: str) -> list[tuple[int, int]]:
953
+ """[start, end) of each inline code span: a backtick run closed by an
954
+ equal run. An unmatched run is literal text."""
955
+ spans: list[tuple[int, int]] = []
956
+ i, n = 0, len(line)
957
+ while i < n:
958
+ if line[i] != "`":
959
+ i += 1
960
+ continue
961
+ j = i
962
+ while j < n and line[j] == "`":
963
+ j += 1
964
+ run = j - i
965
+ k = j
966
+ close = -1
967
+ while k < n:
968
+ if line[k] != "`":
969
+ k += 1
970
+ continue
971
+ m = k
972
+ while m < n and line[m] == "`":
973
+ m += 1
974
+ if m - k == run:
975
+ close = k
976
+ break
977
+ k = m
978
+ if close == -1:
979
+ i = j
980
+ continue
981
+ spans.append((i, close + run))
982
+ i = close + run
983
+ return spans
984
+
985
+
986
+ def _mask(line: str, spans: list[tuple[int, int]]) -> str:
987
+ """Blank out each span, keeping every offset in place."""
988
+ chars = list(line)
989
+ for start, end in spans:
990
+ chars[start:end] = " " * (end - start)
991
+ return "".join(chars)
992
+
993
+
994
+ def _unescape(text: str) -> str:
995
+ """Resolve backslash escapes and entity references in one pass, so an
996
+ escaped `\\&amp;` stays `&amp;`."""
997
+ return _ESCAPE_RE.sub(
998
+ lambda m: m.group(1) or html.unescape(m.group(0)), text
999
+ )
1000
+
1001
+
1002
+ def _clean_link(dest: str) -> str | None:
1003
+ """The local path a link destination names, or None when it names none."""
1004
+ if dest.startswith("<") and dest.endswith(">"):
1005
+ dest = dest[1:-1].strip()
1006
+ dest = _unescape(dest)
1007
+ if (
1008
+ not dest
1009
+ or dest.startswith(("#", "//"))
1010
+ or _URL_SCHEME_RE.match(dest)
1011
+ ):
1012
+ return None
1013
+ dest = unquote(dest.split("#", 1)[0].split("?", 1)[0])
1014
+ if not dest or any(c in _PLACEHOLDER_CHARS for c in dest):
1015
+ return None
1016
+ return dest
1017
+
1018
+
1019
+ def _clean_mention(token: str) -> str | None:
1020
+ """The path a bare mention names, or None for a folder or placeholder."""
1021
+ token = _LINE_SUFFIX_RE.sub("", token.rstrip(_MENTION_TRAILING))
1022
+ token = token.split("#", 1)[0].rstrip(_MENTION_TRAILING)
1023
+ if token.endswith("/") or any(c in _PLACEHOLDER_CHARS for c in token):
1024
+ return None
1025
+ return token
1026
+
1027
+
1028
+ def _reference_type(target: str) -> str:
1029
+ rel = target
1030
+ while rel.startswith("./"):
1031
+ rel = rel[2:]
1032
+ if rel.startswith(tuple(f"{folder}/" for folder in _PACKAGE_FOLDERS)):
1033
+ return "script-asset"
1034
+ return "file-path"
1035
+
1036
+
1037
+ def extract_references(
1038
+ text: str, package_folders: frozenset[str] = frozenset()
1039
+ ) -> list[dict]:
1040
+ """List the file, script and asset references in a SKILL.md body.
1041
+
1042
+ `package_folders` names the folders among `scripts` and `assets` that
1043
+ the skill package has; a bare mention of another one counts only
1044
+ inside the Scripts & Assets section. Returns [{"line", "form",
1045
+ "target"}] in document order (before any existence check).
1046
+ """
1047
+ lines = _split_lines(text)
1048
+ start = _frontmatter_end(lines)
1049
+ code = _code_lines(lines, start)
1050
+ scripts_section = {
1051
+ i
1052
+ for s, e, _ in _section_spans(
1053
+ _body_headings(lines, start, code),
1054
+ len(lines),
1055
+ lambda heading: heading.lower().startswith("scripts"),
1056
+ )
1057
+ for i in range(s, e)
1058
+ }
1059
+
1060
+ refs: list[dict] = []
1061
+ for idx in range(start, len(lines)):
1062
+ if code[idx]:
1063
+ continue
1064
+ line = lines[idx]
1065
+ # links never sit inside inline code; mentions may
1066
+ plain = _mask(line, _inline_code_spans(line))
1067
+ found: list[tuple[int, str, str | None]] = []
1068
+ link_spans: list[tuple[int, int]] = []
1069
+
1070
+ definition = _REF_DEFINITION_RE.match(plain)
1071
+ if definition and not definition.group(1).startswith("^"):
1072
+ target = _clean_link(definition.group(2))
1073
+ found.append((definition.start(2), "link", target))
1074
+ link_spans.append((0, definition.end()))
1075
+ for m in _LINK_RE.finditer(plain):
1076
+ found.append((m.start(1), "link", _clean_link(m.group(1))))
1077
+ link_spans.append(m.span())
1078
+
1079
+ for m in _MENTION_RE.finditer(_mask(line, link_spans)):
1080
+ folder = m.group(1)
1081
+ if (
1082
+ folder in _PACKAGE_FOLDERS
1083
+ and folder not in package_folders
1084
+ and idx not in scripts_section
1085
+ ):
1086
+ continue
1087
+ found.append((m.start(), "mention", _clean_mention(m.group(0))))
1088
+
1089
+ for _, form, target in sorted(found, key=lambda f: f[0]):
1090
+ if target is not None:
1091
+ refs.append({"line": idx + 1, "form": form, "target": target})
1092
+ return refs
1093
+
1094
+
1095
+ def scripts_assets_section(text: str, skill_dir: Path) -> dict:
1096
+ """The package's scripts/ and assets/ folders and its Scripts & Assets heading."""
1097
+ lines = _split_lines(text)
1098
+ start = _frontmatter_end(lines)
1099
+ heading = next(
1100
+ (
1101
+ {"heading": t, "line": idx + 1}
1102
+ for idx, _, t in _body_headings(lines, start, _code_lines(lines, start))
1103
+ if t.lower().startswith("scripts")
1104
+ ),
1105
+ None,
1106
+ )
1107
+ folders = sorted(f for f in _PACKAGE_FOLDERS if (skill_dir / f).is_dir())
1108
+ return {
1109
+ "folders": folders,
1110
+ "section": heading,
1111
+ "missing": bool(folders) and heading is None,
1112
+ }
1113
+
1114
+
1115
+ def _realpath(path: str) -> str:
1116
+ try:
1117
+ return os.path.realpath(path)
1118
+ except (OSError, ValueError):
1119
+ # a name the OS rejects (an embedded NUL, say) resolves nowhere
1120
+ return os.path.abspath(path)
1121
+
1122
+
1123
+ def _within(path: str, root: str) -> bool:
1124
+ path, root = os.path.normcase(path), os.path.normcase(root)
1125
+ return path == root or path.startswith(root.rstrip(os.sep) + os.sep)
1126
+
1127
+
1128
+ def check_references(
1129
+ refs: list[dict],
1130
+ skill_dir: str,
1131
+ source_root: str | None = None,
1132
+ skills_root: str | None = None,
1133
+ ) -> dict:
1134
+ """Resolve each reference and check it exists inside an allowed root.
1135
+
1136
+ A source or skills root that is not a directory becomes a warning.
1137
+ """
1138
+ warnings = [
1139
+ f"--{flag} is not a directory: {path}"
1140
+ for flag, path in (
1141
+ ("source-root", source_root),
1142
+ ("skills-root", skills_root),
1143
+ )
1144
+ if path and not os.path.isdir(path)
1145
+ ]
1146
+ roots = [("skill", _realpath(skill_dir))]
1147
+ if source_root:
1148
+ roots.append(("source", _realpath(source_root)))
1149
+ if skills_root:
1150
+ roots.append(("skills", _realpath(skills_root)))
1151
+ base = roots[0][1]
1152
+
1153
+ references: list[dict] = []
1154
+ counts = {"total": 0, "ok": 0, "missing": 0, "escapes": 0}
1155
+ seen: set[tuple[int, str]] = set()
1156
+ for ref in refs:
1157
+ canonical = _realpath(os.path.join(base, ref["target"]))
1158
+ key = (ref["line"], os.path.normcase(canonical))
1159
+ if key in seen:
1160
+ continue
1161
+ seen.add(key)
1162
+ root = next(
1163
+ (label for label, path in roots if _within(canonical, path)), None
1164
+ )
1165
+ if root is None:
1166
+ status = "escapes"
1167
+ elif os.path.exists(canonical):
1168
+ status = "ok"
1169
+ else:
1170
+ status = "missing"
1171
+ counts["total"] += 1
1172
+ counts[status] += 1
1173
+ references.append({
1174
+ "line": ref["line"],
1175
+ "type": _reference_type(ref["target"]),
1176
+ "form": ref["form"],
1177
+ "target": ref["target"],
1178
+ "canonical": canonical,
1179
+ "status": status,
1180
+ "root": root,
1181
+ })
1182
+
1183
+ labels = dict(roots)
1184
+ return {
1185
+ "roots": {
1186
+ "skill": labels["skill"],
1187
+ "source": labels.get("source"),
1188
+ "skills": labels.get("skills"),
1189
+ },
1190
+ "references": references,
1191
+ "counts": counts,
1192
+ "warnings": warnings,
1193
+ }
1194
+
1195
+
319
1196
  # --------------------------------------------------------------------------
320
1197
  # CLI
321
1198
  # --------------------------------------------------------------------------
@@ -325,14 +1202,22 @@ def _read_text(path: Path) -> str:
325
1202
  return path.read_text(encoding="utf-8")
326
1203
 
327
1204
 
1205
+ def _read_markdown(path: Path, errors: str = "strict") -> str:
1206
+ """The file as UTF-8 for the line model: a leading byte order mark is
1207
+ dropped, and every `\\r` is kept, since `read_text` would turn a lone
1208
+ `\\r` into a line break that `grep -n` does not count."""
1209
+ return path.read_bytes().decode("utf-8-sig", errors=errors)
1210
+
1211
+
328
1212
  def _cmd_scan(args: argparse.Namespace) -> int:
329
1213
  skill_md = Path(args.skill_md)
330
1214
  if not skill_md.is_file():
331
1215
  print(f"error: file not found: {skill_md}", file=sys.stderr)
332
1216
  return 1
333
1217
  try:
334
- text = _read_text(skill_md)
335
- except OSError as exc:
1218
+ # the Line model's read, so an `outline` line matches `grep -n`
1219
+ text = _read_markdown(skill_md)
1220
+ except (OSError, UnicodeDecodeError) as exc:
336
1221
  print(f"error: cannot read {skill_md}: {exc}", file=sys.stderr)
337
1222
  return 1
338
1223
 
@@ -345,6 +1230,7 @@ def _cmd_scan(args: argparse.Namespace) -> int:
345
1230
  "fence_count": fence_count,
346
1231
  "bare_opening_fences": bare,
347
1232
  "table_drift": find_table_drift(text),
1233
+ "scripts_assets": scripts_assets_section(text, skill_md.parent),
348
1234
  }
349
1235
 
350
1236
  json.dump(payload, sys.stdout, indent=2)
@@ -352,13 +1238,332 @@ def _cmd_scan(args: argparse.Namespace) -> int:
352
1238
  return 0
353
1239
 
354
1240
 
1241
+ class UserError(Exception):
1242
+ """A caller-facing input error: exit code 1."""
1243
+
1244
+
1245
+ def _load_skill_md(raw: str) -> tuple[Path, str]:
1246
+ skill_md = Path(raw)
1247
+ if not skill_md.is_file():
1248
+ raise UserError(f"file not found: {skill_md}")
1249
+ try:
1250
+ return skill_md, _read_markdown(skill_md)
1251
+ except (OSError, UnicodeDecodeError) as exc:
1252
+ raise UserError(f"cannot read {skill_md}: {exc}") from exc
1253
+
1254
+
1255
+ def _read_json(source: str, label: str):
1256
+ """Parse JSON from a file path, or from stdin when `source` is '-'."""
1257
+ if source == "-":
1258
+ try:
1259
+ text = sys.stdin.read()
1260
+ except OSError as exc:
1261
+ raise UserError(
1262
+ f"failed to read {label} from stdin: {exc}"
1263
+ ) from exc
1264
+ else:
1265
+ path = Path(source)
1266
+ if not path.is_file():
1267
+ raise UserError(f"{label} file not found: {path}")
1268
+ try:
1269
+ text = _read_text(path)
1270
+ except (OSError, UnicodeDecodeError) as exc:
1271
+ raise UserError(
1272
+ f"failed to read {label} file {path}: {exc}"
1273
+ ) from exc
1274
+ try:
1275
+ return json.loads(text)
1276
+ except json.JSONDecodeError as exc:
1277
+ raise UserError(f"malformed JSON in {label} input: {exc}") from exc
1278
+
1279
+
1280
+ def parse_exports(payload) -> list[dict]:
1281
+ """Normalize the --exports JSON into [{"name", "kind"}] dicts.
1282
+
1283
+ Accepts a list of names or {"name", "kind"} objects, an object with an
1284
+ `exports` list, or the validate-inventory.py result (`inventory`).
1285
+ """
1286
+ items = payload
1287
+ if isinstance(payload, dict):
1288
+ if "exports" in payload:
1289
+ items = payload["exports"]
1290
+ elif isinstance(payload.get("inventory"), dict):
1291
+ items = payload["inventory"].get("exports")
1292
+ if not isinstance(items, list):
1293
+ raise UserError(
1294
+ "--exports must be a JSON array, an object with an `exports` "
1295
+ "array, or a validate-inventory.py result"
1296
+ )
1297
+ exports: list[dict] = []
1298
+ for idx, item in enumerate(items):
1299
+ if isinstance(item, str):
1300
+ name, kind = item, None
1301
+ elif isinstance(item, dict):
1302
+ name, kind = item.get("name"), item.get("kind")
1303
+ else:
1304
+ raise UserError(
1305
+ f"--exports[{idx}] must be a name or an object; got {item!r}"
1306
+ )
1307
+ # surrounding whitespace is never part of a name, and a stray space
1308
+ # would make every line miss
1309
+ if not isinstance(name, str) or not name.strip():
1310
+ raise UserError(f"--exports[{idx}] needs a non-empty `name`")
1311
+ if kind is not None and not isinstance(kind, str):
1312
+ raise UserError(f"--exports[{idx}] `kind` must be a string")
1313
+ kind = (kind or "").strip() or None
1314
+ exports.append({"name": name.strip(), "kind": kind})
1315
+ return exports
1316
+
1317
+
1318
+ def parse_kinds(raw: str) -> set[str]:
1319
+ kinds = {k.strip() for k in raw.split(",") if k.strip()}
1320
+ if not kinds:
1321
+ raise UserError("--kinds needs at least one kind, e.g. function,method")
1322
+ return kinds
1323
+
1324
+
1325
+ # The names an skf-enumerate-stack-skills.py inventory entry gives the
1326
+ # repository and the folder its skill was built from.
1327
+ SOURCE_ALIAS_KEYS = ("source_repo_basename", "source_root_basename")
1328
+
1329
+
1330
+ def _source_aliases(item: dict) -> list[str]:
1331
+ """The entry's SOURCE_ALIAS_KEYS values that are non-empty strings."""
1332
+ found: list[str] = []
1333
+ for key in SOURCE_ALIAS_KEYS:
1334
+ value = item.get(key)
1335
+ if isinstance(value, str) and value.strip():
1336
+ found.append(value.strip())
1337
+ return found
1338
+
1339
+
1340
+ def parse_skill_entries(payload) -> list[dict]:
1341
+ """Normalize the --skills JSON into [{"name", "file", "terms"}] dicts.
1342
+
1343
+ `file` is the SKILL.md path as given (`skill_md`, else `path` joined
1344
+ with SKILL.md); `terms` is the name plus its aliases, case-insensitive
1345
+ duplicates dropped. An entry's source basenames (SOURCE_ALIAS_KEYS)
1346
+ are aliases too, unless another entry has the same term as its name,
1347
+ an alias or a source basename: one word would then cite two skills.
1348
+ """
1349
+ items = payload.get("skills") if isinstance(payload, dict) else payload
1350
+ if not isinstance(items, list):
1351
+ raise UserError(
1352
+ "--skills must be a JSON array of skill entries or an object "
1353
+ "with a `skills` array"
1354
+ )
1355
+ parsed: list[tuple[str, str, list[str], list[str]]] = []
1356
+ seen: set[str] = set()
1357
+ for idx, item in enumerate(items):
1358
+ if not isinstance(item, dict):
1359
+ raise UserError(f"--skills[{idx}] must be an object; got {item!r}")
1360
+ name = item.get("name")
1361
+ if not isinstance(name, str) or not name.strip():
1362
+ raise UserError(f"--skills[{idx}] needs a non-empty `name`")
1363
+ name = name.strip()
1364
+ if name in seen:
1365
+ raise UserError(f"--skills names `{name}` twice")
1366
+ seen.add(name)
1367
+ skill_md, folder = item.get("skill_md"), item.get("path")
1368
+ if isinstance(skill_md, str) and skill_md:
1369
+ file = skill_md
1370
+ elif isinstance(folder, str) and folder:
1371
+ file = os.path.join(folder, "SKILL.md")
1372
+ else:
1373
+ raise UserError(
1374
+ f"--skills[{idx}] ({name}) needs `skill_md` or `path`"
1375
+ )
1376
+ aliases = item.get("aliases") or []
1377
+ if not isinstance(aliases, list) or not all(
1378
+ isinstance(a, str) for a in aliases
1379
+ ):
1380
+ raise UserError(
1381
+ f"--skills[{idx}] ({name}) `aliases` must be strings"
1382
+ )
1383
+ parsed.append((name, file, aliases, _source_aliases(item)))
1384
+
1385
+ # How many entries hold each term, case-insensitively.
1386
+ owners: dict[str, int] = {}
1387
+ for name, _file, aliases, source in parsed:
1388
+ held = {t.strip().lower() for t in (name, *aliases, *source) if t.strip()}
1389
+ for term in held:
1390
+ owners[term] = owners.get(term, 0) + 1
1391
+
1392
+ entries: list[dict] = []
1393
+ for name, file, aliases, source in parsed:
1394
+ unique = [term for term in source if owners[term.lower()] == 1]
1395
+ terms: dict[str, str] = {}
1396
+ for term in (name, *aliases, *unique):
1397
+ term = term.strip()
1398
+ if term:
1399
+ terms.setdefault(term.lower(), term)
1400
+ entries.append(
1401
+ {"name": name, "file": file, "terms": list(terms.values())}
1402
+ )
1403
+ return entries
1404
+
1405
+
1406
+ def parse_pairs(payload) -> list[tuple[str, str]]:
1407
+ """Normalize the --pairs JSON into unique (a, b) tuples."""
1408
+ items = payload.get("pairs") if isinstance(payload, dict) else payload
1409
+ if not isinstance(items, list):
1410
+ raise UserError(
1411
+ "--pairs must be a JSON array of pairs or an object with a "
1412
+ "`pairs` array"
1413
+ )
1414
+ pairs: list[tuple[str, str]] = []
1415
+ seen: set[frozenset[str]] = set()
1416
+ for idx, item in enumerate(items):
1417
+ if isinstance(item, list) and len(item) == 2:
1418
+ a, b = item
1419
+ elif isinstance(item, dict):
1420
+ a, b = item.get("library_a"), item.get("library_b")
1421
+ else:
1422
+ raise UserError(
1423
+ f"--pairs[{idx}] must be [a, b] or "
1424
+ f"{{\"library_a\", \"library_b\"}}; got {item!r}"
1425
+ )
1426
+ if not (isinstance(a, str) and a and isinstance(b, str) and b):
1427
+ raise UserError(f"--pairs[{idx}] needs two non-empty skill names")
1428
+ if a == b:
1429
+ raise UserError(f"--pairs[{idx}] pairs `{a}` with itself")
1430
+ key = frozenset((a, b))
1431
+ if key not in seen:
1432
+ seen.add(key)
1433
+ pairs.append((a, b))
1434
+ return pairs
1435
+
1436
+
1437
+ def _reference_paths(skill_dir: Path) -> list[Path]:
1438
+ """Every `references/**/*.md` under the skill folder, in path order."""
1439
+ ref_dir = skill_dir / "references"
1440
+ if not ref_dir.is_dir():
1441
+ return []
1442
+ paths = [
1443
+ Path(root) / name
1444
+ for root, _dirs, files in os.walk(ref_dir)
1445
+ for name in files
1446
+ if name.lower().endswith(".md")
1447
+ ]
1448
+ return sorted(paths, key=lambda p: p.relative_to(skill_dir).as_posix())
1449
+
1450
+
1451
+ def _read_reference_files(
1452
+ skill_dir: Path, paths: list[Path]
1453
+ ) -> tuple[list[tuple[str, str]], list[str]]:
1454
+ """(relative path, text) of each reference file, in the order given.
1455
+
1456
+ A file that cannot be read becomes a warning; undecodable bytes are
1457
+ replaced, so one bad byte never hides a whole file from the count.
1458
+ """
1459
+ texts: list[tuple[str, str]] = []
1460
+ warnings: list[str] = []
1461
+ for path in paths:
1462
+ rel = path.relative_to(skill_dir).as_posix()
1463
+ try:
1464
+ texts.append((rel, _read_markdown(path, errors="replace")))
1465
+ except OSError as exc:
1466
+ warnings.append(f"{rel}: cannot read: {exc}")
1467
+ return texts, warnings
1468
+
1469
+
1470
+ def _emit(payload: dict) -> int:
1471
+ json.dump(payload, sys.stdout, indent=2)
1472
+ sys.stdout.write("\n")
1473
+ return 0
1474
+
1475
+
1476
+ def _cmd_usage_scope(args: argparse.Namespace) -> int:
1477
+ skill_md, text = _load_skill_md(args.skill_md)
1478
+ exports = parse_exports(_read_json(args.exports, "--exports"))
1479
+ kinds = parse_kinds(args.kinds) if args.kinds is not None else None
1480
+ paths = _reference_paths(skill_md.parent)
1481
+ body = args.body
1482
+ if body == "auto":
1483
+ # a reference file that cannot be read still makes the body split
1484
+ body = "split" if paths else "single"
1485
+ references: list[tuple[str, str]] = []
1486
+ warnings: list[str] = []
1487
+ if body == "split":
1488
+ references, warnings = _read_reference_files(skill_md.parent, paths)
1489
+ payload = usage_scope(
1490
+ text,
1491
+ references,
1492
+ exports,
1493
+ kinds=kinds,
1494
+ skill_label=skill_md.name,
1495
+ body=body,
1496
+ )
1497
+ payload["warnings"] = warnings
1498
+ return _emit(payload)
1499
+
1500
+
1501
+ def _cmd_cross_reference(args: argparse.Namespace) -> int:
1502
+ if args.skills == "-" and args.pairs == "-":
1503
+ raise UserError(
1504
+ "--skills and --pairs cannot both read from stdin ('-')"
1505
+ )
1506
+ # a wrong root would turn every skill into a not-found warning and
1507
+ # leave nothing to cite, which reads like a clean result
1508
+ if args.skills_root is not None and not Path(args.skills_root).is_dir():
1509
+ raise UserError(
1510
+ f"--skills-root is not a directory: {args.skills_root}"
1511
+ )
1512
+ entries = parse_skill_entries(_read_json(args.skills, "--skills"))
1513
+ pairs = None
1514
+ if args.pairs is not None:
1515
+ pairs = parse_pairs(_read_json(args.pairs, "--pairs"))
1516
+
1517
+ skills: list[dict] = []
1518
+ warnings: list[str] = []
1519
+ for entry in entries:
1520
+ skill_md = Path(entry["file"])
1521
+ if args.skills_root and not skill_md.is_absolute():
1522
+ skill_md = Path(args.skills_root) / skill_md
1523
+ text = None
1524
+ if not skill_md.is_file():
1525
+ warnings.append(f"{entry['name']}: SKILL.md not found: {skill_md}")
1526
+ else:
1527
+ try:
1528
+ text = _read_markdown(skill_md)
1529
+ except (OSError, UnicodeDecodeError) as exc:
1530
+ warnings.append(
1531
+ f"{entry['name']}: cannot read {skill_md}: {exc}"
1532
+ )
1533
+ skills.append(
1534
+ {"name": entry["name"], "terms": entry["terms"], "text": text}
1535
+ )
1536
+
1537
+ payload = cross_references(skills, pairs)
1538
+ payload["warnings"] = warnings + payload["warnings"]
1539
+ return _emit(payload)
1540
+
1541
+
1542
+ def _cmd_reference_check(args: argparse.Namespace) -> int:
1543
+ skill_md, text = _load_skill_md(args.skill_md)
1544
+ skill_dir = skill_md.parent
1545
+ package_folders = frozenset(
1546
+ folder for folder in _PACKAGE_FOLDERS if (skill_dir / folder).is_dir()
1547
+ )
1548
+ refs = extract_references(text, package_folders)
1549
+ payload = check_references(
1550
+ refs, str(skill_dir), args.source_root, args.skills_root
1551
+ )
1552
+ warnings = payload.pop("warnings")
1553
+ payload["scripts_assets"] = scripts_assets_section(text, skill_dir)
1554
+ payload["warnings"] = warnings
1555
+ return _emit(payload)
1556
+
1557
+
355
1558
  def _build_parser() -> argparse.ArgumentParser:
356
1559
  parser = argparse.ArgumentParser(
357
1560
  prog="skf-scan-skill-md-structure",
358
1561
  description=(
359
1562
  "Deterministic structural scans for SKILL.md: fence balance, "
360
- "bare opening fences, table column drift, and required-section "
361
- "presence (case-insensitive synonym match)."
1563
+ "bare opening fences, table column drift, required-section "
1564
+ "presence (case-insensitive synonym match), export usage counts "
1565
+ "over the usage sections, literal citations between skills, "
1566
+ "and file, script and asset reference checks."
362
1567
  ),
363
1568
  )
364
1569
  sub = parser.add_subparsers(dest="cmd", required=True)
@@ -372,13 +1577,112 @@ def _build_parser() -> argparse.ArgumentParser:
372
1577
  )
373
1578
  p_scan.set_defaults(func=_cmd_scan)
374
1579
 
1580
+ p_usage = sub.add_parser(
1581
+ "usage-scope",
1582
+ help=(
1583
+ "count the lines that use each export in the usage sections "
1584
+ "(and references/**/*.md for a split-body skill)"
1585
+ ),
1586
+ )
1587
+ p_usage.add_argument("skill_md", help="path to a SKILL.md file")
1588
+ p_usage.add_argument(
1589
+ "--exports",
1590
+ required=True,
1591
+ help=(
1592
+ "path to the exports JSON, or '-' for stdin. Shape: "
1593
+ '["<name>", ...], [{"name": "...", "kind": "..."}, ...], '
1594
+ '{"exports": [...]} or {"inventory": {"exports": [...]}}'
1595
+ ),
1596
+ )
1597
+ p_usage.add_argument(
1598
+ "--kinds",
1599
+ help=(
1600
+ "comma-separated kinds to count, e.g. function,method; an "
1601
+ "export given without a kind is always counted"
1602
+ ),
1603
+ )
1604
+ p_usage.add_argument(
1605
+ "--body",
1606
+ choices=("auto", *_BODIES),
1607
+ default="auto",
1608
+ help=(
1609
+ "single: the first usage-synonym section; split: every "
1610
+ "usage-family section plus references/**/*.md; auto (the "
1611
+ "default): split when the package has a references/**/*.md "
1612
+ "file, else single"
1613
+ ),
1614
+ )
1615
+ p_usage.set_defaults(func=_cmd_usage_scope)
1616
+
1617
+ p_xref = sub.add_parser(
1618
+ "cross-reference",
1619
+ help=(
1620
+ "list the literal citations of each skill's name or aliases "
1621
+ "in the other skills' SKILL.md"
1622
+ ),
1623
+ )
1624
+ p_xref.add_argument(
1625
+ "--skills",
1626
+ required=True,
1627
+ help=(
1628
+ "path to the skills JSON, or '-' for stdin. Shape: "
1629
+ '[{"name": "...", "skill_md" | "path": "...", "aliases": [...]}] '
1630
+ 'or {"skills": [...]} (the skf-enumerate-stack-skills.py output, '
1631
+ "whose source basenames a single skill holds count as aliases)"
1632
+ ),
1633
+ )
1634
+ p_xref.add_argument(
1635
+ "--skills-root",
1636
+ help=(
1637
+ "folder that relative `path` and `skill_md` entries resolve "
1638
+ "against"
1639
+ ),
1640
+ )
1641
+ p_xref.add_argument(
1642
+ "--pairs",
1643
+ help=(
1644
+ "path to a pairs JSON, or '-' for stdin, that limits the "
1645
+ 'citations to those pairs. Shape: [["<a>", "<b>"], ...], '
1646
+ '[{"library_a": "...", "library_b": "..."}] or {"pairs": [...]}'
1647
+ ),
1648
+ )
1649
+ p_xref.set_defaults(func=_cmd_cross_reference)
1650
+
1651
+ p_refs = sub.add_parser(
1652
+ "reference-check",
1653
+ help=(
1654
+ "check that each file, script and asset reference exists and "
1655
+ "resolves inside the allowed roots"
1656
+ ),
1657
+ )
1658
+ p_refs.add_argument("skill_md", help="path to a SKILL.md file")
1659
+ p_refs.add_argument(
1660
+ "--source-root",
1661
+ help=(
1662
+ "the skill's extraction tree (metadata.json source_root), a "
1663
+ "second allowed root"
1664
+ ),
1665
+ )
1666
+ p_refs.add_argument(
1667
+ "--skills-root",
1668
+ help=(
1669
+ "the skills output folder, an allowed root for a stack skill "
1670
+ "whose references reach its constituent skills"
1671
+ ),
1672
+ )
1673
+ p_refs.set_defaults(func=_cmd_reference_check)
1674
+
375
1675
  return parser
376
1676
 
377
1677
 
378
1678
  def main(argv: list[str] | None = None) -> int:
379
1679
  parser = _build_parser()
380
1680
  args = parser.parse_args(argv)
381
- return args.func(args)
1681
+ try:
1682
+ return args.func(args)
1683
+ except UserError as exc:
1684
+ print(f"error: {exc}", file=sys.stderr)
1685
+ return 1
382
1686
 
383
1687
 
384
1688
  if __name__ == "__main__":