bmad-module-skill-forge 2.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (437) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +49 -49
  3. package/docs/404.md +10 -8
  4. package/docs/_data/pinned.yaml +22 -13
  5. package/docs/_internal/STABILITY.md +58 -35
  6. package/docs/agents.md +39 -53
  7. package/docs/architecture.md +65 -51
  8. package/docs/bmad-synergy.md +53 -51
  9. package/docs/campaign.md +127 -75
  10. package/docs/concepts.md +34 -28
  11. package/docs/examples.md +70 -68
  12. package/docs/forge-auto.md +34 -31
  13. package/docs/getting-started.md +118 -57
  14. package/docs/how-it-works.md +25 -21
  15. package/docs/index.md +15 -15
  16. package/docs/skill-model.md +70 -59
  17. package/docs/troubleshooting.md +143 -18
  18. package/docs/verifying-a-skill.md +87 -57
  19. package/docs/why-skf.md +24 -22
  20. package/docs/workflows.md +274 -110
  21. package/package.json +18 -10
  22. package/src/forger/preferences.yaml +8 -1
  23. package/src/knowledge/agentskills-spec.md +2 -0
  24. package/src/knowledge/ccc-bridge.md +40 -24
  25. package/src/knowledge/confidence-tiers.md +9 -5
  26. package/src/knowledge/overview.md +3 -3
  27. package/src/knowledge/progressive-capability.md +8 -8
  28. package/src/knowledge/provenance-tracking.md +9 -5
  29. package/src/knowledge/qmd-registry.md +3 -3
  30. package/src/knowledge/skf-knowledge-index.csv +2 -2
  31. package/src/knowledge/skill-lifecycle.md +18 -11
  32. package/src/knowledge/split-body-strategy.md +0 -3
  33. package/src/knowledge/tool-resolution.md +4 -2
  34. package/src/knowledge/version-paths.md +101 -15
  35. package/src/module-help.csv +3 -3
  36. package/src/module.yaml +10 -0
  37. package/src/shared/data/ast-grep-recipes.yaml +608 -0
  38. package/src/shared/data/ide-context-files.json +30 -0
  39. package/src/shared/health-check.md +31 -59
  40. package/src/shared/references/description-guard-protocol.md +12 -9
  41. package/src/shared/references/feasibility-report-schema.md +19 -13
  42. package/src/shared/references/headless-gate-convention.md +55 -3
  43. package/src/shared/references/output-contract-schema.md +19 -8
  44. package/src/shared/references/pipeline-contracts.md +94 -29
  45. package/src/shared/references/tessl-review.md +77 -0
  46. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
  47. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
  48. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
  49. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
  50. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
  51. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
  52. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
  53. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
  54. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
  55. package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
  56. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
  57. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
  58. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
  59. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
  60. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
  61. package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
  62. package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
  63. package/src/shared/scripts/skf-atomic-write.py +233 -64
  64. package/src/shared/scripts/skf-build-change-manifest.py +2753 -28
  65. package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
  66. package/src/shared/scripts/skf-check-unit-records.py +184 -0
  67. package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
  68. package/src/shared/scripts/skf-classify-changed-files.py +772 -0
  69. package/src/shared/scripts/skf-comention-pairs.py +1333 -123
  70. package/src/shared/scripts/skf-compare-file-hashes.py +267 -105
  71. package/src/shared/scripts/skf-count-imports.py +1867 -0
  72. package/src/shared/scripts/skf-count-tokens.py +1 -1
  73. package/src/shared/scripts/skf-derive-assembly-shape.py +21 -3
  74. package/src/shared/scripts/skf-description-guard.py +99 -11
  75. package/src/shared/scripts/skf-detect-docs.py +694 -2
  76. package/src/shared/scripts/skf-detect-language.py +486 -107
  77. package/src/shared/scripts/skf-detect-registry.py +887 -0
  78. package/src/shared/scripts/skf-detect-scripts-assets.py +197 -55
  79. package/src/shared/scripts/skf-detect-tools.py +209 -27
  80. package/src/shared/scripts/skf-detect-workspaces.py +506 -30
  81. package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
  82. package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
  83. package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
  84. package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
  85. package/src/shared/scripts/skf-extract-public-api.py +5247 -96
  86. package/src/shared/scripts/skf-extraction-inventory.py +1280 -0
  87. package/src/shared/scripts/skf-extraction-snapshot.py +664 -0
  88. package/src/shared/scripts/skf-fetch-temporal.py +674 -0
  89. package/src/shared/scripts/skf-find-cycles.py +97 -29
  90. package/src/shared/scripts/skf-find-test-report.py +476 -0
  91. package/src/shared/scripts/skf-forge-tier-rw.py +469 -205
  92. package/src/shared/scripts/skf-github-fetch.py +365 -0
  93. package/src/shared/scripts/skf-github-probe.py +700 -0
  94. package/src/shared/scripts/skf-hash-content.py +180 -20
  95. package/src/shared/scripts/skf-language-corpora.py +1 -1
  96. package/src/shared/scripts/skf-load-provenance.py +161 -27
  97. package/src/shared/scripts/skf-manifest-ops.py +1 -1
  98. package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
  99. package/src/shared/scripts/skf-merge-doc-urls.py +143 -20
  100. package/src/shared/scripts/skf-names-present.py +221 -0
  101. package/src/shared/scripts/skf-pair-intersect.py +119 -28
  102. package/src/shared/scripts/skf-parse-gaps.py +1384 -0
  103. package/src/shared/scripts/skf-preapply.py +1 -1
  104. package/src/shared/scripts/skf-preflight.py +176 -28
  105. package/src/shared/scripts/skf-promote-staged.py +430 -0
  106. package/src/shared/scripts/skf-provenance-gap-dispatch.py +64 -8
  107. package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
  108. package/src/shared/scripts/skf-quick-batch.py +774 -0
  109. package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
  110. package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
  111. package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
  112. package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
  113. package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
  114. package/src/shared/scripts/skf-resolve-authoritative-files.py +155 -14
  115. package/src/shared/scripts/skf-resolve-package.py +523 -106
  116. package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
  117. package/src/shared/scripts/skf-run-lock.py +577 -0
  118. package/src/shared/scripts/skf-scan-manifests.py +998 -57
  119. package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
  120. package/src/shared/scripts/skf-severity-classify.py +712 -108
  121. package/src/shared/scripts/skf-shape-detect.py +298 -204
  122. package/src/shared/scripts/skf-shard-body.py +9 -8
  123. package/src/shared/scripts/skf-skill-inventory.py +2412 -89
  124. package/src/shared/scripts/skf-skills-module.py +472 -0
  125. package/src/shared/scripts/skf-source-tree.py +1796 -0
  126. package/src/shared/scripts/skf-structural-diff.py +787 -121
  127. package/src/shared/scripts/skf-tessl-review.py +706 -0
  128. package/src/shared/scripts/skf-update-active-symlink.py +120 -67
  129. package/src/shared/scripts/skf-update-run-state.py +624 -0
  130. package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
  131. package/src/shared/scripts/skf-validate-brief-schema.py +23 -2
  132. package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
  133. package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
  134. package/src/shared/scripts/skf-validate-output.py +712 -64
  135. package/src/shared/scripts/skf-validate-pins.py +1 -1
  136. package/src/shared/scripts/skf-verify-no-trace.py +116 -10
  137. package/src/shared/scripts/skf-verify-provenance-completeness.py +3945 -152
  138. package/src/shared/scripts/skf-write-skill-brief.py +406 -25
  139. package/src/shared/tool-requirements.yaml +201 -0
  140. package/src/skf-analyze-source/SKILL.md +41 -36
  141. package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
  142. package/src/skf-analyze-source/customize.toml +36 -24
  143. package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
  144. package/src/skf-analyze-source/references/continue.md +2 -13
  145. package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
  146. package/src/skf-analyze-source/references/generate-briefs.md +121 -55
  147. package/src/skf-analyze-source/references/headless-contract.md +42 -15
  148. package/src/skf-analyze-source/references/identify-units.md +96 -110
  149. package/src/skf-analyze-source/references/init.md +73 -71
  150. package/src/skf-analyze-source/references/map-and-detect.md +123 -105
  151. package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
  152. package/src/skf-analyze-source/references/recommend.md +51 -70
  153. package/src/skf-analyze-source/references/scan-project.md +60 -84
  154. package/src/skf-analyze-source/references/scan-root.md +39 -0
  155. package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
  156. package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
  157. package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
  158. package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
  159. package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
  160. package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
  161. package/src/skf-audit-skill/SKILL.md +43 -51
  162. package/src/skf-audit-skill/assets/drift-report-template.md +28 -3
  163. package/src/skf-audit-skill/customize.toml +41 -20
  164. package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
  165. package/src/skf-audit-skill/references/doc-drift.md +141 -0
  166. package/src/skf-audit-skill/references/headless-contract.md +29 -0
  167. package/src/skf-audit-skill/references/health-check.md +2 -0
  168. package/src/skf-audit-skill/references/init.md +149 -210
  169. package/src/skf-audit-skill/references/re-index.md +103 -110
  170. package/src/skf-audit-skill/references/report.md +96 -33
  171. package/src/skf-audit-skill/references/semantic-diff.md +34 -25
  172. package/src/skf-audit-skill/references/severity-classify.md +79 -65
  173. package/src/skf-audit-skill/references/severity-rules.md +15 -6
  174. package/src/skf-audit-skill/references/structural-diff.md +80 -90
  175. package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
  176. package/src/skf-audit-skill/scripts/render-drift-tables.py +712 -0
  177. package/src/skf-brief-skill/SKILL.md +37 -26
  178. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  179. package/src/skf-brief-skill/assets/scope-templates.md +18 -25
  180. package/src/skf-brief-skill/assets/skill-brief-schema.md +30 -23
  181. package/src/skf-brief-skill/customize.toml +48 -20
  182. package/src/skf-brief-skill/references/analyze-target.md +144 -127
  183. package/src/skf-brief-skill/references/confirm-brief.md +11 -9
  184. package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
  185. package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
  186. package/src/skf-brief-skill/references/gather-intent.md +87 -182
  187. package/src/skf-brief-skill/references/headless-args.md +84 -16
  188. package/src/skf-brief-skill/references/health-check.md +1 -1
  189. package/src/skf-brief-skill/references/invocation-contract.md +18 -13
  190. package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
  191. package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
  192. package/src/skf-brief-skill/references/scope-definition.md +123 -47
  193. package/src/skf-brief-skill/references/step-auto-brief.md +26 -100
  194. package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
  195. package/src/skf-brief-skill/references/version-resolution.md +7 -10
  196. package/src/skf-brief-skill/references/write-brief.md +66 -57
  197. package/src/skf-campaign/SKILL.md +31 -34
  198. package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
  199. package/src/skf-campaign/customize.toml +47 -16
  200. package/src/skf-campaign/references/campaign-contracts.md +39 -29
  201. package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
  202. package/src/skf-campaign/references/health-check.md +7 -16
  203. package/src/skf-campaign/references/step-01-setup.md +46 -84
  204. package/src/skf-campaign/references/step-02-strategy.md +31 -30
  205. package/src/skf-campaign/references/step-03-pins.md +26 -18
  206. package/src/skf-campaign/references/step-04-provenance.md +24 -23
  207. package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
  208. package/src/skf-campaign/references/step-06-batch.md +41 -34
  209. package/src/skf-campaign/references/step-07-capstone.md +40 -25
  210. package/src/skf-campaign/references/step-08-verify.md +28 -29
  211. package/src/skf-campaign/references/step-09-refine.md +29 -30
  212. package/src/skf-campaign/references/step-10-export.md +48 -36
  213. package/src/skf-campaign/references/step-11-maintenance.md +31 -34
  214. package/src/skf-campaign/references/step-resume.md +37 -68
  215. package/src/skf-campaign/scripts/campaign-deps.py +57 -18
  216. package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
  217. package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
  218. package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
  219. package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
  220. package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
  221. package/src/skf-campaign/scripts/campaign-report.py +204 -37
  222. package/src/skf-campaign/scripts/campaign-state.py +1128 -0
  223. package/src/skf-campaign/scripts/campaign-status.py +10 -74
  224. package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
  225. package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
  226. package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
  227. package/src/skf-campaign/templates/campaign-report-template.md +5 -1
  228. package/src/skf-campaign/templates/kickoff-template.md +0 -14
  229. package/src/skf-create-skill/SKILL.md +27 -20
  230. package/src/skf-create-skill/assets/compile-assembly-rules.md +11 -8
  231. package/src/skf-create-skill/assets/skill-sections.md +21 -11
  232. package/src/skf-create-skill/customize.toml +20 -8
  233. package/src/skf-create-skill/references/authoritative-files-protocol.md +36 -58
  234. package/src/skf-create-skill/references/batch-mode.md +81 -0
  235. package/src/skf-create-skill/references/ccc-index-check.md +27 -0
  236. package/src/skf-create-skill/references/compile.md +119 -70
  237. package/src/skf-create-skill/references/component-extraction.md +113 -91
  238. package/src/skf-create-skill/references/enrich.md +41 -23
  239. package/src/skf-create-skill/references/entry-points-by-hand.md +37 -0
  240. package/src/skf-create-skill/references/extract.md +204 -162
  241. package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
  242. package/src/skf-create-skill/references/extraction-patterns-tracing.md +11 -11
  243. package/src/skf-create-skill/references/extraction-patterns.md +588 -201
  244. package/src/skf-create-skill/references/generate-artifacts.md +145 -93
  245. package/src/skf-create-skill/references/health-check.md +1 -1
  246. package/src/skf-create-skill/references/load-brief.md +53 -36
  247. package/src/skf-create-skill/references/relabel-rule.md +12 -0
  248. package/src/skf-create-skill/references/report.md +37 -57
  249. package/src/skf-create-skill/references/source-resolution-protocols.md +58 -169
  250. package/src/skf-create-skill/references/step-auto-shard.md +20 -93
  251. package/src/skf-create-skill/references/step-doc-rot.md +44 -60
  252. package/src/skf-create-skill/references/step-doc-sources.md +28 -19
  253. package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
  254. package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
  255. package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
  256. package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
  257. package/src/skf-create-skill/references/validate.md +173 -99
  258. package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
  259. package/src/skf-create-stack-skill/SKILL.md +29 -56
  260. package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
  261. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
  262. package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
  263. package/src/skf-create-stack-skill/customize.toml +23 -12
  264. package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
  265. package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
  266. package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
  267. package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
  268. package/src/skf-create-stack-skill/references/generate-output.md +138 -128
  269. package/src/skf-create-stack-skill/references/init.md +90 -32
  270. package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
  271. package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
  272. package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
  273. package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
  274. package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
  275. package/src/skf-create-stack-skill/references/report.md +39 -33
  276. package/src/skf-create-stack-skill/references/validate.md +20 -39
  277. package/src/skf-drop-skill/SKILL.md +28 -51
  278. package/src/skf-drop-skill/customize.toml +44 -30
  279. package/src/skf-drop-skill/references/execute.md +83 -146
  280. package/src/skf-drop-skill/references/health-check.md +2 -0
  281. package/src/skf-drop-skill/references/invocation-contract.md +64 -0
  282. package/src/skf-drop-skill/references/report.md +46 -30
  283. package/src/skf-drop-skill/references/select.md +122 -124
  284. package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
  285. package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
  286. package/src/skf-export-skill/SKILL.md +40 -62
  287. package/src/skf-export-skill/assets/managed-section-format.md +14 -50
  288. package/src/skf-export-skill/assets/snippet-format.md +3 -1
  289. package/src/skf-export-skill/customize.toml +42 -13
  290. package/src/skf-export-skill/references/generate-snippet.md +54 -46
  291. package/src/skf-export-skill/references/health-check.md +2 -0
  292. package/src/skf-export-skill/references/invocation-contract.md +15 -0
  293. package/src/skf-export-skill/references/load-skill.md +88 -62
  294. package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
  295. package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
  296. package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
  297. package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
  298. package/src/skf-export-skill/references/package.md +15 -73
  299. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
  300. package/src/skf-export-skill/references/result-envelope.md +31 -16
  301. package/src/skf-export-skill/references/summary.md +41 -17
  302. package/src/skf-export-skill/references/token-report.md +4 -12
  303. package/src/skf-export-skill/references/update-context.md +118 -237
  304. package/src/skf-forger/SKILL.md +61 -37
  305. package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
  306. package/src/skf-forger/customize.toml +43 -0
  307. package/src/skf-forger/references/pipeline-mode.md +73 -18
  308. package/src/skf-forger/scripts/forge-status.py +299 -0
  309. package/src/skf-forger/scripts/parse-pipeline.py +322 -54
  310. package/src/skf-forger/scripts/pipeline-gate.py +428 -0
  311. package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
  312. package/src/skf-quick-skill/SKILL.md +54 -53
  313. package/src/skf-quick-skill/assets/skill-template.md +4 -2
  314. package/src/skf-quick-skill/customize.toml +51 -20
  315. package/src/skf-quick-skill/references/batch-mode.md +74 -21
  316. package/src/skf-quick-skill/references/compile.md +30 -31
  317. package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
  318. package/src/skf-quick-skill/references/finalize.md +69 -18
  319. package/src/skf-quick-skill/references/halt-contract.md +41 -29
  320. package/src/skf-quick-skill/references/health-check.md +5 -3
  321. package/src/skf-quick-skill/references/quick-extract.md +114 -45
  322. package/src/skf-quick-skill/references/registry-resolution.md +9 -64
  323. package/src/skf-quick-skill/references/resolve-target.md +116 -66
  324. package/src/skf-quick-skill/references/write-and-validate.md +79 -40
  325. package/src/skf-refine-architecture/SKILL.md +43 -53
  326. package/src/skf-refine-architecture/customize.toml +49 -20
  327. package/src/skf-refine-architecture/references/compile.md +97 -54
  328. package/src/skf-refine-architecture/references/exit-codes.md +30 -0
  329. package/src/skf-refine-architecture/references/finding-storage.md +21 -0
  330. package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
  331. package/src/skf-refine-architecture/references/improvements.md +14 -20
  332. package/src/skf-refine-architecture/references/init.md +98 -57
  333. package/src/skf-refine-architecture/references/issue-detection.md +32 -39
  334. package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
  335. package/src/skf-refine-architecture/references/report.md +65 -54
  336. package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
  337. package/src/skf-rename-skill/SKILL.md +19 -54
  338. package/src/skf-rename-skill/customize.toml +32 -26
  339. package/src/skf-rename-skill/references/execute.md +230 -184
  340. package/src/skf-rename-skill/references/exit-codes.md +7 -7
  341. package/src/skf-rename-skill/references/health-check.md +1 -7
  342. package/src/skf-rename-skill/references/invocation-contract.md +25 -0
  343. package/src/skf-rename-skill/references/report.md +63 -52
  344. package/src/skf-rename-skill/references/select.md +197 -115
  345. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
  346. package/src/skf-setup/SKILL.md +26 -26
  347. package/src/skf-setup/customize.toml +14 -10
  348. package/src/skf-setup/references/auto-index.md +50 -63
  349. package/src/skf-setup/references/ccc-index.md +29 -90
  350. package/src/skf-setup/references/detect-and-tier.md +58 -67
  351. package/src/skf-setup/references/health-check.md +2 -0
  352. package/src/skf-setup/references/invocation-contract.md +14 -0
  353. package/src/skf-setup/references/report.md +43 -166
  354. package/src/skf-setup/references/tier-rules.md +5 -5
  355. package/src/skf-setup/references/write-config.md +34 -57
  356. package/src/skf-test-skill/SKILL.md +17 -57
  357. package/src/skf-test-skill/assets/output-section-formats.md +33 -34
  358. package/src/skf-test-skill/customize.toml +27 -13
  359. package/src/skf-test-skill/references/coherence-check.md +134 -62
  360. package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
  361. package/src/skf-test-skill/references/coverage-check.md +209 -264
  362. package/src/skf-test-skill/references/detect-mode.md +5 -5
  363. package/src/skf-test-skill/references/external-validators.md +94 -82
  364. package/src/skf-test-skill/references/health-check.md +6 -7
  365. package/src/skf-test-skill/references/init.md +164 -91
  366. package/src/skf-test-skill/references/invocation-contract.md +66 -0
  367. package/src/skf-test-skill/references/migration-section-rules.md +5 -2
  368. package/src/skf-test-skill/references/report.md +164 -142
  369. package/src/skf-test-skill/references/score.md +80 -107
  370. package/src/skf-test-skill/references/scoring-rules.md +42 -155
  371. package/src/skf-test-skill/references/source-access-protocol.md +18 -94
  372. package/src/skf-test-skill/references/step-hard-gate.md +51 -33
  373. package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
  374. package/src/skf-test-skill/scripts/build-result-context.py +380 -0
  375. package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
  376. package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
  377. package/src/skf-test-skill/scripts/compute-score.py +223 -45
  378. package/src/skf-test-skill/scripts/gap-ledger.py +1666 -0
  379. package/src/skf-test-skill/scripts/hard-gate.py +203 -0
  380. package/src/skf-test-skill/scripts/load-coverage-inputs.py +1198 -0
  381. package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
  382. package/src/skf-test-skill/scripts/reconcile-coverage.py +547 -79
  383. package/src/skf-test-skill/scripts/score-signatures.py +479 -0
  384. package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
  385. package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
  386. package/src/skf-test-skill/scripts/verify-declared-numerator.py +133 -56
  387. package/src/skf-test-skill/templates/test-report-template.md +21 -15
  388. package/src/skf-update-skill/SKILL.md +38 -25
  389. package/src/skf-update-skill/customize.toml +27 -12
  390. package/src/skf-update-skill/references/detect-changes.md +253 -223
  391. package/src/skf-update-skill/references/gap-driven.md +336 -0
  392. package/src/skf-update-skill/references/health-check.md +31 -5
  393. package/src/skf-update-skill/references/init.md +254 -84
  394. package/src/skf-update-skill/references/invocation-contract.md +12 -0
  395. package/src/skf-update-skill/references/manual-section-rules.md +6 -23
  396. package/src/skf-update-skill/references/merge.md +182 -49
  397. package/src/skf-update-skill/references/re-extract.md +53 -262
  398. package/src/skf-update-skill/references/report.md +102 -35
  399. package/src/skf-update-skill/references/write.md +357 -155
  400. package/src/skf-update-skill/scripts/skf-new-file-diff.py +183 -46
  401. package/src/skf-verify-stack/SKILL.md +23 -42
  402. package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
  403. package/src/skf-verify-stack/customize.toml +55 -22
  404. package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
  405. package/src/skf-verify-stack/references/coverage.md +67 -64
  406. package/src/skf-verify-stack/references/exit-codes.md +21 -8
  407. package/src/skf-verify-stack/references/init.md +107 -62
  408. package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
  409. package/src/skf-verify-stack/references/integrations.md +106 -65
  410. package/src/skf-verify-stack/references/report.md +59 -42
  411. package/src/skf-verify-stack/references/requirements.md +32 -9
  412. package/src/skf-verify-stack/references/synthesize.md +53 -39
  413. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
  414. package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
  415. package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
  416. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
  417. package/tools/cli/commands/install.js +11 -4
  418. package/tools/cli/commands/status.js +15 -3
  419. package/tools/cli/commands/update.js +11 -4
  420. package/tools/cli/lib/tool-check.js +320 -0
  421. package/tools/cli/lib/version-check.js +22 -9
  422. package/tools/cli/skf-cli.js +4 -1
  423. package/.gitattributes +0 -16
  424. package/.nvmrc +0 -1
  425. package/docs/_internal/RELEASING.md +0 -679
  426. package/release-audits/v1.0.0-launch-audit.md +0 -728
  427. package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
  428. package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
  429. package/src/skf-campaign/manifest.yaml +0 -15
  430. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
  431. package/src/skf-create-skill/references/ecosystem-check.md +0 -74
  432. package/src/skf-drop-skill/references/headless-contract.md +0 -18
  433. package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
  434. package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
  435. package/src/skf-update-skill/references/validate.md +0 -124
  436. package/tools/validate-docs-drift.js +0 -233
  437. package/tools/validate-docs-links.js +0 -665
@@ -1,40 +1,101 @@
1
1
  #!/usr/bin/env python3
2
2
  # /// script
3
- # requires-python = ">=3.10"
3
+ # requires-python = ">=3.11"
4
4
  # dependencies = []
5
5
  # ///
6
- """SKF Co-mention Pairs — compute compose-mode co-mention pairs from an
7
- architecture document with the same precision guards the prompt used to run
8
- by hand.
9
-
10
- `detect-integrations.md` §2 (compose branch) asks the LLM to scan an
11
- architecture markdown document for skill-name co-mentions using three
12
- deterministic guards:
13
-
14
- 1. Word-boundary matching (`\\b{skill_name}\\b`, case-insensitive) — no
15
- substring false positives (`react` must not match inside `reactive`).
16
- 2. Section filtering — paragraphs governed by an H1/H2 header that
17
- normalises to one of {introduction, overview, glossary, table of
18
- contents, references, appendix, index} are excluded, and heading text
19
- itself is never a co-mention source.
20
- 3. Two-paragraph minimum — a pair (A, B) qualifies only when at least two
21
- distinct body paragraphs each contain word-boundary matches for both A
22
- and B.
23
-
24
- Given a fixed architecture doc and skill-name set the qualifying-pair set is
25
- fully determined, so this belongs in a deterministic helper rather than
26
- in-prompt regex/counting. It mirrors the co-import subprocess the code-mode
27
- branch already delegates and the `skf-pair-intersect.py` pre-pass. The LLM
28
- still does the semantic composition (integration-section authoring, tier
29
- inheritance, VS-verdict parsing) — the script only emits the qualifying pairs
30
- and their evidence paragraphs.
31
-
32
- Subcommand:
6
+ """SKF Co-mention Pairs: which loaded skills a document names, and which
7
+ pairs of them it names together.
8
+
9
+ The script reports evidence and the calling step judges it: every pair it
10
+ emits is a candidate, never a confirmed integration. Its three subcommands
11
+ share one skills input, one matcher and one Markdown reader:
12
+
13
+ comention compose-mode co-mention pairs from an architecture document
14
+ (create-stack-skill detect-integrations.md §2, compose branch)
15
+ mentions the skills a document names and the pairs it names together
16
+ (refine-architecture gap-analysis.md scope sets and documented
17
+ pairs)
18
+ infer candidate pairs when there is no architecture document: one
19
+ skill's own docs naming the other, or shared domain keywords,
20
+ never a shared language alone
21
+
22
+ Skills input (--skills):
23
+ A JSON array. Each entry is a skill name (`"react"`) or an object:
24
+ {"name": "<skill>", required, non-empty
25
+ "aliases": ["<term>", ...], other names the skill goes by
26
+ "keywords": ["<kw>", ...], domain keywords (read by infer only)
27
+ "language": "<language>", the skill's language, an array of them
28
+ (a stack lists its libraries'
29
+ languages) or null (infer only)
30
+ "docs": ["<path>", ...]} the skill's own docs (infer only)
31
+ Other keys are ignored, so an inventory entry, such as a `skills[]` entry
32
+ of skf-enumerate-stack-skills.py, can be passed as it is. Entries with
33
+ the same name merge, languages included. Output names a skill by its
34
+ `name`, never an alias.
35
+
36
+ Matching:
37
+ A skill is named where one of its terms (its name and each alias)
38
+ appears, case-insensitively, with no word character right before or
39
+ after it: `react` never matches inside `reactive`. For a term that starts
40
+ and ends with a word character, as every skill name does, this is the
41
+ `\\b{term}\\b` rule; a term such as `C++` or `.NET` matches too. The
42
+ words of a multi-word alias may be split by any whitespace, a line break
43
+ included. comention matches each skill on its own, as it always has, so
44
+ `react` also matches the `react` in `react-dom`. mentions and infer read
45
+ each occurrence once, as the longest term that matches there: when the
46
+ skills include both `react` and `react-dom`, the text `react-dom` names
47
+ `react-dom` only. Two skills that share a term are both named by it.
48
+
49
+ Paragraphs, units and evidence kinds:
50
+ A body paragraph is a run of consecutive non-blank lines that are not
51
+ ATX headers (1-6 `#` then a space or tab); headers of any level end it,
52
+ and the nearest H1/H2 above it governs it. A paragraph splits into
53
+ units: a run of prose, one list item with its continuation lines, one
54
+ table row, or one line of fenced code. A list item or table row inside a
55
+ blockquote (`> - item`) is a unit the same way. Each evidence entry is
56
+ one body paragraph that names both skills of a pair, and its `kind` says
57
+ how:
58
+ co-mention one unit of the paragraph names both skills
59
+ lead-in no unit does, but a run of prose names one skill and a list
60
+ item, table row or code line under it (before the next run
61
+ of prose) names the other: "The express server mounts:"
62
+ then "- passport"
63
+ list-only neither: the two are named only in separate units, such as
64
+ separate list items, table rows or code lines
65
+ A "Tech Stack" table and a "Components" list that each name every
66
+ library give every pair of them two list-only paragraphs. A lead-in
67
+ counts only inside its paragraph: with a blank line before the list, the
68
+ lead-in and the items are two paragraphs. The units are a heuristic, not
69
+ a Markdown parser: consecutive "Label: value" lines with no list marker
70
+ are one run of prose, so they give co-mentions, and a line that starts
71
+ with a number and a period (`2024.`) right after a bullet item is a new
72
+ item, where CommonMark reads a continuation.
73
+
74
+ Each entry also shows where the paragraph names the pair: `unit_excerpt`
75
+ is the collapsed text of the first unit that names both (a co-mention),
76
+ or of the lead-in and the item (a lead-in, with `...` for any items
77
+ between them), cut to 240 characters around the two names; when the
78
+ words between the names do not fit, the middle is cut instead, so both
79
+ names stay. `...` marks every cut. `unit_line` is the line (1-based) of
80
+ the first of the two names. Both are null for a list-only entry, while
81
+ `excerpt` and `line` are always the paragraph's start and first line.
82
+
83
+ Subcommands:
33
84
  comention --doc <md-file-or-'-'> --skills <json-file-or-'-'>
34
- --doc path to the architecture markdown document, or '-' for stdin.
35
- --skills path to a JSON array of loaded skill names
36
- (e.g. `["react", "express"]`), or '-' for stdin.
37
- (--doc and --skills cannot both read from stdin.)
85
+ Emits every pair that one body paragraph or more names in one unit
86
+ or through a lead-in, by the matching above; heading text is never a
87
+ source. A pair only ever listed together is left out and counted, as
88
+ mentions does: that is a structural kind of evidence, and nothing is
89
+ filtered on meaning. Whether a passage describes an integration is the
90
+ calling step's judgment, from its excerpt, and two fields carry what
91
+ it weighs. `excluded_section` is true on an evidence entry whose
92
+ paragraph an H1/H2 header governs that normalises to one of
93
+ {introduction, overview, glossary, table of contents, references,
94
+ appendix, index}, sections that often only list the libraries;
95
+ `paragraph_count` counts the body paragraphs that name the pair,
96
+ one being enough. Fenced code is read as body text here, as this
97
+ mode always read it, so a diagram line names a pair like any other
98
+ line, and a `#` line inside a fence still counts as a header.
38
99
 
39
100
  Emit JSON:
40
101
  {
@@ -42,45 +103,198 @@ Subcommand:
42
103
  {
43
104
  "a": "<skill>", "b": "<skill>",
44
105
  "paragraph_count": N,
106
+ "comention_count": C,
107
+ "lead_in_count": I,
45
108
  "evidence": [{"header": "<governing-header-or-null>",
46
- "excerpt": "<collapsed paragraph text>"}, ...]
109
+ "excerpt": "<collapsed paragraph text>",
110
+ "line": L,
111
+ "kind": "co-mention|lead-in|list-only",
112
+ "unit_line": U-or-null,
113
+ "unit_excerpt": "<text-naming-both>-or-null",
114
+ "excluded_section": true|false},
115
+ ...]
47
116
  },
48
117
  ...
49
118
  ],
119
+ "list_only_pair_count": P,
50
120
  "excluded_section_count": M
51
121
  }
52
122
 
53
- Only unordered pairs (a < b lexicographically) with paragraph_count >= 2
54
- are emitted. `pairs[]` is sorted by paragraph_count DESC, then (a, b)
55
- ASC — stable, mirroring skf-pair-intersect.py. `evidence[]` is in
56
- document order. `excluded_section_count` is the number of DISTINCT
57
- excluded H1/H2 sections (by normalised header) that governed at least
58
- one body paragraph — a diagnostic for the caller, not a gate.
123
+ Only unordered pairs (a < b lexicographically) are emitted, each a
124
+ candidate with comention_count + lead_in_count >= 1 (its co-mention
125
+ and lead-in entries; a list-only entry from another paragraph stays
126
+ in its evidence). `list_only_pair_count` counts the pairs only ever
127
+ listed together, which are not emitted. `pairs[]` is sorted by
128
+ paragraph_count DESC, then (a, b) ASC, mirroring
129
+ skf-pair-intersect.py. `evidence[]` is in document order.
130
+ `excluded_section_count` is the number of DISTINCT such introductory
131
+ H1/H2 sections (by normalised header) that governed at least one
132
+ body paragraph: a diagnostic for the caller.
133
+
134
+ mentions --doc <md-file-or-'-'> --skills <json-file-or-'-'>
135
+ [--technologies <json-file-or-'-'>]
136
+ Fenced code blocks are skipped: their lines are neither paragraphs
137
+ nor headings. No section is excluded, and one paragraph is enough.
138
+
139
+ Emit JSON:
140
+ {
141
+ "skills": [
142
+ {"name": "<skill>",
143
+ "paragraph_count": N, "heading_count": H, "fenced_count": F,
144
+ "terms": [{"term": "<the skill's term>",
145
+ "kind": "name|alias",
146
+ "paragraph_count": N, "heading_count": H,
147
+ "fenced_count": F}, ...],
148
+ "paragraphs": [{"header": "<governing-header-or-null>",
149
+ "excerpt": "<collapsed paragraph text>",
150
+ "line": L}, ...]},
151
+ ...
152
+ ],
153
+ "mentioned": ["<skill>", ...],
154
+ "fenced_only": ["<skill>", ...],
155
+ "unmentioned": ["<skill>", ...],
156
+ "candidates": [<a pair shaped as a comention pair, its
157
+ evidence without `excluded_section`>, ...],
158
+ "list_only_pair_count": P,
159
+ "fenced_block_count": B,
160
+ "fenced_blocks": [{"line": L, "info": "<info string>"}, ...]
161
+ }
162
+
163
+ `skills[]` has one entry per skill, sorted by name: how many body
164
+ paragraphs, headings (any level) and fenced blocks name it, and each
165
+ such paragraph in document order. `terms[]` lists each of the
166
+ skill's terms that named it, spelled as the skills input gives it,
167
+ its name first and then its aliases in input order, with `kind`
168
+ saying which, and how many paragraphs, headings and fenced blocks
169
+ that term named the skill in: the term a scope gate shows for each
170
+ skill, so a skill named only through an alias reads as such.
171
+ `mentioned` lists the skills a paragraph or a heading names,
172
+ `fenced_only` the skills named only inside fenced code (a diagram,
173
+ for example) and `unmentioned` the rest; the three are sorted and
174
+ hold every skill once.
175
+
176
+ `candidates[]` holds each pair that a body paragraph names in one
177
+ unit or through a lead-in (comention_count + lead_in_count >= 1),
178
+ with every paragraph that names both as evidence, sorted by
179
+ comention_count DESC, lead_in_count DESC, paragraph_count DESC, then
180
+ (a, b) ASC. `list_only_pair_count` counts the pairs only ever listed
181
+ together, which are not emitted. `fenced_blocks[]` lists the fenced
182
+ blocks skipped, in document order: the line of each opening fence and
183
+ its info string, trimmed (`mermaid` for a Mermaid diagram, "" for
184
+ none).
185
+
186
+ --technologies takes a JSON array of technology names as a document
187
+ writes them (the caller judges which strings are technologies) and
188
+ adds two keys:
189
+ "technologies": [{"name": "<as given>",
190
+ "covered_by": [{"skill": "<skill>",
191
+ "term": "<the skill's term>",
192
+ "kind": "name|alias|name-contained|alias-contained"},
193
+ ...]}, ...],
194
+ "unverified_technologies": ["<name>", ...]
195
+ in the order given, a repeat (ignoring case and runs of whitespace)
196
+ dropped. A term covers only by squashed equality: a skill covers a
197
+ technology when one of its terms, its name or an alias, equals the
198
+ name once case, whitespace, hyphens, dots and underscores are
199
+ dropped (a `react-router` skill covers `React Router`, a
200
+ `tailwindcss` skill `Tailwind CSS`, a skill with the alias `cognee`
201
+ `Cognee`). A name or alias found only inside a longer name, by the
202
+ matching above with each occurrence read as its longest term, is
203
+ listed as name-contained or alias-contained and covers nothing:
204
+ whether `next` is `Next.js`, `react` is `React Native`, or the
205
+ common-word alias `ai` is `Azure AI Search`, is a question of meaning,
206
+ so the name stays unverified for the caller to judge. The match runs
207
+ one way: a `storybook-react-vite` skill does not cover `React`.
208
+ `covered_by` is sorted by skill, each with the first of its terms
209
+ that covers the name (or, for a contained entry, the term found) and
210
+ its kind, and `unverified_technologies` lists the names no skill
211
+ covers.
212
+
213
+ infer --skills <json-file-or-'-'> [--top-k N]
214
+ A pair is a candidate on either kind of evidence:
215
+ docs-mention one skill's docs name the other. Every line of
216
+ each `docs` file is read, front matter and code
217
+ included; each occurrence names the skill of the
218
+ longest term there (see Matching), and an
219
+ occurrence of the documenting skill's own name or
220
+ alias names no other skill. A mention is where a
221
+ documented contract would show, not proof of one:
222
+ the calling step reads the excerpt and counts a
223
+ documented contract only when the line says how
224
+ the two work together, never on a "see also" or
225
+ "unlike" line.
226
+ shared-keywords the two skills share a domain keyword. Keywords
227
+ compare trimmed and lowercased, with runs of
228
+ whitespace collapsed; a keyword equal to any
229
+ skill's language does not count.
230
+ A shared language alone never makes a pair: `language` is read only
231
+ to set such keywords aside and to count language_only_pair_count,
232
+ the pairs of skills that share a language and have no evidence.
233
+
234
+ Emit JSON:
235
+ {
236
+ "pairs": [
237
+ {
238
+ "a": "<skill>", "b": "<skill>",
239
+ "docs_mention_count": 0-2,
240
+ "shared_keyword_count": K,
241
+ "evidence": [
242
+ {"kind": "docs-mention",
243
+ "documented_by": "<skill>", "doc": "<path>",
244
+ "line": L, "line_count": N,
245
+ "excerpt": "<collapsed line text>"},
246
+ {"kind": "shared-keywords", "keywords": ["<kw>", ...]}
247
+ ]
248
+ },
249
+ ...
250
+ ],
251
+ "truncated": <bool>,
252
+ "total_pairs": T,
253
+ "language_only_pair_count": P
254
+ }
255
+
256
+ A docs-mention entry is the first line (docs in the order given)
257
+ where `documented_by` names the other skill, and line_count counts
258
+ every such line. docs_mention_count is how many of the two skills'
259
+ docs name the other. `pairs[]` is sorted by docs_mention_count DESC,
260
+ shared_keyword_count DESC, then (a, b) ASC, and capped at --top-k
261
+ (default 20, the Top-K of detect-integrations.md §1); `truncated` and
262
+ `total_pairs` report the cap as skf-pair-intersect.py does.
59
263
 
60
264
  CLI examples:
61
265
  uv run skf-comention-pairs.py comention --doc arch.md --skills skills.json
62
266
  echo '["react","express"]' | uv run skf-comention-pairs.py comention \\
63
267
  --doc arch.md --skills -
268
+ uv run skf-comention-pairs.py mentions --doc arch.md --skills skills.json
269
+ uv run skf-comention-pairs.py mentions --doc arch.md --skills skills.json \\
270
+ --technologies technologies.json
271
+ uv run skf-comention-pairs.py infer --skills skills.json --top-k 20
64
272
 
65
273
  Exit codes:
66
- 0 — operation succeeded (including: no qualifying pairs → empty list)
67
- 1 — user error (bad JSON, missing/unreadable file, both inputs on stdin,
68
- malformed skills array)
69
- 2 — unexpected internal error
274
+ 0 operation succeeded (including: nothing qualifies, so empty lists)
275
+ 1 user error (bad JSON, a missing, unreadable or non-UTF-8 file, two
276
+ inputs on stdin, malformed skills or technologies array, negative
277
+ --top-k)
278
+ 2 unexpected internal error
70
279
  """
71
280
 
72
281
  from __future__ import annotations
73
282
 
74
283
  import argparse
284
+ import bisect
285
+ import functools
286
+ import itertools
75
287
  import json
76
288
  import re
77
289
  import sys
78
290
  from pathlib import Path
291
+ from typing import NamedTuple
79
292
 
80
293
 
81
- # Closed exclusion set — governing H1/H2 headers that normalise to one of
82
- # these drop their paragraphs from co-mention analysis (they typically
83
- # enumerate all libraries without describing an integration).
294
+ # The introductory sections: comention marks `excluded_section` on the
295
+ # evidence of a paragraph whose governing H1/H2 header normalises to one of
296
+ # these, since such sections often list every library without describing an
297
+ # integration. The calling step weighs the mark; nothing is dropped.
84
298
  EXCLUDED_HEADERS = frozenset({
85
299
  "introduction",
86
300
  "overview",
@@ -96,21 +310,112 @@ EXCLUDED_HEADERS = frozenset({
96
310
  # as body text. Capture group 1 = hashes (level), group 2 = header text.
97
311
  _HEADER_RE = re.compile(r"^(#{1,6})[ \t]+(.*)$")
98
312
 
313
+ # A fence line: three or more backticks or tildes after any indentation
314
+ # (the rule the other SKF Markdown scanners use), then the info string.
315
+ _FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})(.*)$")
316
+
317
+ # The indentation and blockquote markers (`>`) that can precede a list
318
+ # marker or a table row.
319
+ _QUOTE_RE = re.compile(r"\s*(?:>\s*)*")
320
+
321
+ # List item markers, at any indentation so that a nested item is a unit of
322
+ # its own, and inside a blockquote: a bullet, or a number with `.` or `)`,
323
+ # then whitespace and text.
324
+ _BULLET_RE = re.compile(r"^\s*(?:>\s*)*[-*+]\s+\S")
325
+ _ORDERED_RE = re.compile(r"^\s*(?:>\s*)*(\d{1,9})[.)]\s+\S")
326
+
327
+ # A GFM table delimiter row, such as `| --- | :-: |` or `--- | ---`, matched
328
+ # whole against the row with its blockquote markers and outer whitespace
329
+ # removed: leading and trailing `\s*` here would backtrack quadratically on
330
+ # a long run of spaces.
331
+ _TABLE_DELIM_RE = re.compile(r"\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)*\|?")
332
+
333
+ _WORD_RE = re.compile(r"\S+")
334
+
99
335
  _EXCERPT_LIMIT = 240
100
336
 
337
+ # infer's Top-K cap: the cap detect-integrations.md §1 applies to pairs.
338
+ DEFAULT_TOP_K = 20
339
+
340
+ CO_MENTION = "co-mention"
341
+ LEAD_IN = "lead-in"
342
+ LIST_ONLY = "list-only"
343
+ DOCS_MENTION = "docs-mention"
344
+ SHARED_KEYWORDS = "shared-keywords"
345
+ # The covered_by kinds of a skill name or an alias found only inside a longer
346
+ # technology name: listed, but they cover nothing.
347
+ NAME_CONTAINED = "name-contained"
348
+ ALIAS_CONTAINED = "alias-contained"
349
+
101
350
 
102
351
  class UserError(Exception):
103
352
  """A caller-facing input error → exit code 1."""
104
353
 
105
354
 
355
+ class Skill(NamedTuple):
356
+ """One loaded skill: the terms that name it (its name first, then its
357
+ aliases) and the inputs only infer reads."""
358
+
359
+ name: str
360
+ terms: tuple[str, ...]
361
+ keywords: tuple[str, ...] = ()
362
+ languages: tuple[str, ...] = ()
363
+ docs: tuple[str, ...] = ()
364
+
365
+
366
+ class Unit(NamedTuple):
367
+ """A part of a paragraph that a pair can share: its first line
368
+ (1-based), its text, and whether it is a run of prose (the others are
369
+ list items, table rows and code lines)."""
370
+
371
+ line: int
372
+ text: str
373
+ prose: bool
374
+
375
+
376
+ class Paragraph(NamedTuple):
377
+ """A body paragraph: its governing H1/H2 header (or None), its first
378
+ line (1-based), its text and its units."""
379
+
380
+ header: str | None
381
+ line: int
382
+ text: str
383
+ units: tuple[Unit, ...]
384
+
385
+
386
+ class Fence(NamedTuple):
387
+ """A fenced code block: the line of its opening fence (1-based), its
388
+ info string, trimmed, and its content without the fence lines."""
389
+
390
+ line: int
391
+ info: str
392
+ text: str
393
+
394
+
395
+ class MarkdownDoc(NamedTuple):
396
+ """What read_markdown finds: body paragraphs, the text of every header
397
+ line, and each fenced block it set apart."""
398
+
399
+ paragraphs: list[Paragraph]
400
+ headings: list[str]
401
+ fenced: list[Fence]
402
+
403
+
106
404
  # --------------------------------------------------------------------------
107
405
  # Markdown parsing
108
406
  # --------------------------------------------------------------------------
109
407
 
110
408
 
111
409
  def _strip_closing_hashes(text: str) -> str:
112
- """Remove a closed-ATX trailing run of hashes (e.g. `Overview ##`)."""
113
- return re.sub(r"[ \t]*#+[ \t]*$", "", text)
410
+ """Remove a closed-ATX trailing run of hashes (e.g. `Overview ##`).
411
+
412
+ String methods rather than a `[ \\t]*#+[ \\t]*$` search, which
413
+ backtracks quadratically on a long run of spaces.
414
+ """
415
+ body = text.rstrip(" \t")
416
+ if not body.endswith("#"):
417
+ return text
418
+ return body.rstrip("#").rstrip(" \t")
114
419
 
115
420
 
116
421
  def normalize_header(text: str) -> str:
@@ -122,27 +427,163 @@ def normalize_header(text: str) -> str:
122
427
  return t
123
428
 
124
429
 
125
- def parse_body_paragraphs(doc_text: str) -> list[tuple[str | None, str]]:
126
- """Split the document into body paragraphs, each tagged with the raw text
127
- of its governing (nearest-preceding) H1/H2 header (or None).
430
+ def _fence_roles(lines: list[str]) -> list[str | None]:
431
+ """Mark each line of a fenced code block `open`, `body` or `close`, and
432
+ every other line None. A fence closes on a line of the same character,
433
+ at least as long, with nothing after it; an unclosed fence runs to the
434
+ end of the document."""
435
+ roles: list[str | None] = []
436
+ fence: str | None = None
437
+ for line in lines:
438
+ m = _FENCE_RE.match(line)
439
+ if fence is None:
440
+ # A backtick fence's info string cannot hold a backtick.
441
+ if m is not None and not (m.group(1)[0] == "`" and "`" in m.group(2)):
442
+ fence = m.group(1)
443
+ roles.append("open")
444
+ else:
445
+ roles.append(None)
446
+ elif (
447
+ m is not None
448
+ and m.group(1)[0] == fence[0]
449
+ and len(m.group(1)) >= len(fence)
450
+ and not m.group(2).strip()
451
+ ):
452
+ fence = None
453
+ roles.append("close")
454
+ else:
455
+ roles.append("body")
456
+ return roles
457
+
458
+
459
+ def _fenced_blocks(lines: list[str], roles: list[str | None]) -> list[Fence]:
460
+ """Each fenced code block, with its opening line and info string."""
461
+ blocks: list[tuple[int, str, list[str]]] = []
462
+ for idx, (line, role) in enumerate(zip(lines, roles)):
463
+ if role == "open":
464
+ info = _FENCE_RE.match(line).group(2).strip()
465
+ blocks.append((idx + 1, info, []))
466
+ elif role == "body":
467
+ blocks[-1][2].append(line)
468
+ return [Fence(line, info, "\n".join(body)) for line, info, body in blocks]
469
+
470
+
471
+ def _unquote(line: str) -> str:
472
+ """The line without its indentation and blockquote markers."""
473
+ return line[_QUOTE_RE.match(line).end():]
474
+
475
+
476
+ def _is_list_item(line: str) -> bool:
477
+ return bool(_BULLET_RE.match(line) or _ORDERED_RE.match(line))
478
+
479
+
480
+ def _table_rows(lines: list[str], code: list[bool]) -> set[int]:
481
+ """Indexes of a paragraph's table rows: every line that starts with `|`,
482
+ and each GFM table (a header row, its delimiter row, then each line up
483
+ to a list item or code) even when its rows have no leading pipe."""
484
+ rows = {
485
+ i for i, line in enumerate(lines)
486
+ if not code[i] and _unquote(line).startswith("|")
487
+ }
488
+ for i in range(1, len(lines)):
489
+ if (
490
+ code[i] or code[i - 1]
491
+ or "|" not in lines[i] or "|" not in lines[i - 1]
492
+ or not _TABLE_DELIM_RE.fullmatch(_unquote(lines[i]).rstrip())
493
+ ):
494
+ continue
495
+ rows.update((i - 1, i))
496
+ j = i + 1
497
+ while j < len(lines) and not code[j] and not _is_list_item(lines[j]):
498
+ rows.add(j)
499
+ j += 1
500
+ return rows
501
+
502
+
503
+ def _units(lines: list[str], code: list[bool], first: int) -> tuple[Unit, ...]:
504
+ """Split a paragraph's lines, the first of which is line `first` of the
505
+ document, into the units a pair can share: a run of prose, one list item
506
+ with its continuation lines, one table row, or one line of fenced
507
+ code."""
508
+ rows = _table_rows(lines, code)
509
+ units: list[Unit] = []
510
+ start = 0 # index of the open unit's first line
511
+ # What the open unit holds: "prose", "item" (a list item) or None.
512
+ current: str | None = None
513
+
514
+ def close(end: int) -> None:
515
+ if current is not None:
516
+ units.append(Unit(
517
+ first + start, "\n".join(lines[start:end]), current == "prose"
518
+ ))
519
+
520
+ for i, line in enumerate(lines):
521
+ if code[i] or i in rows:
522
+ close(i)
523
+ current = None
524
+ units.append(Unit(first + i, line, False))
525
+ continue
526
+ ordered = _ORDERED_RE.match(line)
527
+ # A bullet can interrupt running prose; an ordered marker only when
528
+ # it is 1 (CommonMark), so `2024. Then ...` inside prose stays text.
529
+ if _BULLET_RE.match(line) or (
530
+ ordered is not None
531
+ and (current != "prose" or int(ordered.group(1)) == 1)
532
+ ):
533
+ close(i)
534
+ start, current = i, "item"
535
+ elif current is None:
536
+ # Any other line opens a run of prose, or continues the open
537
+ # run or list item.
538
+ start, current = i, "prose"
539
+ close(len(lines))
540
+ return tuple(units)
541
+
542
+
543
+ def read_markdown(doc_text: str, *, skip_fenced: bool) -> MarkdownDoc:
544
+ """Split the document into body paragraphs, headings and fenced blocks.
128
545
 
129
546
  Paragraphs are maximal runs of consecutive non-blank, non-header lines,
130
- separated by blank lines. Header lines of ANY level are never emitted as
131
- paragraphs (headings are not a co-mention source). Only H1/H2 headers
132
- update the governing header; H3-H6 leave it unchanged.
547
+ separated by blank lines. Header lines of ANY level are never emitted
548
+ as paragraphs (headings are not a co-mention source). Only H1/H2
549
+ headers update the governing header; H3-H6 leave it unchanged.
550
+
551
+ With skip_fenced (mentions), fenced code blocks are set apart first:
552
+ their lines end a paragraph, are never headers, and each block lands in
553
+ `fenced`. Without it (comention), fence lines are body text and a `#`
554
+ line inside a fence is a header, as that mode has always read them; the
555
+ units still know which lines are code.
133
556
  """
134
- paragraphs: list[tuple[str | None, str]] = []
557
+ lines = doc_text.splitlines()
558
+ roles = _fence_roles(lines)
559
+ doc = MarkdownDoc(
560
+ paragraphs=[],
561
+ headings=[],
562
+ fenced=_fenced_blocks(lines, roles) if skip_fenced else [],
563
+ )
135
564
  governing: str | None = None
136
- buf: list[str] = []
565
+ buf: list[int] = [] # line indexes of the open paragraph
137
566
 
138
567
  def _flush() -> None:
139
568
  if buf:
140
- text = "\n".join(buf).strip()
569
+ text = "\n".join(lines[i] for i in buf).strip()
141
570
  if text:
142
- paragraphs.append((governing, text))
571
+ doc.paragraphs.append(Paragraph(
572
+ header=governing,
573
+ line=buf[0] + 1,
574
+ text=text,
575
+ units=_units(
576
+ [lines[i] for i in buf],
577
+ [roles[i] is not None for i in buf],
578
+ buf[0] + 1,
579
+ ),
580
+ ))
143
581
  buf.clear()
144
582
 
145
- for raw_line in doc_text.splitlines():
583
+ for idx, raw_line in enumerate(lines):
584
+ if skip_fenced and roles[idx] is not None:
585
+ _flush()
586
+ continue
146
587
  m = _HEADER_RE.match(raw_line)
147
588
  if m is not None:
148
589
  # A header terminates the current paragraph; the header line
@@ -150,19 +591,202 @@ def parse_body_paragraphs(doc_text: str) -> list[tuple[str | None, str]]:
150
591
  _flush()
151
592
  level = len(m.group(1))
152
593
  header_text = _strip_closing_hashes(m.group(2)).strip()
594
+ doc.headings.append(header_text)
153
595
  if level <= 2:
154
596
  governing = header_text if header_text else None
155
597
  continue
156
598
  if raw_line.strip() == "":
157
599
  _flush()
158
600
  continue
159
- buf.append(raw_line)
601
+ buf.append(idx)
160
602
  _flush()
161
- return paragraphs
603
+ return doc
604
+
605
+
606
+ def parse_body_paragraphs(doc_text: str) -> list[tuple[str | None, str]]:
607
+ """Body paragraphs as (governing header text or None, paragraph text),
608
+ read the way comention reads them (fenced code is body text)."""
609
+ return [
610
+ (p.header, p.text)
611
+ for p in read_markdown(doc_text, skip_fenced=False).paragraphs
612
+ ]
162
613
 
163
614
 
164
615
  # --------------------------------------------------------------------------
165
- # Co-mention analysis
616
+ # Skills and matching
617
+ # --------------------------------------------------------------------------
618
+
619
+
620
+ def _norm(text: str) -> str:
621
+ """Lowercase, trimmed, with each run of whitespace collapsed."""
622
+ return " ".join(text.lower().split())
623
+
624
+
625
+ def _dedupe(items: tuple[str, ...], key=lambda s: s) -> tuple[str, ...]:
626
+ """Drop repeats (by key), keeping the first of each in order."""
627
+ seen: set[str] = set()
628
+ out: list[str] = []
629
+ for item in items:
630
+ k = key(item)
631
+ if k not in seen:
632
+ seen.add(k)
633
+ out.append(item)
634
+ return tuple(out)
635
+
636
+
637
+ def _term_body(term: str) -> str:
638
+ """Regex source for a term's words, with any whitespace between them."""
639
+ return r"\s+".join(re.escape(word) for word in term.split())
640
+
641
+
642
+ def _skill_pattern(skill: Skill) -> re.Pattern[str]:
643
+ """One case-insensitive pattern that matches any of the skill's terms
644
+ with no word character on either side."""
645
+ terms = sorted(skill.terms, key=lambda t: (-len(t), t))
646
+ return re.compile(
647
+ "|".join(rf"(?:(?<!\w){_term_body(t)}(?!\w))" for t in terms),
648
+ re.IGNORECASE,
649
+ )
650
+
651
+
652
+ class _Occurrence(NamedTuple):
653
+ """A match of a term in a text, and the skills it names there. `term`
654
+ is the matched term, normalised (mentions and infer only)."""
655
+
656
+ start: int
657
+ end: int
658
+ names: frozenset[str]
659
+ term: str = ""
660
+
661
+
662
+ class _EachSkill:
663
+ """comention's matcher: each skill's terms on their own, so `react`
664
+ also matches the `react` in `react-dom`."""
665
+
666
+ def __init__(self, specs: dict[str, Skill]) -> None:
667
+ self.patterns = {n: _skill_pattern(specs[n]) for n in sorted(specs)}
668
+
669
+ def names(self, text: str) -> set[str]:
670
+ return {n for n, pattern in self.patterns.items() if pattern.search(text)}
671
+
672
+ def find(self, text: str, among: frozenset[str]) -> list[_Occurrence]:
673
+ """Every match in the text of a term of a skill in `among`."""
674
+ return [
675
+ _Occurrence(m.start(), m.end(), frozenset((n,)))
676
+ for n in sorted(among)
677
+ for m in self.patterns[n].finditer(text)
678
+ ]
679
+
680
+
681
+ class _LongestTerm:
682
+ """The matcher of mentions and infer: one pattern over every skill's
683
+ terms, longest first, so each occurrence is the longest term that
684
+ matches there, and names the skills of that term."""
685
+
686
+ def __init__(self, specs: dict[str, Skill]) -> None:
687
+ owners: dict[str, set[str]] = {}
688
+ for skill in specs.values():
689
+ for term in skill.terms:
690
+ owners.setdefault(_norm(term), set()).add(skill.name)
691
+ self.owners = {t: frozenset(n) for t, n in owners.items()}
692
+ self.terms = sorted(self.owners, key=lambda t: (-len(t), t))
693
+ # Plain alternatives (no groups) keep the regex engine's fast path.
694
+ alternatives = "|".join(_term_body(t) for t in self.terms)
695
+ self.pattern = re.compile(
696
+ rf"(?<!\w)(?:{alternatives})(?!\w)", re.IGNORECASE
697
+ ) if self.terms else None
698
+
699
+ def names(self, text: str) -> set[str]:
700
+ return set().union(*(o.names for o in self.find(text)))
701
+
702
+ def find(
703
+ self, text: str, among: frozenset[str] | None = None
704
+ ) -> list[_Occurrence]:
705
+ """Each occurrence in the text, in order, with the skills it names
706
+ (only those in `among`, when given; an occurrence naming none of
707
+ them is left out)."""
708
+ found: list[_Occurrence] = []
709
+ for m in self.pattern.finditer(text) if self.pattern else ():
710
+ term = self._term(m.group(0))
711
+ names = self.owners.get(term, frozenset())
712
+ if among is not None:
713
+ names &= among
714
+ if names:
715
+ found.append(_Occurrence(m.start(), m.end(), names, term))
716
+ return found
717
+
718
+ def _term(self, text: str) -> str:
719
+ """The normalised term the pattern matched in `text`."""
720
+ term = _norm(text)
721
+ if term in self.owners:
722
+ return term
723
+ # A case-insensitive match that lowercasing does not map back to its
724
+ # term (the long s matches `s`): the first term, longest first, that
725
+ # matches the text whole is the one the pattern chose.
726
+ for term in self.terms:
727
+ if re.fullmatch(_term_body(term), text, re.IGNORECASE):
728
+ return term
729
+ return ""
730
+
731
+
732
+ def _coerce(skills: list[Skill | str]) -> dict[str, Skill]:
733
+ """Index skills by name, merging entries that share a name. A plain
734
+ string is a skill with no alias."""
735
+ merged: dict[str, Skill] = {}
736
+ for item in skills:
737
+ skill = item if isinstance(item, Skill) else Skill(item, (item,))
738
+ prev = merged.get(skill.name)
739
+ if prev is not None:
740
+ skill = Skill(
741
+ name=skill.name,
742
+ terms=_dedupe(prev.terms + skill.terms, key=_norm),
743
+ keywords=tuple(sorted(set(prev.keywords + skill.keywords))),
744
+ languages=tuple(sorted(set(prev.languages + skill.languages))),
745
+ docs=_dedupe(prev.docs + skill.docs),
746
+ )
747
+ merged[skill.name] = skill
748
+ return merged
749
+
750
+
751
+ def _string_list(entry: dict, key: str, where: str) -> tuple[str, ...]:
752
+ """The entry's optional `key`: an array of non-empty strings."""
753
+ value = entry.get(key)
754
+ if value is None:
755
+ return ()
756
+ if not isinstance(value, list):
757
+ raise UserError(
758
+ f"{where}.{key} must be an array of non-empty strings; "
759
+ f"got {type(value).__name__}"
760
+ )
761
+ out: list[str] = []
762
+ for j, item in enumerate(value):
763
+ if not isinstance(item, str) or not item.strip():
764
+ raise UserError(
765
+ f"{where}.{key}[{j}] must be a non-empty string; got {item!r}"
766
+ )
767
+ out.append(item.strip())
768
+ return tuple(out)
769
+
770
+
771
+ def _languages(entry: dict, where: str) -> tuple[str, ...]:
772
+ """The entry's optional `language`: a string, or an array of strings (a
773
+ stack lists its libraries' languages), normalised and sorted."""
774
+ value = entry.get("language")
775
+ if value is None:
776
+ return ()
777
+ items = [value] if isinstance(value, str) else value
778
+ if not isinstance(items, list) or not all(
779
+ isinstance(item, str) and item.strip() for item in items
780
+ ):
781
+ raise UserError(
782
+ f"{where}.language must be a non-empty string or an array of "
783
+ f"non-empty strings; got {value!r}"
784
+ )
785
+ return tuple(sorted({_norm(item) for item in items}))
786
+
787
+
788
+ # --------------------------------------------------------------------------
789
+ # Evidence
166
790
  # --------------------------------------------------------------------------
167
791
 
168
792
 
@@ -174,53 +798,488 @@ def _make_excerpt(text: str, limit: int = _EXCERPT_LIMIT) -> str:
174
798
  return collapsed
175
799
 
176
800
 
177
- def analyze(doc_text: str, skill_names: list[str]) -> dict:
178
- """Compute qualifying co-mention pairs and their evidence.
801
+ @functools.lru_cache(maxsize=256)
802
+ def _words(text: str) -> tuple[tuple[int, ...], tuple[int, ...], tuple[int, ...]]:
803
+ """The text's words, found once per text: where each starts and ends,
804
+ and the running total of their lengths."""
805
+ spans = [m.span() for m in _WORD_RE.finditer(text)]
806
+ return (
807
+ tuple(s for s, _ in spans),
808
+ tuple(e for _, e in spans),
809
+ tuple(itertools.accumulate((e - s for s, e in spans), initial=0)),
810
+ )
179
811
 
180
- Deterministic: same (doc_text, skill_names) → identical output.
181
- """
182
- names = sorted(set(skill_names))
183
- patterns = {
184
- n: re.compile(r"\b" + re.escape(n) + r"\b", re.IGNORECASE)
185
- for n in names
812
+
813
+ def _excerpt_around(
814
+ text: str,
815
+ first: tuple[int, int],
816
+ second: tuple[int, int],
817
+ limit: int = _EXCERPT_LIMIT,
818
+ ) -> str:
819
+ """Collapse `text` like an excerpt and cut it to `limit` characters
820
+ around two names, at the spans `first` and `second` (first starting
821
+ first): the words around both, or, when the words between them do not
822
+ fit, the words around each with the middle cut. `...` marks each cut."""
823
+ starts, ends, pre = _words(text)
824
+ last = len(starts) - 1
825
+
826
+ def join(i: int, j: int) -> str:
827
+ return " ".join(text[starts[k]:ends[k]] for k in range(i, j + 1))
828
+
829
+ if pre[-1] + last <= limit: # the whole text fits
830
+ return join(0, last)
831
+
832
+ def word(pos: int) -> int:
833
+ """The index of the word that holds text[pos]."""
834
+ return bisect.bisect_right(ends, pos)
835
+
836
+ def width(i: int, j: int) -> int:
837
+ """Words i..j joined, with a `...` for each side cut."""
838
+ return pre[j + 1] - pre[i] + (j - i) + 3 * (i > 0) + 3 * (j < last)
839
+
840
+ def grow(i: int, j: int, room: int) -> tuple[int, int]:
841
+ """Add words on both sides of i..j, in turn, while they fit."""
842
+ grown = True
843
+ while grown:
844
+ grown = False
845
+ if i > 0 and width(i - 1, j) <= room:
846
+ i, grown = i - 1, True
847
+ if j < last and width(i, j + 1) <= room:
848
+ j, grown = j + 1, True
849
+ return i, j
850
+
851
+ i, j = word(first[0]), word(max(first[1], second[1]) - 1)
852
+ first_end, second_start = word(first[1] - 1), word(second[0])
853
+ if width(i, j) <= limit or second_start <= first_end:
854
+ windows = [grow(i, j, limit)]
855
+ else:
856
+ # The words between the names do not fit: keep each name with the
857
+ # words around it, and cut the middle.
858
+ windows = [
859
+ grow(i, first_end, limit // 2), grow(second_start, j, limit // 2),
860
+ ]
861
+ quote = " ... ".join(join(a, b) for a, b in windows)
862
+ if windows[0][0] > 0:
863
+ quote = "..." + quote
864
+ if windows[-1][1] < last:
865
+ quote += "..."
866
+ # Only a name inside a word too long for its window overflows the limit.
867
+ return _make_excerpt(quote, limit)
868
+
869
+
870
+ def _pair_quote(
871
+ text: str,
872
+ line: int,
873
+ found_a: list[_Occurrence],
874
+ found_b: list[_Occurrence],
875
+ ) -> tuple[int, str]:
876
+ """Where `text`, whose first line is `line`, names two skills nearest
877
+ each other, given where it names each: the line of the first of the
878
+ two names, and the excerpt around them."""
879
+ # One pass in text order: each occurrence pairs with the latest one
880
+ # before it of the other skill (an occurrence of both, with itself).
881
+ marked = sorted(
882
+ [(o.start, o.end, 0, o) for o in found_a]
883
+ + [(o.start, o.end, 1, o) for o in found_b],
884
+ key=lambda m: m[:3],
885
+ )
886
+ latest: list[_Occurrence | None] = [None, None]
887
+ best: tuple[tuple[int, int], _Occurrence, _Occurrence] | None = None
888
+ for _, _, side, o in marked:
889
+ p = latest[1 - side]
890
+ if p is not None:
891
+ key = (max(p.end, o.end) - p.start, p.start)
892
+ if best is None or key < best[0]:
893
+ best = (key, p, o)
894
+ latest[side] = o
895
+ _, first, second = best # the text names both skills
896
+ return (
897
+ line + text.count("\n", 0, first.start),
898
+ _excerpt_around(
899
+ text, (first.start, first.end), (second.start, second.end)
900
+ ),
901
+ )
902
+
903
+
904
+ def _paragraph_evidence(
905
+ para: Paragraph, present: list[str], matcher: _EachSkill | _LongestTerm
906
+ ) -> list[tuple[tuple[str, str], dict]]:
907
+ """One evidence entry per pair of the skills a paragraph names, with
908
+ its kind and where the paragraph names the pair. `present` is sorted,
909
+ so every pair is (a, b) with a < b."""
910
+ if len(present) < 2:
911
+ return []
912
+ among = frozenset(present)
913
+ # Each unit, with where it names each skill.
914
+ units: list[tuple[Unit, dict[str, list[_Occurrence]]]] = []
915
+ for unit in para.units:
916
+ named: dict[str, list[_Occurrence]] = {}
917
+ for o in matcher.find(unit.text, among):
918
+ for n in o.names:
919
+ named.setdefault(n, []).append(o)
920
+ units.append((unit, named))
921
+ # pair -> (kind, unit_line, unit_excerpt); a co-mention wins over a
922
+ # lead-in, and the first unit (or lead-in and item) over later ones.
923
+ where: dict[tuple[str, str], tuple[str, int, str]] = {}
924
+ for unit, named in units:
925
+ for a, b in itertools.combinations(sorted(named), 2):
926
+ if (a, b) not in where:
927
+ where[a, b] = (CO_MENTION, *_pair_quote(
928
+ unit.text, unit.line, named[a], named[b]
929
+ ))
930
+ lead: int | None = None # the run of prose the next units sit under
931
+ for k, (unit, named) in enumerate(units):
932
+ if unit.prose:
933
+ lead = k
934
+ continue
935
+ if lead is None:
936
+ continue
937
+ lead_unit, lead_named = units[lead]
938
+ # Each skill of such a pair is named in one of the two units only:
939
+ # a unit that named both would have made it a co-mention.
940
+ pairs = [
941
+ (min(x, y), max(x, y))
942
+ for x, y in itertools.product(sorted(lead_named), sorted(named))
943
+ if (min(x, y), max(x, y)) not in where and x != y
944
+ ]
945
+ if not pairs:
946
+ continue
947
+ # The lead-in, then the item, with `...` for any items between.
948
+ gap = "\n" if k == lead + 1 else "\n...\n"
949
+ text = lead_unit.text + gap + unit.text
950
+ shift = len(lead_unit.text) + len(gap)
951
+ for a, b in pairs:
952
+ found = [
953
+ lead_named[n] if n in lead_named else [
954
+ o._replace(start=o.start + shift, end=o.end + shift)
955
+ for o in named[n]
956
+ ]
957
+ for n in (a, b)
958
+ ]
959
+ where[a, b] = (LEAD_IN, *_pair_quote(text, lead_unit.line, *found))
960
+ excerpt = _make_excerpt(para.text)
961
+ evidence = []
962
+ for pair in itertools.combinations(present, 2):
963
+ kind, unit_line, unit_excerpt = where.get(pair, (LIST_ONLY, None, None))
964
+ evidence.append((pair, {
965
+ "header": para.header,
966
+ "excerpt": excerpt,
967
+ "line": para.line,
968
+ "kind": kind,
969
+ "unit_line": unit_line,
970
+ "unit_excerpt": unit_excerpt,
971
+ }))
972
+ return evidence
973
+
974
+
975
+ def _pair_entry(a: str, b: str, evidence: list[dict]) -> dict:
976
+ return {
977
+ "a": a,
978
+ "b": b,
979
+ "paragraph_count": len(evidence),
980
+ "comention_count": sum(1 for e in evidence if e["kind"] == CO_MENTION),
981
+ "lead_in_count": sum(1 for e in evidence if e["kind"] == LEAD_IN),
982
+ "evidence": evidence,
186
983
  }
187
- paragraphs = parse_body_paragraphs(doc_text)
984
+
985
+
986
+ # --------------------------------------------------------------------------
987
+ # Subcommand logic
988
+ # --------------------------------------------------------------------------
989
+
990
+
991
+ def analyze(doc_text: str, skills: list[Skill | str]) -> dict:
992
+ """comention: every candidate pair a body paragraph names in one unit
993
+ or through a lead-in, with its evidence, each entry marking whether an
994
+ introductory section holds it, and the count of pairs only listed
995
+ together.
996
+
997
+ Deterministic: same (doc_text, skills) → identical output.
998
+ """
999
+ matcher = _EachSkill(_coerce(skills))
188
1000
 
189
1001
  excluded_sections: set[str] = set()
190
- # (a, b) -> list of evidence dicts, one per qualifying paragraph
1002
+ # (a, b) -> list of evidence dicts, one per paragraph naming both
191
1003
  pair_evidence: dict[tuple[str, str], list[dict]] = {}
192
1004
 
193
- for governing, text in paragraphs:
194
- if governing is not None:
195
- norm = normalize_header(governing)
1005
+ for para in read_markdown(doc_text, skip_fenced=False).paragraphs:
1006
+ excluded = False
1007
+ if para.header is not None:
1008
+ norm = normalize_header(para.header)
196
1009
  if norm in EXCLUDED_HEADERS:
197
1010
  excluded_sections.add(norm)
198
- continue
199
- # names is sorted, so `present` is sorted → pairs are (a < b)
200
- present = [n for n in names if patterns[n].search(text)]
201
- if len(present) < 2:
202
- continue
203
- excerpt = _make_excerpt(text)
204
- evidence_entry = {"header": governing, "excerpt": excerpt}
205
- for i in range(len(present)):
206
- for j in range(i + 1, len(present)):
207
- key = (present[i], present[j])
208
- pair_evidence.setdefault(key, []).append(evidence_entry)
1011
+ excluded = True
1012
+ # `present` is sorted → pairs are (a < b)
1013
+ present = sorted(matcher.names(para.text))
1014
+ for pair, entry in _paragraph_evidence(para, present, matcher):
1015
+ entry["excluded_section"] = excluded
1016
+ pair_evidence.setdefault(pair, []).append(entry)
209
1017
 
210
1018
  pairs: list[dict] = []
1019
+ list_only_pairs = 0
211
1020
  for (a, b), evidence in pair_evidence.items():
212
- if len(evidence) >= 2:
1021
+ entry = _pair_entry(a, b, evidence)
1022
+ if entry["comention_count"] or entry["lead_in_count"]:
1023
+ pairs.append(entry)
1024
+ else:
1025
+ list_only_pairs += 1
1026
+ pairs.sort(key=lambda p: (-p["paragraph_count"], p["a"], p["b"]))
1027
+
1028
+ return {
1029
+ "pairs": pairs,
1030
+ "list_only_pair_count": list_only_pairs,
1031
+ "excluded_section_count": len(excluded_sections),
1032
+ }
1033
+
1034
+
1035
+ def _named(occurrences: list[_Occurrence]) -> set[str]:
1036
+ return set().union(*(o.names for o in occurrences))
1037
+
1038
+
1039
+ def _spelling(skill: Skill, term: str) -> tuple[str, str]:
1040
+ """The skill's own spelling of a normalised term, and its kind."""
1041
+ for i, t in enumerate(skill.terms):
1042
+ if _norm(t) == term:
1043
+ return t, "name" if i == 0 else "alias"
1044
+ return term, "alias"
1045
+
1046
+
1047
+ def _squash(text: str) -> str:
1048
+ """Lowercased, with whitespace, hyphens, dots and underscores dropped."""
1049
+ return re.sub(r"[\s\-._]+", "", text.lower())
1050
+
1051
+
1052
+ def coverage(
1053
+ technologies: list[str], specs: dict[str, Skill], matcher: _LongestTerm
1054
+ ) -> tuple[list[dict], list[str]]:
1055
+ """Which skills cover each technology name, and the names none covers.
1056
+ Names are trimmed, and a repeat (ignoring case and runs of whitespace)
1057
+ is dropped. A term covers only by squashed equality: a name or an
1058
+ alias found only inside the name is listed as `name-contained` or
1059
+ `alias-contained` and covers nothing."""
1060
+ technologies = list(_dedupe(tuple(t.strip() for t in technologies), key=_norm))
1061
+ squashed = [
1062
+ (n, t, "name" if i == 0 else "alias", _squash(t))
1063
+ for n in sorted(specs) for i, t in enumerate(specs[n].terms)
1064
+ ]
1065
+ entries: list[dict] = []
1066
+ unverified: list[str] = []
1067
+ for tech in technologies:
1068
+ # skill -> (term, kind): the skills that cover the name.
1069
+ found: dict[str, tuple[str, str]] = {}
1070
+ # skill -> (term, kind): the skills one of whose terms sits inside
1071
+ # the name.
1072
+ contained: dict[str, tuple[str, str]] = {}
1073
+ for occurrence in matcher.find(tech):
1074
+ for n in sorted(occurrence.names):
1075
+ term, kind = _spelling(specs[n], occurrence.term)
1076
+ kind = NAME_CONTAINED if kind == "name" else ALIAS_CONTAINED
1077
+ contained.setdefault(n, (term, kind))
1078
+ target = _squash(tech)
1079
+ for n, term, kind, flat in squashed:
1080
+ if flat and flat == target:
1081
+ found.setdefault(n, (term, kind))
1082
+ covered_by = {
1083
+ n: {"skill": n, "term": term, "kind": kind}
1084
+ for n, (term, kind) in found.items()
1085
+ }
1086
+ for n, (term, kind) in contained.items():
1087
+ covered_by.setdefault(n, {"skill": n, "term": term, "kind": kind})
1088
+ entries.append({
1089
+ "name": tech,
1090
+ "covered_by": [covered_by[n] for n in sorted(covered_by)],
1091
+ })
1092
+ if not found:
1093
+ unverified.append(tech)
1094
+ return entries, unverified
1095
+
1096
+
1097
+ def mentions(
1098
+ doc_text: str,
1099
+ skills: list[Skill | str],
1100
+ technologies: list[str] | None = None,
1101
+ ) -> dict:
1102
+ """mentions: which skills the document names outside fenced code, the
1103
+ term that named each, the candidate pairs a body paragraph names in one
1104
+ unit or through a lead-in, and, given technology names, which skills
1105
+ cover each."""
1106
+ specs = _coerce(skills)
1107
+ names = sorted(specs)
1108
+ matcher = _LongestTerm(specs)
1109
+ doc = read_markdown(doc_text, skip_fenced=True)
1110
+
1111
+ # (skill, normalised term) -> [paragraphs, headings, fenced blocks]
1112
+ term_counts: dict[tuple[str, str], list[int]] = {}
1113
+
1114
+ def count_terms(occurrences: list[_Occurrence], slot: int) -> None:
1115
+ for name, term in {(n, o.term) for o in occurrences for n in o.names}:
1116
+ term_counts.setdefault((name, term), [0, 0, 0])[slot] += 1
1117
+
1118
+ hits: dict[str, list[dict]] = {n: [] for n in names}
1119
+ pair_evidence: dict[tuple[str, str], list[dict]] = {}
1120
+ for para in doc.paragraphs:
1121
+ occurrences = matcher.find(para.text)
1122
+ count_terms(occurrences, 0)
1123
+ present = sorted(_named(occurrences))
1124
+ if not present:
1125
+ continue
1126
+ excerpt = _make_excerpt(para.text)
1127
+ for n in present:
1128
+ hits[n].append(
1129
+ {"header": para.header, "excerpt": excerpt, "line": para.line}
1130
+ )
1131
+ for pair, entry in _paragraph_evidence(para, present, matcher):
1132
+ pair_evidence.setdefault(pair, []).append(entry)
1133
+
1134
+ in_headings: list[set[str]] = []
1135
+ for heading in doc.headings:
1136
+ occurrences = matcher.find(heading)
1137
+ count_terms(occurrences, 1)
1138
+ in_headings.append(_named(occurrences))
1139
+ in_fenced: list[set[str]] = []
1140
+ for fence in doc.fenced:
1141
+ occurrences = matcher.find(fence.text)
1142
+ count_terms(occurrences, 2)
1143
+ in_fenced.append(_named(occurrences))
1144
+ skills_out: list[dict] = []
1145
+ mentioned: list[str] = []
1146
+ fenced_only: list[str] = []
1147
+ unmentioned: list[str] = []
1148
+ for n in names:
1149
+ heading_count = sum(1 for found in in_headings if n in found)
1150
+ fenced_count = sum(1 for found in in_fenced if n in found)
1151
+ terms = []
1152
+ for term in specs[n].terms:
1153
+ counts = term_counts.get((n, _norm(term)))
1154
+ if counts:
1155
+ terms.append({
1156
+ "term": term,
1157
+ "kind": _spelling(specs[n], _norm(term))[1],
1158
+ "paragraph_count": counts[0],
1159
+ "heading_count": counts[1],
1160
+ "fenced_count": counts[2],
1161
+ })
1162
+ skills_out.append({
1163
+ "name": n,
1164
+ "paragraph_count": len(hits[n]),
1165
+ "heading_count": heading_count,
1166
+ "fenced_count": fenced_count,
1167
+ "terms": terms,
1168
+ "paragraphs": hits[n],
1169
+ })
1170
+ if hits[n] or heading_count:
1171
+ mentioned.append(n)
1172
+ elif fenced_count:
1173
+ fenced_only.append(n)
1174
+ else:
1175
+ unmentioned.append(n)
1176
+
1177
+ candidates: list[dict] = []
1178
+ list_only_pairs = 0
1179
+ for (a, b), evidence in pair_evidence.items():
1180
+ entry = _pair_entry(a, b, evidence)
1181
+ if entry["comention_count"] or entry["lead_in_count"]:
1182
+ candidates.append(entry)
1183
+ else:
1184
+ list_only_pairs += 1
1185
+ candidates.sort(key=lambda p: (
1186
+ -p["comention_count"], -p["lead_in_count"], -p["paragraph_count"],
1187
+ p["a"], p["b"],
1188
+ ))
1189
+
1190
+ result = {
1191
+ "skills": skills_out,
1192
+ "mentioned": mentioned,
1193
+ "fenced_only": fenced_only,
1194
+ "unmentioned": unmentioned,
1195
+ "candidates": candidates,
1196
+ "list_only_pair_count": list_only_pairs,
1197
+ "fenced_block_count": len(doc.fenced),
1198
+ "fenced_blocks": [{"line": f.line, "info": f.info} for f in doc.fenced],
1199
+ }
1200
+ if technologies is not None:
1201
+ entries, unverified = coverage(technologies, specs, matcher)
1202
+ result["technologies"] = entries
1203
+ result["unverified_technologies"] = unverified
1204
+ return result
1205
+
1206
+
1207
+ def _read_doc(path_text: str, owner: str) -> str:
1208
+ """Read one of a skill's `docs` files (infer)."""
1209
+ path = Path(path_text)
1210
+ if not path.is_file():
1211
+ raise UserError(f"docs file of skill {owner!r} not found: {path}")
1212
+ try:
1213
+ return path.read_text(encoding="utf-8")
1214
+ except (OSError, UnicodeDecodeError) as exc:
1215
+ raise UserError(
1216
+ f"failed to read docs file of skill {owner!r} ({path}): {exc}"
1217
+ ) from exc
1218
+
1219
+
1220
+ def infer(skills: list[Skill | str], top_k: int = DEFAULT_TOP_K) -> dict:
1221
+ """infer: candidate pairs without an architecture document, from one
1222
+ skill's docs naming the other or shared domain keywords, never a shared
1223
+ language alone. Top-K capped."""
1224
+ specs = _coerce(skills)
1225
+ names = sorted(specs)
1226
+ languages = {lang for s in specs.values() for lang in s.languages}
1227
+ matcher = _LongestTerm(specs)
1228
+
1229
+ # (documenting skill, named skill) -> its docs-mention entry
1230
+ mentioned: dict[tuple[str, str], dict] = {}
1231
+ for a in names:
1232
+ for doc in specs[a].docs:
1233
+ for lineno, line in enumerate(_read_doc(doc, a).splitlines(), start=1):
1234
+ named: set[str] = set()
1235
+ for occurrence in matcher.find(line):
1236
+ # An occurrence of the documenting skill's own term
1237
+ # names that skill, never another one.
1238
+ if a not in occurrence.names:
1239
+ named |= occurrence.names
1240
+ for b in sorted(named):
1241
+ found = mentioned.get((a, b))
1242
+ if found is None:
1243
+ mentioned[(a, b)] = {
1244
+ "kind": DOCS_MENTION,
1245
+ "documented_by": a,
1246
+ "doc": doc.replace("\\", "/"),
1247
+ "line": lineno,
1248
+ "line_count": 1,
1249
+ "excerpt": _make_excerpt(line),
1250
+ }
1251
+ else:
1252
+ found["line_count"] += 1
1253
+
1254
+ pairs: list[dict] = []
1255
+ language_only_pairs = 0
1256
+ for a, b in itertools.combinations(names, 2):
1257
+ evidence = [mentioned[k] for k in ((a, b), (b, a)) if k in mentioned]
1258
+ docs_mention_count = len(evidence)
1259
+ shared = sorted(
1260
+ (set(specs[a].keywords) & set(specs[b].keywords)) - languages
1261
+ )
1262
+ if shared:
1263
+ evidence.append({"kind": SHARED_KEYWORDS, "keywords": shared})
1264
+ if evidence:
213
1265
  pairs.append({
214
1266
  "a": a,
215
1267
  "b": b,
216
- "paragraph_count": len(evidence),
1268
+ "docs_mention_count": docs_mention_count,
1269
+ "shared_keyword_count": len(shared),
217
1270
  "evidence": evidence,
218
1271
  })
219
- pairs.sort(key=lambda p: (-p["paragraph_count"], p["a"], p["b"]))
1272
+ elif set(specs[a].languages) & set(specs[b].languages):
1273
+ language_only_pairs += 1
1274
+ pairs.sort(key=lambda p: (
1275
+ -p["docs_mention_count"], -p["shared_keyword_count"], p["a"], p["b"],
1276
+ ))
220
1277
 
221
1278
  return {
222
- "pairs": pairs,
223
- "excluded_section_count": len(excluded_sections),
1279
+ "pairs": pairs[:top_k],
1280
+ "truncated": len(pairs) > top_k,
1281
+ "total_pairs": len(pairs),
1282
+ "language_only_pair_count": language_only_pairs,
224
1283
  }
225
1284
 
226
1285
 
@@ -234,39 +1293,76 @@ def _read_source(source: str, label: str) -> str:
234
1293
  if source == "-":
235
1294
  try:
236
1295
  return sys.stdin.read()
237
- except OSError as exc:
1296
+ except (OSError, UnicodeDecodeError) as exc:
238
1297
  raise UserError(f"failed to read {label} from stdin: {exc}") from exc
239
1298
  path = Path(source)
240
1299
  if not path.is_file():
241
1300
  raise UserError(f"{label} file not found: {path}")
242
1301
  try:
243
1302
  return path.read_text(encoding="utf-8")
244
- except OSError as exc:
1303
+ except (OSError, UnicodeDecodeError) as exc:
245
1304
  raise UserError(f"failed to read {label} file {path}: {exc}") from exc
246
1305
 
247
1306
 
248
- def parse_skills(raw_text: str) -> list[str]:
249
- """Parse and validate the --skills JSON array of non-empty strings."""
1307
+ def parse_skills(raw_text: str) -> list[Skill]:
1308
+ """Parse and validate the --skills JSON array of skill names or objects."""
250
1309
  try:
251
1310
  data = json.loads(raw_text)
252
1311
  except json.JSONDecodeError as exc:
253
1312
  raise UserError(f"malformed JSON in --skills input: {exc}") from exc
254
1313
  if not isinstance(data, list):
255
1314
  raise UserError(
256
- "--skills input must be a JSON array of strings; "
1315
+ "--skills input must be a JSON array of skill names or objects; "
1316
+ f"got {type(data).__name__}"
1317
+ )
1318
+ skills: list[Skill] = []
1319
+ for idx, item in enumerate(data):
1320
+ where = f"--skills[{idx}]"
1321
+ if isinstance(item, str) and item.strip():
1322
+ skills.append(Skill(item, (item,)))
1323
+ continue
1324
+ if not isinstance(item, dict):
1325
+ raise UserError(
1326
+ f"{where} must be a non-empty string or an object with a "
1327
+ f"non-empty `name`; got {item!r}"
1328
+ )
1329
+ name = item.get("name")
1330
+ if not isinstance(name, str) or not name.strip():
1331
+ raise UserError(
1332
+ f"{where}.name must be a non-empty string; got {name!r}"
1333
+ )
1334
+ skills.append(Skill(
1335
+ name=name,
1336
+ terms=_dedupe((name, *_string_list(item, "aliases", where)), key=_norm),
1337
+ keywords=tuple(sorted({
1338
+ _norm(k) for k in _string_list(item, "keywords", where)
1339
+ })),
1340
+ languages=_languages(item, where),
1341
+ docs=_dedupe(_string_list(item, "docs", where)),
1342
+ ))
1343
+ return skills
1344
+
1345
+
1346
+ def parse_technologies(raw_text: str) -> list[str]:
1347
+ """Parse and validate the --technologies JSON array of names."""
1348
+ try:
1349
+ data = json.loads(raw_text)
1350
+ except json.JSONDecodeError as exc:
1351
+ raise UserError(f"malformed JSON in --technologies input: {exc}") from exc
1352
+ if not isinstance(data, list):
1353
+ raise UserError(
1354
+ "--technologies input must be a JSON array of names; "
257
1355
  f"got {type(data).__name__}"
258
1356
  )
259
- names: list[str] = []
260
1357
  for idx, item in enumerate(data):
261
- if not isinstance(item, str) or not item:
1358
+ if not isinstance(item, str) or not item.strip():
262
1359
  raise UserError(
263
- f"--skills[{idx}] must be a non-empty string; got {item!r}"
1360
+ f"--technologies[{idx}] must be a non-empty string; got {item!r}"
264
1361
  )
265
- names.append(item)
266
- return names
1362
+ return data
267
1363
 
268
1364
 
269
- def _cmd_comention(args: argparse.Namespace) -> int:
1365
+ def _read_doc_and_skills(args: argparse.Namespace) -> tuple[str, list[Skill]]:
270
1366
  if args.doc == "-" and args.skills == "-":
271
1367
  raise UserError(
272
1368
  "--doc and --skills cannot both read from stdin ('-')"
@@ -278,27 +1374,102 @@ def _cmd_comention(args: argparse.Namespace) -> int:
278
1374
  else:
279
1375
  doc_text = _read_source(args.doc, "--doc")
280
1376
  skills_text = _read_source(args.skills, "--skills")
281
- skill_names = parse_skills(skills_text)
1377
+ return doc_text, parse_skills(skills_text)
1378
+
282
1379
 
283
- result = analyze(doc_text, skill_names)
1380
+ def _emit(result: dict) -> int:
1381
+ json.dump(result, sys.stdout, indent=2)
1382
+ sys.stdout.write("\n")
1383
+ return 0
1384
+
1385
+
1386
+ def _cmd_comention(args: argparse.Namespace) -> int:
1387
+ doc_text, skills = _read_doc_and_skills(args)
1388
+ result = analyze(doc_text, skills)
284
1389
  if args.verbose:
285
1390
  print(
286
- f"analyzed {len(set(skill_names))} distinct skill names; "
287
- f"{len(result['pairs'])} qualifying pairs; "
288
- f"{result['excluded_section_count']} excluded sections",
1391
+ f"analyzed {len({s.name for s in skills})} distinct skill names; "
1392
+ f"{len(result['pairs'])} candidate pairs; "
1393
+ f"{result['list_only_pair_count']} list-only pairs; "
1394
+ f"{result['excluded_section_count']} introductory sections",
289
1395
  file=sys.stderr,
290
1396
  )
291
- json.dump(result, sys.stdout, indent=2)
292
- sys.stdout.write("\n")
293
- return 0
1397
+ return _emit(result)
1398
+
1399
+
1400
+ def _cmd_mentions(args: argparse.Namespace) -> int:
1401
+ if args.technologies == "-" and "-" in (args.doc, args.skills):
1402
+ raise UserError(
1403
+ "only one of --doc, --skills and --technologies can read from "
1404
+ "stdin ('-')"
1405
+ )
1406
+ doc_text, skills = _read_doc_and_skills(args)
1407
+ technologies = None
1408
+ if args.technologies is not None:
1409
+ technologies = parse_technologies(
1410
+ _read_source(args.technologies, "--technologies")
1411
+ )
1412
+ result = mentions(doc_text, skills, technologies)
1413
+ if args.verbose:
1414
+ summary = (
1415
+ f"analyzed {len(result['skills'])} distinct skill names; "
1416
+ f"{len(result['mentioned'])} mentioned, "
1417
+ f"{len(result['fenced_only'])} only in fenced code, "
1418
+ f"{len(result['unmentioned'])} unmentioned; "
1419
+ f"{len(result['candidates'])} candidate pairs; "
1420
+ f"{result['list_only_pair_count']} list-only pairs"
1421
+ )
1422
+ if technologies is not None:
1423
+ summary += (
1424
+ f"; {len(result['technologies'])} technologies, "
1425
+ f"{len(result['unverified_technologies'])} not covered"
1426
+ )
1427
+ print(summary, file=sys.stderr)
1428
+ return _emit(result)
1429
+
1430
+
1431
+ def _cmd_infer(args: argparse.Namespace) -> int:
1432
+ if args.top_k < 0:
1433
+ raise UserError(f"--top-k must be >= 0; got {args.top_k}")
1434
+ skills = parse_skills(_read_source(args.skills, "--skills"))
1435
+ result = infer(skills, top_k=args.top_k)
1436
+ if args.verbose:
1437
+ print(
1438
+ f"analyzed {len({s.name for s in skills})} distinct skill names; "
1439
+ f"{result['total_pairs']} candidate pairs, "
1440
+ f"{len(result['pairs'])} emitted; "
1441
+ f"{result['language_only_pair_count']} language-only pairs",
1442
+ file=sys.stderr,
1443
+ )
1444
+ return _emit(result)
1445
+
1446
+
1447
+ def _add_doc_and_skills(p: argparse.ArgumentParser, doc_help: str) -> None:
1448
+ p.add_argument("--doc", required=True, help=doc_help)
1449
+ p.add_argument(
1450
+ "--skills",
1451
+ required=True,
1452
+ help=(
1453
+ "path to a JSON array of loaded skills, or '-' for stdin. "
1454
+ "Shape: [\"<skill>\" | {\"name\": \"<skill>\", "
1455
+ "\"aliases\": [\"<term>\", ...]}, ...]"
1456
+ ),
1457
+ )
1458
+ p.add_argument(
1459
+ "--verbose",
1460
+ action="store_true",
1461
+ help="print a one-line summary to stderr",
1462
+ )
294
1463
 
295
1464
 
296
1465
  def _build_parser() -> argparse.ArgumentParser:
297
1466
  parser = argparse.ArgumentParser(
298
1467
  prog="skf-comention-pairs",
299
1468
  description=(
300
- "Compute compose-mode co-mention pairs from an architecture "
301
- "document (detect-integrations.md §2 compose branch)."
1469
+ "Report which loaded skills a document names and the pairs it "
1470
+ "names together (comention: detect-integrations.md §2 compose "
1471
+ "branch; mentions: refine-architecture scope sets), or candidate "
1472
+ "pairs from the skills' own docs and keywords (infer)."
302
1473
  ),
303
1474
  )
304
1475
  sub = parser.add_subparsers(dest="cmd", required=True)
@@ -306,29 +1477,68 @@ def _build_parser() -> argparse.ArgumentParser:
306
1477
  p_cm = sub.add_parser(
307
1478
  "comention",
308
1479
  help=(
309
- "emit qualifying co-mention pairs (>=2 body paragraphs, "
310
- "word-boundary, section-filtered) as JSON"
1480
+ "emit every candidate pair a body paragraph names in one unit "
1481
+ "or through a lead-in (word-boundary), with evidence kinds and "
1482
+ "introductory sections marked, and count the pairs only listed "
1483
+ "together, as JSON"
311
1484
  ),
312
1485
  )
313
- p_cm.add_argument(
314
- "--doc",
315
- required=True,
316
- help="path to the architecture markdown document, or '-' for stdin",
1486
+ _add_doc_and_skills(
1487
+ p_cm, "path to the architecture markdown document, or '-' for stdin"
1488
+ )
1489
+ p_cm.set_defaults(func=_cmd_comention)
1490
+
1491
+ p_mn = sub.add_parser(
1492
+ "mentions",
1493
+ help=(
1494
+ "emit which skills a document names (fenced code skipped), the "
1495
+ "term that named each, the pairs a paragraph names together "
1496
+ "and, with --technologies, which skills cover each name, as JSON"
1497
+ ),
1498
+ )
1499
+ _add_doc_and_skills(
1500
+ p_mn, "path to the markdown document, or '-' for stdin"
1501
+ )
1502
+ p_mn.add_argument(
1503
+ "--technologies",
1504
+ help=(
1505
+ "path to a JSON array of technology names as the document "
1506
+ "writes them, or '-' for stdin: adds which skills cover each "
1507
+ "name and the names none covers"
1508
+ ),
1509
+ )
1510
+ p_mn.set_defaults(func=_cmd_mentions)
1511
+
1512
+ p_in = sub.add_parser(
1513
+ "infer",
1514
+ help=(
1515
+ "emit candidate pairs with no architecture document: one "
1516
+ "skill's docs naming the other or shared domain keywords, never "
1517
+ "a shared language alone, Top-K capped, as JSON"
1518
+ ),
317
1519
  )
318
- p_cm.add_argument(
1520
+ p_in.add_argument(
319
1521
  "--skills",
320
1522
  required=True,
321
1523
  help=(
322
- "path to a JSON array of loaded skill names, or '-' for stdin. "
323
- "Shape: [\"<skill>\", ...]"
1524
+ "path to a JSON array of loaded skills, or '-' for stdin. "
1525
+ "Shape: [{\"name\": \"<skill>\", \"aliases\": [...], "
1526
+ "\"keywords\": [...], \"language\": \"<language>\" | [...], "
1527
+ "\"docs\": [\"<path>\", ...]}, ...]"
324
1528
  ),
325
1529
  )
326
- p_cm.add_argument(
1530
+ p_in.add_argument(
1531
+ "--top-k",
1532
+ type=int,
1533
+ default=DEFAULT_TOP_K,
1534
+ help=f"cap the output at the top N pairs (default {DEFAULT_TOP_K})",
1535
+ )
1536
+ p_in.add_argument(
327
1537
  "--verbose",
328
1538
  action="store_true",
329
1539
  help="print a one-line summary to stderr",
330
1540
  )
331
- p_cm.set_defaults(func=_cmd_comention)
1541
+ p_in.set_defaults(func=_cmd_infer)
332
1542
 
333
1543
  return parser
334
1544