bmad-module-skill-forge 2.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (435) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +49 -49
  3. package/docs/404.md +10 -8
  4. package/docs/_data/pinned.yaml +22 -13
  5. package/docs/_internal/STABILITY.md +58 -35
  6. package/docs/agents.md +39 -53
  7. package/docs/architecture.md +65 -51
  8. package/docs/bmad-synergy.md +53 -51
  9. package/docs/campaign.md +127 -75
  10. package/docs/concepts.md +34 -28
  11. package/docs/examples.md +70 -68
  12. package/docs/forge-auto.md +34 -31
  13. package/docs/getting-started.md +118 -57
  14. package/docs/how-it-works.md +25 -21
  15. package/docs/index.md +15 -15
  16. package/docs/skill-model.md +70 -59
  17. package/docs/troubleshooting.md +135 -18
  18. package/docs/verifying-a-skill.md +83 -57
  19. package/docs/why-skf.md +24 -22
  20. package/docs/workflows.md +274 -110
  21. package/package.json +18 -10
  22. package/src/forger/preferences.yaml +8 -1
  23. package/src/knowledge/agentskills-spec.md +2 -0
  24. package/src/knowledge/ccc-bridge.md +40 -24
  25. package/src/knowledge/confidence-tiers.md +9 -5
  26. package/src/knowledge/overview.md +3 -3
  27. package/src/knowledge/progressive-capability.md +8 -8
  28. package/src/knowledge/provenance-tracking.md +9 -5
  29. package/src/knowledge/qmd-registry.md +2 -2
  30. package/src/knowledge/skf-knowledge-index.csv +2 -2
  31. package/src/knowledge/skill-lifecycle.md +18 -11
  32. package/src/knowledge/split-body-strategy.md +0 -3
  33. package/src/knowledge/tool-resolution.md +4 -2
  34. package/src/knowledge/version-paths.md +101 -15
  35. package/src/module-help.csv +3 -3
  36. package/src/module.yaml +10 -0
  37. package/src/shared/data/ast-grep-recipes.yaml +608 -0
  38. package/src/shared/data/ide-context-files.json +30 -0
  39. package/src/shared/health-check.md +32 -12
  40. package/src/shared/references/description-guard-protocol.md +12 -9
  41. package/src/shared/references/feasibility-report-schema.md +19 -13
  42. package/src/shared/references/headless-gate-convention.md +55 -3
  43. package/src/shared/references/output-contract-schema.md +19 -8
  44. package/src/shared/references/pipeline-contracts.md +94 -29
  45. package/src/shared/references/tessl-review.md +77 -0
  46. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
  47. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
  48. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
  49. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
  50. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
  51. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
  52. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
  53. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
  54. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
  55. package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
  56. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
  57. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
  58. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
  59. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
  60. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
  61. package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
  62. package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
  63. package/src/shared/scripts/skf-atomic-write.py +30 -14
  64. package/src/shared/scripts/skf-build-change-manifest.py +2203 -24
  65. package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
  66. package/src/shared/scripts/skf-check-unit-records.py +184 -0
  67. package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
  68. package/src/shared/scripts/skf-classify-changed-files.py +694 -0
  69. package/src/shared/scripts/skf-comention-pairs.py +1333 -123
  70. package/src/shared/scripts/skf-compare-file-hashes.py +1 -1
  71. package/src/shared/scripts/skf-count-imports.py +1867 -0
  72. package/src/shared/scripts/skf-count-tokens.py +1 -1
  73. package/src/shared/scripts/skf-derive-assembly-shape.py +3 -2
  74. package/src/shared/scripts/skf-description-guard.py +99 -11
  75. package/src/shared/scripts/skf-detect-docs.py +694 -2
  76. package/src/shared/scripts/skf-detect-language.py +486 -107
  77. package/src/shared/scripts/skf-detect-registry.py +887 -0
  78. package/src/shared/scripts/skf-detect-scripts-assets.py +1 -1
  79. package/src/shared/scripts/skf-detect-tools.py +209 -27
  80. package/src/shared/scripts/skf-detect-workspaces.py +506 -30
  81. package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
  82. package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
  83. package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
  84. package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
  85. package/src/shared/scripts/skf-extract-public-api.py +4084 -85
  86. package/src/shared/scripts/skf-extraction-inventory.py +746 -0
  87. package/src/shared/scripts/skf-extraction-snapshot.py +539 -0
  88. package/src/shared/scripts/skf-fetch-temporal.py +674 -0
  89. package/src/shared/scripts/skf-find-cycles.py +97 -29
  90. package/src/shared/scripts/skf-find-test-report.py +476 -0
  91. package/src/shared/scripts/skf-forge-tier-rw.py +444 -203
  92. package/src/shared/scripts/skf-github-fetch.py +365 -0
  93. package/src/shared/scripts/skf-github-probe.py +661 -0
  94. package/src/shared/scripts/skf-hash-content.py +166 -12
  95. package/src/shared/scripts/skf-language-corpora.py +1 -1
  96. package/src/shared/scripts/skf-load-provenance.py +159 -27
  97. package/src/shared/scripts/skf-manifest-ops.py +1 -1
  98. package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
  99. package/src/shared/scripts/skf-merge-doc-urls.py +1 -1
  100. package/src/shared/scripts/skf-names-present.py +221 -0
  101. package/src/shared/scripts/skf-pair-intersect.py +119 -28
  102. package/src/shared/scripts/skf-parse-gaps.py +1384 -0
  103. package/src/shared/scripts/skf-preapply.py +1 -1
  104. package/src/shared/scripts/skf-preflight.py +161 -27
  105. package/src/shared/scripts/skf-promote-staged.py +430 -0
  106. package/src/shared/scripts/skf-provenance-gap-dispatch.py +39 -7
  107. package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
  108. package/src/shared/scripts/skf-quick-batch.py +774 -0
  109. package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
  110. package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
  111. package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
  112. package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
  113. package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
  114. package/src/shared/scripts/skf-resolve-authoritative-files.py +128 -13
  115. package/src/shared/scripts/skf-resolve-package.py +523 -106
  116. package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
  117. package/src/shared/scripts/skf-run-lock.py +577 -0
  118. package/src/shared/scripts/skf-scan-manifests.py +998 -57
  119. package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
  120. package/src/shared/scripts/skf-severity-classify.py +712 -108
  121. package/src/shared/scripts/skf-shape-detect.py +298 -204
  122. package/src/shared/scripts/skf-shard-body.py +9 -8
  123. package/src/shared/scripts/skf-skill-inventory.py +2412 -89
  124. package/src/shared/scripts/skf-skills-module.py +472 -0
  125. package/src/shared/scripts/skf-source-tree.py +1796 -0
  126. package/src/shared/scripts/skf-structural-diff.py +670 -119
  127. package/src/shared/scripts/skf-tessl-review.py +706 -0
  128. package/src/shared/scripts/skf-update-active-symlink.py +5 -5
  129. package/src/shared/scripts/skf-update-run-state.py +624 -0
  130. package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
  131. package/src/shared/scripts/skf-validate-brief-schema.py +4 -1
  132. package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
  133. package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
  134. package/src/shared/scripts/skf-validate-output.py +712 -64
  135. package/src/shared/scripts/skf-validate-pins.py +1 -1
  136. package/src/shared/scripts/skf-verify-no-trace.py +116 -10
  137. package/src/shared/scripts/skf-verify-provenance-completeness.py +2919 -54
  138. package/src/shared/scripts/skf-write-skill-brief.py +367 -19
  139. package/src/shared/tool-requirements.yaml +201 -0
  140. package/src/skf-analyze-source/SKILL.md +41 -36
  141. package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
  142. package/src/skf-analyze-source/customize.toml +36 -24
  143. package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
  144. package/src/skf-analyze-source/references/continue.md +2 -13
  145. package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
  146. package/src/skf-analyze-source/references/generate-briefs.md +121 -55
  147. package/src/skf-analyze-source/references/headless-contract.md +42 -15
  148. package/src/skf-analyze-source/references/identify-units.md +96 -110
  149. package/src/skf-analyze-source/references/init.md +73 -71
  150. package/src/skf-analyze-source/references/map-and-detect.md +123 -105
  151. package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
  152. package/src/skf-analyze-source/references/recommend.md +51 -70
  153. package/src/skf-analyze-source/references/scan-project.md +60 -84
  154. package/src/skf-analyze-source/references/scan-root.md +39 -0
  155. package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
  156. package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
  157. package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
  158. package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
  159. package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
  160. package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
  161. package/src/skf-audit-skill/SKILL.md +43 -51
  162. package/src/skf-audit-skill/assets/drift-report-template.md +27 -3
  163. package/src/skf-audit-skill/customize.toml +41 -20
  164. package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
  165. package/src/skf-audit-skill/references/doc-drift.md +141 -0
  166. package/src/skf-audit-skill/references/headless-contract.md +29 -0
  167. package/src/skf-audit-skill/references/init.md +148 -210
  168. package/src/skf-audit-skill/references/re-index.md +96 -108
  169. package/src/skf-audit-skill/references/report.md +96 -33
  170. package/src/skf-audit-skill/references/semantic-diff.md +27 -18
  171. package/src/skf-audit-skill/references/severity-classify.md +79 -65
  172. package/src/skf-audit-skill/references/severity-rules.md +15 -6
  173. package/src/skf-audit-skill/references/structural-diff.md +75 -90
  174. package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
  175. package/src/skf-audit-skill/scripts/render-drift-tables.py +685 -0
  176. package/src/skf-brief-skill/SKILL.md +36 -25
  177. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  178. package/src/skf-brief-skill/assets/scope-templates.md +17 -24
  179. package/src/skf-brief-skill/assets/skill-brief-schema.md +29 -15
  180. package/src/skf-brief-skill/customize.toml +48 -20
  181. package/src/skf-brief-skill/references/analyze-target.md +142 -125
  182. package/src/skf-brief-skill/references/confirm-brief.md +10 -8
  183. package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
  184. package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
  185. package/src/skf-brief-skill/references/gather-intent.md +87 -182
  186. package/src/skf-brief-skill/references/headless-args.md +84 -16
  187. package/src/skf-brief-skill/references/health-check.md +1 -1
  188. package/src/skf-brief-skill/references/invocation-contract.md +18 -13
  189. package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
  190. package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
  191. package/src/skf-brief-skill/references/scope-definition.md +123 -47
  192. package/src/skf-brief-skill/references/step-auto-brief.md +24 -80
  193. package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
  194. package/src/skf-brief-skill/references/version-resolution.md +3 -2
  195. package/src/skf-brief-skill/references/write-brief.md +66 -57
  196. package/src/skf-campaign/SKILL.md +31 -34
  197. package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
  198. package/src/skf-campaign/customize.toml +47 -16
  199. package/src/skf-campaign/references/campaign-contracts.md +39 -29
  200. package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
  201. package/src/skf-campaign/references/health-check.md +7 -16
  202. package/src/skf-campaign/references/step-01-setup.md +46 -84
  203. package/src/skf-campaign/references/step-02-strategy.md +31 -30
  204. package/src/skf-campaign/references/step-03-pins.md +26 -18
  205. package/src/skf-campaign/references/step-04-provenance.md +24 -23
  206. package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
  207. package/src/skf-campaign/references/step-06-batch.md +41 -34
  208. package/src/skf-campaign/references/step-07-capstone.md +40 -25
  209. package/src/skf-campaign/references/step-08-verify.md +28 -29
  210. package/src/skf-campaign/references/step-09-refine.md +29 -30
  211. package/src/skf-campaign/references/step-10-export.md +48 -36
  212. package/src/skf-campaign/references/step-11-maintenance.md +31 -34
  213. package/src/skf-campaign/references/step-resume.md +37 -68
  214. package/src/skf-campaign/scripts/campaign-deps.py +57 -18
  215. package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
  216. package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
  217. package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
  218. package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
  219. package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
  220. package/src/skf-campaign/scripts/campaign-report.py +204 -37
  221. package/src/skf-campaign/scripts/campaign-state.py +1128 -0
  222. package/src/skf-campaign/scripts/campaign-status.py +10 -74
  223. package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
  224. package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
  225. package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
  226. package/src/skf-campaign/templates/campaign-report-template.md +5 -1
  227. package/src/skf-campaign/templates/kickoff-template.md +0 -14
  228. package/src/skf-create-skill/SKILL.md +27 -20
  229. package/src/skf-create-skill/assets/compile-assembly-rules.md +6 -5
  230. package/src/skf-create-skill/assets/skill-sections.md +15 -5
  231. package/src/skf-create-skill/customize.toml +20 -8
  232. package/src/skf-create-skill/references/authoritative-files-protocol.md +41 -42
  233. package/src/skf-create-skill/references/batch-mode.md +81 -0
  234. package/src/skf-create-skill/references/ccc-index-check.md +27 -0
  235. package/src/skf-create-skill/references/compile.md +107 -29
  236. package/src/skf-create-skill/references/component-extraction.md +113 -91
  237. package/src/skf-create-skill/references/enrich.md +41 -23
  238. package/src/skf-create-skill/references/entry-points-by-hand.md +20 -0
  239. package/src/skf-create-skill/references/extract.md +200 -161
  240. package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
  241. package/src/skf-create-skill/references/extraction-patterns-tracing.md +2 -2
  242. package/src/skf-create-skill/references/extraction-patterns.md +584 -196
  243. package/src/skf-create-skill/references/generate-artifacts.md +145 -93
  244. package/src/skf-create-skill/references/health-check.md +1 -1
  245. package/src/skf-create-skill/references/load-brief.md +53 -36
  246. package/src/skf-create-skill/references/relabel-rule.md +12 -0
  247. package/src/skf-create-skill/references/report.md +28 -59
  248. package/src/skf-create-skill/references/source-resolution-protocols.md +49 -155
  249. package/src/skf-create-skill/references/step-auto-shard.md +20 -93
  250. package/src/skf-create-skill/references/step-doc-rot.md +44 -60
  251. package/src/skf-create-skill/references/step-doc-sources.md +28 -19
  252. package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
  253. package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
  254. package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
  255. package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
  256. package/src/skf-create-skill/references/validate.md +173 -99
  257. package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
  258. package/src/skf-create-stack-skill/SKILL.md +29 -56
  259. package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
  260. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
  261. package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
  262. package/src/skf-create-stack-skill/customize.toml +23 -12
  263. package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
  264. package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
  265. package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
  266. package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
  267. package/src/skf-create-stack-skill/references/generate-output.md +138 -128
  268. package/src/skf-create-stack-skill/references/init.md +90 -32
  269. package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
  270. package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
  271. package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
  272. package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
  273. package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
  274. package/src/skf-create-stack-skill/references/report.md +39 -33
  275. package/src/skf-create-stack-skill/references/validate.md +20 -39
  276. package/src/skf-drop-skill/SKILL.md +28 -51
  277. package/src/skf-drop-skill/customize.toml +44 -30
  278. package/src/skf-drop-skill/references/execute.md +79 -146
  279. package/src/skf-drop-skill/references/invocation-contract.md +64 -0
  280. package/src/skf-drop-skill/references/report.md +46 -30
  281. package/src/skf-drop-skill/references/select.md +118 -124
  282. package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
  283. package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
  284. package/src/skf-export-skill/SKILL.md +40 -62
  285. package/src/skf-export-skill/assets/managed-section-format.md +14 -50
  286. package/src/skf-export-skill/assets/snippet-format.md +3 -1
  287. package/src/skf-export-skill/customize.toml +42 -13
  288. package/src/skf-export-skill/references/generate-snippet.md +54 -46
  289. package/src/skf-export-skill/references/health-check.md +2 -0
  290. package/src/skf-export-skill/references/invocation-contract.md +15 -0
  291. package/src/skf-export-skill/references/load-skill.md +88 -62
  292. package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
  293. package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
  294. package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
  295. package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
  296. package/src/skf-export-skill/references/package.md +15 -73
  297. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
  298. package/src/skf-export-skill/references/result-envelope.md +31 -16
  299. package/src/skf-export-skill/references/summary.md +41 -17
  300. package/src/skf-export-skill/references/token-report.md +4 -12
  301. package/src/skf-export-skill/references/update-context.md +118 -237
  302. package/src/skf-forger/SKILL.md +61 -37
  303. package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
  304. package/src/skf-forger/customize.toml +43 -0
  305. package/src/skf-forger/references/pipeline-mode.md +73 -18
  306. package/src/skf-forger/scripts/forge-status.py +299 -0
  307. package/src/skf-forger/scripts/parse-pipeline.py +322 -54
  308. package/src/skf-forger/scripts/pipeline-gate.py +428 -0
  309. package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
  310. package/src/skf-quick-skill/SKILL.md +54 -53
  311. package/src/skf-quick-skill/assets/skill-template.md +4 -2
  312. package/src/skf-quick-skill/customize.toml +51 -20
  313. package/src/skf-quick-skill/references/batch-mode.md +74 -21
  314. package/src/skf-quick-skill/references/compile.md +30 -31
  315. package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
  316. package/src/skf-quick-skill/references/finalize.md +69 -18
  317. package/src/skf-quick-skill/references/halt-contract.md +41 -29
  318. package/src/skf-quick-skill/references/health-check.md +5 -3
  319. package/src/skf-quick-skill/references/quick-extract.md +114 -45
  320. package/src/skf-quick-skill/references/registry-resolution.md +9 -64
  321. package/src/skf-quick-skill/references/resolve-target.md +116 -66
  322. package/src/skf-quick-skill/references/write-and-validate.md +79 -40
  323. package/src/skf-refine-architecture/SKILL.md +43 -53
  324. package/src/skf-refine-architecture/customize.toml +49 -20
  325. package/src/skf-refine-architecture/references/compile.md +97 -54
  326. package/src/skf-refine-architecture/references/exit-codes.md +30 -0
  327. package/src/skf-refine-architecture/references/finding-storage.md +21 -0
  328. package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
  329. package/src/skf-refine-architecture/references/improvements.md +14 -20
  330. package/src/skf-refine-architecture/references/init.md +98 -57
  331. package/src/skf-refine-architecture/references/issue-detection.md +32 -39
  332. package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
  333. package/src/skf-refine-architecture/references/report.md +65 -54
  334. package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
  335. package/src/skf-rename-skill/SKILL.md +19 -54
  336. package/src/skf-rename-skill/customize.toml +32 -26
  337. package/src/skf-rename-skill/references/execute.md +230 -184
  338. package/src/skf-rename-skill/references/exit-codes.md +7 -7
  339. package/src/skf-rename-skill/references/health-check.md +1 -7
  340. package/src/skf-rename-skill/references/invocation-contract.md +25 -0
  341. package/src/skf-rename-skill/references/report.md +63 -52
  342. package/src/skf-rename-skill/references/select.md +197 -115
  343. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
  344. package/src/skf-setup/SKILL.md +26 -26
  345. package/src/skf-setup/customize.toml +14 -10
  346. package/src/skf-setup/references/auto-index.md +50 -63
  347. package/src/skf-setup/references/ccc-index.md +29 -90
  348. package/src/skf-setup/references/detect-and-tier.md +58 -67
  349. package/src/skf-setup/references/health-check.md +2 -0
  350. package/src/skf-setup/references/invocation-contract.md +14 -0
  351. package/src/skf-setup/references/report.md +43 -166
  352. package/src/skf-setup/references/tier-rules.md +5 -5
  353. package/src/skf-setup/references/write-config.md +34 -57
  354. package/src/skf-test-skill/SKILL.md +17 -57
  355. package/src/skf-test-skill/assets/output-section-formats.md +33 -34
  356. package/src/skf-test-skill/customize.toml +27 -13
  357. package/src/skf-test-skill/references/coherence-check.md +134 -62
  358. package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
  359. package/src/skf-test-skill/references/coverage-check.md +217 -254
  360. package/src/skf-test-skill/references/detect-mode.md +5 -5
  361. package/src/skf-test-skill/references/external-validators.md +94 -82
  362. package/src/skf-test-skill/references/health-check.md +6 -7
  363. package/src/skf-test-skill/references/init.md +163 -88
  364. package/src/skf-test-skill/references/invocation-contract.md +66 -0
  365. package/src/skf-test-skill/references/migration-section-rules.md +5 -2
  366. package/src/skf-test-skill/references/report.md +162 -142
  367. package/src/skf-test-skill/references/score.md +80 -107
  368. package/src/skf-test-skill/references/scoring-rules.md +41 -155
  369. package/src/skf-test-skill/references/source-access-protocol.md +19 -92
  370. package/src/skf-test-skill/references/step-hard-gate.md +51 -33
  371. package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
  372. package/src/skf-test-skill/scripts/build-result-context.py +287 -0
  373. package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
  374. package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
  375. package/src/skf-test-skill/scripts/compute-score.py +223 -45
  376. package/src/skf-test-skill/scripts/gap-ledger.py +1381 -0
  377. package/src/skf-test-skill/scripts/hard-gate.py +203 -0
  378. package/src/skf-test-skill/scripts/load-coverage-inputs.py +760 -0
  379. package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
  380. package/src/skf-test-skill/scripts/reconcile-coverage.py +273 -79
  381. package/src/skf-test-skill/scripts/score-signatures.py +459 -0
  382. package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
  383. package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
  384. package/src/skf-test-skill/scripts/verify-declared-numerator.py +127 -52
  385. package/src/skf-test-skill/templates/test-report-template.md +21 -15
  386. package/src/skf-update-skill/SKILL.md +38 -25
  387. package/src/skf-update-skill/customize.toml +27 -12
  388. package/src/skf-update-skill/references/detect-changes.md +233 -222
  389. package/src/skf-update-skill/references/gap-driven.md +336 -0
  390. package/src/skf-update-skill/references/health-check.md +31 -5
  391. package/src/skf-update-skill/references/init.md +250 -84
  392. package/src/skf-update-skill/references/invocation-contract.md +12 -0
  393. package/src/skf-update-skill/references/manual-section-rules.md +6 -23
  394. package/src/skf-update-skill/references/merge.md +180 -47
  395. package/src/skf-update-skill/references/re-extract.md +53 -262
  396. package/src/skf-update-skill/references/report.md +100 -35
  397. package/src/skf-update-skill/references/write.md +331 -149
  398. package/src/skf-update-skill/scripts/skf-new-file-diff.py +29 -7
  399. package/src/skf-verify-stack/SKILL.md +23 -42
  400. package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
  401. package/src/skf-verify-stack/customize.toml +55 -22
  402. package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
  403. package/src/skf-verify-stack/references/coverage.md +67 -64
  404. package/src/skf-verify-stack/references/exit-codes.md +21 -8
  405. package/src/skf-verify-stack/references/init.md +107 -62
  406. package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
  407. package/src/skf-verify-stack/references/integrations.md +106 -65
  408. package/src/skf-verify-stack/references/report.md +59 -42
  409. package/src/skf-verify-stack/references/requirements.md +32 -9
  410. package/src/skf-verify-stack/references/synthesize.md +53 -39
  411. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
  412. package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
  413. package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
  414. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
  415. package/tools/cli/commands/install.js +11 -4
  416. package/tools/cli/commands/status.js +15 -3
  417. package/tools/cli/commands/update.js +11 -4
  418. package/tools/cli/lib/tool-check.js +320 -0
  419. package/tools/cli/lib/version-check.js +22 -9
  420. package/tools/cli/skf-cli.js +4 -1
  421. package/.gitattributes +0 -16
  422. package/.nvmrc +0 -1
  423. package/docs/_internal/RELEASING.md +0 -679
  424. package/release-audits/v1.0.0-launch-audit.md +0 -728
  425. package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
  426. package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
  427. package/src/skf-campaign/manifest.yaml +0 -15
  428. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
  429. package/src/skf-create-skill/references/ecosystem-check.md +0 -74
  430. package/src/skf-drop-skill/references/headless-contract.md +0 -18
  431. package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
  432. package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
  433. package/src/skf-update-skill/references/validate.md +0 -124
  434. package/tools/validate-docs-drift.js +0 -233
  435. package/tools/validate-docs-links.js +0 -665
@@ -0,0 +1,1619 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # dependencies = []
5
+ # ///
6
+ """Build, check and promote refine-architecture's refined document, and check
7
+ the refinement rules and the [VS] verdicts the steps classify with.
8
+
9
+ Compile (references/compile.md) never retypes the architecture document. It
10
+ stages an insertion plan, one entry per finding, and this script copies the
11
+ original line for line, puts each entry's block where its anchor says,
12
+ appends the Refinement Summary, checks that every original line survived and
13
+ writes the draft. The review's [C] promotes the draft through this script
14
+ too, so the output file is never replaced by a draft that lost a line.
15
+
16
+ Init (references/init.md) checks the refinement rules with `rules`, and
17
+ issue detection (references/issue-detection.md) joins the [VS] report's
18
+ verdict rows to the skill inventory and the document scope with `verdicts`,
19
+ so neither step applies a table rule or a join by hand.
20
+
21
+ RA's blocks. Every block the script adds sits between two marker lines, so a
22
+ later pass can set the whole annotation aside and write a fresh one:
23
+
24
+ <!-- RA:BEGIN <kind> <id> -->
25
+ ...
26
+ <!-- RA:END -->
27
+
28
+ <kind> is gap, issue or improvement (an anchored entry), section (a fallback
29
+ section: <id> is the kind it collects) or summary (the Refinement Summary).
30
+ Setting RA's blocks aside removes each BEGIN line through the next END line,
31
+ outside fenced code. A BEGIN followed by another BEGIN before any END, and an
32
+ END with no BEGIN, are malformed: they stay in place as ordinary lines and
33
+ are reported, so a marker the user deleted never takes their text with it.
34
+ A document refined before the markers existed holds unmarked annotations:
35
+ when it has a `Refinement Summary` heading and at least one `RA:` heading or
36
+ RA callout (a blockquote whose first line names "Gap Identified by Refine
37
+ Architecture", "Issue Detected by Refine Architecture" or "Improvement
38
+ Suggested by Refine Architecture"), each `RA:` section and the Refinement
39
+ Summary section (to the next heading of the same or a higher level) and
40
+ each such callout are set aside too. Nothing marks where an older pass's
41
+ text ends, so such a section can hold a paragraph of the user's that follows
42
+ it: set_aside names every block set aside, by its lines in the file as
43
+ given, so the caller can show them instead of claiming they were checked.
44
+
45
+ Subcommands:
46
+
47
+ inspect --doc PATH [--stripped OUT] [-o JSON]
48
+ Report whether PATH holds an earlier RA pass and, with --stripped,
49
+ write a copy of it with RA's blocks set aside (the analysis input).
50
+ -o also writes the JSON to that file when the command succeeded.
51
+ JSON: {status: "ok", doc, previous_pass, signals[], marked_blocks,
52
+ legacy_blocks, malformed_markers[], lines_set_aside, set_aside[],
53
+ stripped}. set_aside holds {start, end, kind, first_line} per block,
54
+ kind "marked" or "legacy", first_line the text of its start line.
55
+ signals holds "ra-markers" (marked blocks, or malformed markers),
56
+ "legacy-annotations" and "refined-document" (the file name is
57
+ refined-architecture-*.md); previous_pass is true when any is there.
58
+
59
+ apply --original PATH --plan PLAN --draft OUT [-o JSON]
60
+ Check the plan, build the draft from PATH with RA's blocks set aside,
61
+ check that it preserves PATH, and write it to OUT. Nothing is written
62
+ when the plan has a problem or the check fails, so an earlier draft at
63
+ OUT stays as it was. -o also writes the JSON to that file, only when
64
+ the draft was written, so the file always describes the draft on disk.
65
+ JSON on success: {status: "ok", draft, counts, evidence, summary,
66
+ unverified_technologies[], placed[], fallback[], closed_fence_line,
67
+ preserved: true, missing: [], altered: [], lines_set_aside,
68
+ set_aside[]}. summary is the Refinement Summary as the draft holds
69
+ it, its placeholders filled.
70
+
71
+ check --original PATH --refined PATH
72
+ Set RA's blocks aside in both files and confirm the original's lines
73
+ appear in the refined file, in order and unchanged (a line's
74
+ terminator aside). Lines the refined file adds are allowed.
75
+ JSON: {status: "preserved" | "not-preserved", preserved, missing[],
76
+ missing_count, altered[], altered_count, added_count}. missing holds
77
+ {line, text}, altered {line, original, refined_line, refined}, with
78
+ line numbers in the files as given; each list keeps its first 50.
79
+
80
+ promote --original PATH --draft DRAFT --output OUT --timestamp TS [-o JSON]
81
+ Check DRAFT against PATH as check does, then rename an existing OUT to
82
+ <OUT stem>-<TS>.md (-2, -3 ... when that name is taken) and move DRAFT
83
+ to OUT. When the move fails, the earlier OUT is renamed back.
84
+ JSON: {status: "promoted", output, previous, preserved: true,
85
+ draft_removed}. draft_removed is false only when OUT is on another
86
+ file system and the draft, copied there, could not be deleted. -o
87
+ also writes the JSON to that file, only when the draft was promoted;
88
+ when that write fails, the draft and the earlier OUT are put back.
89
+ When a file cannot be put back, the error says where each one is.
90
+
91
+ context --inspect JSON --apply JSON --promote JSON -o OUT
92
+ Merge the records of inspect, apply and promote into the payload the
93
+ shared emitter writes the result contract from, and write it to OUT:
94
+ {status: "success", refined_path, previous_refined_path,
95
+ previous_pass, gap_count, issue_count, improvement_count,
96
+ unverified_count, result_contract: {skill, status, outputs, summary}}.
97
+ Prints {status: "ok", context: OUT}. A record that is missing or not
98
+ the JSON its subcommand wrote is an error (exit 2).
99
+
100
+ rules --rules PATH
101
+ Check the refinement rules file (the bundled references/
102
+ refinement-rules.md or a team's copy). It must hold the six tables
103
+ the steps read, each the first table under a heading of that title
104
+ (any level, case ignored): Gap Classification, Issue Classification,
105
+ Issue Severity, VS Report Integration, Improvement Classification and
106
+ Improvement Value. The first cell of each Issue Severity and
107
+ Improvement Value row names a tier, which becomes the summary count
108
+ {<tier>_count}, so a tier name starts with a letter and holds only the
109
+ letters A to Z, digits and spaces; no two tiers of the two tables
110
+ share a name, ignoring case and runs of spaces; and none is named Gap,
111
+ Issue, Improvement, Unverified or Skill, whose counts the summary
112
+ already holds. The VS Report Integration table has one row per
113
+ verdict token (Verified, Plausible, Risky, Blocked, case-sensitive),
114
+ and each row's Raises cell names one tier of Issue Severity, as a
115
+ whole word, ignoring case, or says No issue or None, never both:
116
+ what it bolds when it bolds a tier or No issue or None
117
+ (`A **Major** issue`, as the bundled rows do, or `**No issue**`),
118
+ else what its text names (`Major`, `No issue`).
119
+ JSON: {status: "ok" | "violations", rules, missing_tables[], tiers:
120
+ {issue[], improvement[]}, vs_raises: {<token>: <tier> | null},
121
+ violations[]}. A violation is {table, line, rule, detail}, line null
122
+ when it names no row; rule is table-missing, table-empty, tier-name,
123
+ tier-reserved, tier-duplicate, vs-token-unknown, vs-token-duplicate,
124
+ vs-token-unmapped or vs-raises.
125
+
126
+ verdicts --report PATH --generated-at TS --skills JSON --in-scope NAMES --rules PATH
127
+ Read the [VS] report's verdict rows again through the shared
128
+ feasibility-report reader, and join each to the skill inventory and
129
+ the document scope. --generated-at is the report's generatedAt when
130
+ the run started ("" when it had none), --skills a JSON array of the
131
+ inventory's skills, each a name or {name, aliases}, and --in-scope
132
+ the in-scope skill names, comma-separated. Each row's lib_a and lib_b
133
+ map to the skill of that name, ignoring case and runs of spaces, else
134
+ to the one skill that has it as an alias. A row whose two libraries
135
+ are two in-scope skills goes to in_scope with raises, the tier the
136
+ rules' VS Report Integration table maps its token to (null: no
137
+ issue); any other row goes to out_of_scope with reason
138
+ "no-inventory-skill" (a library names no single skill, such as a
139
+ [VS] cycle row's `cycle`), "same-skill" (both name one skill, such as
140
+ its name and an alias) or "out-of-scope" (two skills, one or both
141
+ outside the scope).
142
+ JSON: {status: "ok", report, generatedAt, overallVerdict, in_scope[],
143
+ out_of_scope[], issue_count}, each row {lib_a, lib_b, verdict,
144
+ rationale, skill_a, skill_b} plus raises or reason, in report order;
145
+ issue_count counts the in_scope rows that raise a tier. When the
146
+ report is no longer the one the run started from (it cannot be read,
147
+ breaks the feasibility-report contract or has another generatedAt),
148
+ or the rules no longer pass `rules` (they break a rule above or
149
+ cannot be read), it prints {status: "stale", problems: [{source,
150
+ detail}]} instead, source "report" or "rules".
151
+
152
+ The insertion plan (apply --plan) is one JSON object:
153
+
154
+ {
155
+ "entries": [
156
+ {"id": "gap-1", "kind": "gap", "anchor": "## Data Layer",
157
+ "skills": ["loro", "yjs"], "block": "#### RA: ...\\n\\n> [!NOTE] ..."},
158
+ {"id": "issue-1", "kind": "issue", "tier": "Critical",
159
+ "anchor": "Sync runs over gRPC.", "occurrence": 2, "block": "> [!WARNING] ..."},
160
+ {"id": "improvement-1", "kind": "improvement", "tier": "High",
161
+ "anchor": null, "block": "#### RA: Enhancement: ..."}
162
+ ],
163
+ "summary": "## Refinement Summary\\n... {gap_count} ...",
164
+ "unverified_technologies": ["Redis"],
165
+ "skill_count": 4,
166
+ "tiers": {"issue": [...], "improvement": [...]},
167
+ "fallback_headings": {"gap": "...", "issue": "...", "improvement": "..."}
168
+ }
169
+
170
+ id unique, letters, digits, '.', '_' or '-' (it goes into the
171
+ BEGIN marker)
172
+ kind gap, issue or improvement
173
+ tier an issue's severity or an improvement's value, one of the
174
+ kind's tiers (default Critical, Major, Minor and High, Medium,
175
+ Low; "tiers" replaces them); a gap has none
176
+ anchor text one line of the document holds, outside its frontmatter,
177
+ or null for the kind's fallback section. It must occur on
178
+ exactly one line, unless "occurrence" (1-based) picks one of
179
+ the lines that hold it. An anchor that is an ATX heading
180
+ (`## API`) matches the headings of that level and title, so
181
+ `### API Gateway` never holds it; only when no heading does is
182
+ it matched as text.
183
+ skills the skills the finding cites (the Evidence Sources counts)
184
+ block the Markdown to insert, holding no RA marker and no unclosed
185
+ code fence. A gap or improvement block opens with its ATX
186
+ heading: the script sets that heading one level below the
187
+ heading that governs the anchor.
188
+ summary the Refinement Summary, holding no RA marker. The script fills
189
+ {gap_count}, {issue_count} and {improvement_count} (all three
190
+ are required, so no count is typed), {<tier>_count} for every
191
+ tier (lower case, spaces as '_': {critical_count}),
192
+ {unverified_count} (the length of unverified_technologies),
193
+ {unverified_technologies} (their names, comma-separated, or
194
+ "none"), {skill_count} (the plan's skill_count, the skills the
195
+ run used as evidence, when the plan gives it) and
196
+ {evidence_rows} (one `| <skill> | <count> |` row per cited
197
+ skill, most cited first). Any other {lower_case} placeholder
198
+ left in it is a problem.
199
+
200
+ Placement. An issue goes right after the block that holds its anchor: a
201
+ heading line, the enclosing paragraph, table or blockquote, the whole list
202
+ (a loose list's later items included, and a code fence indented under one
203
+ of its items, which belongs to the item) or the code fence. A gap or
204
+ improvement goes at the end of the section its anchor sits in (the anchor
205
+ heading's own section, or the nearest heading above the anchor line), before
206
+ the next heading of the same or a higher level. Entries placed at one point
207
+ keep this order: issues, gaps, improvements, each by tier and then by plan
208
+ order. Entries with a null anchor, or with no heading above the anchor, go
209
+ to the fallback sections appended at the end of the document (gaps, then
210
+ issues by severity, then improvements by value), and the Refinement Summary
211
+ comes last. A document that ends inside an unclosed code fence gets a
212
+ closing fence line first (closed_fence_line), so nothing RA adds lands in
213
+ the code. Original lines keep their bytes and terminators; added lines use
214
+ the document's own line ending.
215
+
216
+ apply's problems. When the plan cannot be applied, apply prints
217
+ {status: "problems", problems[]} and exits 1. Each problem has an id (null
218
+ for the plan itself), a reason and a detail: plan-invalid, entry-invalid,
219
+ tier-unknown, block-has-marker, block-unclosed-fence, block-no-heading,
220
+ anchor-not-found (with closest: the line that holds the longest part of the
221
+ anchor, its heading), anchor-ambiguous (with matches: each line and its
222
+ heading), occurrence-out-of-range, summary-has-marker,
223
+ summary-unclosed-fence, summary-count-typed and summary-placeholder.
224
+
225
+ Exit codes:
226
+ 0 inspect, apply, check, promote, context, rules or verdicts succeeded
227
+ (check: preserved; rules: no violation)
228
+ 1 apply: the plan has problems, or the draft would not preserve the
229
+ original (status "not-preserved"); check: not preserved; promote: the
230
+ draft does not preserve the original, and nothing was moved; rules:
231
+ the rules break a rule (status "violations"); verdicts: the report
232
+ changed or the rules no longer pass (status "stale")
233
+ 2 a usage error (argparse prints it, no JSON), or a file that cannot be
234
+ read or is not UTF-8 text, or a context record that is not its
235
+ subcommand's JSON, or a --skills file that is not a JSON array of
236
+ skills ({status: "error", error})
237
+ 3 a write failed ({status: "error", error}): the shared atomic writer is
238
+ missing or failed, or promote could not rename or move a file or write
239
+ its -o record (it puts the earlier output back first, and the error
240
+ names where each file is when it cannot); verdicts: the shared
241
+ feasibility-report reader is missing
242
+
243
+ Writes go through the shared skf-atomic-write.py, and verdicts reads the
244
+ report through the shared skf-validate-feasibility-report.py: both sit in
245
+ the shared scripts folder beside this skill's folder, installed and in a
246
+ dev checkout.
247
+ """
248
+
249
+ from __future__ import annotations
250
+
251
+ import argparse
252
+ import difflib
253
+ import errno
254
+ import importlib.util
255
+ import json
256
+ import os
257
+ import re
258
+ import shutil
259
+ import subprocess
260
+ import sys
261
+ from pathlib import Path
262
+ from typing import NamedTuple
263
+
264
+ SHARED_SCRIPTS = Path(__file__).resolve().parent.parent.parent / "shared" / "scripts"
265
+ ATOMIC_WRITER = SHARED_SCRIPTS / "skf-atomic-write.py"
266
+ FEASIBILITY_READER = SHARED_SCRIPTS / "skf-validate-feasibility-report.py"
267
+
268
+ KINDS = ("gap", "issue", "improvement")
269
+ # Entries placed at one point, and the fallback sections, keep this order.
270
+ KIND_RANK = {"issue": 0, "gap": 1, "improvement": 2}
271
+ FALLBACK_ORDER = ("gap", "issue", "improvement")
272
+ DEFAULT_TIERS = {"issue": ["Critical", "Major", "Minor"], "improvement": ["High", "Medium", "Low"]}
273
+ DEFAULT_FALLBACK_HEADINGS = {
274
+ "gap": "RA: Additional Integration Paths",
275
+ "issue": "RA: Additional Issues Detected",
276
+ "improvement": "RA: Additional Improvements Suggested",
277
+ }
278
+ REQUIRED_PLACEHOLDERS = ("gap_count", "issue_count", "improvement_count")
279
+ LIST_CAP = 50
280
+
281
+ BEGIN_RE = re.compile(r"^\s{0,3}<!--\s*RA:BEGIN\b.*-->\s*$")
282
+ END_RE = re.compile(r"^\s{0,3}<!--\s*RA:END\s*-->\s*$")
283
+ MARKER_TEXT_RE = re.compile(r"<!--\s*RA:(?:BEGIN|END)\b")
284
+ FENCE_OPEN_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
285
+ HEADING_RE = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$")
286
+ LIST_RE = re.compile(r"^\s{0,3}(?:[-*+]|\d{1,9}[.)])(?:\s|$)")
287
+ INDENT_RE = re.compile(r"^(?: {2,}|\t)\S")
288
+ QUOTE_RE = re.compile(r"^ {0,3}>")
289
+ PLACEHOLDER_RE = re.compile(r"\{([a-z][a-z0-9_]*)\}")
290
+ ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
291
+ TIMESTAMP_RE = re.compile(r"^\d{8}-\d{6}$")
292
+ REFINED_NAME_RE = re.compile(r"^refined-architecture-.+\.md$")
293
+ LEGACY_CALLOUTS = (
294
+ "Gap Identified by Refine Architecture",
295
+ "Issue Detected by Refine Architecture",
296
+ "Improvement Suggested by Refine Architecture",
297
+ )
298
+ SUMMARY_TITLE = "refinement summary"
299
+
300
+ # The tables the steps read from the refinement rules, in the file's order.
301
+ RULE_TABLES = ("Gap Classification", "Issue Classification", "Issue Severity", "VS Report Integration",
302
+ "Improvement Classification", "Improvement Value")
303
+ TIER_TABLES = (("issue", "Issue Severity"), ("improvement", "Improvement Value"))
304
+ VS_TABLE = "VS Report Integration"
305
+ VS_TOKENS = ("Verified", "Plausible", "Risky", "Blocked")
306
+ # A tier becomes the summary's {<tier>_count}, which PLACEHOLDER_RE fills only
307
+ # for a name of ASCII letters, digits and spaces that starts with a letter.
308
+ TIER_NAME_RE = re.compile(r"[A-Za-z][A-Za-z0-9 ]*")
309
+ RESERVED_COUNTS = (*REQUIRED_PLACEHOLDERS, "unverified_count", "skill_count")
310
+ TABLE_ROW_RE = re.compile(r"^ {0,3}\|")
311
+ TABLE_DELIM_RE = re.compile(r"^ {0,3}\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)*\|?\s*$")
312
+ CELL_SPLIT_RE = re.compile(r"(?<!\\)\|")
313
+ BOLD_RE = re.compile(r"\*\*(.+?)\*\*|__(.+?)__")
314
+ NO_ISSUE_RE = re.compile(r"(?<![A-Za-z0-9])(?:no\s+issue|none)(?![A-Za-z0-9])", re.IGNORECASE)
315
+
316
+
317
+ class DocError(Exception):
318
+ """A file that cannot be read as UTF-8 text (exit 2)."""
319
+
320
+
321
+ class WriteError(Exception):
322
+ """A write that failed (exit 3)."""
323
+
324
+
325
+ class HelperMissing(Exception):
326
+ """A shared helper this script runs is not installed (exit 3)."""
327
+
328
+
329
+ class Line(NamedTuple):
330
+ number: int # 1-based, in the file the line was read from
331
+ text: str # without its terminator
332
+ end: str # "\n", "\r\n", or "" for a last line without one
333
+
334
+
335
+ class Structure(NamedTuple):
336
+ """What each line of a document is, outside and inside its code fences."""
337
+
338
+ front: list[bool]
339
+ fence: list[bool] # a fence's opening, content and closing lines
340
+ fence_close: list[int] # for a fence line: the index of its closing line
341
+ level: list[int] # ATX heading level, 0 for any other line
342
+ open_fence: tuple[str, int] | None # (char, length) of a fence left open at the end
343
+
344
+
345
+ class Stripped:
346
+ """A document with RA's blocks set aside, and what was set aside."""
347
+
348
+ def __init__(self) -> None:
349
+ self.kept: list[Line] = []
350
+ self.marked = 0
351
+ self.legacy = 0
352
+ self.malformed: list[int] = [] # 1-based line numbers
353
+ self.spans: list[dict] = [] # each block set aside: {start, end, kind, first_line}
354
+
355
+
356
+ # --------------------------------------------------------------------------
357
+ # Reading and writing
358
+ # --------------------------------------------------------------------------
359
+
360
+
361
+ def split_lines(text: str) -> list[Line]:
362
+ """Split on \\n alone (a \\r before it belongs to the terminator)."""
363
+ lines = []
364
+ for number, piece in enumerate(re.findall(r"[^\n]*\n|[^\n]+\Z", text), start=1):
365
+ if piece.endswith("\r\n"):
366
+ lines.append(Line(number, piece[:-2], "\r\n"))
367
+ elif piece.endswith("\n"):
368
+ lines.append(Line(number, piece[:-1], "\n"))
369
+ else:
370
+ lines.append(Line(number, piece, ""))
371
+ return lines
372
+
373
+
374
+ def read_lines(path: str) -> list[Line]:
375
+ try:
376
+ data = Path(path).read_bytes()
377
+ except OSError as e:
378
+ raise DocError(f"{path}: {e.strerror or e}") from e
379
+ try:
380
+ return split_lines(data.decode("utf-8"))
381
+ except UnicodeDecodeError as e:
382
+ raise DocError(f"{path}: not UTF-8 text ({e.reason} at byte {e.start})") from e
383
+
384
+
385
+ def render(lines: list[Line]) -> str:
386
+ return "".join(line.text + line.end for line in lines)
387
+
388
+
389
+ def write_atomic(path: Path, data: bytes) -> None:
390
+ """Write through the shared skf-atomic-write.py (temp file, fsync, rename)."""
391
+ if not ATOMIC_WRITER.is_file():
392
+ raise WriteError(f"the shared atomic writer is not installed: {ATOMIC_WRITER.as_posix()}")
393
+ proc = subprocess.run(
394
+ [sys.executable, str(ATOMIC_WRITER), "write", "--target", str(path)],
395
+ input=data,
396
+ capture_output=True,
397
+ check=False,
398
+ )
399
+ if proc.returncode != 0:
400
+ message = proc.stderr.decode("utf-8", "replace").strip()
401
+ try:
402
+ message = json.loads(message).get("message", message)
403
+ except (ValueError, AttributeError):
404
+ message = message.splitlines()[-1] if message else f"exit {proc.returncode}"
405
+ raise WriteError(f"{path.as_posix()}: {message}")
406
+
407
+
408
+ # --------------------------------------------------------------------------
409
+ # Markdown structure
410
+ # --------------------------------------------------------------------------
411
+
412
+
413
+ def scan(texts: list[str]) -> Structure:
414
+ n = len(texts)
415
+ front = [False] * n
416
+ if n and texts[0].strip() == "---":
417
+ for i in range(1, n):
418
+ if texts[i].strip() in ("---", "..."):
419
+ for j in range(i + 1):
420
+ front[j] = True
421
+ break
422
+ fence = [False] * n
423
+ fence_close = [-1] * n
424
+ level = [0] * n
425
+ open_fence = None
426
+ i = 0
427
+ while i < n:
428
+ if front[i]:
429
+ i += 1
430
+ continue
431
+ m = FENCE_OPEN_RE.match(texts[i])
432
+ if m and not (m.group(1)[0] == "`" and "`" in m.group(2)):
433
+ char, length = m.group(1)[0], len(m.group(1))
434
+ close_re = re.compile(r"^ {0,3}" + re.escape(char) + "{" + str(length) + r",}\s*$")
435
+ j = i + 1
436
+ while j < n and not close_re.match(texts[j]):
437
+ j += 1
438
+ last = min(j, n - 1)
439
+ if j >= n:
440
+ open_fence = (char, length)
441
+ for k in range(i, last + 1):
442
+ fence[k] = True
443
+ fence_close[k] = last
444
+ i = last + 1
445
+ continue
446
+ h = HEADING_RE.match(texts[i])
447
+ if h:
448
+ level[i] = len(h.group(1))
449
+ i += 1
450
+ return Structure(front, fence, fence_close, level, open_fence)
451
+
452
+
453
+ def heading_text(text: str) -> str:
454
+ h = HEADING_RE.match(text)
455
+ title = (h.group(2) or "") if h else ""
456
+ return re.sub(r"(?:^|[ \t]+)#+$", "", title).strip()
457
+
458
+
459
+ def nearest_heading(texts: list[str], st: Structure, index: int) -> int | None:
460
+ for i in range(index, -1, -1):
461
+ if st.level[i] and not st.fence[i]:
462
+ return i
463
+ return None
464
+
465
+
466
+ def section_end(st: Structure, start: int) -> int:
467
+ """The index of the next heading of the same or a higher level, else the end."""
468
+ lvl = st.level[start]
469
+ for i in range(start + 1, len(st.level)):
470
+ if st.level[i] and not st.fence[i] and st.level[i] <= lvl:
471
+ return i
472
+ return len(st.level)
473
+
474
+
475
+ def _block_line(texts: list[str], st: Structure, i: int) -> bool:
476
+ return bool(texts[i].strip()) and not st.front[i] and not st.fence[i] and not st.level[i]
477
+
478
+
479
+ def _run_of(texts: list[str], st: Structure, i: int) -> tuple[int, int]:
480
+ """The first and last index of the run of block lines that holds line i."""
481
+ start = end = i
482
+ while start > 0 and _block_line(texts, st, start - 1):
483
+ start -= 1
484
+ while end + 1 < len(texts) and _block_line(texts, st, end + 1):
485
+ end += 1
486
+ return start, end
487
+
488
+
489
+ def _fence_start(st: Structure, i: int) -> int:
490
+ """The opening line of the code fence that holds line i."""
491
+ while i > 0 and st.fence[i - 1] and st.fence_close[i - 1] == st.fence_close[i]:
492
+ i -= 1
493
+ return i
494
+
495
+
496
+ def _indented_fence(texts: list[str], st: Structure, i: int) -> bool:
497
+ """Line i is in a code fence whose opening line is indented, as under a list item."""
498
+ return st.fence[i] and INDENT_RE.match(texts[_fence_start(st, i)]) is not None
499
+
500
+
501
+ def _content_above(texts: list[str], st: Structure, i: int) -> int:
502
+ """The nearest line above i that is not blank, hopping over indented fences."""
503
+ j = i - 1
504
+ while j >= 0:
505
+ if not texts[j].strip() and not st.fence[j]:
506
+ j -= 1
507
+ elif _indented_fence(texts, st, j):
508
+ j = _fence_start(st, j) - 1
509
+ else:
510
+ break
511
+ return j
512
+
513
+
514
+ def block_end(texts: list[str], st: Structure, a: int) -> int:
515
+ """The last index of the paragraph, table, blockquote, list or fence that holds line a."""
516
+ n = len(texts)
517
+ if st.fence[a]:
518
+ close = st.fence_close[a]
519
+ if not _indented_fence(texts, st, a):
520
+ return close
521
+ # A fence indented under a list item belongs to that item's list.
522
+ j = _fence_start(st, a) - 1
523
+ while j >= 0 and not texts[j].strip() and not st.fence[j]:
524
+ j -= 1
525
+ if j >= 0 and (_block_line(texts, st, j) or _indented_fence(texts, st, j)):
526
+ return max(close, block_end(texts, st, j))
527
+ return close
528
+ start, end = _run_of(texts, st, a)
529
+ in_list = any(LIST_RE.match(texts[i]) for i in range(start, end + 1))
530
+ if not in_list and INDENT_RE.match(texts[start]):
531
+ # An indented paragraph after a blank line, or after a fence indented
532
+ # under an item, continues the list item above it.
533
+ j = _content_above(texts, st, start)
534
+ if j >= 0 and _block_line(texts, st, j):
535
+ above, _ = _run_of(texts, st, j)
536
+ in_list = INDENT_RE.match(texts[j]) is not None or any(
537
+ LIST_RE.match(texts[i]) for i in range(above, j + 1))
538
+ if in_list:
539
+ # A loose list goes on past a blank line while the next line is an
540
+ # item, indented under one, or opens a fence indented under one.
541
+ while True:
542
+ j = end + 1
543
+ while j < n and not texts[j].strip() and not st.fence[j]:
544
+ j += 1
545
+ if j < n and _indented_fence(texts, st, j):
546
+ end = st.fence_close[j]
547
+ continue
548
+ if j < n and _block_line(texts, st, j) and (LIST_RE.match(texts[j]) or INDENT_RE.match(texts[j])):
549
+ end = j
550
+ while end + 1 < n and _block_line(texts, st, end + 1):
551
+ end += 1
552
+ continue
553
+ break
554
+ return end
555
+
556
+
557
+ # --------------------------------------------------------------------------
558
+ # Setting RA's blocks aside
559
+ # --------------------------------------------------------------------------
560
+
561
+
562
+ def strip_ra(lines: list[Line]) -> Stripped:
563
+ texts = [line.text for line in lines]
564
+ st = scan(texts)
565
+ keep = [True] * len(lines)
566
+ result = Stripped()
567
+ open_at = None
568
+ for i, text in enumerate(texts):
569
+ if st.front[i] or st.fence[i]:
570
+ continue
571
+ if BEGIN_RE.match(text):
572
+ if open_at is not None:
573
+ result.malformed.append(lines[open_at].number)
574
+ open_at = i
575
+ elif END_RE.match(text):
576
+ if open_at is None:
577
+ result.malformed.append(lines[i].number)
578
+ else:
579
+ for j in range(open_at, i + 1):
580
+ keep[j] = False
581
+ result.marked += 1
582
+ result.spans.append(_span(lines[open_at], lines[i], "marked"))
583
+ open_at = None
584
+ if open_at is not None:
585
+ result.malformed.append(lines[open_at].number)
586
+ result.malformed.sort()
587
+ rest = [line for line, k in zip(lines, keep) if k]
588
+ spans = legacy_spans(rest)
589
+ result.legacy = len(spans)
590
+ result.spans += [_span(rest[start], rest[end - 1], "legacy") for start, end in spans]
591
+ result.spans.sort(key=lambda span: span["start"])
592
+ drop = {i for start, end in spans for i in range(start, end)}
593
+ result.kept = [line for i, line in enumerate(rest) if i not in drop]
594
+ return result
595
+
596
+
597
+ def _span(first: Line, last: Line, kind: str) -> dict:
598
+ return {"start": first.number, "end": last.number, "kind": kind, "first_line": first.text.strip()}
599
+
600
+
601
+ def legacy_spans(lines: list[Line]) -> list[tuple[int, int]]:
602
+ """The unmarked annotations of a pass made before the markers existed."""
603
+ texts = [line.text for line in lines]
604
+ st = scan(texts)
605
+ summaries, heads, callouts = [], [], []
606
+ for i, text in enumerate(texts):
607
+ if st.front[i] or st.fence[i]:
608
+ continue
609
+ if st.level[i]:
610
+ title = heading_text(text)
611
+ if title.casefold() == SUMMARY_TITLE:
612
+ summaries.append(i)
613
+ elif title.startswith("RA:"):
614
+ heads.append(i)
615
+ elif QUOTE_RE.match(text) and any(name in text for name in LEGACY_CALLOUTS):
616
+ if i == 0 or not QUOTE_RE.match(texts[i - 1]):
617
+ callouts.append(i)
618
+ if not summaries or not (heads or callouts):
619
+ return []
620
+ spans = [(i, section_end(st, i)) for i in heads + summaries]
621
+ for i in callouts:
622
+ end = i
623
+ while end + 1 < len(texts) and QUOTE_RE.match(texts[end + 1]) and not st.fence[end + 1]:
624
+ end += 1
625
+ spans.append((i, end + 1))
626
+ merged: list[tuple[int, int]] = []
627
+ for start, end in sorted(spans):
628
+ if merged and start <= merged[-1][1]:
629
+ if end > merged[-1][1]:
630
+ merged[-1] = (merged[-1][0], end)
631
+ continue
632
+ merged.append((start, end))
633
+ return merged
634
+
635
+
636
+ # --------------------------------------------------------------------------
637
+ # The preservation check
638
+ # --------------------------------------------------------------------------
639
+
640
+
641
+ def check_preservation(original: list[Line], refined: list[Line]) -> dict:
642
+ a = strip_ra(original).kept
643
+ b = strip_ra(refined).kept
644
+ matcher = difflib.SequenceMatcher(None, [x.text for x in a], [y.text for y in b], autojunk=False)
645
+ missing, altered, added = [], [], 0
646
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
647
+ if tag == "delete":
648
+ missing.extend(a[i1:i2])
649
+ elif tag == "insert":
650
+ added += j2 - j1
651
+ elif tag == "replace":
652
+ pairs = min(i2 - i1, j2 - j1)
653
+ altered.extend(zip(a[i1:i1 + pairs], b[j1:j1 + pairs]))
654
+ missing.extend(a[i1 + pairs:i2])
655
+ added += (j2 - j1) - pairs
656
+ missing.sort(key=lambda line: line.number)
657
+ preserved = not missing and not altered
658
+ return {
659
+ "status": "preserved" if preserved else "not-preserved",
660
+ "preserved": preserved,
661
+ "missing": [{"line": x.number, "text": x.text} for x in missing[:LIST_CAP]],
662
+ "missing_count": len(missing),
663
+ "altered": [
664
+ {"line": x.number, "original": x.text, "refined_line": y.number, "refined": y.text}
665
+ for x, y in altered[:LIST_CAP]
666
+ ],
667
+ "altered_count": len(altered),
668
+ "added_count": added,
669
+ }
670
+
671
+
672
+ # --------------------------------------------------------------------------
673
+ # The insertion plan
674
+ # --------------------------------------------------------------------------
675
+
676
+
677
+ def _problem(entry_id, reason: str, detail: str, **extra) -> dict:
678
+ return {"id": entry_id, "reason": reason, "detail": detail, **extra}
679
+
680
+
681
+ def _fence_open(texts: list[str]) -> bool:
682
+ return scan(texts).open_fence is not None
683
+
684
+
685
+ def _placeholder(tier: str) -> str:
686
+ return re.sub(r"\s+", "_", tier.strip().lower()) + "_count"
687
+
688
+
689
+ def read_plan(path: str) -> dict:
690
+ try:
691
+ raw = Path(path).read_bytes().decode("utf-8")
692
+ except OSError as e:
693
+ raise DocError(f"{path}: {e.strerror or e}") from e
694
+ except UnicodeDecodeError as e:
695
+ raise DocError(f"{path}: not UTF-8 text ({e.reason} at byte {e.start})") from e
696
+ try:
697
+ return json.loads(raw)
698
+ except ValueError as e:
699
+ return {"__invalid__": f"{path}: not JSON ({e})"}
700
+
701
+
702
+ def check_plan(plan, base: list[Line]) -> tuple[list[dict], dict]:
703
+ """Return (problems, resolved): the plan's problems and, when none, what apply needs."""
704
+ if not isinstance(plan, dict) or "__invalid__" in plan:
705
+ detail = plan.get("__invalid__") if isinstance(plan, dict) else "the plan must be a JSON object"
706
+ return [_problem(None, "plan-invalid", detail or "the plan must be a JSON object")], {}
707
+ problems: list[dict] = []
708
+ tiers = {kind: list(names) for kind, names in DEFAULT_TIERS.items()}
709
+ given = plan.get("tiers")
710
+ if given is not None:
711
+ if not isinstance(given, dict):
712
+ problems.append(_problem(None, "plan-invalid", "tiers must be an object"))
713
+ else:
714
+ for kind, names in given.items():
715
+ ok = (kind in tiers and isinstance(names, list) and names
716
+ and all(isinstance(t, str) and t.strip() for t in names)
717
+ and len({t.casefold() for t in names}) == len(names))
718
+ if not ok:
719
+ problems.append(_problem(None, "plan-invalid",
720
+ f"tiers.{kind} must be issue or improvement, with distinct names"))
721
+ else:
722
+ tiers[kind] = [t.strip() for t in names]
723
+ placeholders = [_placeholder(t) for kind in ("issue", "improvement") for t in tiers[kind]]
724
+ if len(set(placeholders)) != len(placeholders):
725
+ problems.append(_problem(None, "plan-invalid", "an issue tier and an improvement tier share a name"))
726
+ headings = dict(DEFAULT_FALLBACK_HEADINGS)
727
+ given = plan.get("fallback_headings")
728
+ if given is not None:
729
+ if not isinstance(given, dict) or not all(
730
+ k in headings and isinstance(v, str) and v.strip() and "\n" not in v for k, v in given.items()):
731
+ problems.append(_problem(None, "plan-invalid",
732
+ "fallback_headings maps gap, issue or improvement to one line of text"))
733
+ else:
734
+ headings.update({k: v.strip().lstrip("#").strip() for k, v in given.items()})
735
+ unverified = plan.get("unverified_technologies", [])
736
+ if not isinstance(unverified, list) or not all(isinstance(t, str) for t in unverified):
737
+ problems.append(_problem(None, "plan-invalid", "unverified_technologies must be a list of strings"))
738
+ unverified = []
739
+ skill_count = plan.get("skill_count")
740
+ if skill_count is not None and (not isinstance(skill_count, int) or isinstance(skill_count, bool)
741
+ or skill_count < 0):
742
+ problems.append(_problem(None, "plan-invalid", "skill_count must be a whole number from 0"))
743
+ skill_count = None
744
+ summary = plan.get("summary")
745
+ if not isinstance(summary, str) or not summary.strip():
746
+ problems.append(_problem(None, "plan-invalid", "summary must be the Refinement Summary's Markdown"))
747
+ summary = ""
748
+ entries = plan.get("entries")
749
+ if not isinstance(entries, list):
750
+ problems.append(_problem(None, "plan-invalid", "entries must be a list"))
751
+ entries = []
752
+
753
+ texts = [line.text for line in base]
754
+ st = scan(texts)
755
+ searchable = [i for i in range(len(texts)) if not st.front[i]]
756
+ seen: set[str] = set()
757
+ resolved_entries = []
758
+ for index, entry in enumerate(entries):
759
+ if not isinstance(entry, dict):
760
+ problems.append(_problem(None, "entry-invalid", f"entries[{index}] is not an object"))
761
+ continue
762
+ entry_id = entry.get("id")
763
+ if not isinstance(entry_id, str) or not ID_RE.match(entry_id):
764
+ problems.append(_problem(None, "entry-invalid",
765
+ f"entries[{index}]: id must be letters, digits, '.', '_' or '-'"))
766
+ continue
767
+ if entry_id in seen:
768
+ problems.append(_problem(entry_id, "entry-invalid", "the id is used twice"))
769
+ continue
770
+ seen.add(entry_id)
771
+ kind = entry.get("kind")
772
+ if kind not in KINDS:
773
+ problems.append(_problem(entry_id, "entry-invalid", "kind must be gap, issue or improvement"))
774
+ continue
775
+ tier = None
776
+ if kind in tiers:
777
+ given_tier = entry.get("tier")
778
+ match = [t for t in tiers[kind] if isinstance(given_tier, str) and t.casefold() == given_tier.strip().casefold()]
779
+ if not match:
780
+ problems.append(_problem(entry_id, "tier-unknown", f"tier must be one of {', '.join(tiers[kind])}"))
781
+ continue
782
+ tier = match[0]
783
+ skills = entry.get("skills", [])
784
+ if not isinstance(skills, list) or not all(isinstance(s, str) and s.strip() for s in skills):
785
+ problems.append(_problem(entry_id, "entry-invalid", "skills must be a list of skill names"))
786
+ continue
787
+ block = entry.get("block")
788
+ if not isinstance(block, str) or not block.strip():
789
+ problems.append(_problem(entry_id, "entry-invalid", "block must be the Markdown to insert"))
790
+ continue
791
+ block_lines = block.replace("\r\n", "\n").strip("\n").split("\n")
792
+ if MARKER_TEXT_RE.search(block):
793
+ problems.append(_problem(entry_id, "block-has-marker", "a block holds no RA:BEGIN or RA:END marker"))
794
+ continue
795
+ if _fence_open(block_lines):
796
+ problems.append(_problem(entry_id, "block-unclosed-fence", "the block opens a code fence it never closes"))
797
+ continue
798
+ if kind != "issue" and not HEADING_RE.match(next(t for t in block_lines if t.strip())):
799
+ problems.append(_problem(entry_id, "block-no-heading", "a gap or improvement block opens with its heading"))
800
+ continue
801
+ anchor_line = None
802
+ anchor = entry.get("anchor")
803
+ if anchor is not None:
804
+ if not isinstance(anchor, str) or not anchor.strip() or "\n" in anchor.strip():
805
+ problems.append(_problem(entry_id, "entry-invalid", "anchor must be one line of text, or null"))
806
+ continue
807
+ anchor = anchor.strip()
808
+ hits = _heading_hits(anchor, texts, st) or [i for i in searchable if anchor in texts[i]]
809
+ occurrence = entry.get("occurrence")
810
+ if occurrence is not None and (not isinstance(occurrence, int) or isinstance(occurrence, bool)
811
+ or occurrence < 1):
812
+ problems.append(_problem(entry_id, "entry-invalid", "occurrence must be a whole number from 1"))
813
+ continue
814
+ if not hits:
815
+ problems.append(_problem(entry_id, "anchor-not-found", "no line of the document holds the anchor",
816
+ anchor=anchor, closest=_closest(anchor, base, texts, st, searchable)))
817
+ continue
818
+ if occurrence is None and len(hits) > 1:
819
+ problems.append(_problem(entry_id, "anchor-ambiguous",
820
+ f"{len(hits)} lines hold the anchor: quote one that only one line holds, "
821
+ "or add occurrence", anchor=anchor,
822
+ matches=[_where(i, base, texts, st) for i in hits[:LIST_CAP]]))
823
+ continue
824
+ if occurrence is not None and occurrence > len(hits):
825
+ problems.append(_problem(entry_id, "occurrence-out-of-range",
826
+ f"occurrence {occurrence}, but {len(hits)} line(s) hold the anchor",
827
+ anchor=anchor))
828
+ continue
829
+ anchor_line = hits[(occurrence or 1) - 1]
830
+ resolved_entries.append({"index": index, "id": entry_id, "kind": kind, "tier": tier,
831
+ "skills": [s.strip() for s in skills], "lines": block_lines,
832
+ "anchor": anchor_line})
833
+
834
+ if summary:
835
+ summary_lines = summary.replace("\r\n", "\n").strip("\n").split("\n")
836
+ if MARKER_TEXT_RE.search(summary):
837
+ problems.append(_problem(None, "summary-has-marker", "the summary holds no RA:BEGIN or RA:END marker"))
838
+ if _fence_open(summary_lines):
839
+ problems.append(_problem(None, "summary-unclosed-fence", "the summary opens a code fence it never closes"))
840
+ found = set(PLACEHOLDER_RE.findall(summary))
841
+ typed = [name for name in REQUIRED_PLACEHOLDERS if name not in found]
842
+ if typed:
843
+ problems.append(_problem(None, "summary-count-typed",
844
+ "the summary must leave these counts to the script: "
845
+ + ", ".join("{" + name + "}" for name in typed)))
846
+ known = set(REQUIRED_PLACEHOLDERS) | set(placeholders) | {"unverified_count", "unverified_technologies",
847
+ "evidence_rows"}
848
+ if skill_count is not None:
849
+ known.add("skill_count")
850
+ unknown = sorted(found - known)
851
+ if unknown:
852
+ problems.append(_problem(None, "summary-placeholder",
853
+ "fill these yourself or remove them: "
854
+ + ", ".join("{" + name + "}" for name in unknown)))
855
+ resolved = {"entries": resolved_entries, "summary": summary, "tiers": tiers, "headings": headings,
856
+ "unverified": unverified, "skill_count": skill_count}
857
+ return problems, resolved
858
+
859
+
860
+ def _heading_hits(anchor: str, texts: list[str], st: Structure) -> list[int]:
861
+ """The headings an ATX heading anchor names: same level, same title."""
862
+ h = HEADING_RE.match(anchor)
863
+ if not h:
864
+ return []
865
+ level, title = len(h.group(1)), heading_text(anchor)
866
+ return [i for i, lvl in enumerate(st.level)
867
+ if lvl == level and not st.fence[i] and not st.front[i] and heading_text(texts[i]) == title]
868
+
869
+
870
+ def _where(i: int, base: list[Line], texts: list[str], st: Structure) -> dict:
871
+ h = nearest_heading(texts, st, i)
872
+ return {"line": base[i].number, "text": texts[i], "heading": texts[h].strip() if h is not None else None}
873
+
874
+
875
+ def _closest(anchor: str, base: list[Line], texts: list[str], st: Structure, searchable: list[int]) -> dict | None:
876
+ best, best_size = None, 0
877
+ for i in searchable:
878
+ if not texts[i].strip():
879
+ continue
880
+ m = difflib.SequenceMatcher(None, anchor, texts[i], autojunk=False)
881
+ size = m.find_longest_match(0, len(anchor), 0, len(texts[i])).size
882
+ if size > best_size:
883
+ best, best_size = i, size
884
+ if best is None or best_size * 2 < len(anchor):
885
+ return None
886
+ return _where(best, base, texts, st)
887
+
888
+
889
+ # --------------------------------------------------------------------------
890
+ # Building the draft
891
+ # --------------------------------------------------------------------------
892
+
893
+
894
+ def _set_level(block: list[str], level: int) -> list[str]:
895
+ out = list(block)
896
+ for i, text in enumerate(out):
897
+ if text.strip():
898
+ m = HEADING_RE.match(text)
899
+ out[i] = "#" * level + " " + (m.group(2) or "").strip()
900
+ break
901
+ return out
902
+
903
+
904
+ def _region(kind: str, region_id: str, body: list[str]) -> list[str]:
905
+ return [f"<!-- RA:BEGIN {kind} {region_id} -->", "", *body, "", "<!-- RA:END -->"]
906
+
907
+
908
+ def compose(base: list[Line], resolved: dict) -> tuple[list[Line], dict]:
909
+ texts = [line.text for line in base]
910
+ st = scan(texts)
911
+ n = len(base)
912
+ newline = next((line.end for line in base if line.end), "\n")
913
+ rank = {kind: {t: r for r, t in enumerate(resolved["tiers"].get(kind, []))} for kind in KINDS}
914
+ at: dict[int, list[tuple[tuple, list[str], dict]]] = {}
915
+ fallback: dict[str, list[dict]] = {kind: [] for kind in KINDS}
916
+ for entry in resolved["entries"]:
917
+ a = entry["anchor"]
918
+ key = (KIND_RANK[entry["kind"]], rank[entry["kind"]].get(entry["tier"], 0), entry["index"])
919
+ if a is None:
920
+ fallback[entry["kind"]].append(entry)
921
+ continue
922
+ if entry["kind"] == "issue":
923
+ if st.level[a] and not st.fence[a]:
924
+ k = a + 1
925
+ else:
926
+ k = block_end(texts, st, a) + 1
927
+ body = entry["lines"]
928
+ else:
929
+ g = a if (st.level[a] and not st.fence[a]) else nearest_heading(texts, st, a)
930
+ if g is None:
931
+ fallback[entry["kind"]].append(entry)
932
+ continue
933
+ k = section_end(st, g)
934
+ body = _set_level(entry["lines"], min(st.level[g] + 1, 6))
935
+ at.setdefault(k, []).append((key, _region(entry["kind"], entry["id"], body), entry))
936
+
937
+ tail: list[list[str]] = []
938
+ fallback_ids: list[str] = []
939
+ for kind in FALLBACK_ORDER:
940
+ items = sorted(fallback[kind], key=lambda e: (rank[kind].get(e["tier"], 0), e["index"]))
941
+ if not items:
942
+ continue
943
+ fallback_ids += [e["id"] for e in items]
944
+ body = ["## " + resolved["headings"][kind]]
945
+ for e in items:
946
+ lines = e["lines"] if kind == "issue" else _set_level(e["lines"], 3)
947
+ body += ["", *lines]
948
+ tail.append(_region("section", kind, body))
949
+ tail.append(_region("summary", "refinement-summary", fill_summary(resolved)))
950
+
951
+ out: list[Line] = []
952
+ placed = []
953
+
954
+ def add(text: str) -> None:
955
+ if out and not out[-1].end:
956
+ out[-1] = Line(out[-1].number, out[-1].text, newline)
957
+ out.append(Line(len(out) + 1, text, newline))
958
+
959
+ def insert(k: int) -> None:
960
+ for _, region, entry in sorted(at.get(k, []), key=lambda item: item[0]):
961
+ placed.append({"id": entry["id"], "kind": entry["kind"], "line": len(out) + 1})
962
+ for text in region:
963
+ add(text)
964
+
965
+ for i, line in enumerate(base):
966
+ insert(i)
967
+ out.append(Line(len(out) + 1, line.text, line.end))
968
+ closed_fence_line = None
969
+ if st.open_fence is not None:
970
+ char, length = st.open_fence
971
+ add(char * length)
972
+ closed_fence_line = len(out)
973
+ insert(n)
974
+ for region in tail:
975
+ for text in region:
976
+ add(text)
977
+ # The last line keeps the document's own ending: none when it had none.
978
+ if out and base and not base[-1].end and out[-1].text == "<!-- RA:END -->":
979
+ out[-1] = Line(out[-1].number, out[-1].text, "")
980
+ info = {
981
+ "placed": placed,
982
+ "fallback": fallback_ids,
983
+ "closed_fence_line": closed_fence_line,
984
+ }
985
+ return out, info
986
+
987
+
988
+ def tally(resolved: dict) -> tuple[dict, dict]:
989
+ entries = resolved["entries"]
990
+ counts = {kind: sum(1 for e in entries if e["kind"] == kind) for kind in KINDS}
991
+ for kind in ("issue", "improvement"):
992
+ counts[f"{kind}_tiers"] = {t: sum(1 for e in entries if e["kind"] == kind and e["tier"] == t)
993
+ for t in resolved["tiers"][kind]}
994
+ counts["unverified"] = len(resolved["unverified"])
995
+ counts["skills"] = resolved["skill_count"]
996
+ evidence: dict[str, int] = {}
997
+ for e in entries:
998
+ for skill in dict.fromkeys(e["skills"]):
999
+ evidence[skill] = evidence.get(skill, 0) + 1
1000
+ evidence = dict(sorted(evidence.items(), key=lambda item: (-item[1], item[0])))
1001
+ return counts, evidence
1002
+
1003
+
1004
+ def fill_summary(resolved: dict) -> list[str]:
1005
+ counts, evidence = tally(resolved)
1006
+ values = {"gap_count": counts["gap"], "issue_count": counts["issue"],
1007
+ "improvement_count": counts["improvement"], "unverified_count": counts["unverified"],
1008
+ "unverified_technologies": ", ".join(resolved["unverified"]) or "none",
1009
+ "evidence_rows": "\n".join(f"| {skill} | {count} |" for skill, count in evidence.items())}
1010
+ if counts["skills"] is not None:
1011
+ values["skill_count"] = counts["skills"]
1012
+ for kind in ("issue", "improvement"):
1013
+ for tier, count in counts[f"{kind}_tiers"].items():
1014
+ values[_placeholder(tier)] = count
1015
+ text = PLACEHOLDER_RE.sub(lambda m: str(values.get(m.group(1), m.group(0))), resolved["summary"])
1016
+ return text.replace("\r\n", "\n").strip("\n").split("\n")
1017
+
1018
+
1019
+ # --------------------------------------------------------------------------
1020
+ # Refinement rules and [VS] verdicts
1021
+ # --------------------------------------------------------------------------
1022
+
1023
+
1024
+ def _plain(cell: str) -> str:
1025
+ """A table cell without the bold, italic or code markers that wrap it."""
1026
+ text = cell.strip()
1027
+ while True:
1028
+ for mark in ("**", "__", "`", "*", "_"):
1029
+ if len(text) > 2 * len(mark) and text.startswith(mark) and text.endswith(mark):
1030
+ text = text[len(mark):-len(mark)].strip()
1031
+ break
1032
+ else:
1033
+ return text
1034
+
1035
+
1036
+ def _cells(text: str) -> list[str]:
1037
+ row = text.strip()
1038
+ if row.startswith("|"):
1039
+ row = row[1:]
1040
+ if row.endswith("|") and not row.endswith("\\|"):
1041
+ row = row[:-1]
1042
+ return [cell.strip() for cell in CELL_SPLIT_RE.split(row)]
1043
+
1044
+
1045
+ def rule_tables(texts: list[str]) -> dict[str, list[tuple[int, list[str]]]]:
1046
+ """{heading title, case folded: the data rows of the first table in its section}.
1047
+
1048
+ A section runs from its heading to the next heading of the same or a
1049
+ higher level; frontmatter and fenced code are skipped. Each row is
1050
+ (1-based line, cells). The first heading of a title wins.
1051
+ """
1052
+ st = scan(texts)
1053
+ usable = [not (st.front[i] or st.fence[i]) for i in range(len(texts))]
1054
+ tables: dict[str, list[tuple[int, list[str]]]] = {}
1055
+ for h, level in enumerate(st.level):
1056
+ if not level or not usable[h]:
1057
+ continue
1058
+ key = _plain(heading_text(texts[h])).casefold()
1059
+ if key in tables:
1060
+ continue
1061
+ end = section_end(st, h)
1062
+ for i in range(h + 1, end - 1):
1063
+ if usable[i] and usable[i + 1] and TABLE_ROW_RE.match(texts[i]) and TABLE_DELIM_RE.match(texts[i + 1]):
1064
+ rows = []
1065
+ j = i + 2
1066
+ while j < end and usable[j] and TABLE_ROW_RE.match(texts[j]):
1067
+ rows.append((j + 1, _cells(texts[j])))
1068
+ j += 1
1069
+ tables[key] = rows
1070
+ break
1071
+ return tables
1072
+
1073
+
1074
+ def _violation(table: str, line, rule: str, detail: str) -> dict:
1075
+ return {"table": table, "line": line, "rule": rule, "detail": detail}
1076
+
1077
+
1078
+ def _named_tiers(text: str, severities: dict[str, str]) -> list[str]:
1079
+ """The tiers `text` names as whole words, ignoring case and runs of spaces, longest name first.
1080
+
1081
+ A longer tier's words are not read again as a shorter one (`Very High` is not also `High`).
1082
+ """
1083
+ found: list[str] = []
1084
+ taken: list[tuple[int, int]] = []
1085
+ for tier in sorted(severities.values(), key=len, reverse=True):
1086
+ words = r"\s+".join(re.escape(word) for word in tier.split())
1087
+ for m in re.finditer(r"(?<![A-Za-z0-9])" + words + r"(?![A-Za-z0-9])", text, re.IGNORECASE):
1088
+ if not any(m.start() < end and start < m.end() for start, end in taken):
1089
+ taken.append(m.span())
1090
+ if tier not in found:
1091
+ found.append(tier)
1092
+ return found
1093
+
1094
+
1095
+ def vs_raised(cell: str, severities: dict[str, str]) -> tuple[list[str], bool]:
1096
+ """The tiers a VS Report Integration Raises cell names, and whether it says no issue.
1097
+
1098
+ What the cell bolds counts when it bolds a tier or No issue or None (`A **Major** issue`,
1099
+ `**No issue**, whatever the rationale says`): the prose around it is not read. Otherwise
1100
+ the tiers its text names as whole words count (`Major`, `A Critical issue`), and it says
1101
+ no issue when its text says No issue or None, ignoring case. A cell that names a tier and
1102
+ also says no issue, both in bold or both in its text, raises neither: check_rules reports it.
1103
+ """
1104
+ bold = [(m.group(1) or m.group(2)).strip() for m in BOLD_RE.finditer(cell)]
1105
+ named = [severities[_placeholder(b)] for b in bold if TIER_NAME_RE.fullmatch(b) and _placeholder(b) in severities]
1106
+ no_issue = any(NO_ISSUE_RE.fullmatch(b) for b in bold)
1107
+ if named or no_issue:
1108
+ return list(dict.fromkeys(named)), no_issue
1109
+ return _named_tiers(cell, severities), bool(NO_ISSUE_RE.search(cell))
1110
+
1111
+
1112
+ def check_rules(path: str) -> dict:
1113
+ """Check the refinement rules file: the tables the steps read and the tier rules."""
1114
+ texts = [line.text for line in read_lines(path)]
1115
+ tables = rule_tables(texts)
1116
+ violations = []
1117
+ missing = [name for name in RULE_TABLES if name.casefold() not in tables]
1118
+ for name in missing:
1119
+ violations.append(_violation(name, None, "table-missing", f"lacks the {name} table"))
1120
+ tiers: dict[str, list[str]] = {}
1121
+ seen: dict[str, str] = {} # each tier's count placeholder: the tier that took it
1122
+ for kind, table in TIER_TABLES:
1123
+ rows = tables.get(table.casefold())
1124
+ if rows is None:
1125
+ continue
1126
+ named = [(line, _plain(cells[0])) for line, cells in rows if _plain(cells[0])]
1127
+ tiers[kind] = [name for _, name in named]
1128
+ if not named:
1129
+ violations.append(_violation(table, None, "table-empty", f"the {table} table names no tier"))
1130
+ for line, name in named:
1131
+ if not TIER_NAME_RE.fullmatch(name):
1132
+ violations.append(_violation(table, line, "tier-name", f"tier `{name}` (line {line}) must start with "
1133
+ "a letter and hold only the letters A to Z, digits and spaces"))
1134
+ continue
1135
+ key = _placeholder(name)
1136
+ if key in RESERVED_COUNTS:
1137
+ violations.append(_violation(table, line, "tier-reserved", f"tier `{name}` (line {line}) takes a "
1138
+ "name whose count the Refinement Summary already holds"))
1139
+ elif key in seen:
1140
+ violations.append(_violation(table, line, "tier-duplicate", f"tier `{name}` (line {line}) has the "
1141
+ f"same name as `{seen[key]}`, ignoring case"))
1142
+ else:
1143
+ seen[key] = name
1144
+ severities = {_placeholder(t): t for t in tiers.get("issue", []) if TIER_NAME_RE.fullmatch(t)}
1145
+ vs_raises: dict[str, str | None] = {}
1146
+ rows = tables.get(VS_TABLE.casefold())
1147
+ if rows is not None:
1148
+ rowed = set()
1149
+ for line, cells in rows:
1150
+ token = _plain(cells[0])
1151
+ if token not in VS_TOKENS:
1152
+ violations.append(_violation(VS_TABLE, line, "vs-token-unknown", f"VS row `{token}` (line {line}) "
1153
+ "is not a verdict token: `Verified`, `Plausible`, `Risky` or "
1154
+ "`Blocked`, case-sensitive"))
1155
+ continue
1156
+ if token in rowed:
1157
+ violations.append(_violation(VS_TABLE, line, "vs-token-duplicate",
1158
+ f"VS token `{token}` has a second row (line {line})"))
1159
+ continue
1160
+ rowed.add(token)
1161
+ named, no_issue = vs_raised(cells[1] if len(cells) > 1 else "", severities)
1162
+ if named and no_issue:
1163
+ violations.append(_violation(VS_TABLE, line, "vs-raises", f"VS row `{token}` (line {line}) names "
1164
+ f"{', '.join(named)} and also says No issue or None: bold the one "
1165
+ "it raises"))
1166
+ elif len(named) == 1 or no_issue:
1167
+ vs_raises[token] = named[0] if named else None
1168
+ elif named:
1169
+ violations.append(_violation(VS_TABLE, line, "vs-raises", f"VS row `{token}` (line {line}) names "
1170
+ f"more than one tier of the Issue Severity table: {', '.join(named)}"))
1171
+ else:
1172
+ violations.append(_violation(VS_TABLE, line, "vs-raises", f"VS row `{token}` (line {line}) raises "
1173
+ "neither a tier of the Issue Severity table nor No issue"))
1174
+ for token in VS_TOKENS:
1175
+ if token not in rowed:
1176
+ violations.append(_violation(VS_TABLE, None, "vs-token-unmapped",
1177
+ f"no VS Report Integration row maps `{token}`"))
1178
+ return {
1179
+ "status": "violations" if violations else "ok",
1180
+ "rules": Path(path).as_posix(),
1181
+ "missing_tables": missing,
1182
+ "tiers": {kind: tiers.get(kind, []) for kind, _ in TIER_TABLES},
1183
+ "vs_raises": {token: vs_raises[token] for token in VS_TOKENS if token in vs_raises},
1184
+ "violations": violations,
1185
+ }
1186
+
1187
+
1188
+ def load_feasibility_reader():
1189
+ """Import the shared feasibility-report reader, or None when it is missing."""
1190
+ try:
1191
+ if not FEASIBILITY_READER.is_file():
1192
+ return None
1193
+ except OSError:
1194
+ return None # a folder on the way that cannot be searched
1195
+ spec = importlib.util.spec_from_file_location("skf_validate_feasibility_report", FEASIBILITY_READER)
1196
+ if spec is None or spec.loader is None:
1197
+ return None
1198
+ module = importlib.util.module_from_spec(spec)
1199
+ spec.loader.exec_module(module)
1200
+ return module
1201
+
1202
+
1203
+ def _key(name: str) -> str:
1204
+ return " ".join(name.split()).casefold()
1205
+
1206
+
1207
+ def read_skill_terms(path: str) -> list[tuple[str, list[str]]]:
1208
+ """The --skills JSON array: (name, aliases) per skill, a plain string being a name."""
1209
+ try:
1210
+ raw = Path(path).read_bytes().decode("utf-8")
1211
+ except OSError as e:
1212
+ raise DocError(f"{path}: {e.strerror or e}") from e
1213
+ except UnicodeDecodeError as e:
1214
+ raise DocError(f"{path}: not UTF-8 text ({e.reason} at byte {e.start})") from e
1215
+ try:
1216
+ data = json.loads(raw)
1217
+ except ValueError as e:
1218
+ raise DocError(f"{path}: not JSON ({e})") from e
1219
+ shape = f"{path}: not a JSON array of skills, each a name or {{name, aliases}}"
1220
+ if not isinstance(data, list):
1221
+ raise DocError(shape)
1222
+ skills = []
1223
+ for item in data:
1224
+ if isinstance(item, str) and item.strip():
1225
+ skills.append((item.strip(), []))
1226
+ continue
1227
+ if not isinstance(item, dict) or not isinstance(item.get("name"), str) or not item["name"].strip():
1228
+ raise DocError(shape)
1229
+ aliases = item.get("aliases") or []
1230
+ if not isinstance(aliases, list) or not all(isinstance(a, str) for a in aliases):
1231
+ raise DocError(shape)
1232
+ skills.append((item["name"].strip(), [a for a in aliases if a.strip()]))
1233
+ return skills
1234
+
1235
+
1236
+ def skill_matcher(skills: list[tuple[str, list[str]]]):
1237
+ """A function from a library as a report names it to its inventory skill, or None.
1238
+
1239
+ A skill's name wins, ignoring case and runs of spaces; otherwise the one
1240
+ skill that has the library as an alias. An alias two skills share names
1241
+ neither.
1242
+ """
1243
+ names: dict[str, str] = {}
1244
+ aliases: dict[str, set[str]] = {}
1245
+ for name, _ in skills:
1246
+ names.setdefault(_key(name), name)
1247
+ for name, terms in skills:
1248
+ for term in terms:
1249
+ aliases.setdefault(_key(term), set()).add(name)
1250
+
1251
+ def match(library: str) -> str | None:
1252
+ key = _key(library)
1253
+ if key in names:
1254
+ return names[key]
1255
+ owners = aliases.get(key, set())
1256
+ return next(iter(owners)) if len(owners) == 1 else None
1257
+
1258
+ return match
1259
+
1260
+
1261
+ def _report_problem(report: dict, code: int, generated_at: str) -> str | None:
1262
+ """Why the report is not the one the run started from, or None."""
1263
+ if code == 2 or report.get("violation") == "io-error":
1264
+ return f"it cannot be read again: {report.get('error') or 'unreadable'}"
1265
+ if code != 0:
1266
+ return "it no longer passes the feasibility-report contract"
1267
+ found = report.get("generatedAt") or ""
1268
+ if found != generated_at:
1269
+ return f"its generatedAt is {found or 'empty'}, not {generated_at or 'empty'}: [VS] rewrote it during the run"
1270
+ return None
1271
+
1272
+
1273
+ def cmd_inspect(args) -> tuple[dict, int]:
1274
+ lines = read_lines(args.doc)
1275
+ stripped = strip_ra(lines)
1276
+ signals = []
1277
+ if stripped.marked or stripped.malformed:
1278
+ signals.append("ra-markers")
1279
+ if stripped.legacy:
1280
+ signals.append("legacy-annotations")
1281
+ if REFINED_NAME_RE.match(Path(args.doc).name):
1282
+ signals.append("refined-document")
1283
+ out = None
1284
+ if args.stripped:
1285
+ out = Path(args.stripped)
1286
+ write_atomic(out, render(stripped.kept).encode("utf-8"))
1287
+ return {
1288
+ "status": "ok",
1289
+ "doc": args.doc,
1290
+ "previous_pass": bool(signals),
1291
+ "signals": signals,
1292
+ "marked_blocks": stripped.marked,
1293
+ "legacy_blocks": stripped.legacy,
1294
+ "malformed_markers": stripped.malformed,
1295
+ "lines_set_aside": len(lines) - len(stripped.kept),
1296
+ "set_aside": stripped.spans,
1297
+ "stripped": out.as_posix() if out else None,
1298
+ }, 0
1299
+
1300
+
1301
+ def cmd_apply(args) -> tuple[dict, int]:
1302
+ original = read_lines(args.original)
1303
+ plan = read_plan(args.plan)
1304
+ stripped = strip_ra(original)
1305
+ problems, resolved = check_plan(plan, stripped.kept)
1306
+ if problems:
1307
+ return {"status": "problems", "problems": problems}, 1
1308
+ draft, info = compose(stripped.kept, resolved)
1309
+ check = check_preservation(original, draft)
1310
+ if not check["preserved"]:
1311
+ return {"status": "not-preserved", **{k: v for k, v in check.items() if k != "status"}}, 1
1312
+ out = Path(args.draft)
1313
+ write_atomic(out, render(draft).encode("utf-8"))
1314
+ counts, evidence = tally(resolved)
1315
+ return {
1316
+ "status": "ok",
1317
+ "draft": out.as_posix(),
1318
+ "counts": counts,
1319
+ "evidence": evidence,
1320
+ "summary": "\n".join(fill_summary(resolved)),
1321
+ "unverified_technologies": resolved["unverified"],
1322
+ **info,
1323
+ "preserved": True,
1324
+ "missing": [],
1325
+ "altered": [],
1326
+ "lines_set_aside": len(original) - len(stripped.kept),
1327
+ "set_aside": stripped.spans,
1328
+ }, 0
1329
+
1330
+
1331
+ def cmd_check(args) -> tuple[dict, int]:
1332
+ result = check_preservation(read_lines(args.original), read_lines(args.refined))
1333
+ return result, 0 if result["preserved"] else 1
1334
+
1335
+
1336
+ def _free_name(output: Path, timestamp: str) -> Path:
1337
+ candidate = output.with_name(f"{output.stem}-{timestamp}{output.suffix}")
1338
+ n = 2
1339
+ while candidate.exists() or candidate.is_symlink():
1340
+ candidate = output.with_name(f"{output.stem}-{timestamp}-{n}{output.suffix}")
1341
+ n += 1
1342
+ return candidate
1343
+
1344
+
1345
+ def cmd_promote(args) -> tuple[dict, int]:
1346
+ draft_path, output = Path(args.draft), Path(args.output)
1347
+ draft = read_lines(args.draft)
1348
+ check = check_preservation(read_lines(args.original), draft)
1349
+ if not check["preserved"]:
1350
+ return {"status": "not-preserved", **{k: v for k, v in check.items() if k != "status"}}, 1
1351
+ if output.exists() and not output.is_file():
1352
+ raise WriteError(f"{output.as_posix()}: not a file")
1353
+ previous = None
1354
+ draft_removed = True
1355
+ if output.exists():
1356
+ previous = _free_name(output, args.timestamp)
1357
+ try:
1358
+ os.rename(output, previous)
1359
+ except OSError as e:
1360
+ raise WriteError(f"cannot rename {output.as_posix()}: {e.strerror or e}") from e
1361
+ try:
1362
+ output.parent.mkdir(parents=True, exist_ok=True)
1363
+ try:
1364
+ os.replace(draft_path, output)
1365
+ except OSError as e:
1366
+ if e.errno != errno.EXDEV:
1367
+ raise
1368
+ # Another file system: copy through the atomic writer, then drop the draft.
1369
+ write_atomic(output, draft_path.read_bytes())
1370
+ try:
1371
+ draft_path.unlink()
1372
+ except OSError:
1373
+ draft_removed = False
1374
+ except (OSError, WriteError) as e:
1375
+ reason = e.strerror if isinstance(e, OSError) and e.strerror else str(e)
1376
+ message = f"cannot move {draft_path.as_posix()} to {output.as_posix()}: {reason}"
1377
+ if previous is not None:
1378
+ try:
1379
+ os.rename(previous, output)
1380
+ except OSError as back:
1381
+ message += (f"; the earlier output could not be renamed back ({back.strerror or back}), so it "
1382
+ f"stays at {previous.as_posix()}, and the draft is at {draft_path.as_posix()}")
1383
+ raise WriteError(message) from e
1384
+ return {
1385
+ "status": "promoted",
1386
+ "output": output.as_posix(),
1387
+ "previous": previous.as_posix() if previous else None,
1388
+ "preserved": True,
1389
+ "draft_removed": draft_removed,
1390
+ }, 0
1391
+
1392
+
1393
+ def _undo_promote(args, result: dict) -> str:
1394
+ """Put the draft and the earlier output back: the promotion's record could not be written.
1395
+
1396
+ Returns "" when both are back, else where each file was left.
1397
+ """
1398
+ output, previous = Path(args.output).as_posix(), result.get("previous")
1399
+ try:
1400
+ shutil.move(output, args.draft)
1401
+ except OSError as e:
1402
+ left = f"the draft could not be moved back ({e.strerror or e}), so it stays at {output} as the refined document"
1403
+ return left + (f", and the earlier output is at {previous}" if previous else "")
1404
+ if previous:
1405
+ try:
1406
+ os.rename(previous, output)
1407
+ except OSError as e:
1408
+ return (f"the earlier output could not be renamed back ({e.strerror or e}), so it stays at {previous}, "
1409
+ f"and the draft is back at {Path(args.draft).as_posix()}")
1410
+ return ""
1411
+
1412
+
1413
+ def _record(path: str, command: str, status: str, keys: tuple[str, ...]) -> dict:
1414
+ """A JSON record one of the other subcommands wrote with -o."""
1415
+ try:
1416
+ record = json.loads(Path(path).read_bytes().decode("utf-8"))
1417
+ except OSError as e:
1418
+ raise DocError(f"{path}: {e.strerror or e}") from e
1419
+ except (UnicodeDecodeError, ValueError) as e:
1420
+ raise DocError(f"{path}: not the JSON record {command} writes ({e})") from e
1421
+ if not isinstance(record, dict) or record.get("status") != status or any(k not in record for k in keys):
1422
+ raise DocError(f"{path}: not the record of a successful {command}")
1423
+ return record
1424
+
1425
+
1426
+ def cmd_context(args) -> tuple[dict, int]:
1427
+ inspected = _record(args.inspect, "inspect", "ok", ("previous_pass",))
1428
+ applied = _record(args.apply, "apply", "ok", ("counts",))
1429
+ promoted = _record(args.promote, "promote", "promoted", ("output", "previous"))
1430
+ counts = applied["counts"]
1431
+ names = ("gap", "issue", "improvement", "unverified")
1432
+ if not isinstance(counts, dict) or not all(isinstance(counts.get(k), int) for k in names):
1433
+ raise DocError(f"{args.apply}: its counts lack {', '.join(names)}")
1434
+ totals = {f"{k}_count": counts[k] for k in names}
1435
+ payload = {
1436
+ "status": "success",
1437
+ "refined_path": promoted["output"],
1438
+ "previous_refined_path": promoted["previous"],
1439
+ "previous_pass": bool(inspected["previous_pass"]),
1440
+ **totals,
1441
+ "result_contract": {
1442
+ "skill": "skf-refine-architecture",
1443
+ "status": "success",
1444
+ "outputs": [{"type": "report", "path": promoted["output"]}],
1445
+ "summary": {**totals, "previous_refined_path": promoted["previous"]},
1446
+ },
1447
+ }
1448
+ out = Path(args.out)
1449
+ write_atomic(out, (json.dumps(payload, indent=2, ensure_ascii=False) + "\n").encode("utf-8"))
1450
+ return {"status": "ok", "context": out.as_posix()}, 0
1451
+
1452
+
1453
+ def cmd_rules(args) -> tuple[dict, int]:
1454
+ result = check_rules(args.rules)
1455
+ return result, 1 if result["violations"] else 0
1456
+
1457
+
1458
+ def cmd_verdicts(args) -> tuple[dict, int]:
1459
+ match = skill_matcher(read_skill_terms(args.skills))
1460
+ in_scope = {_key(name) for name in args.in_scope.split(",") if name.strip()}
1461
+ reader = load_feasibility_reader()
1462
+ if reader is None:
1463
+ raise HelperMissing(f"the shared feasibility-report reader is not installed: {FEASIBILITY_READER.as_posix()}")
1464
+ problems = []
1465
+ report, code = reader.validate_report(args.report)
1466
+ problem = _report_problem(report, code, args.generated_at)
1467
+ if problem:
1468
+ problems.append({"source": "report", "detail": f"{Path(args.report).as_posix()}: {problem}"})
1469
+ try:
1470
+ rules = check_rules(args.rules)
1471
+ except DocError as e:
1472
+ problems.append({"source": "rules", "detail": str(e)})
1473
+ else:
1474
+ problems += [{"source": "rules", "detail": v["detail"]} for v in rules["violations"]]
1475
+ if problems:
1476
+ return {"status": "stale", "problems": problems}, 1
1477
+ joined: list[dict] = []
1478
+ left_out: list[dict] = []
1479
+ for row in report["pairVerdicts"]:
1480
+ skill_a, skill_b = match(row["lib_a"]), match(row["lib_b"])
1481
+ entry = {**row, "skill_a": skill_a, "skill_b": skill_b}
1482
+ if skill_a is None or skill_b is None:
1483
+ left_out.append({**entry, "reason": "no-inventory-skill"})
1484
+ elif skill_a == skill_b:
1485
+ left_out.append({**entry, "reason": "same-skill"})
1486
+ elif _key(skill_a) in in_scope and _key(skill_b) in in_scope:
1487
+ joined.append({**entry, "raises": rules["vs_raises"].get(row["verdict"])})
1488
+ else:
1489
+ left_out.append({**entry, "reason": "out-of-scope"})
1490
+ return {
1491
+ "status": "ok",
1492
+ "report": Path(report["path"]).as_posix(),
1493
+ "generatedAt": report.get("generatedAt"),
1494
+ "overallVerdict": report.get("overallVerdict"),
1495
+ "in_scope": joined,
1496
+ "out_of_scope": left_out,
1497
+ "issue_count": sum(1 for row in joined if row["raises"] is not None),
1498
+ }, 0
1499
+
1500
+
1501
+ def _timestamp(value: str) -> str:
1502
+ if not TIMESTAMP_RE.match(value):
1503
+ raise argparse.ArgumentTypeError(f"{value!r} is not YYYYMMDD-HHmmss")
1504
+ return value
1505
+
1506
+
1507
+ def _build_parser() -> argparse.ArgumentParser:
1508
+ parser = argparse.ArgumentParser(
1509
+ prog="skf-check-preservation",
1510
+ description=(
1511
+ "Build refine-architecture's draft from an insertion plan, check that a refined "
1512
+ "document keeps every line of the original, promote the draft to the output file, "
1513
+ "and stage the result contract's payload from the run's records. RA's marked "
1514
+ "blocks (and the unmarked annotations of an older pass) are set aside in both "
1515
+ "documents first. Also check the refinement rules, and join the [VS] report's "
1516
+ "verdicts to the skill inventory and the document scope. Prints JSON."
1517
+ ),
1518
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1519
+ epilog=(
1520
+ "Examples:\n"
1521
+ " uv run skf-check-preservation.py inspect --doc arch.md --stripped run/analysis-doc.md\n"
1522
+ " uv run skf-check-preservation.py apply --original arch.md --plan run/insertion-plan.json "
1523
+ "--draft forge/.skf-ra-draft-app.md -o run/apply.json\n"
1524
+ " uv run skf-check-preservation.py check --original arch.md --refined refined.md\n"
1525
+ " uv run skf-check-preservation.py promote --original arch.md --draft forge/.skf-ra-draft-app.md "
1526
+ "--output docs/refined-architecture-app.md --timestamp 20261001-120000\n"
1527
+ " uv run skf-check-preservation.py context --inspect run/inspect.json --apply run/apply.json "
1528
+ "--promote run/promote.json --out run/result-context.json\n"
1529
+ " uv run skf-check-preservation.py rules --rules references/refinement-rules.md\n"
1530
+ " uv run skf-check-preservation.py verdicts --report forge/feasibility-report-app-latest.md "
1531
+ "--generated-at 2026-10-01T10:00:00Z --skills run/skill-terms.json --in-scope loro,yjs "
1532
+ "--rules references/refinement-rules.md"
1533
+ ),
1534
+ )
1535
+ sub = parser.add_subparsers(dest="command", required=True)
1536
+ p = sub.add_parser("inspect", help="Detect an earlier RA pass and write the analysis copy.")
1537
+ p.add_argument("--doc", required=True, help="the architecture document")
1538
+ p.add_argument("--stripped", help="write the document with RA's blocks set aside here")
1539
+ p.add_argument("-o", "--output-json", help="also write the JSON result here")
1540
+ p.set_defaults(func=cmd_inspect)
1541
+ p = sub.add_parser("apply", help="Build the draft from the insertion plan.")
1542
+ p.add_argument("--original", required=True, help="the architecture document")
1543
+ p.add_argument("--plan", required=True, help="the insertion plan (JSON)")
1544
+ p.add_argument("--draft", required=True, help="where to write the draft")
1545
+ p.add_argument("-o", "--output-json", help="also write the JSON result here")
1546
+ p.set_defaults(func=cmd_apply)
1547
+ p = sub.add_parser("check", help="Check that a refined document preserves the original.")
1548
+ p.add_argument("--original", required=True, help="the architecture document")
1549
+ p.add_argument("--refined", required=True, help="the refined document or draft")
1550
+ p.set_defaults(func=cmd_check)
1551
+ p = sub.add_parser("promote", help="Replace the output with the draft, keeping the earlier output.")
1552
+ p.add_argument("--original", required=True, help="the architecture document")
1553
+ p.add_argument("--draft", required=True, help="the draft to promote")
1554
+ p.add_argument("--output", required=True, help="the refined document's path")
1555
+ p.add_argument("--timestamp", required=True, type=_timestamp,
1556
+ help="the run's timestamp, YYYYMMDD-HHmmss, for the earlier output's new name")
1557
+ p.add_argument("-o", "--output-json", help="also write the JSON result here")
1558
+ p.set_defaults(func=cmd_promote)
1559
+ p = sub.add_parser("context", help="Merge the run's records into the result contract's payload.")
1560
+ p.add_argument("--inspect", required=True, help="inspect's -o record")
1561
+ p.add_argument("--apply", required=True, help="apply's -o record")
1562
+ p.add_argument("--promote", required=True, help="promote's -o record")
1563
+ p.add_argument("--out", required=True, help="where to write the payload")
1564
+ p.set_defaults(func=cmd_context)
1565
+ p = sub.add_parser("rules", help="Check the refinement rules: the six tables and the tier rules.")
1566
+ p.add_argument("--rules", required=True, help="the refinement rules file")
1567
+ p.set_defaults(func=cmd_rules)
1568
+ p = sub.add_parser("verdicts", help="Join the [VS] report's verdicts to the inventory and the scope.")
1569
+ p.add_argument("--report", required=True, help="the [VS] feasibility report the run uses")
1570
+ p.add_argument("--generated-at", required=True,
1571
+ help="the report's generatedAt when the run started, \"\" when it had none")
1572
+ p.add_argument("--skills", required=True, help="JSON array of the inventory's skills, names or {name, aliases}")
1573
+ p.add_argument("--in-scope", required=True, help="the in-scope skill names, comma-separated")
1574
+ p.add_argument("--rules", required=True, help="the refinement rules file")
1575
+ p.set_defaults(func=cmd_verdicts)
1576
+ return parser
1577
+
1578
+
1579
+ def main(argv: list[str] | None = None) -> int:
1580
+ args = _build_parser().parse_args(argv)
1581
+ try:
1582
+ result, code = args.func(args)
1583
+ except DocError as e:
1584
+ result, code = {"status": "error", "error": str(e)}, 2
1585
+ except (WriteError, HelperMissing) as e:
1586
+ result, code = {"status": "error", "error": str(e)}, 3
1587
+ text = json.dumps(result, indent=2, ensure_ascii=False)
1588
+ if getattr(args, "output_json", None) and code == 0:
1589
+ try:
1590
+ write_atomic(Path(args.output_json), (text + "\n").encode("utf-8"))
1591
+ except WriteError as e:
1592
+ message = str(e)
1593
+ if args.command == "promote":
1594
+ left = _undo_promote(args, result)
1595
+ message += f"; {left}" if left else ""
1596
+ result, code = {"status": "error", "error": message}, 3
1597
+ text = json.dumps(result, indent=2, ensure_ascii=False)
1598
+ print(text)
1599
+ return code
1600
+
1601
+
1602
+ def _force_utf8(*streams) -> None:
1603
+ """Reconfigure stdout and stderr to UTF-8, keeping each stream's error handler.
1604
+
1605
+ A Windows console pipes them as cp1252, which cannot print every character
1606
+ a document line or a path may hold.
1607
+ """
1608
+ for stream in streams:
1609
+ if hasattr(stream, "reconfigure"):
1610
+ errors = getattr(stream, "errors", None)
1611
+ if errors is None:
1612
+ stream.reconfigure(encoding="utf-8")
1613
+ else:
1614
+ stream.reconfigure(encoding="utf-8", errors=errors)
1615
+
1616
+
1617
+ if __name__ == "__main__":
1618
+ _force_utf8(sys.stdout, sys.stderr)
1619
+ sys.exit(main())