bmad-module-skill-forge 2.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (437) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +49 -49
  3. package/docs/404.md +10 -8
  4. package/docs/_data/pinned.yaml +22 -13
  5. package/docs/_internal/STABILITY.md +58 -35
  6. package/docs/agents.md +39 -53
  7. package/docs/architecture.md +65 -51
  8. package/docs/bmad-synergy.md +53 -51
  9. package/docs/campaign.md +127 -75
  10. package/docs/concepts.md +34 -28
  11. package/docs/examples.md +70 -68
  12. package/docs/forge-auto.md +34 -31
  13. package/docs/getting-started.md +118 -57
  14. package/docs/how-it-works.md +25 -21
  15. package/docs/index.md +15 -15
  16. package/docs/skill-model.md +70 -59
  17. package/docs/troubleshooting.md +143 -18
  18. package/docs/verifying-a-skill.md +87 -57
  19. package/docs/why-skf.md +24 -22
  20. package/docs/workflows.md +274 -110
  21. package/package.json +18 -10
  22. package/src/forger/preferences.yaml +8 -1
  23. package/src/knowledge/agentskills-spec.md +2 -0
  24. package/src/knowledge/ccc-bridge.md +40 -24
  25. package/src/knowledge/confidence-tiers.md +9 -5
  26. package/src/knowledge/overview.md +3 -3
  27. package/src/knowledge/progressive-capability.md +8 -8
  28. package/src/knowledge/provenance-tracking.md +9 -5
  29. package/src/knowledge/qmd-registry.md +3 -3
  30. package/src/knowledge/skf-knowledge-index.csv +2 -2
  31. package/src/knowledge/skill-lifecycle.md +18 -11
  32. package/src/knowledge/split-body-strategy.md +0 -3
  33. package/src/knowledge/tool-resolution.md +4 -2
  34. package/src/knowledge/version-paths.md +101 -15
  35. package/src/module-help.csv +3 -3
  36. package/src/module.yaml +10 -0
  37. package/src/shared/data/ast-grep-recipes.yaml +608 -0
  38. package/src/shared/data/ide-context-files.json +30 -0
  39. package/src/shared/health-check.md +31 -59
  40. package/src/shared/references/description-guard-protocol.md +12 -9
  41. package/src/shared/references/feasibility-report-schema.md +19 -13
  42. package/src/shared/references/headless-gate-convention.md +55 -3
  43. package/src/shared/references/output-contract-schema.md +19 -8
  44. package/src/shared/references/pipeline-contracts.md +94 -29
  45. package/src/shared/references/tessl-review.md +77 -0
  46. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
  47. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
  48. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
  49. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
  50. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
  51. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
  52. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
  53. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
  54. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
  55. package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
  56. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
  57. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
  58. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
  59. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
  60. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
  61. package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
  62. package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
  63. package/src/shared/scripts/skf-atomic-write.py +233 -64
  64. package/src/shared/scripts/skf-build-change-manifest.py +2753 -28
  65. package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
  66. package/src/shared/scripts/skf-check-unit-records.py +184 -0
  67. package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
  68. package/src/shared/scripts/skf-classify-changed-files.py +772 -0
  69. package/src/shared/scripts/skf-comention-pairs.py +1333 -123
  70. package/src/shared/scripts/skf-compare-file-hashes.py +267 -105
  71. package/src/shared/scripts/skf-count-imports.py +1867 -0
  72. package/src/shared/scripts/skf-count-tokens.py +1 -1
  73. package/src/shared/scripts/skf-derive-assembly-shape.py +21 -3
  74. package/src/shared/scripts/skf-description-guard.py +99 -11
  75. package/src/shared/scripts/skf-detect-docs.py +694 -2
  76. package/src/shared/scripts/skf-detect-language.py +486 -107
  77. package/src/shared/scripts/skf-detect-registry.py +887 -0
  78. package/src/shared/scripts/skf-detect-scripts-assets.py +197 -55
  79. package/src/shared/scripts/skf-detect-tools.py +209 -27
  80. package/src/shared/scripts/skf-detect-workspaces.py +506 -30
  81. package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
  82. package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
  83. package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
  84. package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
  85. package/src/shared/scripts/skf-extract-public-api.py +5247 -96
  86. package/src/shared/scripts/skf-extraction-inventory.py +1280 -0
  87. package/src/shared/scripts/skf-extraction-snapshot.py +664 -0
  88. package/src/shared/scripts/skf-fetch-temporal.py +674 -0
  89. package/src/shared/scripts/skf-find-cycles.py +97 -29
  90. package/src/shared/scripts/skf-find-test-report.py +476 -0
  91. package/src/shared/scripts/skf-forge-tier-rw.py +469 -205
  92. package/src/shared/scripts/skf-github-fetch.py +365 -0
  93. package/src/shared/scripts/skf-github-probe.py +700 -0
  94. package/src/shared/scripts/skf-hash-content.py +180 -20
  95. package/src/shared/scripts/skf-language-corpora.py +1 -1
  96. package/src/shared/scripts/skf-load-provenance.py +161 -27
  97. package/src/shared/scripts/skf-manifest-ops.py +1 -1
  98. package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
  99. package/src/shared/scripts/skf-merge-doc-urls.py +143 -20
  100. package/src/shared/scripts/skf-names-present.py +221 -0
  101. package/src/shared/scripts/skf-pair-intersect.py +119 -28
  102. package/src/shared/scripts/skf-parse-gaps.py +1384 -0
  103. package/src/shared/scripts/skf-preapply.py +1 -1
  104. package/src/shared/scripts/skf-preflight.py +176 -28
  105. package/src/shared/scripts/skf-promote-staged.py +430 -0
  106. package/src/shared/scripts/skf-provenance-gap-dispatch.py +64 -8
  107. package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
  108. package/src/shared/scripts/skf-quick-batch.py +774 -0
  109. package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
  110. package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
  111. package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
  112. package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
  113. package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
  114. package/src/shared/scripts/skf-resolve-authoritative-files.py +155 -14
  115. package/src/shared/scripts/skf-resolve-package.py +523 -106
  116. package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
  117. package/src/shared/scripts/skf-run-lock.py +577 -0
  118. package/src/shared/scripts/skf-scan-manifests.py +998 -57
  119. package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
  120. package/src/shared/scripts/skf-severity-classify.py +712 -108
  121. package/src/shared/scripts/skf-shape-detect.py +298 -204
  122. package/src/shared/scripts/skf-shard-body.py +9 -8
  123. package/src/shared/scripts/skf-skill-inventory.py +2412 -89
  124. package/src/shared/scripts/skf-skills-module.py +472 -0
  125. package/src/shared/scripts/skf-source-tree.py +1796 -0
  126. package/src/shared/scripts/skf-structural-diff.py +787 -121
  127. package/src/shared/scripts/skf-tessl-review.py +706 -0
  128. package/src/shared/scripts/skf-update-active-symlink.py +120 -67
  129. package/src/shared/scripts/skf-update-run-state.py +624 -0
  130. package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
  131. package/src/shared/scripts/skf-validate-brief-schema.py +23 -2
  132. package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
  133. package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
  134. package/src/shared/scripts/skf-validate-output.py +712 -64
  135. package/src/shared/scripts/skf-validate-pins.py +1 -1
  136. package/src/shared/scripts/skf-verify-no-trace.py +116 -10
  137. package/src/shared/scripts/skf-verify-provenance-completeness.py +3945 -152
  138. package/src/shared/scripts/skf-write-skill-brief.py +406 -25
  139. package/src/shared/tool-requirements.yaml +201 -0
  140. package/src/skf-analyze-source/SKILL.md +41 -36
  141. package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
  142. package/src/skf-analyze-source/customize.toml +36 -24
  143. package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
  144. package/src/skf-analyze-source/references/continue.md +2 -13
  145. package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
  146. package/src/skf-analyze-source/references/generate-briefs.md +121 -55
  147. package/src/skf-analyze-source/references/headless-contract.md +42 -15
  148. package/src/skf-analyze-source/references/identify-units.md +96 -110
  149. package/src/skf-analyze-source/references/init.md +73 -71
  150. package/src/skf-analyze-source/references/map-and-detect.md +123 -105
  151. package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
  152. package/src/skf-analyze-source/references/recommend.md +51 -70
  153. package/src/skf-analyze-source/references/scan-project.md +60 -84
  154. package/src/skf-analyze-source/references/scan-root.md +39 -0
  155. package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
  156. package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
  157. package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
  158. package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
  159. package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
  160. package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
  161. package/src/skf-audit-skill/SKILL.md +43 -51
  162. package/src/skf-audit-skill/assets/drift-report-template.md +28 -3
  163. package/src/skf-audit-skill/customize.toml +41 -20
  164. package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
  165. package/src/skf-audit-skill/references/doc-drift.md +141 -0
  166. package/src/skf-audit-skill/references/headless-contract.md +29 -0
  167. package/src/skf-audit-skill/references/health-check.md +2 -0
  168. package/src/skf-audit-skill/references/init.md +149 -210
  169. package/src/skf-audit-skill/references/re-index.md +103 -110
  170. package/src/skf-audit-skill/references/report.md +96 -33
  171. package/src/skf-audit-skill/references/semantic-diff.md +34 -25
  172. package/src/skf-audit-skill/references/severity-classify.md +79 -65
  173. package/src/skf-audit-skill/references/severity-rules.md +15 -6
  174. package/src/skf-audit-skill/references/structural-diff.md +80 -90
  175. package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
  176. package/src/skf-audit-skill/scripts/render-drift-tables.py +712 -0
  177. package/src/skf-brief-skill/SKILL.md +37 -26
  178. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  179. package/src/skf-brief-skill/assets/scope-templates.md +18 -25
  180. package/src/skf-brief-skill/assets/skill-brief-schema.md +30 -23
  181. package/src/skf-brief-skill/customize.toml +48 -20
  182. package/src/skf-brief-skill/references/analyze-target.md +144 -127
  183. package/src/skf-brief-skill/references/confirm-brief.md +11 -9
  184. package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
  185. package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
  186. package/src/skf-brief-skill/references/gather-intent.md +87 -182
  187. package/src/skf-brief-skill/references/headless-args.md +84 -16
  188. package/src/skf-brief-skill/references/health-check.md +1 -1
  189. package/src/skf-brief-skill/references/invocation-contract.md +18 -13
  190. package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
  191. package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
  192. package/src/skf-brief-skill/references/scope-definition.md +123 -47
  193. package/src/skf-brief-skill/references/step-auto-brief.md +26 -100
  194. package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
  195. package/src/skf-brief-skill/references/version-resolution.md +7 -10
  196. package/src/skf-brief-skill/references/write-brief.md +66 -57
  197. package/src/skf-campaign/SKILL.md +31 -34
  198. package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
  199. package/src/skf-campaign/customize.toml +47 -16
  200. package/src/skf-campaign/references/campaign-contracts.md +39 -29
  201. package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
  202. package/src/skf-campaign/references/health-check.md +7 -16
  203. package/src/skf-campaign/references/step-01-setup.md +46 -84
  204. package/src/skf-campaign/references/step-02-strategy.md +31 -30
  205. package/src/skf-campaign/references/step-03-pins.md +26 -18
  206. package/src/skf-campaign/references/step-04-provenance.md +24 -23
  207. package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
  208. package/src/skf-campaign/references/step-06-batch.md +41 -34
  209. package/src/skf-campaign/references/step-07-capstone.md +40 -25
  210. package/src/skf-campaign/references/step-08-verify.md +28 -29
  211. package/src/skf-campaign/references/step-09-refine.md +29 -30
  212. package/src/skf-campaign/references/step-10-export.md +48 -36
  213. package/src/skf-campaign/references/step-11-maintenance.md +31 -34
  214. package/src/skf-campaign/references/step-resume.md +37 -68
  215. package/src/skf-campaign/scripts/campaign-deps.py +57 -18
  216. package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
  217. package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
  218. package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
  219. package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
  220. package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
  221. package/src/skf-campaign/scripts/campaign-report.py +204 -37
  222. package/src/skf-campaign/scripts/campaign-state.py +1128 -0
  223. package/src/skf-campaign/scripts/campaign-status.py +10 -74
  224. package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
  225. package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
  226. package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
  227. package/src/skf-campaign/templates/campaign-report-template.md +5 -1
  228. package/src/skf-campaign/templates/kickoff-template.md +0 -14
  229. package/src/skf-create-skill/SKILL.md +27 -20
  230. package/src/skf-create-skill/assets/compile-assembly-rules.md +11 -8
  231. package/src/skf-create-skill/assets/skill-sections.md +21 -11
  232. package/src/skf-create-skill/customize.toml +20 -8
  233. package/src/skf-create-skill/references/authoritative-files-protocol.md +36 -58
  234. package/src/skf-create-skill/references/batch-mode.md +81 -0
  235. package/src/skf-create-skill/references/ccc-index-check.md +27 -0
  236. package/src/skf-create-skill/references/compile.md +119 -70
  237. package/src/skf-create-skill/references/component-extraction.md +113 -91
  238. package/src/skf-create-skill/references/enrich.md +41 -23
  239. package/src/skf-create-skill/references/entry-points-by-hand.md +37 -0
  240. package/src/skf-create-skill/references/extract.md +204 -162
  241. package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
  242. package/src/skf-create-skill/references/extraction-patterns-tracing.md +11 -11
  243. package/src/skf-create-skill/references/extraction-patterns.md +588 -201
  244. package/src/skf-create-skill/references/generate-artifacts.md +145 -93
  245. package/src/skf-create-skill/references/health-check.md +1 -1
  246. package/src/skf-create-skill/references/load-brief.md +53 -36
  247. package/src/skf-create-skill/references/relabel-rule.md +12 -0
  248. package/src/skf-create-skill/references/report.md +37 -57
  249. package/src/skf-create-skill/references/source-resolution-protocols.md +58 -169
  250. package/src/skf-create-skill/references/step-auto-shard.md +20 -93
  251. package/src/skf-create-skill/references/step-doc-rot.md +44 -60
  252. package/src/skf-create-skill/references/step-doc-sources.md +28 -19
  253. package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
  254. package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
  255. package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
  256. package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
  257. package/src/skf-create-skill/references/validate.md +173 -99
  258. package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
  259. package/src/skf-create-stack-skill/SKILL.md +29 -56
  260. package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
  261. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
  262. package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
  263. package/src/skf-create-stack-skill/customize.toml +23 -12
  264. package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
  265. package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
  266. package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
  267. package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
  268. package/src/skf-create-stack-skill/references/generate-output.md +138 -128
  269. package/src/skf-create-stack-skill/references/init.md +90 -32
  270. package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
  271. package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
  272. package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
  273. package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
  274. package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
  275. package/src/skf-create-stack-skill/references/report.md +39 -33
  276. package/src/skf-create-stack-skill/references/validate.md +20 -39
  277. package/src/skf-drop-skill/SKILL.md +28 -51
  278. package/src/skf-drop-skill/customize.toml +44 -30
  279. package/src/skf-drop-skill/references/execute.md +83 -146
  280. package/src/skf-drop-skill/references/health-check.md +2 -0
  281. package/src/skf-drop-skill/references/invocation-contract.md +64 -0
  282. package/src/skf-drop-skill/references/report.md +46 -30
  283. package/src/skf-drop-skill/references/select.md +122 -124
  284. package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
  285. package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
  286. package/src/skf-export-skill/SKILL.md +40 -62
  287. package/src/skf-export-skill/assets/managed-section-format.md +14 -50
  288. package/src/skf-export-skill/assets/snippet-format.md +3 -1
  289. package/src/skf-export-skill/customize.toml +42 -13
  290. package/src/skf-export-skill/references/generate-snippet.md +54 -46
  291. package/src/skf-export-skill/references/health-check.md +2 -0
  292. package/src/skf-export-skill/references/invocation-contract.md +15 -0
  293. package/src/skf-export-skill/references/load-skill.md +88 -62
  294. package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
  295. package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
  296. package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
  297. package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
  298. package/src/skf-export-skill/references/package.md +15 -73
  299. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
  300. package/src/skf-export-skill/references/result-envelope.md +31 -16
  301. package/src/skf-export-skill/references/summary.md +41 -17
  302. package/src/skf-export-skill/references/token-report.md +4 -12
  303. package/src/skf-export-skill/references/update-context.md +118 -237
  304. package/src/skf-forger/SKILL.md +61 -37
  305. package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
  306. package/src/skf-forger/customize.toml +43 -0
  307. package/src/skf-forger/references/pipeline-mode.md +73 -18
  308. package/src/skf-forger/scripts/forge-status.py +299 -0
  309. package/src/skf-forger/scripts/parse-pipeline.py +322 -54
  310. package/src/skf-forger/scripts/pipeline-gate.py +428 -0
  311. package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
  312. package/src/skf-quick-skill/SKILL.md +54 -53
  313. package/src/skf-quick-skill/assets/skill-template.md +4 -2
  314. package/src/skf-quick-skill/customize.toml +51 -20
  315. package/src/skf-quick-skill/references/batch-mode.md +74 -21
  316. package/src/skf-quick-skill/references/compile.md +30 -31
  317. package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
  318. package/src/skf-quick-skill/references/finalize.md +69 -18
  319. package/src/skf-quick-skill/references/halt-contract.md +41 -29
  320. package/src/skf-quick-skill/references/health-check.md +5 -3
  321. package/src/skf-quick-skill/references/quick-extract.md +114 -45
  322. package/src/skf-quick-skill/references/registry-resolution.md +9 -64
  323. package/src/skf-quick-skill/references/resolve-target.md +116 -66
  324. package/src/skf-quick-skill/references/write-and-validate.md +79 -40
  325. package/src/skf-refine-architecture/SKILL.md +43 -53
  326. package/src/skf-refine-architecture/customize.toml +49 -20
  327. package/src/skf-refine-architecture/references/compile.md +97 -54
  328. package/src/skf-refine-architecture/references/exit-codes.md +30 -0
  329. package/src/skf-refine-architecture/references/finding-storage.md +21 -0
  330. package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
  331. package/src/skf-refine-architecture/references/improvements.md +14 -20
  332. package/src/skf-refine-architecture/references/init.md +98 -57
  333. package/src/skf-refine-architecture/references/issue-detection.md +32 -39
  334. package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
  335. package/src/skf-refine-architecture/references/report.md +65 -54
  336. package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
  337. package/src/skf-rename-skill/SKILL.md +19 -54
  338. package/src/skf-rename-skill/customize.toml +32 -26
  339. package/src/skf-rename-skill/references/execute.md +230 -184
  340. package/src/skf-rename-skill/references/exit-codes.md +7 -7
  341. package/src/skf-rename-skill/references/health-check.md +1 -7
  342. package/src/skf-rename-skill/references/invocation-contract.md +25 -0
  343. package/src/skf-rename-skill/references/report.md +63 -52
  344. package/src/skf-rename-skill/references/select.md +197 -115
  345. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
  346. package/src/skf-setup/SKILL.md +26 -26
  347. package/src/skf-setup/customize.toml +14 -10
  348. package/src/skf-setup/references/auto-index.md +50 -63
  349. package/src/skf-setup/references/ccc-index.md +29 -90
  350. package/src/skf-setup/references/detect-and-tier.md +58 -67
  351. package/src/skf-setup/references/health-check.md +2 -0
  352. package/src/skf-setup/references/invocation-contract.md +14 -0
  353. package/src/skf-setup/references/report.md +43 -166
  354. package/src/skf-setup/references/tier-rules.md +5 -5
  355. package/src/skf-setup/references/write-config.md +34 -57
  356. package/src/skf-test-skill/SKILL.md +17 -57
  357. package/src/skf-test-skill/assets/output-section-formats.md +33 -34
  358. package/src/skf-test-skill/customize.toml +27 -13
  359. package/src/skf-test-skill/references/coherence-check.md +134 -62
  360. package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
  361. package/src/skf-test-skill/references/coverage-check.md +209 -264
  362. package/src/skf-test-skill/references/detect-mode.md +5 -5
  363. package/src/skf-test-skill/references/external-validators.md +94 -82
  364. package/src/skf-test-skill/references/health-check.md +6 -7
  365. package/src/skf-test-skill/references/init.md +164 -91
  366. package/src/skf-test-skill/references/invocation-contract.md +66 -0
  367. package/src/skf-test-skill/references/migration-section-rules.md +5 -2
  368. package/src/skf-test-skill/references/report.md +164 -142
  369. package/src/skf-test-skill/references/score.md +80 -107
  370. package/src/skf-test-skill/references/scoring-rules.md +42 -155
  371. package/src/skf-test-skill/references/source-access-protocol.md +18 -94
  372. package/src/skf-test-skill/references/step-hard-gate.md +51 -33
  373. package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
  374. package/src/skf-test-skill/scripts/build-result-context.py +380 -0
  375. package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
  376. package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
  377. package/src/skf-test-skill/scripts/compute-score.py +223 -45
  378. package/src/skf-test-skill/scripts/gap-ledger.py +1666 -0
  379. package/src/skf-test-skill/scripts/hard-gate.py +203 -0
  380. package/src/skf-test-skill/scripts/load-coverage-inputs.py +1198 -0
  381. package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
  382. package/src/skf-test-skill/scripts/reconcile-coverage.py +547 -79
  383. package/src/skf-test-skill/scripts/score-signatures.py +479 -0
  384. package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
  385. package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
  386. package/src/skf-test-skill/scripts/verify-declared-numerator.py +133 -56
  387. package/src/skf-test-skill/templates/test-report-template.md +21 -15
  388. package/src/skf-update-skill/SKILL.md +38 -25
  389. package/src/skf-update-skill/customize.toml +27 -12
  390. package/src/skf-update-skill/references/detect-changes.md +253 -223
  391. package/src/skf-update-skill/references/gap-driven.md +336 -0
  392. package/src/skf-update-skill/references/health-check.md +31 -5
  393. package/src/skf-update-skill/references/init.md +254 -84
  394. package/src/skf-update-skill/references/invocation-contract.md +12 -0
  395. package/src/skf-update-skill/references/manual-section-rules.md +6 -23
  396. package/src/skf-update-skill/references/merge.md +182 -49
  397. package/src/skf-update-skill/references/re-extract.md +53 -262
  398. package/src/skf-update-skill/references/report.md +102 -35
  399. package/src/skf-update-skill/references/write.md +357 -155
  400. package/src/skf-update-skill/scripts/skf-new-file-diff.py +183 -46
  401. package/src/skf-verify-stack/SKILL.md +23 -42
  402. package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
  403. package/src/skf-verify-stack/customize.toml +55 -22
  404. package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
  405. package/src/skf-verify-stack/references/coverage.md +67 -64
  406. package/src/skf-verify-stack/references/exit-codes.md +21 -8
  407. package/src/skf-verify-stack/references/init.md +107 -62
  408. package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
  409. package/src/skf-verify-stack/references/integrations.md +106 -65
  410. package/src/skf-verify-stack/references/report.md +59 -42
  411. package/src/skf-verify-stack/references/requirements.md +32 -9
  412. package/src/skf-verify-stack/references/synthesize.md +53 -39
  413. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
  414. package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
  415. package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
  416. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
  417. package/tools/cli/commands/install.js +11 -4
  418. package/tools/cli/commands/status.js +15 -3
  419. package/tools/cli/commands/update.js +11 -4
  420. package/tools/cli/lib/tool-check.js +320 -0
  421. package/tools/cli/lib/version-check.js +22 -9
  422. package/tools/cli/skf-cli.js +4 -1
  423. package/.gitattributes +0 -16
  424. package/.nvmrc +0 -1
  425. package/docs/_internal/RELEASING.md +0 -679
  426. package/release-audits/v1.0.0-launch-audit.md +0 -728
  427. package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
  428. package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
  429. package/src/skf-campaign/manifest.yaml +0 -15
  430. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
  431. package/src/skf-create-skill/references/ecosystem-check.md +0 -74
  432. package/src/skf-drop-skill/references/headless-contract.md +0 -18
  433. package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
  434. package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
  435. package/src/skf-update-skill/references/validate.md +0 -124
  436. package/tools/validate-docs-drift.js +0 -233
  437. package/tools/validate-docs-links.js +0 -665
@@ -0,0 +1,1666 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # dependencies = []
5
+ # ///
6
+ """SKF test-skill gap ledger: one typed JSON record per finding.
7
+
8
+ test-skill's hard gate (step-hard-gate.md) must see every Critical and High
9
+ finding before scoring, and update-skill must read every gap with its source
10
+ and remediation after a FAIL. Markdown written by the model serves neither:
11
+ the coverage section holds tables and counts, the coherence formats carry no
12
+ severity, and the Gap Entry Format was applied only in the report step, after
13
+ the gate. This script keeps the findings in a JSON ledger instead. Each stage
14
+ appends the gaps it finds, classified against the Gap Severity table in
15
+ scoring-rules.md when they are written; hard-gate.py counts the blocking ones,
16
+ and the Gap Report is rendered from the same records on every path.
17
+
18
+ Ledger file: `{forge_version}/test-findings-{run_id}.json`
19
+
20
+ {
21
+ "schema_version": 1,
22
+ "run_id": "<run id from the file name>" | null,
23
+ "stages": ["coverage-check", "coherence-check", ...],
24
+ "records": [
25
+ {
26
+ "id": "GAP-001", # assigned here, in append order
27
+ "severity": "Critical" | "High" | "Medium" | "Low" | "Info",
28
+ "category": "<slug from CATEGORIES>",
29
+ "group": "Coverage" | "Coherence" | "Structural" | "Discovery" | "External",
30
+ "title": "<one line>",
31
+ "source": "<file:line or a section reference>",
32
+ "remediation": "<exact action that fixes the gap>",
33
+ "issue": "<what is wrong>", # optional
34
+ "export": "<export name>", # optional
35
+ "stage": "<the stage that appended it>"
36
+ }
37
+ ]
38
+ }
39
+
40
+ `stages` lists every stage that appended, even with no records, so the gate
41
+ can tell a stage that found nothing from a stage that never ran. `group` is
42
+ derived from the category; callers never supply `id`, `group` or `stage`.
43
+
44
+ Subcommands:
45
+
46
+ append --ledger <path> --stage <stage> [--input <file>]...
47
+ Read records from each <file> in turn, or from stdin without --input:
48
+ a JSON array, one record object, or {"records": [...]}. An empty array
49
+ records the stage with no findings. Every record is validated first
50
+ and nothing is written unless all are valid. A record equal to one
51
+ already in the ledger (same severity, category, title, source and
52
+ export) is not added again, so a stage that runs twice leaves one
53
+ copy. Creates the ledger when it does not exist.
54
+ Appends may overlap (parallel tool calls): each holds an exclusive
55
+ lock on <ledger>.skf-lock from its read to its write, and another
56
+ append waits for it (up to 30 s, then LEDGER_LOCKED), so no record an
57
+ append reported is lost. The write is atomic: a temp file of its own
58
+ beside the ledger, then a rename. The lock file is removed afterwards.
59
+ Output: {"status": "ok", "ledger", "stage", "appended": [ids],
60
+ "duplicates": [ids], "record_count"}
61
+
62
+ append --ledger <path> --stage <stage> --from <kind> --input <file>...
63
+ [--surface <file>] [--signatures <file>] [--numerator <file>]
64
+ [--stale <file>] [--provenance <file>] [--skill-dir <dir>]
65
+ [--metadata <path>] [--served <family>]...
66
+ Build the records from the result file a script wrote (see Adapters
67
+ below) instead of reading them, then append them as above, with the
68
+ same output. A gap a script finds is recorded with a fixed title,
69
+ Source, `export` and remediation, so a rerun dedupes it. Each kind
70
+ reads one --input (structure reads one or more) and only the options
71
+ its entry names; any other option, or a file that does not hold the
72
+ result its kind reads, is INVALID_INPUT and nothing is written.
73
+
74
+ Adapters (--from), each record classified by its Gap Severity row
75
+ (scoring-rules.md). `{meta}` is --metadata, the metadata.json path a record
76
+ cites (never read; default `metadata.json`):
77
+
78
+ coverage --input reconcile-coverage.py's result; --surface, --signatures,
79
+ --numerator, --stale, --provenance, --skill-dir, --metadata.
80
+ Barrel branch: each `missing` name is a Medium `missing-export`
81
+ gap `Missing export: {name}`, or a Medium `missing-type` gap
82
+ `Missing type: {name}` when the --signatures result lists it in
83
+ `missingTypes`; its Source is the file and line the --surface
84
+ result's `exports[]` records for it, else `{meta}`. Each `stale`
85
+ name the --stale result (classify-stale) marks `fabricated` is a
86
+ Critical `fabricated-signature` gap `Fabricated signature:
87
+ {name}` at its `source` (one with no `source` is INVALID_INPUT,
88
+ never a lesser gap). One whose `defined_at` (where the source
89
+ declares it) is a Python or TS/JS `file:line` is a documented
90
+ extra when its name has no dot and the --surface result was
91
+ built from an extraction that read the whole scope (an
92
+ `extraction` object, not `no-ast-grep`, `truncated` or
93
+ `fallback.needed`: only such an extraction fills
94
+ `excluded.outsideScope`, the names a brief scoped out) and its
95
+ `excluded.outsideScope` list does not list
96
+ it: an Info `observation` gap `Documented extra: {name}` at
97
+ `defined_at`. With no `defined_at`, a homonym (its `declared_in`
98
+ lists several declaring files) is one too, with --skill-dir,
99
+ when the skill's `[AST:path:L<n>]` and `[SRC:path:L<n>]`
100
+ citations on the lines of SKILL.md and references/**/*.md that
101
+ name it (outside the citations), or on the next non-blank line
102
+ after one, cite exactly one of those files: the gap
103
+ is at that file's `declared_in` item, and two cited or none
104
+ leave it Medium. The documented extra's issue names the line
105
+ that documents it (with --skill-dir) and its remediation asks no
106
+ change (update-skill never routes it) and says whether signature
107
+ scoring compared its documented signature (a name the
108
+ --signatures result lists in `comparedNames`). `excluded` is
109
+ read once a stale name that is not fabricated needs it, and a
110
+ malformed one is then INVALID_INPUT. Any other is a Medium `stale-documentation` gap
111
+ `Stale documentation: {name}` at the first line of SKILL.md,
112
+ then of references/*.md in path order, that writes the name
113
+ (validate-inventory.py's match, with --skill-dir), else
114
+ `SKILL.md`. Scalar and stack branches: a
115
+ `missingCount` above 0 is one Medium `missing-export` gap
116
+ `{missingCount} of {denominator} exports not documented` at
117
+ `{meta}`, except that a scalar count taken from the verified
118
+ numerator (`numeratorSource` `verified`) is replaced by one
119
+ `Missing export: {name}` gap per --numerator `absent` name, at
120
+ the first --provenance entry of that name, else `{meta}`.
121
+ Every per-name gap carries `export`.
122
+ guards --input load-coverage-inputs.py's surface result; --metadata.
123
+ `guards.deflation.fires` is a Medium `metadata-drift` gap at
124
+ `{meta}` (else the result's `inputs.metadata`), and
125
+ `guards.inflation.fires` a Medium `denominator-inflation` gap at
126
+ the result's `inputs.brief` (else `skill-brief.yaml`), whose
127
+ remediation recommends `stats.effective_denominator` when
128
+ `guards.umbrella.umbrella` is true, else `scope.tier_a_include`.
129
+ `guards.staleScope.fires` is one Medium `brief-scope-stale` gap
130
+ at that brief, whose issue names each `unmatchedInclude` glob,
131
+ each `restored` name with its file and each
132
+ `unmatchedTierAInclude` glob. The deflation and inflation gaps
133
+ say the brief has no `scope.tier_a_include` when
134
+ `guards.tierAInclude` lists no glob, that no glob matches a file
135
+ when each is an `unmatchedTierAInclude` glob, and else that no
136
+ glob counts a public name (a surface without
137
+ `guards.tierAInclude` reads the `unmatchedTierAInclude` globs
138
+ alone). A stored `stats.effective_denominator` of 0 (`pct`
139
+ null) is written as a count against 0, not a percentage, and
140
+ its fix re-runs Create Skill to count it again.
141
+ numerator --input verify-declared-numerator.py's result; --metadata.
142
+ `inflated` true is a High `numerator-inflation` gap at `{meta}`
143
+ whose issue lists the `absent` names.
144
+ metadata-coherence
145
+ --input check-metadata-coherence.py's result; --metadata. Each
146
+ `findings[]` entry, titled as it is and with its `detail` as the
147
+ issue, at `{meta}`: Medium is a Medium `metadata-drift` gap, Info
148
+ an Info `multi-denominator` gap.
149
+ provenance-line
150
+ --input skf-verify-provenance-completeness.py verify's result.
151
+ Each `stale[]` item whose reason is `line-not-definition`, at
152
+ its `{source_file}:{source_line}` with `export`: a Low
153
+ `provenance-line` gap `Provenance line is not the definition of
154
+ {export_name}` when `definition_lines` holds a line, else an Info
155
+ `provenance-unverified` gap `Provenance line not verified for
156
+ {export_name}`. Its other items are not gaps here.
157
+ coherence --input aggregate-coherence.py's result. Each
158
+ `invalidReferences[]` entry at `SKILL.md:{line}`, by `status`:
159
+ `missing` is a Critical `broken-reference` gap, `inaccurate` a
160
+ High `inaccurate-reference` gap (its `issues` as the issue) and
161
+ `escapes` a High `reference-escape` gap naming its `canonical`
162
+ path.
163
+ structure --input one or more skf-scan-skill-md-structure.py results,
164
+ each told apart by its keys; --served. A `scan --required-sections`
165
+ result: each family not `satisfied` is a High `structural` gap
166
+ `naive-coherence: missing required section: {family}`, unless
167
+ --served names it (another heading serves it: the caller's
168
+ judgment) or it is `description` and `frontmatter_description`
169
+ is true. A `scan` result: unbalanced fences are a High
170
+ `structural` gap, and each bare opening fence and each
171
+ `table_drift` row a Medium `structural` gap at `SKILL.md:{line}`.
172
+ A `usage-scope` result: each `zero_usage` export is a Medium
173
+ `structural` gap with `export`. A `scan` or `reference-check`
174
+ result whose `scripts_assets.missing` is true is a Medium
175
+ `scripts-assets` gap. The titles are coherence-check.md's
176
+ `naive-coherence:` texts; a `reference-check` result (contextual
177
+ mode) titles its gap `coherence:` instead.
178
+
179
+ render --ledger <path> [--heading]
180
+ Print the Gap Report body in Markdown on stdout: the totals, the
181
+ Remediation Summary table and one Gap Entry per record, Critical first,
182
+ then by id. Each entry names its category as `{group} ({category})`,
183
+ which skf-parse-gaps.py reads back for a report whose ledger is gone.
184
+ --heading adds the `## Gap Report` line. With no records the body says
185
+ no gaps were found.
186
+
187
+ summary --ledger <path>
188
+ Output: {"status": "ok", "ledger", "run_id", "stages", "total",
189
+ "counts": {severity: n}, "blocking", "non_blocking",
190
+ "by_category": {category: n}}
191
+
192
+ A Critical or High record blocks only when a stage before the hard gate
193
+ appended it. The report stage appends after the gate (the discovery test),
194
+ so render and summary count its Critical and High records as non-blocking:
195
+ render lists them on a line of their own, after the hard gate.
196
+
197
+ categories
198
+ Output the category vocabulary and the severities:
199
+ {"status": "ok", "severities": [...],
200
+ "categories": [{"category", "group", "description"}]}
201
+
202
+ Exit codes:
203
+ 0 the result was printed
204
+ 1 the ledger is missing, unreadable, not a valid ledger, held by another
205
+ append past the wait, or not writable
206
+ 2 the input records are invalid (nothing was written), or a usage error
207
+ (argparse: a missing or unknown argument, usage on stderr, no JSON)
208
+
209
+ Errors print {"status": "error", "code", "error", ...} on stdout, except for
210
+ render, which prints it on stderr so a redirected Gap Report stays clean. The
211
+ codes: LEDGER_MISSING, LEDGER_INVALID, LEDGER_LOCKED, WRITE_FAILED (exit 1),
212
+ INVALID_INPUT and INVALID_RECORD (exit 2, with `errors: [{index, error}]`).
213
+ """
214
+
215
+ from __future__ import annotations
216
+
217
+ import argparse
218
+ import contextlib
219
+ import errno
220
+ import importlib.util
221
+ import json
222
+ import os
223
+ import posixpath
224
+ import re
225
+ import secrets
226
+ import sys
227
+ import time
228
+ from pathlib import Path
229
+
230
+ if os.name == "nt":
231
+ import msvcrt
232
+ else:
233
+ import fcntl
234
+
235
+ SCHEMA_VERSION = 1
236
+
237
+ # How long an append waits for another append's lock before LEDGER_LOCKED.
238
+ # An append holds it for milliseconds; the wait covers a burst of parallel
239
+ # tool calls, not a writer that hangs.
240
+ LOCK_TIMEOUT_SECONDS = 30.0
241
+ _LOCK_POLL_SECONDS = 0.02
242
+ # Windows refuses to replace a file another process holds open, and a virus
243
+ # scanner or the search indexer may open a just-written ledger for a moment:
244
+ # save_ledger retries the rename there for up to this long before failing.
245
+ _IS_WINDOWS = os.name == "nt"
246
+ _REPLACE_WAIT_SECONDS = 5.0
247
+
248
+ SEVERITIES = ("Critical", "High", "Medium", "Low", "Info")
249
+ BLOCKING = frozenset({"Critical", "High"})
250
+ _SEVERITY_BY_LOWER = {s.lower(): s for s in SEVERITIES}
251
+ # Stages that append after the hard gate decided (report.md records the
252
+ # discovery test there): a Critical or High gap they record blocked nothing.
253
+ POST_GATE_STAGES = frozenset({"report"})
254
+
255
+ # Category slug -> (group, what the category covers). update-skill routes a
256
+ # gap on its category, not its severity, so the slugs are a closed set: a typo
257
+ # fails the append instead of reaching update-skill as an unknown category.
258
+ CATEGORIES: dict[str, tuple[str, str]] = {
259
+ "missing-export": ("Coverage", "an exported function or class the skill does not document"),
260
+ "signature-mismatch": ("Coverage", "a documented signature that differs from the source"),
261
+ "fabricated-signature": ("Coverage", "a documented export that is absent at the cited source line"),
262
+ "stale-documentation": ("Coverage", "documentation for an export the enumerated source surface lacks"),
263
+ "missing-type": ("Coverage", "a type or interface the skill does not document"),
264
+ "provenance-completeness": ("Coverage", "an export the skill documents that the provenance map lacks"),
265
+ "provenance-line": ("Coverage", "a provenance line that is not the definition of its export"),
266
+ "provenance-unverified": ("Coverage", "a provenance line the line-check rules could not verify"),
267
+ "metadata-drift": ("Coverage", "export counts in the metadata that diverge from each other"),
268
+ "denominator-inflation": ("Coverage", "a scope include union larger than the provenance map"),
269
+ "brief-scope-stale": (
270
+ "Coverage", "a scope.include or scope.tier_a_include glob in the brief that matches no source file"),
271
+ "numerator-inflation": (
272
+ "Coverage",
273
+ "a documented count equal to the denominator while declared exports are absent from the skill",
274
+ ),
275
+ "multi-denominator": ("Coverage", "barrel and documented-surface counts that diverge by design"),
276
+ "migration-section": ("Coverage", "a migration section that disagrees with the annotation data"),
277
+ "broken-reference": ("Coherence", "a reference whose target does not exist"),
278
+ "inaccurate-reference": ("Coherence", "a reference whose target exists but does not match it"),
279
+ "reference-escape": (
280
+ "Coherence",
281
+ "a reference whose real path leaves the skill, its source and, for a stack, the skills folder",
282
+ ),
283
+ "integration-pattern": ("Coherence", "an integration pattern that is incomplete"),
284
+ "split-body-mismatch": ("Coherence", "SKILL.md and a references/ file that disagree about an export"),
285
+ "scripts-assets": ("Coherence", "a Scripts & Assets section that is missing or names a missing file"),
286
+ "scripts-assets-provenance": ("Coherence", "a script or asset file without a provenance entry"),
287
+ "structural": (
288
+ "Structural",
289
+ "a section, code fence, table or usage example in SKILL.md that is missing or wrong",
290
+ ),
291
+ "metadata": ("Structural", "missing optional metadata or examples"),
292
+ "observation": ("Structural", "a style suggestion, a documented extra or another non-blocking observation"),
293
+ "discovery": ("Discovery", "a discovery test that misrouted prompts or did not run"),
294
+ "description": ("Discovery", "a description whose triggers need optimizing"),
295
+ "external-validator": ("External", "a skill-check diagnostic or a Tessl Review suggestion"),
296
+ }
297
+
298
+ REQUIRED_FIELDS = ("severity", "category", "title", "source", "remediation")
299
+ OPTIONAL_FIELDS = ("issue", "export")
300
+ ASSIGNED_FIELDS = ("id", "group", "stage")
301
+
302
+ # The Remediation Summary's Estimated Effort column, per severity.
303
+ EFFORT = {
304
+ "Critical": "Read the source code and write or correct the documentation",
305
+ "High": "Read the source code and write or correct the documentation",
306
+ "Medium": "Add type definitions, interface docs or the missing section",
307
+ "Low": "Add examples or metadata",
308
+ "Info": "Optional improvements, no action required",
309
+ }
310
+
311
+ _ID_RE = re.compile(r"^GAP-(\d{3,})$")
312
+ _STAGE_RE = re.compile(r"^[a-z][a-z0-9-]*$")
313
+ _RUN_ID_FROM_NAME = re.compile(r"^test-findings-(.+)\.json$")
314
+
315
+
316
+ class LedgerError(Exception):
317
+ """A ledger that is missing, unreadable or malformed."""
318
+
319
+ def __init__(self, code: str, message: str) -> None:
320
+ super().__init__(message)
321
+ self.code = code
322
+
323
+
324
+ # --------------------------------------------------------------------------
325
+ # Records
326
+ # --------------------------------------------------------------------------
327
+
328
+
329
+ def _one_line(value: str) -> str:
330
+ return " ".join(value.split())
331
+
332
+
333
+ def normalize_record(raw: object) -> tuple[dict | None, str | None]:
334
+ """Validate one input record. Returns (record, None) or (None, error).
335
+
336
+ >>> rec, err = normalize_record({"severity": "high", "category": "broken-reference",
337
+ ... "title": " Broken link ", "source": "SKILL.md:12", "remediation": "Fix it."})
338
+ >>> (rec["severity"], rec["title"], err)
339
+ ('High', 'Broken link', None)
340
+ >>> normalize_record({"severity": "Severe"})[1]
341
+ "missing required field 'category'"
342
+ """
343
+ if not isinstance(raw, dict):
344
+ return None, f"a record must be a JSON object, got {type(raw).__name__}"
345
+ for key in ASSIGNED_FIELDS:
346
+ if key in raw:
347
+ return None, f"'{key}' is assigned by the ledger; leave it out"
348
+ unknown = sorted(set(raw) - set(REQUIRED_FIELDS) - set(OPTIONAL_FIELDS))
349
+ if unknown:
350
+ return None, f"unknown field(s): {', '.join(unknown)}"
351
+ for key in REQUIRED_FIELDS:
352
+ if key not in raw:
353
+ return None, f"missing required field '{key}'"
354
+ if not isinstance(raw[key], str):
355
+ return None, f"'{key}' must be a string"
356
+ for key in OPTIONAL_FIELDS:
357
+ if raw.get(key) is not None and not isinstance(raw[key], str):
358
+ return None, f"'{key}' must be a string or null"
359
+ severity = _SEVERITY_BY_LOWER.get(raw["severity"].strip().lower())
360
+ if severity is None:
361
+ return None, (
362
+ f"unknown severity '{raw['severity']}' (expected one of {', '.join(SEVERITIES)})"
363
+ )
364
+ category = raw["category"].strip()
365
+ if category not in CATEGORIES:
366
+ return None, f"unknown category '{category}' (run the categories subcommand for the list)"
367
+ record = {
368
+ "severity": severity,
369
+ "category": category,
370
+ "group": CATEGORIES[category][0],
371
+ "title": _one_line(raw["title"]),
372
+ "source": _one_line(raw["source"]),
373
+ "remediation": raw["remediation"].strip(),
374
+ }
375
+ for key in ("title", "source", "remediation"):
376
+ if not record[key]:
377
+ return None, f"'{key}' must not be empty"
378
+ issue = (raw.get("issue") or "").strip()
379
+ if issue:
380
+ record["issue"] = issue
381
+ export = _one_line(raw.get("export") or "")
382
+ if export:
383
+ record["export"] = export
384
+ return record, None
385
+
386
+
387
+ def _dedupe_key(record: dict) -> tuple[str, str, str, str, str | None]:
388
+ """What makes two records the same gap. The export is part of it: two
389
+ missing exports can share a generic title and a barrel Source."""
390
+ return (
391
+ record["severity"],
392
+ record["category"],
393
+ record["title"],
394
+ record["source"],
395
+ record.get("export") or None,
396
+ )
397
+
398
+
399
+ def _id_number(record: dict) -> int:
400
+ m = _ID_RE.match(record.get("id", ""))
401
+ return int(m.group(1)) if m else 0
402
+
403
+
404
+ def ordered(records: list[dict]) -> list[dict]:
405
+ """Records by severity (Critical first), then by id."""
406
+ rank = {s: i for i, s in enumerate(SEVERITIES)}
407
+ return sorted(records, key=lambda r: (rank[r["severity"]], _id_number(r)))
408
+
409
+
410
+ def count_by_severity(records: list[dict]) -> dict[str, int]:
411
+ counts = {s: 0 for s in SEVERITIES}
412
+ for record in records:
413
+ counts[record["severity"]] += 1
414
+ return counts
415
+
416
+
417
+ def is_blocking(record: dict) -> bool:
418
+ """A Critical or High gap that a stage before the hard gate recorded.
419
+
420
+ >>> is_blocking({"severity": "High", "stage": "coherence-check"})
421
+ True
422
+ >>> is_blocking({"severity": "High", "stage": "report"})
423
+ False
424
+ """
425
+ return record["severity"] in BLOCKING and record.get("stage") not in POST_GATE_STAGES
426
+
427
+
428
+ # --------------------------------------------------------------------------
429
+ # Ledger file
430
+ # --------------------------------------------------------------------------
431
+
432
+
433
+ def new_ledger(path: Path) -> dict:
434
+ m = _RUN_ID_FROM_NAME.match(path.name)
435
+ return {
436
+ "schema_version": SCHEMA_VERSION,
437
+ "run_id": m.group(1) if m else None,
438
+ "stages": [],
439
+ "records": [],
440
+ }
441
+
442
+
443
+ def validate_ledger(data: object) -> None:
444
+ """Raise LedgerError unless `data` is a well-formed ledger."""
445
+ if not isinstance(data, dict):
446
+ raise LedgerError("LEDGER_INVALID", "the ledger must be a JSON object")
447
+ if data.get("schema_version") != SCHEMA_VERSION:
448
+ raise LedgerError(
449
+ "LEDGER_INVALID",
450
+ f"unsupported schema_version {data.get('schema_version')!r} (expected {SCHEMA_VERSION})",
451
+ )
452
+ stages = data.get("stages")
453
+ if not isinstance(stages, list) or not all(isinstance(s, str) for s in stages):
454
+ raise LedgerError("LEDGER_INVALID", "'stages' must be a list of strings")
455
+ records = data.get("records")
456
+ if not isinstance(records, list):
457
+ raise LedgerError("LEDGER_INVALID", "'records' must be a list")
458
+ seen: set[str] = set()
459
+ for index, record in enumerate(records):
460
+ if not isinstance(record, dict):
461
+ raise LedgerError("LEDGER_INVALID", f"record {index} is not an object")
462
+ rid = record.get("id")
463
+ if not isinstance(rid, str) or not _ID_RE.match(rid):
464
+ raise LedgerError("LEDGER_INVALID", f"record {index} has no valid id")
465
+ if rid in seen:
466
+ raise LedgerError("LEDGER_INVALID", f"duplicate id {rid}")
467
+ seen.add(rid)
468
+ if record.get("severity") not in SEVERITIES:
469
+ raise LedgerError("LEDGER_INVALID", f"{rid} has an unknown severity")
470
+ if record.get("category") not in CATEGORIES:
471
+ raise LedgerError("LEDGER_INVALID", f"{rid} has an unknown category")
472
+ for key in ("title", "source", "remediation"):
473
+ if not isinstance(record.get(key), str) or not record[key]:
474
+ raise LedgerError("LEDGER_INVALID", f"{rid} has no {key}")
475
+ for key in OPTIONAL_FIELDS:
476
+ if record.get(key) is not None and not isinstance(record[key], str):
477
+ raise LedgerError("LEDGER_INVALID", f"{rid} has a {key} that is not a string")
478
+
479
+
480
+ def load_ledger(path: Path) -> dict:
481
+ """Read and validate a ledger. Raises LedgerError."""
482
+ if not path.is_file():
483
+ raise LedgerError("LEDGER_MISSING", f"no ledger at {path}")
484
+ try:
485
+ data = json.loads(path.read_text(encoding="utf-8"))
486
+ except (OSError, UnicodeDecodeError) as exc:
487
+ raise LedgerError("LEDGER_INVALID", f"cannot read {path}: {exc}") from exc
488
+ except json.JSONDecodeError as exc:
489
+ raise LedgerError("LEDGER_INVALID", f"{path} is not valid JSON: {exc}") from exc
490
+ validate_ledger(data)
491
+ return data
492
+
493
+
494
+ def save_ledger(path: Path, data: dict) -> None:
495
+ """Write the ledger atomically: a temp file of its own beside it, then a
496
+ rename. Call it under ledger_lock() so no other append runs between the
497
+ read and this write."""
498
+ path.parent.mkdir(parents=True, exist_ok=True)
499
+ tmp = path.with_name(f"{path.name}.{os.getpid()}-{secrets.token_hex(4)}.skf-tmp")
500
+ payload = (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
501
+ # O_EXCL: never write into another writer's temp file. O_BINARY (Windows
502
+ # only; 0 elsewhere) keeps the \n line ends.
503
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
504
+ fd = os.open(tmp, flags, 0o644)
505
+ try:
506
+ with open(fd, "wb") as fh:
507
+ fh.write(payload)
508
+ fh.flush()
509
+ os.fsync(fh.fileno())
510
+ deadline = time.monotonic() + _REPLACE_WAIT_SECONDS
511
+ while True:
512
+ try:
513
+ os.replace(tmp, path)
514
+ break
515
+ except PermissionError:
516
+ if not _IS_WINDOWS or time.monotonic() >= deadline:
517
+ raise
518
+ time.sleep(_LOCK_POLL_SECONDS)
519
+ except BaseException:
520
+ with contextlib.suppress(OSError):
521
+ tmp.unlink()
522
+ raise
523
+
524
+
525
+ def _try_lock(fd: int) -> bool:
526
+ """Lock fd exclusively without waiting; False while another process holds it."""
527
+ try:
528
+ if os.name == "nt":
529
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
530
+ else:
531
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
532
+ except OSError as exc:
533
+ if exc.errno in (errno.EAGAIN, errno.EACCES, errno.EDEADLK):
534
+ return False
535
+ raise
536
+ return True
537
+
538
+
539
+ def _unlock(fd: int) -> None:
540
+ with contextlib.suppress(OSError):
541
+ if os.name == "nt":
542
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
543
+ else:
544
+ fcntl.flock(fd, fcntl.LOCK_UN)
545
+
546
+
547
+ def _names_open_file(fd: int, path: Path) -> bool:
548
+ """True when `path` still names the file open as `fd`."""
549
+ try:
550
+ return os.path.samestat(os.fstat(fd), os.stat(path))
551
+ except OSError:
552
+ return False
553
+
554
+
555
+ @contextlib.contextmanager
556
+ def ledger_lock(path: Path, timeout: float | None = None):
557
+ """Hold the exclusive append lock on `<ledger>.skf-lock`.
558
+
559
+ The same fcntl / msvcrt lock as skf-atomic-write.py flip-link, but this
560
+ one waits: up to `timeout` seconds (LOCK_TIMEOUT_SECONDS), then it raises
561
+ LedgerError LEDGER_LOCKED. The lock dies with its process, so a crashed
562
+ append leaves nothing to clean up. The lock file is removed on release.
563
+ On POSIX that happens while the lock is still held, and a waiter checks
564
+ that the file it locked is still the one at the path: otherwise a waiter
565
+ on the removed file and a newcomer on a new one would both get a lock.
566
+ Windows removes no file that is open, so there the file goes after the
567
+ release, unless a waiter has it open. While the removal is under way (the
568
+ remover's own delete handle is open, or a virus scanner's or the search
569
+ indexer's), Windows refuses a new open of the file with PermissionError:
570
+ an append takes that for a held lock and tries again until the same
571
+ deadline, then raises LEDGER_LOCKED naming the last refusal. A refusal
572
+ with a folder at the path, or with nothing there on an immediate second
573
+ try, is a lasting denial and is raised as is.
574
+ """
575
+ timeout = LOCK_TIMEOUT_SECONDS if timeout is None else timeout
576
+ lock_path = path.with_name(path.name + ".skf-lock")
577
+ path.parent.mkdir(parents=True, exist_ok=True)
578
+ deadline = time.monotonic() + timeout
579
+
580
+ def refuse(cause: OSError | None = None) -> LedgerError:
581
+ message = f"another append held {lock_path} for more than {timeout:g} s"
582
+ if cause is not None:
583
+ message += f" (last open refused: {cause})"
584
+ return LedgerError("LEDGER_LOCKED", message)
585
+
586
+ vanished = False
587
+ while True:
588
+ try:
589
+ fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o644)
590
+ except PermissionError as exc:
591
+ # Windows refuses to open a file whose removal is pending. A folder
592
+ # at the path, or nothing there twice running (once can be the
593
+ # removal ending just now), is a lasting denial: raise it.
594
+ if not _IS_WINDOWS or os.path.isdir(lock_path):
595
+ raise
596
+ if not os.path.lexists(lock_path):
597
+ if vanished:
598
+ raise
599
+ vanished = True
600
+ continue
601
+ vanished = False
602
+ if time.monotonic() >= deadline:
603
+ raise refuse(exc) from exc
604
+ time.sleep(_LOCK_POLL_SECONDS)
605
+ continue
606
+ try:
607
+ while not _try_lock(fd):
608
+ if time.monotonic() >= deadline:
609
+ raise refuse()
610
+ time.sleep(_LOCK_POLL_SECONDS)
611
+ if os.name == "nt" or _names_open_file(fd, lock_path):
612
+ break
613
+ _unlock(fd)
614
+ except BaseException:
615
+ os.close(fd)
616
+ raise
617
+ os.close(fd)
618
+ if time.monotonic() >= deadline:
619
+ raise refuse()
620
+ try:
621
+ yield
622
+ finally:
623
+ if os.name != "nt":
624
+ with contextlib.suppress(OSError):
625
+ lock_path.unlink()
626
+ _unlock(fd)
627
+ os.close(fd)
628
+ if os.name == "nt":
629
+ with contextlib.suppress(OSError):
630
+ lock_path.unlink()
631
+
632
+
633
+ def append_records(ledger: dict, stage: str, records: list[dict]) -> tuple[list[str], list[str]]:
634
+ """Append validated records in place. Returns (appended ids, duplicate ids)."""
635
+ existing = {_dedupe_key(r): r["id"] for r in ledger["records"]}
636
+ next_number = max((_id_number(r) for r in ledger["records"]), default=0) + 1
637
+ appended: list[str] = []
638
+ duplicates: list[str] = []
639
+ for record in records:
640
+ key = _dedupe_key(record)
641
+ if key in existing:
642
+ duplicates.append(existing[key])
643
+ continue
644
+ rid = f"GAP-{next_number:03d}"
645
+ next_number += 1
646
+ ledger["records"].append({"id": rid, **record, "stage": stage})
647
+ existing[key] = rid
648
+ appended.append(rid)
649
+ if stage not in ledger["stages"]:
650
+ ledger["stages"].append(stage)
651
+ return appended, duplicates
652
+
653
+
654
+ def parse_input(text: str) -> list:
655
+ """The records an append reads: an array, one object or {"records": [...]}."""
656
+ data = json.loads(text)
657
+ if isinstance(data, list):
658
+ return data
659
+ if isinstance(data, dict):
660
+ if set(data) == {"records"}:
661
+ if not isinstance(data["records"], list):
662
+ raise ValueError("'records' must be a list")
663
+ return data["records"]
664
+ return [data]
665
+ raise ValueError("input must be a JSON array or object")
666
+
667
+
668
+ # --------------------------------------------------------------------------
669
+ # Adapters: a script's result file -> records (append --from)
670
+ # --------------------------------------------------------------------------
671
+
672
+
673
+ class AdapterError(Exception):
674
+ """A --from input that does not hold the result its kind reads."""
675
+
676
+
677
+ FAMILIES = ("description", "usage", "api_surface")
678
+ # The options (argparse dests) each kind reads, besides --input. A kind
679
+ # refuses any other, so a call that passes a file it would ignore fails.
680
+ ADAPTER_OPTIONS: dict[str, frozenset[str]] = {
681
+ "coverage": frozenset(
682
+ {"surface", "signatures", "numerator", "stale", "provenance", "skill_dir", "metadata"}
683
+ ),
684
+ "guards": frozenset({"metadata"}),
685
+ "numerator": frozenset({"metadata"}),
686
+ "metadata-coherence": frozenset({"metadata"}),
687
+ "provenance-line": frozenset(),
688
+ "coherence": frozenset(),
689
+ "structure": frozenset({"served"}),
690
+ }
691
+ ALL_ADAPTER_OPTIONS = frozenset().union(*ADAPTER_OPTIONS.values())
692
+ _DEFAULT_METADATA = "metadata.json"
693
+ _DEFAULT_BRIEF = "skill-brief.yaml"
694
+ # The remediation of a missing required section: the SKF template heading
695
+ # that serves the family.
696
+ _SECTION_FIX = {
697
+ "description": (
698
+ "Add a `## Overview` section to SKILL.md that says what the library does and when "
699
+ "to use it, or a non-empty `description` to its frontmatter."
700
+ ),
701
+ "usage": (
702
+ "Add a `## Quick Start` section to SKILL.md with a runnable example of the "
703
+ "library's most common calls."
704
+ ),
705
+ "api_surface": (
706
+ "Add a `## Key API Summary` section to SKILL.md that lists the library's exports "
707
+ "with their purpose."
708
+ ),
709
+ }
710
+
711
+ _SIBLINGS: dict[str, object] = {}
712
+
713
+
714
+ def _sibling(filename: str):
715
+ """A script beside this one, loaded once: validate-inventory.py (the
716
+ documented-name match the stale-documentation Source uses)."""
717
+ module = _SIBLINGS.get(filename)
718
+ if module is None:
719
+ path = Path(__file__).resolve().parent / filename
720
+ spec = importlib.util.spec_from_file_location("skf_" + filename[:-3].replace("-", "_"), path)
721
+ if spec is None or spec.loader is None or not path.is_file():
722
+ raise AdapterError(f"{filename} not found beside {Path(__file__).name}")
723
+ module = importlib.util.module_from_spec(spec)
724
+ spec.loader.exec_module(module)
725
+ _SIBLINGS[filename] = module
726
+ return module
727
+
728
+
729
+ def read_result(path: str, flag: str):
730
+ """The JSON a script wrote. Raises AdapterError."""
731
+ try:
732
+ return json.loads(Path(path).read_bytes().decode("utf-8-sig"))
733
+ except (OSError, UnicodeDecodeError) as exc:
734
+ raise AdapterError(f"cannot read {flag} {path}: {exc}") from exc
735
+ except json.JSONDecodeError as exc:
736
+ raise AdapterError(f"{flag} {path} is not JSON: {exc.msg}") from exc
737
+
738
+
739
+ def _object(data: object, label: str, *keys: str) -> dict:
740
+ """`data` as the object a script writes, with each of `keys`."""
741
+ if not isinstance(data, dict) or any(key not in data for key in keys):
742
+ needs = ", ".join(f"'{key}'" for key in keys)
743
+ raise AdapterError(f"{label} is not that script's result: it needs an object with {needs}")
744
+ return data
745
+
746
+
747
+ def _list(data: dict, key: str, label: str) -> list:
748
+ value = data.get(key)
749
+ if value is None:
750
+ return []
751
+ if not isinstance(value, list):
752
+ raise AdapterError(f"{label}: '{key}' must be a list")
753
+ return value
754
+
755
+
756
+ def _names(data: dict, key: str, label: str) -> list[str]:
757
+ return [n for n in _list(data, key, label) if isinstance(n, str) and n]
758
+
759
+
760
+ def _count(value: object) -> int | None:
761
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
762
+
763
+
764
+ def _code(names: list[str]) -> str:
765
+ return ", ".join(f"`{n}`" for n in names)
766
+
767
+
768
+ def _gap(severity: str, category: str, title: str, source: str, remediation: str,
769
+ issue: str | None = None, export: str | None = None) -> dict:
770
+ record = {"severity": severity, "category": category, "title": title, "source": source,
771
+ "remediation": remediation}
772
+ if issue:
773
+ record["issue"] = issue
774
+ if export:
775
+ record["export"] = export
776
+ return record
777
+
778
+
779
+ def _surface_sources(surface: dict | None) -> dict[str, str]:
780
+ """name -> `file:line` (or `file`) from a surface result's `exports[]`:
781
+ the first record with a file, one with a line preferred."""
782
+ sources: dict[str, tuple[bool, str]] = {}
783
+ for rec in (surface or {}).get("exports") or []:
784
+ if not isinstance(rec, dict) or not isinstance(rec.get("name"), str):
785
+ continue
786
+ file = rec.get("file")
787
+ if not isinstance(file, str) or not file:
788
+ continue
789
+ line = _count(rec.get("line"))
790
+ where = (line is not None, f"{file}:{line}" if line is not None else file)
791
+ known = sources.get(rec["name"])
792
+ if known is None or (where[0] and not known[0]):
793
+ sources[rec["name"]] = where
794
+ return {name: where for name, (_, where) in sources.items()}
795
+
796
+
797
+ def _provenance_sources(provenance: dict | None) -> dict[str, str]:
798
+ """export_name -> `source_file:source_line` of its first provenance entry."""
799
+ sources: dict[str, str] = {}
800
+ for entry in (provenance or {}).get("entries") or []:
801
+ if not isinstance(entry, dict) or not isinstance(entry.get("export_name"), str):
802
+ continue
803
+ file = entry.get("source_file")
804
+ if entry["export_name"] in sources or not isinstance(file, str) or not file:
805
+ continue
806
+ line = _count(entry.get("source_line"))
807
+ sources[entry["export_name"]] = f"{file}:{line}" if line is not None else file
808
+ return sources
809
+
810
+
811
+ def _documenting_line(skill_dir: Path, name: str) -> str | None:
812
+ """The first line of SKILL.md, then of references/*.md in path order,
813
+ that writes `name` (validate-inventory.py's match), as `file:line`."""
814
+ pattern = _sibling("validate-inventory.py").name_pattern(name)
815
+ refs = skill_dir / "references"
816
+ files = [skill_dir / "SKILL.md"] + (sorted(refs.glob("*.md")) if refs.is_dir() else [])
817
+ for path in files:
818
+ try:
819
+ text = path.read_bytes().decode("utf-8-sig", errors="replace")
820
+ except OSError:
821
+ continue
822
+ for number, line in enumerate(text.split("\n"), start=1):
823
+ if pattern.search(line):
824
+ return f"{path.relative_to(skill_dir).as_posix()}:{number}"
825
+ return None
826
+
827
+
828
+ def _outside_scope(surface: dict | None) -> set[str] | None:
829
+ """The names a surface result's `excluded.outsideScope` lists (defined in
830
+ a file the brief scopes out). None with no surface, no such list, or a
831
+ surface built without an extraction (no `extraction` object) or from
832
+ one that did not read the whole scope (a `no-ast-grep` status, a
833
+ `truncated` run, or `fallback.needed`): only an extraction fills that
834
+ list, so the names a brief scoped out are then unknown."""
835
+ excluded = (surface or {}).get("excluded")
836
+ if excluded is None:
837
+ return None
838
+ if not isinstance(excluded, dict):
839
+ raise AdapterError("--surface: 'excluded' must be an object")
840
+ rows = excluded.get("outsideScope")
841
+ if rows is None:
842
+ return None
843
+ if not isinstance(rows, list):
844
+ raise AdapterError("--surface: 'excluded.outsideScope' must be a list")
845
+ extraction = (surface or {}).get("extraction")
846
+ if not isinstance(extraction, dict):
847
+ return None
848
+ fallback = extraction.get("fallback")
849
+ if (extraction.get("status") == "no-ast-grep" or extraction.get("truncated")
850
+ or (isinstance(fallback, dict) and fallback.get("needed"))):
851
+ return None
852
+ return {row["name"] for row in rows if isinstance(row, dict) and isinstance(row.get("name"), str)}
853
+
854
+
855
+ # The extensions classify-stale reads a `defined_at` in.
856
+ # Keep identical to PYTHON_EXTENSIONS | TSJS_EXTENSIONS in skf-verify-provenance-completeness.py.
857
+ _DEFINED_AT_EXTENSIONS = frozenset({".py", ".pyi", ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"})
858
+ # classify-stale's `defined_at`: a Python or TS/JS file and a line.
859
+ _DEFINED_AT_RE = re.compile(
860
+ r"^.+(?:" + "|".join(re.escape(e) for e in sorted(_DEFINED_AT_EXTENSIONS)) + r"):[1-9]\d*$", re.IGNORECASE)
861
+
862
+
863
+ # A source citation the skill writes, `[AST:path:L12]`, `[SRC:path:L12-34]` or `[SRC:path:L12-L34]` (one with no
864
+ # `:L<n>` part is ignored). Groups: prefix, path, first line, the `L` of a range's end, the range's end.
865
+ # Keep identical to _SKILL_CITATION_RE in skf-verify-provenance-completeness.py.
866
+ _SKILL_CITATION_RE = re.compile(
867
+ r"\[(AST|SRC):([^\[\]\n]+?):L(\d+)(?:-(L?)(\d+))?\]"
868
+ )
869
+ # Any bracketed citation (`[AST:...]`, `[SRC:...]`, `[EXT:...]`): a line names a documented name outside them.
870
+ _ANY_CITATION_RE = re.compile(r"\[[A-Z]+:[^\[\]\n]*\]")
871
+
872
+
873
+ def _cited_path(path: str) -> str:
874
+ """A cited or `declared_in` path as both compare: forward slashes, no `./`."""
875
+ path = path.strip().replace("\\", "/")
876
+ while path.startswith("./"):
877
+ path = path[2:]
878
+ return posixpath.normpath(path) if path else path
879
+
880
+
881
+ def skill_markdown_files(skill_dir: Path) -> list[Path]:
882
+ """`SKILL.md` then every `references/**/*.md`, in a stable order.
883
+ Keep identical to skill_markdown_files in skf-verify-provenance-completeness.py."""
884
+ files: list[Path] = []
885
+ skill_md = skill_dir / "SKILL.md"
886
+ if skill_md.is_file():
887
+ files.append(skill_md)
888
+ refs = skill_dir / "references"
889
+ if refs.is_dir():
890
+ files.extend(sorted(p for p in refs.rglob("*.md") if p.is_file()))
891
+ return files
892
+
893
+
894
+ def _skill_cited_files(skill_dir: Path, name: str) -> set[str]:
895
+ """The files the skill's `[AST:]` and `[SRC:]` citations name on a line of
896
+ SKILL.md or references/**/*.md that writes `name` outside its citations
897
+ (validate-inventory.py's match), or on the next non-blank line (a
898
+ heading, then its citation)."""
899
+ pattern = _sibling("validate-inventory.py").name_pattern(name)
900
+ cited: set[str] = set()
901
+ for path in skill_markdown_files(skill_dir):
902
+ try:
903
+ text = path.read_bytes().decode("utf-8-sig", errors="replace")
904
+ except OSError:
905
+ continue
906
+ lines = text.split("\n")
907
+ for number, line in enumerate(lines):
908
+ if not pattern.search(_ANY_CITATION_RE.sub(" ", line)):
909
+ continue
910
+ following = next((ln for ln in lines[number + 1:] if ln.strip()), "")
911
+ for cited_line in (line, following):
912
+ cited.update(_cited_path(m.group(2)) for m in _SKILL_CITATION_RE.finditer(cited_line))
913
+ return cited
914
+
915
+
916
+ def _documented_extra_at(name: str, item: dict, outside: set[str] | None,
917
+ skill_dir: str | None = None) -> tuple[str, int] | None:
918
+ """Where the source declares a stale name that is a documented extra, given
919
+ a surface built from an extraction whose `excluded.outsideScope` does not
920
+ list the name: (the item's `defined_at`, 0), or, with no `defined_at`,
921
+ (the one `declared_in` item whose file the skill cites on a line that
922
+ names it, the number of files `declared_in` lists) when it lists several.
923
+ None for anything else, which stays a Medium gap: no `defined_at` and no
924
+ single cited declaring file (two or more cited, none, or no --skill-dir),
925
+ a `defined_at` or `declared_in` item not a Python or TS/JS `file:line`, a
926
+ dotted name, no surface or one without an extraction, or a name a brief
927
+ scoped out."""
928
+ if outside is None or name in outside or "." in name:
929
+ return None
930
+ defined_at = item.get("defined_at")
931
+ if defined_at is not None:
932
+ if not isinstance(defined_at, str) or not _DEFINED_AT_RE.match(defined_at):
933
+ return None
934
+ return defined_at, 0
935
+ declared_in = item.get("declared_in")
936
+ if not skill_dir or not isinstance(declared_in, list) or not all(
937
+ isinstance(d, str) and _DEFINED_AT_RE.match(d) for d in declared_in):
938
+ return None
939
+ by_file = {_cited_path(d.rsplit(":", 1)[0]): d for d in declared_in}
940
+ if len(by_file) < 2:
941
+ return None
942
+ cited = _skill_cited_files(Path(skill_dir), name)
943
+ chosen = [d for f, d in sorted(by_file.items()) if f in cited]
944
+ return (chosen[0], len(by_file)) if len(chosen) == 1 else None
945
+
946
+
947
+ def _missing_export(name: str, where: str | None, meta: str, kind: str = "export") -> dict:
948
+ if where:
949
+ read = f"read its definition at `{where}`"
950
+ else:
951
+ read = f"find its definition in the source (`{meta}` lists it)"
952
+ if kind == "type":
953
+ return _gap("Medium", "missing-type", f"Missing type: {name}", where or meta,
954
+ f"Document the type `{name}` in SKILL.md: {read} and list its fields or members.",
955
+ issue=f"the type `{name}` is on the source API surface, and the skill does not document it",
956
+ export=name)
957
+ return _gap("Medium", "missing-export", f"Missing export: {name}", where or meta,
958
+ f"Document `{name}` in SKILL.md: {read} and add its signature, its purpose and a usage "
959
+ "example.",
960
+ issue=f"`{name}` is on the source API surface, and SKILL.md and references/ do not document it",
961
+ export=name)
962
+
963
+
964
+ def from_coverage(cov: object, surface: object = None, signatures: object = None, numerator: object = None,
965
+ stale: object = None, provenance: object = None, skill_dir: str | None = None,
966
+ metadata: str | None = None) -> list[dict]:
967
+ """reconcile-coverage.py's result: missing and stale names, or the missing count."""
968
+ cov = _object(cov, "--input (reconcile-coverage.py)", "branch")
969
+ meta = metadata or _DEFAULT_METADATA
970
+ branch = cov["branch"]
971
+ records: list[dict] = []
972
+ if branch == "enumerated":
973
+ types: set[str] = set()
974
+ # the names whose documented signature signature scoring compared
975
+ scored: set[str] = set()
976
+ if signatures is not None:
977
+ signatures = _object(signatures, "--signatures (score-signatures.py)", "missingTypes")
978
+ types = set(_names(signatures, "missingTypes", "--signatures"))
979
+ scored = set(_names(signatures, "comparedNames", "--signatures"))
980
+ if surface is not None:
981
+ surface = _object(surface, "--surface (load-coverage-inputs.py surface)", "exports")
982
+ where = _surface_sources(surface)
983
+ # `excluded` is read once a stale name may be a documented extra
984
+ outside: set[str] | None = None
985
+ outside_read = False
986
+ for name in _names(cov, "missing", "--input"):
987
+ records.append(_missing_export(name, where.get(name), meta, "type" if name in types else "export"))
988
+ classified: dict[str, dict] = {}
989
+ if stale is not None:
990
+ if not isinstance(stale, list):
991
+ raise AdapterError("--stale is not classify-stale JSON (a list)")
992
+ classified = {i["name"]: i for i in stale if isinstance(i, dict) and isinstance(i.get("name"), str)}
993
+ for name in _names(cov, "stale", "--input"):
994
+ item = classified.get(name) or {}
995
+ cited = item.get("source")
996
+ if item.get("fabricated") is True:
997
+ if not isinstance(cited, str) or not cited:
998
+ # a Critical gap needs the line it cites: never downgrade it
999
+ raise AdapterError(f"--stale: {name!r} is fabricated with no source")
1000
+ records.append(_gap(
1001
+ "Critical", "fabricated-signature", f"Fabricated signature: {name}", cited,
1002
+ f"Remove `{name}` from SKILL.md and references/, or correct it to the export the source "
1003
+ f"defines: no file its provenance cites defines it (`{cited}`).",
1004
+ issue=f"SKILL.md documents `{name}`, which the source API surface lacks and the cited "
1005
+ "file does not define",
1006
+ export=name))
1007
+ continue
1008
+ if not outside_read:
1009
+ outside, outside_read = _outside_scope(surface), True
1010
+ extra = _documented_extra_at(name, item, outside, skill_dir)
1011
+ doc = _documenting_line(Path(skill_dir), name) if skill_dir else None
1012
+ if extra:
1013
+ declared, among = extra
1014
+ # a homonym: the one declaring file the skill's own citations name
1015
+ picked = (f"; of the {among} files that declare it, the skill cites only "
1016
+ f"`{declared.rsplit(':', 1)[0]}`") if among else ""
1017
+ # never routed: update-skill leaves an `observation` alone. Signature scoring compares a
1018
+ # documented signature of a name on the surface's records (a nested sub-package's) alone.
1019
+ checked = ("Signature scoring compares its documented signature with the surface's record of it."
1020
+ if name in scored else
1021
+ "Its documented signature was not checked against that declaration.")
1022
+ records.append(_gap(
1023
+ "Info", "observation", f"Documented extra: {name}", declared,
1024
+ f"No change: `{name}` is a documented extra, a name the source still declares at "
1025
+ f"`{declared}` outside the enumerated surface, and its documentation stays. {checked}",
1026
+ issue=f"{f'`{doc}`' if doc else 'the skill'} documents `{name}`, which the enumerated source API "
1027
+ f"surface lacks and the source still declares{picked}",
1028
+ export=name))
1029
+ continue
1030
+ records.append(_gap(
1031
+ "Medium", "stale-documentation", f"Stale documentation: {name}", doc or "SKILL.md",
1032
+ f"Check `{name}` against the source and update or remove its documentation at "
1033
+ f"{doc or 'SKILL.md'}: the enumerated source surface does not export it.",
1034
+ issue=f"SKILL.md documents `{name}`, which the enumerated source API surface lacks",
1035
+ export=name))
1036
+ elif branch in ("scalar", "stack"):
1037
+ if branch == "scalar" and cov.get("numeratorSource") == "verified":
1038
+ if numerator is None:
1039
+ raise AdapterError("the scalar count used the verified numerator: pass its result with "
1040
+ "--numerator")
1041
+ absent = _names(_object(numerator, "--numerator (verify-declared-numerator.py)", "absent"),
1042
+ "absent", "--numerator")
1043
+ where = _provenance_sources(_object(provenance, "--provenance (provenance map)", "entries")
1044
+ if provenance is not None else None)
1045
+ records += [_missing_export(name, where.get(name), meta) for name in absent]
1046
+ else:
1047
+ missing, denominator = _count(cov.get("missingCount")), _count(cov.get("denominator"))
1048
+ if missing:
1049
+ records.append(_gap(
1050
+ "Medium", "missing-export", f"{missing} of {denominator} exports not documented", meta,
1051
+ f"Find the {missing} exports the denominator counts that SKILL.md and references/ do not "
1052
+ f"name (`{meta}` lists the exports) and document each with its signature.",
1053
+ issue=f"SKILL.md and references/ name {cov.get('documented')} of the {denominator} exports "
1054
+ f"the {branch} denominator counts"))
1055
+ elif branch != "docsOnly":
1056
+ raise AdapterError(f"--input (reconcile-coverage.py): unknown branch {branch!r}")
1057
+ return records
1058
+
1059
+
1060
+ def from_guards(surface: object, metadata: str | None = None) -> list[dict]:
1061
+ """load-coverage-inputs.py surface's deflation, inflation and stale-scope guards."""
1062
+ surface = _object(surface, "--input (load-coverage-inputs.py surface)", "guards", "inputs")
1063
+ guards = surface["guards"]
1064
+ if guards is None:
1065
+ return []
1066
+ if not isinstance(guards, dict):
1067
+ raise AdapterError("--input (load-coverage-inputs.py surface): 'guards' must be an object or null")
1068
+ inputs = surface["inputs"] if isinstance(surface["inputs"], dict) else {}
1069
+ meta = metadata or inputs.get("metadata") or _DEFAULT_METADATA
1070
+ brief = inputs.get("brief") or _DEFAULT_BRIEF
1071
+ records: list[dict] = []
1072
+ stale = guards.get("staleScope") or {}
1073
+ label = "--input (load-coverage-inputs.py surface): guards.staleScope"
1074
+ # A tier A glob that matches no file counts no name, and so does one that
1075
+ # matches only excluded files or files that define no name: when the
1076
+ # guards fire, the brief has no tier A glob, or none of its globs counts a
1077
+ # name. `tierAInclude` (the brief's tier A globs) is absent from a surface
1078
+ # older than #712, where only the stale globs say the brief has some.
1079
+ tier_a_globs = _names(stale, "unmatchedTierAInclude", label)
1080
+ brief_tier_a = list(dict.fromkeys(
1081
+ _names(guards, "tierAInclude", "--input (load-coverage-inputs.py surface): guards") + tier_a_globs))
1082
+ if not brief_tier_a:
1083
+ no_tier_a = "the brief has no `scope.tier_a_include`"
1084
+ elif set(brief_tier_a) <= set(tier_a_globs):
1085
+ no_tier_a = "no `scope.tier_a_include` glob in the brief matches a file"
1086
+ else:
1087
+ no_tier_a = "no `scope.tier_a_include` glob in the brief counts a public name"
1088
+ deflation = guards.get("deflation") or {}
1089
+ if deflation.get("fires") is True:
1090
+ if deflation.get("pct") is None: # a stored 0: no percentage above it
1091
+ above = (f" against a `stats.effective_denominator` of "
1092
+ f"{_count(deflation.get('effectiveDenominator')) or 0}")
1093
+ else:
1094
+ above = (f", {deflation.get('pct')}% above `stats.effective_denominator` "
1095
+ f"({deflation.get('effectiveDenominator')})")
1096
+ if deflation.get("pct") is None: # a 0 measures no tier: count it again
1097
+ first = ("Edit `scope.tier_a_include` in the brief so its globs match the files that define the "
1098
+ "authored tier" if brief_tier_a else "Check `scope.include` in the brief")
1099
+ fix = (f"{first}, then re-run Create Skill so `stats.effective_denominator` in `{meta}` is counted "
1100
+ "again.")
1101
+ elif brief_tier_a:
1102
+ fix = (f"Set `stats.effective_denominator` in `{meta}` to the public surface the source re-derives, "
1103
+ "or edit `scope.tier_a_include` in the brief so its globs match the files of the authored tier "
1104
+ "the smaller count measures.")
1105
+ else:
1106
+ fix = (f"Set `stats.effective_denominator` in `{meta}` to the public surface the source re-derives, "
1107
+ "or add `scope.tier_a_include` to the brief to name the authored tier the smaller count "
1108
+ "measures.")
1109
+ records.append(_gap(
1110
+ "Medium", "metadata-drift",
1111
+ "denominator deflation: effective_denominator below source public surface without tier_a_include",
1112
+ meta, fix,
1113
+ issue=f"the re-derived surface counts {deflation.get('rederived')} exports{above}, and {no_tier_a}"))
1114
+ inflation = guards.get("inflation") or {}
1115
+ if inflation.get("fires") is True:
1116
+ if (guards.get("umbrella") or {}).get("umbrella") is True:
1117
+ fix = (f"Set `stats.effective_denominator` in `{meta}` to the authored surface: the root barrel "
1118
+ "is an umbrella of re-exports, so a `scope.tier_a_include` glob would still count them.")
1119
+ elif brief_tier_a:
1120
+ fix = (f"Edit `scope.tier_a_include` in `{brief}` so its globs match the files of the authored "
1121
+ "surface: the denominator then counts it instead of the coarse `scope.include` union.")
1122
+ else:
1123
+ fix = (f"Add `scope.tier_a_include` to `{brief}`, listing the files of the authored surface, so "
1124
+ "the denominator counts it instead of the coarse `scope.include` union.")
1125
+ records.append(_gap(
1126
+ "Medium", "denominator-inflation",
1127
+ "denominator inflation: coarse scope.include union exceeds authored surface", brief, fix,
1128
+ issue=f"the `scope.include` union counts {inflation.get('scopeIncludeUnion')} exports, "
1129
+ f"{inflation.get('pct')}% above the {inflation.get('provenanceEntries')} provenance entries, "
1130
+ f"and {no_tier_a}"))
1131
+ if stale.get("fires") is True:
1132
+ globs = _names(stale, "unmatchedInclude", label)
1133
+ restored = sorted({(r["name"], r["file"] if isinstance(r.get("file"), str) else "")
1134
+ for r in _list(stale, "restored", label)
1135
+ if isinstance(r, dict) and isinstance(r.get("name"), str) and r["name"]})
1136
+ names = ", ".join(f"`{name}` in `{file}`" if file else f"`{name}`" for name, file in restored)
1137
+ if len(globs) == 1:
1138
+ unmatched = f"the `scope.include` glob {_code(globs)} matches no file in the source tested"
1139
+ else:
1140
+ unmatched = f"the `scope.include` globs {_code(globs)} match no file in the source tested"
1141
+ if not restored:
1142
+ back = "no root export was restored"
1143
+ elif len(restored) == 1:
1144
+ back = f"the `all` set restores 1 root export, defined in a file no include glob covers: {names}"
1145
+ else:
1146
+ back = (f"the `all` set restores {len(restored)} root exports, defined in files no include glob "
1147
+ f"covers: {names}")
1148
+ if len(tier_a_globs) == 1:
1149
+ tier_a = (f"the `scope.tier_a_include` glob {_code(tier_a_globs)} matches no file in the source "
1150
+ "tested, so it counts no name")
1151
+ else:
1152
+ tier_a = (f"the `scope.tier_a_include` globs {_code(tier_a_globs)} match no file in the source "
1153
+ "tested, so they count no name")
1154
+ exclude = ", and add to `scope.exclude` any restored file the brief meant to leave out"
1155
+ if not tier_a_globs: # the include globs alone, as #677 wrote the gap
1156
+ lists, rewrite, clauses = ["scope.include"], "an include glob", [unmatched, back]
1157
+ elif not globs: # no include glob is stale, so nothing was restored
1158
+ lists, rewrite, clauses, exclude = ["scope.tier_a_include"], "a tier A glob", [tier_a], ""
1159
+ else:
1160
+ lists = ["scope.include", "scope.tier_a_include"]
1161
+ rewrite, clauses = "an include or tier A glob", [unmatched, back, tier_a]
1162
+ edit = " and ".join(f"`{key}`" for key in lists)
1163
+ records.append(_gap(
1164
+ "Medium", "brief-scope-stale", f"stale brief scope: {' and '.join(lists)} globs match no source file",
1165
+ brief,
1166
+ f"Edit {edit} in `{brief}` by hand so each glob matches the files it meant in this version of the "
1167
+ f"source (update-skill does not rewrite {rewrite}){exclude}.",
1168
+ issue="; ".join(clauses)))
1169
+ return records
1170
+
1171
+
1172
+ def from_numerator(numerator: object, metadata: str | None = None) -> list[dict]:
1173
+ """verify-declared-numerator.py's result: the numerator inflation gap."""
1174
+ numerator = _object(numerator, "--input (verify-declared-numerator.py)", "inflated")
1175
+ if numerator["inflated"] is not True:
1176
+ return []
1177
+ meta = metadata or _DEFAULT_METADATA
1178
+ declared, verified = _count(numerator.get("declared")) or 0, _count(numerator.get("verified")) or 0
1179
+ absent = _names(numerator, "absent", "--input")
1180
+ return [_gap(
1181
+ "High", "numerator-inflation",
1182
+ f"numerator inflation: {declared - verified} of {declared} declared exports absent from "
1183
+ "SKILL.md/references",
1184
+ meta,
1185
+ f"Document the absent exports in SKILL.md or references/: the skill documents {verified} of its "
1186
+ f"{declared} declared exports.",
1187
+ issue=f"a declared count (`stats.exports_documented`, or the provenance map's distinct entry names) "
1188
+ f"equals `stats.effective_denominator`, and these declared exports are absent from SKILL.md and "
1189
+ f"references/: {_code(absent)}")]
1190
+
1191
+
1192
+ def from_metadata_coherence(result: object, metadata: str | None = None) -> list[dict]:
1193
+ """check-metadata-coherence.py's findings: count drift and multi-denominator notes."""
1194
+ result = _object(result, "--input (check-metadata-coherence.py)", "findings")
1195
+ meta = metadata or _DEFAULT_METADATA
1196
+ records: list[dict] = []
1197
+ for index, finding in enumerate(_list(result, "findings", "--input")):
1198
+ if not isinstance(finding, dict) or not isinstance(finding.get("title"), str):
1199
+ raise AdapterError(f"--input: findings[{index}] has no title")
1200
+ detail = finding.get("detail") if isinstance(finding.get("detail"), str) else None
1201
+ if finding.get("severity") == "Medium":
1202
+ records.append(_gap(
1203
+ "Medium", "metadata-drift", finding["title"], meta,
1204
+ f"Recount the exports and correct the diverging counts in `{meta}`; update-skill applies it as "
1205
+ "a metadata update.",
1206
+ issue=detail))
1207
+ elif finding.get("severity") == "Info":
1208
+ records.append(_gap(
1209
+ "Info", "multi-denominator", finding["title"], meta,
1210
+ "No action required: the barrel and the documented surface measure different sets by design, "
1211
+ "and the report records both counts.",
1212
+ issue=detail))
1213
+ else:
1214
+ raise AdapterError(f"--input: findings[{index}] has severity {finding.get('severity')!r}, "
1215
+ "neither Medium nor Info")
1216
+ return records
1217
+
1218
+
1219
+ def from_provenance_line(result: object) -> list[dict]:
1220
+ """verify's `line-not-definition` items: provenance line gaps."""
1221
+ result = _object(result, "--input (skf-verify-provenance-completeness.py verify)", "stale")
1222
+ records: list[dict] = []
1223
+ for item in _list(result, "stale", "--input"):
1224
+ if not isinstance(item, dict) or item.get("reason") != "line-not-definition":
1225
+ continue
1226
+ name, file, line = item.get("export_name"), item.get("source_file"), item.get("source_line")
1227
+ defs = [n for n in item.get("definition_lines") or [] if _count(n) is not None]
1228
+ where = f"{file}:{line}"
1229
+ if defs:
1230
+ lines = ", ".join(str(n) for n in defs)
1231
+ records.append(_gap(
1232
+ "Low", "provenance-line", f"Provenance line is not the definition of {name}", where,
1233
+ f"Set the provenance `source_line` of `{name}` in `{file}` to its definition line ({lines}) "
1234
+ "and move its citations to that line; update-skill `--from-test-report` applies this when the "
1235
+ "file defines it on one line.",
1236
+ issue=f"the provenance map records `{name}` at `{where}`, which is not the line that defines "
1237
+ f"it; the lines that define it are {lines}.",
1238
+ export=name))
1239
+ else:
1240
+ records.append(_gap(
1241
+ "Info", "provenance-unverified", f"Provenance line not verified for {name}", where,
1242
+ f"Open `{file}` and confirm that line {line} defines `{name}`; if another line does, set the "
1243
+ "provenance `source_line` to it.",
1244
+ issue=f"the line-check rules found no definition line for `{name}` in `{file}`; check by hand",
1245
+ export=name))
1246
+ return records
1247
+
1248
+
1249
+ def from_coherence(result: object) -> list[dict]:
1250
+ """aggregate-coherence.py's invalid references."""
1251
+ result = _object(result, "--input (aggregate-coherence.py)", "invalidReferences")
1252
+ records: list[dict] = []
1253
+ for index, ref in enumerate(_list(result, "invalidReferences", "--input")):
1254
+ if not isinstance(ref, dict):
1255
+ raise AdapterError(f"--input: invalidReferences[{index}] is not an object")
1256
+ target, line = ref.get("target"), _count(ref.get("line"))
1257
+ where = f"SKILL.md:{line}" if line is not None else "SKILL.md"
1258
+ status = ref.get("status")
1259
+ if status == "missing":
1260
+ records.append(_gap(
1261
+ "Critical", "broken-reference", f"coherence: broken reference: {target}", where,
1262
+ f"Point the reference to `{target}` at {where} at a target that exists, or remove it: "
1263
+ "nothing exists at its target.",
1264
+ issue="; ".join(i for i in ref.get("issues") or [] if isinstance(i, str)) or None))
1265
+ elif status == "inaccurate":
1266
+ issues = "; ".join(i for i in ref.get("issues") or [] if isinstance(i, str))
1267
+ records.append(_gap(
1268
+ "High", "inaccurate-reference", f"coherence: inaccurate reference: {target}", where,
1269
+ f"Correct the reference to `{target}` at {where} so it matches its target"
1270
+ + (f": {issues}." if issues else "."),
1271
+ issue=issues or None))
1272
+ elif status == "escapes":
1273
+ canonical = ref.get("canonical")
1274
+ records.append(_gap(
1275
+ "High", "reference-escape",
1276
+ f"coherence: reference escapes skill/source sandbox: {target} → {canonical}", where,
1277
+ f"Point the reference to `{target}` at {where} inside the skill package or its source tree, "
1278
+ f"or remove it: it resolves to `{canonical}`."))
1279
+ else:
1280
+ raise AdapterError(f"--input: invalidReferences[{index}] has an unknown status {status!r}")
1281
+ return records
1282
+
1283
+
1284
+ def _structure_shape(data: object) -> str | None:
1285
+ """Which skf-scan-skill-md-structure.py result `data` is."""
1286
+ if not isinstance(data, dict):
1287
+ return None
1288
+ if all(isinstance(data.get(f), dict) and "satisfied" in data[f] for f in FAMILIES):
1289
+ return "required-sections"
1290
+ if "zero_usage" in data:
1291
+ return "usage-scope"
1292
+ if "unbalanced_fences" in data:
1293
+ return "scan"
1294
+ if "references" in data and "counts" in data:
1295
+ return "reference-check"
1296
+ return None
1297
+
1298
+
1299
+ def _scripts_assets_gap(data: dict, prefix: str) -> list[dict]:
1300
+ section = data.get("scripts_assets")
1301
+ if not isinstance(section, dict) or section.get("missing") is not True:
1302
+ return []
1303
+ folders = [f for f in section.get("folders") or [] if isinstance(f, str)]
1304
+ named = " and ".join(f"`{f}/`" for f in folders) or "`scripts/` and `assets/`"
1305
+ return [_gap(
1306
+ "Medium", "scripts-assets",
1307
+ f"{prefix}: scripts/assets directory exists but Scripts & Assets section missing", "SKILL.md",
1308
+ f"Add a `## Scripts & Assets` section to SKILL.md that lists each file in {named} with its purpose.")]
1309
+
1310
+
1311
+ def from_structure(results: list[object], served: list[str] | None = None) -> list[dict]:
1312
+ """skf-scan-skill-md-structure.py results: the naive structural gaps, and
1313
+ the Scripts & Assets gap of either mode."""
1314
+ served_set = set(served or [])
1315
+ records: list[dict] = []
1316
+ for index, data in enumerate(results):
1317
+ shape = _structure_shape(data)
1318
+ if shape is None:
1319
+ raise AdapterError(f"--input #{index + 1} is not a scan, scan --required-sections, usage-scope or "
1320
+ "reference-check result")
1321
+ if shape == "required-sections":
1322
+ for family in FAMILIES:
1323
+ entry = data[family]
1324
+ if entry.get("satisfied") is True or family in served_set:
1325
+ continue
1326
+ if family == "description" and data.get("frontmatter_description") is True:
1327
+ continue
1328
+ tried = [t for t in entry.get("tried") or [] if isinstance(t, str)]
1329
+ records.append(_gap(
1330
+ "High", "structural", f"naive-coherence: missing required section: {family}", "SKILL.md",
1331
+ _SECTION_FIX[family],
1332
+ issue=f"no heading of SKILL.md serves the {family} family (synonyms tried: "
1333
+ f"{', '.join(tried)})"))
1334
+ elif shape == "scan":
1335
+ if data.get("unbalanced_fences") is True:
1336
+ records.append(_gap(
1337
+ "High", "structural", "naive-coherence: unbalanced code fence (unclosed block)", "SKILL.md",
1338
+ "Close the code block SKILL.md leaves open with a fence line of three backticks.",
1339
+ issue=f"SKILL.md has {data.get('fence_count')} fence lines, so one block is never closed"))
1340
+ for fence in _list(data, "bare_opening_fences", "--input"):
1341
+ line = _count(fence.get("line")) if isinstance(fence, dict) else None
1342
+ records.append(_gap(
1343
+ "Medium", "structural",
1344
+ f"naive-coherence: opening code fence at line {line} missing language tag",
1345
+ f"SKILL.md:{line}",
1346
+ f"Add a language tag (such as `bash` or `python`) to the opening fence at SKILL.md line "
1347
+ f"{line}."))
1348
+ for row in _list(data, "table_drift", "--input"):
1349
+ if not isinstance(row, dict):
1350
+ continue
1351
+ line, expected = _count(row.get("line")), _count(row.get("expected_cols"))
1352
+ where = f"section `{row['section']}`: " if row.get("section") else ""
1353
+ records.append(_gap(
1354
+ "Medium", "structural",
1355
+ f"naive-coherence: table row at line {line} has {row.get('actual_cols')} columns; "
1356
+ f"header has {expected}",
1357
+ f"SKILL.md:{line}",
1358
+ f"Give the row at SKILL.md line {line} the {expected} columns its header has (write a pipe "
1359
+ "inside a cell as `\\|`).",
1360
+ issue=f"{where}{row.get('row')}" if row.get("row") else None))
1361
+ records += _scripts_assets_gap(data, "naive-coherence")
1362
+ elif shape == "usage-scope":
1363
+ for export in _list(data, "zero_usage", "--input"):
1364
+ if not isinstance(export, dict) or not isinstance(export.get("name"), str):
1365
+ continue
1366
+ name, kind = export["name"], export.get("kind") or "export"
1367
+ records.append(_gap(
1368
+ "Medium", "structural",
1369
+ f"naive-coherence: exported {kind} `{name}` is not referenced in any usage-family "
1370
+ "section or reference file",
1371
+ "SKILL.md",
1372
+ f"Add an example that calls `{name}` to SKILL.md's usage section (or to a references/ file "
1373
+ "for a split body).",
1374
+ export=name))
1375
+ else:
1376
+ records += _scripts_assets_gap(data, "coherence")
1377
+ return records
1378
+
1379
+
1380
+ def build_records(args: argparse.Namespace) -> list[dict]:
1381
+ """The records `append --from` appends. Raises AdapterError."""
1382
+ kind = args.adapter
1383
+ stray = sorted(o for o in ALL_ADAPTER_OPTIONS - ADAPTER_OPTIONS[kind] if getattr(args, o) is not None)
1384
+ if stray:
1385
+ flags = ", ".join("--" + o.replace("_", "-") for o in stray)
1386
+ raise AdapterError(f"--from {kind} does not read {flags}")
1387
+ inputs = args.input or []
1388
+ if not inputs or (kind != "structure" and len(inputs) > 1):
1389
+ wanted = "one or more --input files" if kind == "structure" else "one --input file"
1390
+ raise AdapterError(f"--from {kind} reads {wanted}")
1391
+ if kind == "structure":
1392
+ return from_structure([read_result(p, "--input") for p in inputs], args.served)
1393
+ data = read_result(inputs[0], "--input")
1394
+ if kind == "coverage":
1395
+ files = {o: read_result(getattr(args, o), "--" + o) if getattr(args, o) is not None else None
1396
+ for o in ("surface", "signatures", "numerator", "stale", "provenance")}
1397
+ return from_coverage(data, skill_dir=args.skill_dir, metadata=args.metadata, **files)
1398
+ if kind == "guards":
1399
+ return from_guards(data, args.metadata)
1400
+ if kind == "numerator":
1401
+ return from_numerator(data, args.metadata)
1402
+ if kind == "metadata-coherence":
1403
+ return from_metadata_coherence(data, args.metadata)
1404
+ if kind == "provenance-line":
1405
+ return from_provenance_line(data)
1406
+ return from_coherence(data)
1407
+
1408
+
1409
+ # --------------------------------------------------------------------------
1410
+ # Gap Report
1411
+ # --------------------------------------------------------------------------
1412
+
1413
+
1414
+ def render_gap_report(ledger: dict, heading: bool = False) -> str:
1415
+ """The Gap Report section body, from the ledger (report.md §4c writes it)."""
1416
+ records = ordered(ledger["records"])
1417
+ counts = count_by_severity(records)
1418
+ blocking = sum(1 for r in records if is_blocking(r))
1419
+ after_gate = counts["Critical"] + counts["High"] - blocking
1420
+ lines: list[str] = []
1421
+ if heading:
1422
+ lines += ["## Gap Report", ""]
1423
+ lines += [
1424
+ f"**Total Gaps:** {len(records)}",
1425
+ f"**Blocking (Critical + High):** {blocking}",
1426
+ f"**Non-blocking (Medium + Low + Info):** {len(records) - blocking - after_gate}",
1427
+ ]
1428
+ if after_gate:
1429
+ lines.append(f"**Found after the hard gate (Critical + High, non-blocking):** {after_gate}")
1430
+ lines.append("")
1431
+ if not records:
1432
+ lines += ["No gaps found.", ""]
1433
+ return "\n".join(lines)
1434
+ lines += [
1435
+ "### Remediation Summary",
1436
+ "",
1437
+ "| Severity | Count | Estimated Effort |",
1438
+ "|----------|-------|------------------|",
1439
+ ]
1440
+ for severity in SEVERITIES:
1441
+ effort = EFFORT[severity] if counts[severity] else "None"
1442
+ lines.append(f"| {severity} | {counts[severity]} | {effort} |")
1443
+ lines += [f"| **Total** | **{len(records)}** | |", ""]
1444
+ for record in records:
1445
+ lines += [
1446
+ f"### {record['id']}: {record['title']}",
1447
+ "",
1448
+ f"**Severity:** {record['severity']}",
1449
+ # The group comes from the category, as at append time: a ledger
1450
+ # written by hand may lack it.
1451
+ f"**Category:** {CATEGORIES[record['category']][0]} ({record['category']})",
1452
+ f"**Source:** {record['source']}",
1453
+ ]
1454
+ if record.get("export"):
1455
+ lines.append(f"**Export:** {record['export']}")
1456
+ lines.append("")
1457
+ if record.get("issue"):
1458
+ lines += [f"**Issue:** {record['issue']}", ""]
1459
+ lines += [f"**Remediation:** {record['remediation']}", ""]
1460
+ return "\n".join(lines)
1461
+
1462
+
1463
+ def summarize(path: Path, ledger: dict) -> dict:
1464
+ records = ledger["records"]
1465
+ counts = count_by_severity(records)
1466
+ by_category: dict[str, int] = {}
1467
+ for record in records:
1468
+ by_category[record["category"]] = by_category.get(record["category"], 0) + 1
1469
+ blocking = sum(1 for r in records if is_blocking(r))
1470
+ return {
1471
+ "status": "ok",
1472
+ "ledger": str(path),
1473
+ "run_id": ledger.get("run_id"),
1474
+ "stages": list(ledger["stages"]),
1475
+ "total": len(records),
1476
+ "counts": counts,
1477
+ "blocking": blocking,
1478
+ "non_blocking": len(records) - blocking,
1479
+ "by_category": dict(sorted(by_category.items())),
1480
+ }
1481
+
1482
+
1483
+ # --------------------------------------------------------------------------
1484
+ # CLI
1485
+ # --------------------------------------------------------------------------
1486
+
1487
+
1488
+ def _emit(payload: dict, stream=None) -> None:
1489
+ print(json.dumps(payload, indent=2, ensure_ascii=False), file=stream or sys.stdout)
1490
+
1491
+
1492
+ def _error(code: str, message: str, **extra) -> dict:
1493
+ return {"status": "error", "code": code, "error": message, **extra}
1494
+
1495
+
1496
+ def _read_records(args: argparse.Namespace) -> list:
1497
+ """The records an append reads: built by --from, else from each --input
1498
+ file in turn, else from stdin. Raises AdapterError, OSError or ValueError."""
1499
+ if args.adapter:
1500
+ return build_records(args)
1501
+ given = sorted(o for o in ALL_ADAPTER_OPTIONS if getattr(args, o) is not None)
1502
+ if given:
1503
+ flags = ", ".join("--" + o.replace("_", "-") for o in given)
1504
+ raise AdapterError(f"{flags} work only with --from")
1505
+ if not args.input:
1506
+ # A byte order mark on stdin (a Windows editor or shell) is not JSON.
1507
+ return parse_input(sys.stdin.read().removeprefix("\ufeff"))
1508
+ records: list = []
1509
+ for path in args.input:
1510
+ records += parse_input(Path(path).read_text(encoding="utf-8-sig"))
1511
+ return records
1512
+
1513
+
1514
+ def _cmd_append(args: argparse.Namespace) -> int:
1515
+ path = Path(args.ledger)
1516
+ if not _STAGE_RE.match(args.stage):
1517
+ _emit(_error("INVALID_INPUT", f"--stage must be a lowercase slug, got '{args.stage}'"))
1518
+ return 2
1519
+ try:
1520
+ raw_records = _read_records(args)
1521
+ except AdapterError as exc:
1522
+ _emit(_error("INVALID_INPUT", str(exc)))
1523
+ return 2
1524
+ except (OSError, UnicodeDecodeError, ValueError) as exc:
1525
+ # json.JSONDecodeError is a ValueError.
1526
+ _emit(_error("INVALID_INPUT", f"cannot read the records: {exc}"))
1527
+ return 2
1528
+ records: list[dict] = []
1529
+ errors: list[dict] = []
1530
+ for index, raw in enumerate(raw_records):
1531
+ record, err = normalize_record(raw)
1532
+ if err:
1533
+ errors.append({"index": index, "error": err})
1534
+ else:
1535
+ records.append(record)
1536
+ if errors:
1537
+ _emit(_error("INVALID_RECORD", f"{len(errors)} invalid record(s); nothing was written", errors=errors))
1538
+ return 2
1539
+ try:
1540
+ # Read, append and write as one step: an append that overlaps this
1541
+ # one waits here instead of writing over the records it adds.
1542
+ with ledger_lock(path):
1543
+ ledger = load_ledger(path) if path.exists() else new_ledger(path)
1544
+ appended, duplicates = append_records(ledger, args.stage, records)
1545
+ save_ledger(path, ledger)
1546
+ except LedgerError as exc:
1547
+ _emit(_error(exc.code, str(exc)))
1548
+ return 1
1549
+ except OSError as exc:
1550
+ _emit(_error("WRITE_FAILED", f"cannot write {path}: {exc}"))
1551
+ return 1
1552
+ _emit(
1553
+ {
1554
+ "status": "ok",
1555
+ "ledger": str(path),
1556
+ "stage": args.stage,
1557
+ "appended": appended,
1558
+ "duplicates": duplicates,
1559
+ "record_count": len(ledger["records"]),
1560
+ }
1561
+ )
1562
+ return 0
1563
+
1564
+
1565
+ def _cmd_render(args: argparse.Namespace) -> int:
1566
+ path = Path(args.ledger)
1567
+ try:
1568
+ ledger = load_ledger(path)
1569
+ except LedgerError as exc:
1570
+ _emit(_error(exc.code, str(exc)), stream=sys.stderr)
1571
+ return 1
1572
+ sys.stdout.write(render_gap_report(ledger, heading=args.heading))
1573
+ return 0
1574
+
1575
+
1576
+ def _cmd_summary(args: argparse.Namespace) -> int:
1577
+ path = Path(args.ledger)
1578
+ try:
1579
+ ledger = load_ledger(path)
1580
+ except LedgerError as exc:
1581
+ _emit(_error(exc.code, str(exc)))
1582
+ return 1
1583
+ _emit(summarize(path, ledger))
1584
+ return 0
1585
+
1586
+
1587
+ def _cmd_categories(args: argparse.Namespace) -> int:
1588
+ _emit(
1589
+ {
1590
+ "status": "ok",
1591
+ "severities": list(SEVERITIES),
1592
+ "categories": [
1593
+ {"category": slug, "group": group, "description": description}
1594
+ for slug, (group, description) in CATEGORIES.items()
1595
+ ],
1596
+ }
1597
+ )
1598
+ return 0
1599
+
1600
+
1601
+ def _build_parser() -> argparse.ArgumentParser:
1602
+ parser = argparse.ArgumentParser(
1603
+ prog="gap-ledger",
1604
+ description="Append, render and summarize the test-skill gap ledger.",
1605
+ )
1606
+ sub = parser.add_subparsers(dest="cmd", required=True)
1607
+
1608
+ p = sub.add_parser("append", help="append records from stdin, --input or a script's result (--from)")
1609
+ p.add_argument("--ledger", required=True, help="{forge_version}/test-findings-{run_id}.json")
1610
+ p.add_argument("--stage", required=True, help="the stage appending, e.g. coverage-check")
1611
+ p.add_argument("--input", action="append",
1612
+ help="a JSON file with the records (default: stdin); with --from, the script result to read "
1613
+ "(structure takes it more than once)")
1614
+ p.add_argument("--from", dest="adapter", choices=tuple(ADAPTER_OPTIONS),
1615
+ help="build the records from the --input result of a script (see the docstring's Adapters)")
1616
+ p.add_argument("--surface", help="coverage: load-coverage-inputs.py surface's result (Sources, kinds, "
1617
+ "the names outside scope)")
1618
+ p.add_argument("--signatures", help="coverage: score-signatures.py score's result (missingTypes)")
1619
+ p.add_argument("--numerator", help="coverage: verify-declared-numerator.py's result (absent names)")
1620
+ p.add_argument("--stale", help="coverage: classify-stale's result (fabricated signatures, documented extras)")
1621
+ p.add_argument("--provenance", help="coverage: the provenance map (an absent name's Source)")
1622
+ p.add_argument("--skill-dir", help="coverage: the skill package (a stale name's documenting line)")
1623
+ p.add_argument("--metadata", help="the metadata.json path a record cites as its Source (not read)")
1624
+ p.add_argument("--served", action="append", choices=FAMILIES,
1625
+ help="structure: a family another heading serves (the caller's judgment); repeatable")
1626
+ p.set_defaults(func=_cmd_append)
1627
+
1628
+ p = sub.add_parser("render", help="print the Gap Report in Markdown")
1629
+ p.add_argument("--ledger", required=True)
1630
+ p.add_argument("--heading", action="store_true", help="start with the ## Gap Report line")
1631
+ p.set_defaults(func=_cmd_render)
1632
+
1633
+ p = sub.add_parser("summary", help="counts by severity and category")
1634
+ p.add_argument("--ledger", required=True)
1635
+ p.set_defaults(func=_cmd_summary)
1636
+
1637
+ p = sub.add_parser("categories", help="list the category vocabulary")
1638
+ p.set_defaults(func=_cmd_categories)
1639
+
1640
+ return parser
1641
+
1642
+
1643
+ def _force_utf8(*streams) -> None:
1644
+ """Reconfigure the JSON and Markdown streams to UTF-8.
1645
+
1646
+ A default Windows console uses cp1252, which cannot carry a non-ASCII
1647
+ title or remediation. Keeps each stream's error handler; skips test
1648
+ doubles without reconfigure(). For stdin this must run before the read.
1649
+ """
1650
+ for stream in streams:
1651
+ if hasattr(stream, "reconfigure"):
1652
+ errors = getattr(stream, "errors", None)
1653
+ if errors is None:
1654
+ stream.reconfigure(encoding="utf-8")
1655
+ else:
1656
+ stream.reconfigure(encoding="utf-8", errors=errors)
1657
+
1658
+
1659
+ def main(argv: list[str] | None = None) -> int:
1660
+ _force_utf8(sys.stdin, sys.stdout, sys.stderr)
1661
+ args = _build_parser().parse_args(argv)
1662
+ return args.func(args)
1663
+
1664
+
1665
+ if __name__ == "__main__":
1666
+ raise SystemExit(main())