bmad-module-skill-forge 2.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (437) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +49 -49
  3. package/docs/404.md +10 -8
  4. package/docs/_data/pinned.yaml +22 -13
  5. package/docs/_internal/STABILITY.md +58 -35
  6. package/docs/agents.md +39 -53
  7. package/docs/architecture.md +65 -51
  8. package/docs/bmad-synergy.md +53 -51
  9. package/docs/campaign.md +127 -75
  10. package/docs/concepts.md +34 -28
  11. package/docs/examples.md +70 -68
  12. package/docs/forge-auto.md +34 -31
  13. package/docs/getting-started.md +118 -57
  14. package/docs/how-it-works.md +25 -21
  15. package/docs/index.md +15 -15
  16. package/docs/skill-model.md +70 -59
  17. package/docs/troubleshooting.md +143 -18
  18. package/docs/verifying-a-skill.md +87 -57
  19. package/docs/why-skf.md +24 -22
  20. package/docs/workflows.md +274 -110
  21. package/package.json +18 -10
  22. package/src/forger/preferences.yaml +8 -1
  23. package/src/knowledge/agentskills-spec.md +2 -0
  24. package/src/knowledge/ccc-bridge.md +40 -24
  25. package/src/knowledge/confidence-tiers.md +9 -5
  26. package/src/knowledge/overview.md +3 -3
  27. package/src/knowledge/progressive-capability.md +8 -8
  28. package/src/knowledge/provenance-tracking.md +9 -5
  29. package/src/knowledge/qmd-registry.md +3 -3
  30. package/src/knowledge/skf-knowledge-index.csv +2 -2
  31. package/src/knowledge/skill-lifecycle.md +18 -11
  32. package/src/knowledge/split-body-strategy.md +0 -3
  33. package/src/knowledge/tool-resolution.md +4 -2
  34. package/src/knowledge/version-paths.md +101 -15
  35. package/src/module-help.csv +3 -3
  36. package/src/module.yaml +10 -0
  37. package/src/shared/data/ast-grep-recipes.yaml +608 -0
  38. package/src/shared/data/ide-context-files.json +30 -0
  39. package/src/shared/health-check.md +31 -59
  40. package/src/shared/references/description-guard-protocol.md +12 -9
  41. package/src/shared/references/feasibility-report-schema.md +19 -13
  42. package/src/shared/references/headless-gate-convention.md +55 -3
  43. package/src/shared/references/output-contract-schema.md +19 -8
  44. package/src/shared/references/pipeline-contracts.md +94 -29
  45. package/src/shared/references/tessl-review.md +77 -0
  46. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
  47. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
  48. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
  49. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
  50. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
  51. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
  52. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
  53. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
  54. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
  55. package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
  56. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
  57. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
  58. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
  59. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
  60. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
  61. package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
  62. package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
  63. package/src/shared/scripts/skf-atomic-write.py +233 -64
  64. package/src/shared/scripts/skf-build-change-manifest.py +2753 -28
  65. package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
  66. package/src/shared/scripts/skf-check-unit-records.py +184 -0
  67. package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
  68. package/src/shared/scripts/skf-classify-changed-files.py +772 -0
  69. package/src/shared/scripts/skf-comention-pairs.py +1333 -123
  70. package/src/shared/scripts/skf-compare-file-hashes.py +267 -105
  71. package/src/shared/scripts/skf-count-imports.py +1867 -0
  72. package/src/shared/scripts/skf-count-tokens.py +1 -1
  73. package/src/shared/scripts/skf-derive-assembly-shape.py +21 -3
  74. package/src/shared/scripts/skf-description-guard.py +99 -11
  75. package/src/shared/scripts/skf-detect-docs.py +694 -2
  76. package/src/shared/scripts/skf-detect-language.py +486 -107
  77. package/src/shared/scripts/skf-detect-registry.py +887 -0
  78. package/src/shared/scripts/skf-detect-scripts-assets.py +197 -55
  79. package/src/shared/scripts/skf-detect-tools.py +209 -27
  80. package/src/shared/scripts/skf-detect-workspaces.py +506 -30
  81. package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
  82. package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
  83. package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
  84. package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
  85. package/src/shared/scripts/skf-extract-public-api.py +5247 -96
  86. package/src/shared/scripts/skf-extraction-inventory.py +1280 -0
  87. package/src/shared/scripts/skf-extraction-snapshot.py +664 -0
  88. package/src/shared/scripts/skf-fetch-temporal.py +674 -0
  89. package/src/shared/scripts/skf-find-cycles.py +97 -29
  90. package/src/shared/scripts/skf-find-test-report.py +476 -0
  91. package/src/shared/scripts/skf-forge-tier-rw.py +469 -205
  92. package/src/shared/scripts/skf-github-fetch.py +365 -0
  93. package/src/shared/scripts/skf-github-probe.py +700 -0
  94. package/src/shared/scripts/skf-hash-content.py +180 -20
  95. package/src/shared/scripts/skf-language-corpora.py +1 -1
  96. package/src/shared/scripts/skf-load-provenance.py +161 -27
  97. package/src/shared/scripts/skf-manifest-ops.py +1 -1
  98. package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
  99. package/src/shared/scripts/skf-merge-doc-urls.py +143 -20
  100. package/src/shared/scripts/skf-names-present.py +221 -0
  101. package/src/shared/scripts/skf-pair-intersect.py +119 -28
  102. package/src/shared/scripts/skf-parse-gaps.py +1384 -0
  103. package/src/shared/scripts/skf-preapply.py +1 -1
  104. package/src/shared/scripts/skf-preflight.py +176 -28
  105. package/src/shared/scripts/skf-promote-staged.py +430 -0
  106. package/src/shared/scripts/skf-provenance-gap-dispatch.py +64 -8
  107. package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
  108. package/src/shared/scripts/skf-quick-batch.py +774 -0
  109. package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
  110. package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
  111. package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
  112. package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
  113. package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
  114. package/src/shared/scripts/skf-resolve-authoritative-files.py +155 -14
  115. package/src/shared/scripts/skf-resolve-package.py +523 -106
  116. package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
  117. package/src/shared/scripts/skf-run-lock.py +577 -0
  118. package/src/shared/scripts/skf-scan-manifests.py +998 -57
  119. package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
  120. package/src/shared/scripts/skf-severity-classify.py +712 -108
  121. package/src/shared/scripts/skf-shape-detect.py +298 -204
  122. package/src/shared/scripts/skf-shard-body.py +9 -8
  123. package/src/shared/scripts/skf-skill-inventory.py +2412 -89
  124. package/src/shared/scripts/skf-skills-module.py +472 -0
  125. package/src/shared/scripts/skf-source-tree.py +1796 -0
  126. package/src/shared/scripts/skf-structural-diff.py +787 -121
  127. package/src/shared/scripts/skf-tessl-review.py +706 -0
  128. package/src/shared/scripts/skf-update-active-symlink.py +120 -67
  129. package/src/shared/scripts/skf-update-run-state.py +624 -0
  130. package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
  131. package/src/shared/scripts/skf-validate-brief-schema.py +23 -2
  132. package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
  133. package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
  134. package/src/shared/scripts/skf-validate-output.py +712 -64
  135. package/src/shared/scripts/skf-validate-pins.py +1 -1
  136. package/src/shared/scripts/skf-verify-no-trace.py +116 -10
  137. package/src/shared/scripts/skf-verify-provenance-completeness.py +3945 -152
  138. package/src/shared/scripts/skf-write-skill-brief.py +406 -25
  139. package/src/shared/tool-requirements.yaml +201 -0
  140. package/src/skf-analyze-source/SKILL.md +41 -36
  141. package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
  142. package/src/skf-analyze-source/customize.toml +36 -24
  143. package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
  144. package/src/skf-analyze-source/references/continue.md +2 -13
  145. package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
  146. package/src/skf-analyze-source/references/generate-briefs.md +121 -55
  147. package/src/skf-analyze-source/references/headless-contract.md +42 -15
  148. package/src/skf-analyze-source/references/identify-units.md +96 -110
  149. package/src/skf-analyze-source/references/init.md +73 -71
  150. package/src/skf-analyze-source/references/map-and-detect.md +123 -105
  151. package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
  152. package/src/skf-analyze-source/references/recommend.md +51 -70
  153. package/src/skf-analyze-source/references/scan-project.md +60 -84
  154. package/src/skf-analyze-source/references/scan-root.md +39 -0
  155. package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
  156. package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
  157. package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
  158. package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
  159. package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
  160. package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
  161. package/src/skf-audit-skill/SKILL.md +43 -51
  162. package/src/skf-audit-skill/assets/drift-report-template.md +28 -3
  163. package/src/skf-audit-skill/customize.toml +41 -20
  164. package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
  165. package/src/skf-audit-skill/references/doc-drift.md +141 -0
  166. package/src/skf-audit-skill/references/headless-contract.md +29 -0
  167. package/src/skf-audit-skill/references/health-check.md +2 -0
  168. package/src/skf-audit-skill/references/init.md +149 -210
  169. package/src/skf-audit-skill/references/re-index.md +103 -110
  170. package/src/skf-audit-skill/references/report.md +96 -33
  171. package/src/skf-audit-skill/references/semantic-diff.md +34 -25
  172. package/src/skf-audit-skill/references/severity-classify.md +79 -65
  173. package/src/skf-audit-skill/references/severity-rules.md +15 -6
  174. package/src/skf-audit-skill/references/structural-diff.md +80 -90
  175. package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
  176. package/src/skf-audit-skill/scripts/render-drift-tables.py +712 -0
  177. package/src/skf-brief-skill/SKILL.md +37 -26
  178. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  179. package/src/skf-brief-skill/assets/scope-templates.md +18 -25
  180. package/src/skf-brief-skill/assets/skill-brief-schema.md +30 -23
  181. package/src/skf-brief-skill/customize.toml +48 -20
  182. package/src/skf-brief-skill/references/analyze-target.md +144 -127
  183. package/src/skf-brief-skill/references/confirm-brief.md +11 -9
  184. package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
  185. package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
  186. package/src/skf-brief-skill/references/gather-intent.md +87 -182
  187. package/src/skf-brief-skill/references/headless-args.md +84 -16
  188. package/src/skf-brief-skill/references/health-check.md +1 -1
  189. package/src/skf-brief-skill/references/invocation-contract.md +18 -13
  190. package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
  191. package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
  192. package/src/skf-brief-skill/references/scope-definition.md +123 -47
  193. package/src/skf-brief-skill/references/step-auto-brief.md +26 -100
  194. package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
  195. package/src/skf-brief-skill/references/version-resolution.md +7 -10
  196. package/src/skf-brief-skill/references/write-brief.md +66 -57
  197. package/src/skf-campaign/SKILL.md +31 -34
  198. package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
  199. package/src/skf-campaign/customize.toml +47 -16
  200. package/src/skf-campaign/references/campaign-contracts.md +39 -29
  201. package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
  202. package/src/skf-campaign/references/health-check.md +7 -16
  203. package/src/skf-campaign/references/step-01-setup.md +46 -84
  204. package/src/skf-campaign/references/step-02-strategy.md +31 -30
  205. package/src/skf-campaign/references/step-03-pins.md +26 -18
  206. package/src/skf-campaign/references/step-04-provenance.md +24 -23
  207. package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
  208. package/src/skf-campaign/references/step-06-batch.md +41 -34
  209. package/src/skf-campaign/references/step-07-capstone.md +40 -25
  210. package/src/skf-campaign/references/step-08-verify.md +28 -29
  211. package/src/skf-campaign/references/step-09-refine.md +29 -30
  212. package/src/skf-campaign/references/step-10-export.md +48 -36
  213. package/src/skf-campaign/references/step-11-maintenance.md +31 -34
  214. package/src/skf-campaign/references/step-resume.md +37 -68
  215. package/src/skf-campaign/scripts/campaign-deps.py +57 -18
  216. package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
  217. package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
  218. package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
  219. package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
  220. package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
  221. package/src/skf-campaign/scripts/campaign-report.py +204 -37
  222. package/src/skf-campaign/scripts/campaign-state.py +1128 -0
  223. package/src/skf-campaign/scripts/campaign-status.py +10 -74
  224. package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
  225. package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
  226. package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
  227. package/src/skf-campaign/templates/campaign-report-template.md +5 -1
  228. package/src/skf-campaign/templates/kickoff-template.md +0 -14
  229. package/src/skf-create-skill/SKILL.md +27 -20
  230. package/src/skf-create-skill/assets/compile-assembly-rules.md +11 -8
  231. package/src/skf-create-skill/assets/skill-sections.md +21 -11
  232. package/src/skf-create-skill/customize.toml +20 -8
  233. package/src/skf-create-skill/references/authoritative-files-protocol.md +36 -58
  234. package/src/skf-create-skill/references/batch-mode.md +81 -0
  235. package/src/skf-create-skill/references/ccc-index-check.md +27 -0
  236. package/src/skf-create-skill/references/compile.md +119 -70
  237. package/src/skf-create-skill/references/component-extraction.md +113 -91
  238. package/src/skf-create-skill/references/enrich.md +41 -23
  239. package/src/skf-create-skill/references/entry-points-by-hand.md +37 -0
  240. package/src/skf-create-skill/references/extract.md +204 -162
  241. package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
  242. package/src/skf-create-skill/references/extraction-patterns-tracing.md +11 -11
  243. package/src/skf-create-skill/references/extraction-patterns.md +588 -201
  244. package/src/skf-create-skill/references/generate-artifacts.md +145 -93
  245. package/src/skf-create-skill/references/health-check.md +1 -1
  246. package/src/skf-create-skill/references/load-brief.md +53 -36
  247. package/src/skf-create-skill/references/relabel-rule.md +12 -0
  248. package/src/skf-create-skill/references/report.md +37 -57
  249. package/src/skf-create-skill/references/source-resolution-protocols.md +58 -169
  250. package/src/skf-create-skill/references/step-auto-shard.md +20 -93
  251. package/src/skf-create-skill/references/step-doc-rot.md +44 -60
  252. package/src/skf-create-skill/references/step-doc-sources.md +28 -19
  253. package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
  254. package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
  255. package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
  256. package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
  257. package/src/skf-create-skill/references/validate.md +173 -99
  258. package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
  259. package/src/skf-create-stack-skill/SKILL.md +29 -56
  260. package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
  261. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
  262. package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
  263. package/src/skf-create-stack-skill/customize.toml +23 -12
  264. package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
  265. package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
  266. package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
  267. package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
  268. package/src/skf-create-stack-skill/references/generate-output.md +138 -128
  269. package/src/skf-create-stack-skill/references/init.md +90 -32
  270. package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
  271. package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
  272. package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
  273. package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
  274. package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
  275. package/src/skf-create-stack-skill/references/report.md +39 -33
  276. package/src/skf-create-stack-skill/references/validate.md +20 -39
  277. package/src/skf-drop-skill/SKILL.md +28 -51
  278. package/src/skf-drop-skill/customize.toml +44 -30
  279. package/src/skf-drop-skill/references/execute.md +83 -146
  280. package/src/skf-drop-skill/references/health-check.md +2 -0
  281. package/src/skf-drop-skill/references/invocation-contract.md +64 -0
  282. package/src/skf-drop-skill/references/report.md +46 -30
  283. package/src/skf-drop-skill/references/select.md +122 -124
  284. package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
  285. package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
  286. package/src/skf-export-skill/SKILL.md +40 -62
  287. package/src/skf-export-skill/assets/managed-section-format.md +14 -50
  288. package/src/skf-export-skill/assets/snippet-format.md +3 -1
  289. package/src/skf-export-skill/customize.toml +42 -13
  290. package/src/skf-export-skill/references/generate-snippet.md +54 -46
  291. package/src/skf-export-skill/references/health-check.md +2 -0
  292. package/src/skf-export-skill/references/invocation-contract.md +15 -0
  293. package/src/skf-export-skill/references/load-skill.md +88 -62
  294. package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
  295. package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
  296. package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
  297. package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
  298. package/src/skf-export-skill/references/package.md +15 -73
  299. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
  300. package/src/skf-export-skill/references/result-envelope.md +31 -16
  301. package/src/skf-export-skill/references/summary.md +41 -17
  302. package/src/skf-export-skill/references/token-report.md +4 -12
  303. package/src/skf-export-skill/references/update-context.md +118 -237
  304. package/src/skf-forger/SKILL.md +61 -37
  305. package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
  306. package/src/skf-forger/customize.toml +43 -0
  307. package/src/skf-forger/references/pipeline-mode.md +73 -18
  308. package/src/skf-forger/scripts/forge-status.py +299 -0
  309. package/src/skf-forger/scripts/parse-pipeline.py +322 -54
  310. package/src/skf-forger/scripts/pipeline-gate.py +428 -0
  311. package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
  312. package/src/skf-quick-skill/SKILL.md +54 -53
  313. package/src/skf-quick-skill/assets/skill-template.md +4 -2
  314. package/src/skf-quick-skill/customize.toml +51 -20
  315. package/src/skf-quick-skill/references/batch-mode.md +74 -21
  316. package/src/skf-quick-skill/references/compile.md +30 -31
  317. package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
  318. package/src/skf-quick-skill/references/finalize.md +69 -18
  319. package/src/skf-quick-skill/references/halt-contract.md +41 -29
  320. package/src/skf-quick-skill/references/health-check.md +5 -3
  321. package/src/skf-quick-skill/references/quick-extract.md +114 -45
  322. package/src/skf-quick-skill/references/registry-resolution.md +9 -64
  323. package/src/skf-quick-skill/references/resolve-target.md +116 -66
  324. package/src/skf-quick-skill/references/write-and-validate.md +79 -40
  325. package/src/skf-refine-architecture/SKILL.md +43 -53
  326. package/src/skf-refine-architecture/customize.toml +49 -20
  327. package/src/skf-refine-architecture/references/compile.md +97 -54
  328. package/src/skf-refine-architecture/references/exit-codes.md +30 -0
  329. package/src/skf-refine-architecture/references/finding-storage.md +21 -0
  330. package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
  331. package/src/skf-refine-architecture/references/improvements.md +14 -20
  332. package/src/skf-refine-architecture/references/init.md +98 -57
  333. package/src/skf-refine-architecture/references/issue-detection.md +32 -39
  334. package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
  335. package/src/skf-refine-architecture/references/report.md +65 -54
  336. package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
  337. package/src/skf-rename-skill/SKILL.md +19 -54
  338. package/src/skf-rename-skill/customize.toml +32 -26
  339. package/src/skf-rename-skill/references/execute.md +230 -184
  340. package/src/skf-rename-skill/references/exit-codes.md +7 -7
  341. package/src/skf-rename-skill/references/health-check.md +1 -7
  342. package/src/skf-rename-skill/references/invocation-contract.md +25 -0
  343. package/src/skf-rename-skill/references/report.md +63 -52
  344. package/src/skf-rename-skill/references/select.md +197 -115
  345. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
  346. package/src/skf-setup/SKILL.md +26 -26
  347. package/src/skf-setup/customize.toml +14 -10
  348. package/src/skf-setup/references/auto-index.md +50 -63
  349. package/src/skf-setup/references/ccc-index.md +29 -90
  350. package/src/skf-setup/references/detect-and-tier.md +58 -67
  351. package/src/skf-setup/references/health-check.md +2 -0
  352. package/src/skf-setup/references/invocation-contract.md +14 -0
  353. package/src/skf-setup/references/report.md +43 -166
  354. package/src/skf-setup/references/tier-rules.md +5 -5
  355. package/src/skf-setup/references/write-config.md +34 -57
  356. package/src/skf-test-skill/SKILL.md +17 -57
  357. package/src/skf-test-skill/assets/output-section-formats.md +33 -34
  358. package/src/skf-test-skill/customize.toml +27 -13
  359. package/src/skf-test-skill/references/coherence-check.md +134 -62
  360. package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
  361. package/src/skf-test-skill/references/coverage-check.md +209 -264
  362. package/src/skf-test-skill/references/detect-mode.md +5 -5
  363. package/src/skf-test-skill/references/external-validators.md +94 -82
  364. package/src/skf-test-skill/references/health-check.md +6 -7
  365. package/src/skf-test-skill/references/init.md +164 -91
  366. package/src/skf-test-skill/references/invocation-contract.md +66 -0
  367. package/src/skf-test-skill/references/migration-section-rules.md +5 -2
  368. package/src/skf-test-skill/references/report.md +164 -142
  369. package/src/skf-test-skill/references/score.md +80 -107
  370. package/src/skf-test-skill/references/scoring-rules.md +42 -155
  371. package/src/skf-test-skill/references/source-access-protocol.md +18 -94
  372. package/src/skf-test-skill/references/step-hard-gate.md +51 -33
  373. package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
  374. package/src/skf-test-skill/scripts/build-result-context.py +380 -0
  375. package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
  376. package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
  377. package/src/skf-test-skill/scripts/compute-score.py +223 -45
  378. package/src/skf-test-skill/scripts/gap-ledger.py +1666 -0
  379. package/src/skf-test-skill/scripts/hard-gate.py +203 -0
  380. package/src/skf-test-skill/scripts/load-coverage-inputs.py +1198 -0
  381. package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
  382. package/src/skf-test-skill/scripts/reconcile-coverage.py +547 -79
  383. package/src/skf-test-skill/scripts/score-signatures.py +479 -0
  384. package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
  385. package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
  386. package/src/skf-test-skill/scripts/verify-declared-numerator.py +133 -56
  387. package/src/skf-test-skill/templates/test-report-template.md +21 -15
  388. package/src/skf-update-skill/SKILL.md +38 -25
  389. package/src/skf-update-skill/customize.toml +27 -12
  390. package/src/skf-update-skill/references/detect-changes.md +253 -223
  391. package/src/skf-update-skill/references/gap-driven.md +336 -0
  392. package/src/skf-update-skill/references/health-check.md +31 -5
  393. package/src/skf-update-skill/references/init.md +254 -84
  394. package/src/skf-update-skill/references/invocation-contract.md +12 -0
  395. package/src/skf-update-skill/references/manual-section-rules.md +6 -23
  396. package/src/skf-update-skill/references/merge.md +182 -49
  397. package/src/skf-update-skill/references/re-extract.md +53 -262
  398. package/src/skf-update-skill/references/report.md +102 -35
  399. package/src/skf-update-skill/references/write.md +357 -155
  400. package/src/skf-update-skill/scripts/skf-new-file-diff.py +183 -46
  401. package/src/skf-verify-stack/SKILL.md +23 -42
  402. package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
  403. package/src/skf-verify-stack/customize.toml +55 -22
  404. package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
  405. package/src/skf-verify-stack/references/coverage.md +67 -64
  406. package/src/skf-verify-stack/references/exit-codes.md +21 -8
  407. package/src/skf-verify-stack/references/init.md +107 -62
  408. package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
  409. package/src/skf-verify-stack/references/integrations.md +106 -65
  410. package/src/skf-verify-stack/references/report.md +59 -42
  411. package/src/skf-verify-stack/references/requirements.md +32 -9
  412. package/src/skf-verify-stack/references/synthesize.md +53 -39
  413. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
  414. package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
  415. package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
  416. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
  417. package/tools/cli/commands/install.js +11 -4
  418. package/tools/cli/commands/status.js +15 -3
  419. package/tools/cli/commands/update.js +11 -4
  420. package/tools/cli/lib/tool-check.js +320 -0
  421. package/tools/cli/lib/version-check.js +22 -9
  422. package/tools/cli/skf-cli.js +4 -1
  423. package/.gitattributes +0 -16
  424. package/.nvmrc +0 -1
  425. package/docs/_internal/RELEASING.md +0 -679
  426. package/release-audits/v1.0.0-launch-audit.md +0 -728
  427. package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
  428. package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
  429. package/src/skf-campaign/manifest.yaml +0 -15
  430. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
  431. package/src/skf-create-skill/references/ecosystem-check.md +0 -74
  432. package/src/skf-drop-skill/references/headless-contract.md +0 -18
  433. package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
  434. package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
  435. package/src/skf-update-skill/references/validate.md +0 -124
  436. package/tools/validate-docs-drift.js +0 -233
  437. package/tools/validate-docs-links.js +0 -665
@@ -1,20 +1,24 @@
1
1
  #!/usr/bin/env python3
2
2
  # /// script
3
- # requires-python = ">=3.9"
3
+ # requires-python = ">=3.11"
4
4
  # dependencies = []
5
5
  # ///
6
- """Structural Diff — Deterministic comparison of skill export inventories.
6
+ """Structural Diff: deterministic comparison of skill export inventories.
7
7
 
8
8
  Compares a baseline export inventory against a current export inventory and
9
9
  produces a structured JSON diff showing added, removed, changed, and moved
10
- entries. Used by audit-skill (structural-diff step) and update-skill to
11
- replace LLM-based, token-spending inventory comparison — the LLM can silently
12
- drop or mis-match entries when diffing dozens/hundreds of exports by hand.
10
+ entries. Used by audit-skill (structural-diff step, references/structural-diff.md
11
+ section 1) to replace LLM-based, token-spending inventory comparison: the LLM
12
+ can silently drop or mis-match entries when diffing dozens/hundreds of exports
13
+ by hand.
13
14
 
14
15
  CLI:
15
16
  python3 skf-structural-diff.py baseline.json current.json
16
17
  python3 skf-structural-diff.py baseline.json current.json -o diff-result.json
17
18
  python3 skf-structural-diff.py provenance-map.json snapshot.json --reexport-map map.json
19
+ python3 skf-structural-diff.py provenance-map.json snapshot.json --group-by source_library
20
+ python3 skf-structural-diff.py provenance-map.json snapshot.json --files category-a.json
21
+ python3 skf-structural-diff.py provenance-map.json extraction.json --current-extra details.json
18
22
 
19
23
  Input:
20
24
  Two JSON files. Each file may be either:
@@ -31,9 +35,70 @@ Input:
31
35
  - file <- file | source_file
32
36
  - line <- line | source_line
33
37
  - signature <- signature
38
+ - params <- params (a provenance map's parameter list)
39
+ - return_type <- return_type
34
40
  - confidence <- confidence
35
-
36
- "name" is the primary match key. Nameless entries are skipped.
41
+ - extraction_method <- extraction_method
42
+
43
+ Nameless entries are skipped. ast_node_type is ignored on purpose: it
44
+ describes the tool's match, not the export, and a relabel changes it along
45
+ with confidence.
46
+
47
+ Matching:
48
+ An export is identified by its name and its file together, so two exports
49
+ that share a name (a `GET` handler in each of two route files, two `parse`
50
+ functions) are never taken for one another, whatever order the inventories
51
+ list them in. The file is compared stripped, with backslashes read as
52
+ forward slashes and no leading `./`, so a path written on Windows or with
53
+ a `./` prefix names the same file as the plain one; every list emits the
54
+ file as the inventory wrote it. One side listing the same name twice in
55
+ one file (overload signatures, a definition in both branches of an `if`)
56
+ keeps the entry on the first line, as the AST Extraction Protocol
57
+ deduplicates.
58
+
59
+ An entry left on one side only is paired with one left on the other side
60
+ as a move when its name occurs exactly once in each inventory. When the
61
+ name occurs more than once on either side, the leftover entries cannot be
62
+ paired by name alone: they stay in removed[] and added[], and the name is
63
+ listed in ambiguous_names[] with the files on each side, for a reviewer
64
+ to judge.
65
+
66
+ Public surface:
67
+ A current entry may carry `public`, true or false: the mark
68
+ skf-extraction-snapshot.py build --scope-type public-api gives each
69
+ export by the public-surface rule a public-api skill's provenance map is
70
+ kept to. After the pairing above, a current entry marked false that
71
+ pairs with none (no baseline entry of its name and file, and no move)
72
+ goes to not_public[], not added[]: create-skill and update-skill never
73
+ document an export off the surface, so it is no drift. One whose name is
74
+ in ambiguous_names[] stays in added[], where a reviewer pairs it. A
75
+ matched or moved entry is compared whatever its mark, so a baseline
76
+ entry off the surface is never reported removed. An entry with no mark
77
+ (any other scope type, a family with no barrel, an older snapshot) is
78
+ added as before. not_public[] never counts toward the exit code.
79
+
80
+ File scope (--files FILE):
81
+ For a caller that re-extracted only the files that changed (update-skill's
82
+ Category B), --files limits the diff to the exports of the files FILE
83
+ names: a JSON array of paths, or the output of skf-classify-changed-files.py
84
+ classify, which names category_a's modified, added and deleted files and
85
+ both paths of each moved_files item. A path compares in the form an
86
+ export's file does (see Matching). An export of any other file is taken as
87
+ unchanged on both sides: it enters no list and no count, and its name
88
+ still counts when a move needs a name unique on both sides, so the scoped
89
+ diff pairs the moves a diff of the whole inventories would. The current
90
+ inventory must hold the exports of every named file the source still has,
91
+ or they read as removed.
92
+
93
+ Extra current entries (--current-extra FILE):
94
+ For a caller that adds to a recipe runner's output what the recipes do
95
+ not record (update-skill's Category B: each export's params and
96
+ return_type, read at its line, and the exports read by eye), FILE is an
97
+ inventory in any input format above, merged into the current one before
98
+ the diff: an entry with the name and file of a current entry fills each
99
+ field that entry lacks (null or absent), and any other entry is added as
100
+ an export of its own. Names and files compare as written, the file in the
101
+ form Matching describes.
37
102
 
38
103
  Canonicalization (applied symmetrically to BOTH sides before matching):
39
104
  The baseline extractor (skf-create-skill) and the re-extractor (audit step 2)
@@ -55,57 +120,231 @@ Canonicalization (applied symmetrically to BOTH sides before matching):
55
120
 
56
121
  The reexport map is taken from --reexport-map when provided; otherwise it is
57
122
  derived from the baseline provenance map itself (top-level `reexport_map`
58
- plus any per-entry `reexported_as`) — the same projection produced by
123
+ plus any per-entry `reexported_as`), the same projection produced by
59
124
  `skf-load-provenance.py normalize`.
60
125
 
126
+ params (each parameter) and return_type are canonicalized the same way as
127
+ a signature. A parameter the recipe runner records as {name, type,
128
+ default, optional} is first written in the provenance map's form
129
+ (skf-extraction-inventory.py typed_param: `name: type`, `name?: type`,
130
+ ` = default`), so a runner's export compares with the map's entry.
131
+
61
132
  Change detection:
62
- A field is only compared when present (non-null) on BOTH sides — a field
63
- absent on one side means "insufficient data", never an asserted change. This
64
- avoids false positives when the two inventories carry different metadata.
133
+ The diffed fields are type (compared through export-type, below),
134
+ signature, params, return_type and line. A field is only compared when
135
+ present (non-null) on BOTH sides: a field absent on one side means
136
+ "insufficient data", never an asserted change.
137
+ This avoids false positives when the two inventories carry different
138
+ metadata. The line of a moved export is not compared: a move changes it.
139
+
140
+ export-type: no record is rewritten, but when a matched export's two
141
+ types differ, a semantic kind a by-eye read records is the base kind
142
+ the recipe runner records for the same declaration when the base-kind
143
+ side's canonical signature shows that form: `async_function` is
144
+ `function` for an `async def`, `async function`, `async fn` or
145
+ `async unsafe fn`, or a `const`, `let` or `var` bound to an `async`
146
+ arrow or function (after `export`, `default`, `declare` or `pub`);
147
+ `decorator` is `function` for a Python `def` or `async def`; `enum` is
148
+ `class` for a Python class with a stdlib `Enum`, `IntEnum`, `StrEnum`,
149
+ `ReprEnum`, `Flag` or `IntFlag` base. Either side may hold the by-eye
150
+ kind; with no signature on the base-kind side the types differ. Each
151
+ export whose types it takes as one counts once as `export-type` in
152
+ applied_transforms.
153
+
154
+ A signature has two parts, the parameters and the return type. A side
155
+ carries the parameters as params or in its signature, and the return
156
+ type as return_type or in its signature. When both sides carry a part
157
+ but no field holds it on both (a provenance map's params and return_type
158
+ against a snapshot's signature text), that part is never compared, and
159
+ the export is listed in signature_unverified[]: a change there cannot be
160
+ seen, so a diff that finds nothing does not pass for a verified one.
161
+
162
+ Provenance labels:
163
+ confidence and extraction_method describe the tool that extracted an export,
164
+ not the source, so a difference in them is not drift. For an export matched
165
+ on both sides, a label that differs (compared case-insensitively after trimming,
166
+ and only when both sides have a value) is reported once per export in
167
+ label_changes[]. A blank label counts as no value and is emitted as null.
168
+ The stack spellings of extraction_method compare equal to the library ones
169
+ (ast_bridge = ast-grep, source_reading = source-read); emitted values keep
170
+ the spelling each side wrote. Label changes never enter changed[], never
171
+ make an export count as changed, and never affect the exit code.
65
172
 
66
173
  Output:
67
174
  JSON object:
68
- summary: { added, removed, changed, moved, unchanged }
175
+ summary: { added, removed, changed, moved, unchanged,
176
+ label_changes, ambiguous_names,
177
+ signature_unverified, not_public }
69
178
  added: list of entries present in current but not baseline
70
179
  removed: list of entries present in baseline but not current
71
- changed: list of { name, field, baseline_value, current_value }
72
- moved: list of { name, previous_file, current_file }
180
+ changed: list of { name, field, baseline_value, current_value,
181
+ file, line, confidence } where field is type,
182
+ signature, params, return_type or line, and file,
183
+ line and confidence are the export's current ones
184
+ moved: list of { name, previous_file, current_file,
185
+ previous_line, line, confidence }
186
+ ambiguous_names: list of { name, removed: [{file, line}], added:
187
+ [{file, line}] }: a name left on both sides that
188
+ occurs more than once on a side, so its entries stay
189
+ in removed[] and added[] unpaired
190
+ label_changes: list of { name, file, baseline: {confidence,
191
+ extraction_method}, current: {confidence,
192
+ extraction_method} }; informational, not drift
193
+ signature_unverified: list of { name, file, baseline, current }: a
194
+ matched export with a signature part no field
195
+ holds on both sides (see Change detection);
196
+ baseline and current list which of signature,
197
+ params and return_type each side carries.
198
+ Informational: never a change, never in the exit
199
+ code
200
+ not_public: list of current entries marked `public: false`
201
+ that pair with no baseline entry and whose name is
202
+ not ambiguous (see Public surface): exports off a
203
+ public-api skill's public surface, left out of
204
+ added[]. Not drift, never in the exit code
73
205
  unchanged_count: number of entries that matched with no field change
74
- applied_transforms: list of { transform, count } — which canonicalization
75
- transforms actually fired and how many values each
76
- touched (empty when none fired). Surfaced by audit
206
+ (a label change or an unverified signature alone
207
+ leaves an entry unchanged)
208
+ applied_transforms: list of { transform, count }: which canonicalization
209
+ transforms, and the export-type comparison,
210
+ actually fired and how many values each touched
211
+ (export-type: how many exports; empty when none
212
+ fired). Surfaced by audit
77
213
  step 6's Provenance section so a reviewer can tell
78
214
  which differences the diff collapsed.
79
215
 
216
+ added[], removed[] and not_public[] hold normalized records: name, type,
217
+ signature, params, return_type, file, line, confidence and
218
+ extraction_method, and `public` when the entry carries a true or false
219
+ mark (see Public surface).
220
+
221
+ With --group-by source_library, each side is split by its entries'
222
+ source_library (a stack's libraries; an entry without one falls in a
223
+ null group, diffed against the other side's) and each group is diffed on
224
+ its own, so an export never matches one of another library. The lists
225
+ above then hold every group's items, each tagged with its source_library,
226
+ the summary sums the groups, and two keys are added:
227
+ group_by: "source_library"
228
+ groups: list of { source_library, summary }, one per group
229
+ An inventory whose entries all lack a source_library is an error when
230
+ the other inventory's entries have one: its every export would land in
231
+ the null group and read as added or removed.
232
+
233
+ With --files, one key is added:
234
+ file_scope: { files, baseline_left_out, current_left_out }: the
235
+ paths FILE names, and the exports of each inventory
236
+ left out as outside them
237
+
238
+ With -o, the JSON is written to the file and stdout gets one line:
239
+ { status: "ok", output: <file>, summary: {...} }.
240
+
80
241
  Exit codes:
81
- 0 — inventories are identical (no diff)
82
- 1 — differences found (or error)
242
+ 0 no export added, removed, changed or moved
243
+ 1 differences found
244
+ 2 error: an input that cannot be read or parsed, a --files file that is
245
+ neither a path list nor a classify output, --group-by over an
246
+ inventory whose entries all lack the field while the other's have it,
247
+ an output that cannot be written, or a usage error ({status: "error",
248
+ error} on stdout, or argparse's usage on stderr)
249
+ Label differences (label_changes), unverified signatures
250
+ (signature_unverified) and exports off the public surface (not_public) do
251
+ not count toward the exit code.
83
252
  """
84
253
 
85
254
  from __future__ import annotations
86
255
 
87
256
  import argparse
88
257
  import collections
258
+ import importlib.util
89
259
  import json
90
260
  import re
91
261
  import sys
92
262
  from pathlib import Path
93
263
 
264
+ _INVENTORY = None
265
+
266
+
267
+ def _typed_params(params: object, language: object) -> object:
268
+ """A recipe runner's {name, type, default, optional} parameters in the provenance map's typed form, through
269
+ skf-extraction-inventory.py's typed_param (loaded once from this folder); any other value as it is."""
270
+ global _INVENTORY
271
+ if not isinstance(params, list) or not any(isinstance(p, dict) for p in params):
272
+ return params
273
+ if _INVENTORY is None:
274
+ path = Path(__file__).resolve().parent / "skf-extraction-inventory.py"
275
+ spec = importlib.util.spec_from_file_location("skf_extraction_inventory", path)
276
+ if spec is None or spec.loader is None:
277
+ raise ImportError(f"cannot load {path}")
278
+ _INVENTORY = importlib.util.module_from_spec(spec)
279
+ spec.loader.exec_module(_INVENTORY)
280
+ lang = language if isinstance(language, str) else None
281
+ return [s for s in (_INVENTORY.typed_param(p, lang) for p in params) if s is not None]
282
+
94
283
 
95
284
  # Fields compared for change detection (in order).
96
- # "name" is the primary key and is not diffed as a field.
97
- # "file" is excluded — file moves are tracked separately in the "moved" list.
98
- DIFF_FIELDS = ["type", "signature", "line", "confidence"]
285
+ # "name" and "file" identify the export and are not diffed as fields; a file
286
+ # change is tracked separately in the "moved" list.
287
+ # Provenance labels are excluded too: they say which tool extracted the export,
288
+ # so a relabel is reported in label_changes[] and is never drift.
289
+ # params and return_type are the provenance map's form of a signature.
290
+ DIFF_FIELDS = ["type", "signature", "params", "return_type", "line"]
291
+
292
+ # The fields that hold a signature, and for each of its two parts (the
293
+ # parameters, the return type) the fields that hold that part: a signature
294
+ # string holds both.
295
+ SIGNATURE_FIELDS = ("signature", "params", "return_type")
296
+ SIGNATURE_PARTS = (("params", "signature"), ("return_type", "signature"))
297
+
298
+ # The one --group-by field: a stack's per-library split.
299
+ GROUP_FIELDS = ["source_library"]
300
+
301
+ # Provenance labels compared for the informational label_changes[] list.
302
+ LABEL_FIELDS = ["confidence", "extraction_method"]
303
+
304
+ # Stack provenance maps spell extraction_method differently from library maps
305
+ # and the audit re-index; compare them as the same tool.
306
+ _METHOD_ALIASES = {"ast_bridge": "ast-grep", "source_reading": "source-read"}
99
307
 
100
308
  # Well-known stdlib module prefixes whose unqualified form is importable at
101
309
  # the call site. Longer prefixes must precede their own containing prefix
102
310
  # (collections.abc before collections) so the alternation strips the longest.
103
311
  # The negative lookbehind `(?<![\w.])` ensures we only strip a top-level module
104
- # reference — never a user-defined namespace such as `pkg.typing.Foo`.
312
+ # reference, never a user-defined namespace such as `pkg.typing.Foo`.
105
313
  _STDLIB_PREFIX_RE = re.compile(
106
314
  r"(?<![\w.])(?:typing|dataclasses|collections\.abc|collections|enum)\."
107
315
  )
108
316
 
317
+ # export-type: a semantic kind a by-eye read records, against the base kind
318
+ # the recipe runner records for the same declaration, names one kind when
319
+ # the base-kind side's canonical signature shows that form. Any other pair
320
+ # of types, or a base-kind side with no signature, is compared as written.
321
+ # An async form: `async def`, `async function`, `async fn` or `async unsafe
322
+ # fn`, or a `const` / `let` / `var` bound to an `async` arrow or function
323
+ # (`export const add = async (x) => x;`), after any `export`, `default`,
324
+ # `declare` or `pub` modifier.
325
+ _ASYNC_FORM_RE = re.compile(
326
+ r"^\s*(?:(?:export|default|declare|pub(?:\s*\([^()]*\))?)\s+)*"
327
+ r"(?:async\s+(?:unsafe\s+)?(?:def|function|fn)\b"
328
+ r"|(?:const|let|var)\s+[\w$]+\s*(?::(?:[^=]|=>)*)?=\s*async\b)"
329
+ )
330
+ _DEF_FORM_RE = re.compile(r"^\s*(?:async\s+)?def\s")
331
+ _CLASS_BASES_RE = re.compile(r"^\s*class\s+\w+\s*\(([^()]*)\)")
332
+ _ENUM_BASES = frozenset({"Enum", "IntEnum", "StrEnum", "ReprEnum", "Flag", "IntFlag"})
333
+
334
+
335
+ def _enum_class_form(signature: str) -> bool:
336
+ """True when a Python class declaration lists a stdlib enum base."""
337
+ m = _CLASS_BASES_RE.match(signature)
338
+ return m is not None and any(base.strip() in _ENUM_BASES for base in m.group(1).split(","))
339
+
340
+
341
+ # (by-eye kind, base kind) -> the test of the base-kind side's signature.
342
+ EXPORT_TYPE_FORMS = {
343
+ ("async_function", "function"): lambda signature: _ASYNC_FORM_RE.match(signature) is not None,
344
+ ("decorator", "function"): lambda signature: _DEF_FORM_RE.match(signature) is not None,
345
+ ("enum", "class"): _enum_class_form,
346
+ }
347
+
109
348
 
110
349
  # --------------------------------------------------------------------------
111
350
  # Loading
@@ -165,6 +404,83 @@ def load_inventory(path: Path) -> tuple[list[dict], str | None]:
165
404
  return entries_from_data(data, str(path))
166
405
 
167
406
 
407
+ # The keys one field may be written under (see Input): an entry lacks the
408
+ # field only when it has none of them.
409
+ _ALIAS_GROUPS = {
410
+ key: group
411
+ for group in (("name", "export_name"), ("type", "export_type"), ("file", "source_file"),
412
+ ("line", "source_line"))
413
+ for key in group
414
+ }
415
+
416
+
417
+ def merge_extra(entries: list[dict], extra: list[dict]) -> list[dict]:
418
+ """The current entries with --current-extra's merged in by name and file:
419
+ an extra entry fills each field the entry of its name and file lacks,
420
+ and one with no such entry is added.
421
+
422
+ >>> merge_extra([{"export_name": "f", "source_file": "a.py", "source_line": 3}],
423
+ ... [{"export_name": "f", "source_file": "./a.py", "params": ["x: int"]},
424
+ ... {"name": "g", "file": "a.py", "line": 9}])
425
+ [{'export_name': 'f', 'source_file': 'a.py', 'source_line': 3, 'params': ['x: int']}, {'name': 'g', 'file': 'a.py', 'line': 9}]
426
+ """
427
+ merged = [dict(entry) if isinstance(entry, dict) else entry for entry in entries]
428
+ by_key: dict[tuple, dict] = {}
429
+ for entry in merged:
430
+ if isinstance(entry, dict):
431
+ name = _first(entry, "name", "export_name")
432
+ if isinstance(name, str):
433
+ by_key.setdefault((name.strip(), _file_key(_first(entry, "file", "source_file"))), entry)
434
+ for item in extra:
435
+ if not isinstance(item, dict):
436
+ continue
437
+ name = _first(item, "name", "export_name")
438
+ target = by_key.get((name.strip(), _file_key(_first(item, "file", "source_file")))) \
439
+ if isinstance(name, str) else None
440
+ if target is None:
441
+ merged.append(dict(item))
442
+ continue
443
+ for field, value in item.items():
444
+ if value is not None and all(target.get(key) is None for key in _ALIAS_GROUPS.get(field, (field,))):
445
+ target[field] = value
446
+ return merged
447
+
448
+
449
+ def files_from_data(data: object, source: str = "") -> tuple[list[str], str | None]:
450
+ """The paths of a --files file, already parsed.
451
+
452
+ Accepts:
453
+ - [...] (a JSON array of paths)
454
+ - {"category_a": {...}} (skf-classify-changed-files.py classify output:
455
+ category_a's modified, added and deleted
456
+ paths, and both paths of each moved_files item)
457
+
458
+ Returns (paths, error_message).
459
+ """
460
+ where = f" in '{source}'" if source else ""
461
+ if isinstance(data, list):
462
+ if all(isinstance(path, str) for path in data):
463
+ return data, None
464
+ return [], f"--files list{where} must hold only path strings"
465
+ if not isinstance(data, dict) or not isinstance(data.get("category_a"), dict):
466
+ return [], (
467
+ f"Unrecognised --files format{where}: expected an array of paths or "
468
+ f"the output of skf-classify-changed-files.py classify"
469
+ )
470
+ paths: list[str] = []
471
+ for key in ("modified", "added", "deleted"):
472
+ listed = data["category_a"].get(key, [])
473
+ if not isinstance(listed, list) or not all(isinstance(path, str) for path in listed):
474
+ return [], f"--files category_a.{key}{where} must be a list of paths"
475
+ paths.extend(listed)
476
+ moves = data.get("moved_files", [])
477
+ if not isinstance(moves, list) or not all(isinstance(move, dict) for move in moves):
478
+ return [], f"--files moved_files{where} must be a list of {{old_path, new_path}} objects"
479
+ for move in moves:
480
+ paths.extend(move[key] for key in ("old_path", "new_path") if isinstance(move.get(key), str))
481
+ return paths, None
482
+
483
+
168
484
  # --------------------------------------------------------------------------
169
485
  # Re-export map
170
486
  # --------------------------------------------------------------------------
@@ -248,19 +564,47 @@ def canon_signature(sig: object) -> tuple[object, set[str]]:
248
564
  return out, applied
249
565
 
250
566
 
567
+ def _canon_value(value: object, transform_counts: collections.Counter) -> object:
568
+ """Canonicalize a signature, a return type or each item of a parameter list."""
569
+ if isinstance(value, list):
570
+ return [_canon_value(item, transform_counts) for item in value]
571
+ canon, fired = canon_signature(value)
572
+ for t in fired:
573
+ transform_counts[t] += 1
574
+ return canon
575
+
576
+
577
+ def _is_line(value: object) -> bool:
578
+ return isinstance(value, int) and not isinstance(value, bool)
579
+
580
+
581
+ def _file_key(file: object) -> str | None:
582
+ """The file half of an export's key: stripped, forward slashes, no
583
+ leading `./` (normalize_rel_path of skf-resolve-authoritative-files.py),
584
+ or None when the entry names no file."""
585
+ if not isinstance(file, str):
586
+ return None
587
+ path = file.strip().replace("\\", "/")
588
+ while path.startswith("./"):
589
+ path = path[2:]
590
+ return path or None
591
+
592
+
251
593
  def _normalize_entries(
252
594
  entries: list[dict],
253
595
  reexport_map: dict[str, str],
254
596
  transform_counts: collections.Counter,
255
- ) -> dict[str, dict]:
256
- """Build a name-keyed dict of canonicalized records.
597
+ ) -> dict[tuple, dict]:
598
+ """Build a dict of canonicalized records keyed by (name, file).
257
599
 
258
600
  Applies field-name aliasing, signature canonicalization, and re-export
259
601
  name resolution. Accumulates fired-transform counts into transform_counts.
260
- Nameless entries are skipped. On duplicate resolved names, the last entry
261
- wins (consistent with prior behaviour).
602
+ Nameless entries are skipped. The key holds the file in _file_key's form;
603
+ the record keeps it as written. When one (name, file) repeats, the entry
604
+ on the first line wins, whatever order the inventory lists them in (an
605
+ entry with no line only wins when no other has one).
262
606
  """
263
- result: dict[str, dict] = {}
607
+ result: dict[tuple, dict] = {}
264
608
  for entry in entries:
265
609
  if not isinstance(entry, dict):
266
610
  continue
@@ -273,78 +617,224 @@ def _normalize_entries(
273
617
  transform_counts["reexport-resolution"] += 1
274
618
  name = resolved
275
619
 
276
- canon_sig, sig_transforms = canon_signature(entry.get("signature"))
277
- for t in sig_transforms:
278
- transform_counts[t] += 1
279
-
280
- # Normalized record — also the public entry shape emitted in
620
+ # Normalized record: also the public entry shape emitted in
281
621
  # added[]/removed[], so both sides render into one consistent table
282
622
  # regardless of the input shape they came from.
283
- result[name] = {
623
+ record = {
284
624
  "name": name,
285
625
  "type": _first(entry, "type", "export_type"),
286
- "signature": canon_sig,
626
+ "signature": _canon_value(entry.get("signature"), transform_counts),
627
+ "params": _canon_value(_typed_params(entry.get("params"), entry.get("language")), transform_counts),
628
+ "return_type": _canon_value(entry.get("return_type"), transform_counts),
287
629
  "file": _first(entry, "file", "source_file"),
288
630
  "line": _first(entry, "line", "source_line"),
289
631
  "confidence": entry.get("confidence"),
632
+ "extraction_method": entry.get("extraction_method"),
290
633
  }
634
+ # The snapshot's public-surface mark, kept only when it is one, so an
635
+ # unmarked inventory's records are as before.
636
+ if isinstance(entry.get("public"), bool):
637
+ record["public"] = entry["public"]
638
+ key = (name, _file_key(record["file"]))
639
+ kept = result.get(key)
640
+ if kept is not None and not (
641
+ _is_line(record["line"]) and (not _is_line(kept["line"]) or record["line"] < kept["line"])
642
+ ):
643
+ continue
644
+ result[key] = record
291
645
  return result
292
646
 
293
647
 
648
+ def _label_key(field: str, value: object) -> object:
649
+ """Comparison key for a provenance label, or None when the side has no value.
650
+
651
+ Strings compare case-insensitively after trimming; a blank string counts
652
+ as no value. Stack extraction_method spellings map to the library ones.
653
+ """
654
+ if value is None:
655
+ return None
656
+ if isinstance(value, str):
657
+ value = value.strip().casefold()
658
+ if not value:
659
+ return None
660
+ if field == "extraction_method":
661
+ value = _METHOD_ALIASES.get(value, value)
662
+ return value
663
+
664
+
665
+ def _label_side(rec: dict) -> dict:
666
+ """One side's labels as emitted: the value as written, or null when blank."""
667
+ return {
668
+ k: (rec.get(k) if _label_key(k, rec.get(k)) is not None else None)
669
+ for k in LABEL_FIELDS
670
+ }
671
+
672
+
673
+ def _label_change(base_rec: dict, curr_rec: dict) -> dict | None:
674
+ """The label_changes[] item for one matched export, or None when labels agree.
675
+
676
+ A label is compared only when both sides have a value for it.
677
+ """
678
+ for field in LABEL_FIELDS:
679
+ base_key = _label_key(field, base_rec.get(field))
680
+ curr_key = _label_key(field, curr_rec.get(field))
681
+ if base_key is None or curr_key is None:
682
+ continue
683
+ if base_key != curr_key:
684
+ return {
685
+ "name": curr_rec["name"],
686
+ "file": curr_rec["file"],
687
+ "baseline": _label_side(base_rec),
688
+ "current": _label_side(curr_rec),
689
+ }
690
+ return None
691
+
692
+
693
+ def _export_type_collapses(base_rec: dict, curr_rec: dict) -> bool:
694
+ """True when two matched records' types name one kind (EXPORT_TYPE_FORMS):
695
+ one side's by-eye kind against the other's base kind, whose canonical
696
+ signature shows that form. Either side may hold the by-eye kind."""
697
+ for by_eye, base in ((base_rec, curr_rec), (curr_rec, base_rec)):
698
+ kinds = (by_eye.get("type"), base.get("type"))
699
+ signature = base.get("signature")
700
+ if not all(isinstance(kind, str) for kind in kinds) or not isinstance(signature, str):
701
+ continue
702
+ form = EXPORT_TYPE_FORMS.get(kinds)
703
+ if form is not None and form(signature):
704
+ return True
705
+ return False
706
+
707
+
708
+ def _signature_fields(rec: dict) -> list[str]:
709
+ return [field for field in SIGNATURE_FIELDS if rec.get(field) is not None]
710
+
711
+
712
+ def _unverified_signature(base_rec: dict, curr_rec: dict) -> dict | None:
713
+ """The signature_unverified[] item for one matched export, or None when
714
+ every signature part both sides carry sits in a field both sides have."""
715
+ for fields in SIGNATURE_PARTS:
716
+ base_has = {field for field in fields if base_rec.get(field) is not None}
717
+ curr_has = {field for field in fields if curr_rec.get(field) is not None}
718
+ if base_has and curr_has and not (base_has & curr_has):
719
+ return {
720
+ "name": curr_rec["name"],
721
+ "file": curr_rec["file"],
722
+ "baseline": _signature_fields(base_rec),
723
+ "current": _signature_fields(curr_rec),
724
+ }
725
+ return None
726
+
727
+
294
728
  # --------------------------------------------------------------------------
295
729
  # Diff
296
730
  # --------------------------------------------------------------------------
297
731
 
298
732
 
299
- def diff_inventories(
300
- baseline_entries: list[dict],
301
- current_entries: list[dict],
302
- reexport_map: dict[str, str] | None = None,
303
- ) -> dict:
304
- """Compute the structural diff between two export inventories.
733
+ LISTS = ["added", "removed", "changed", "moved", "ambiguous_names", "label_changes", "signature_unverified",
734
+ "not_public"]
305
735
 
306
- Returns a dict with keys: summary, added, removed, changed, moved,
307
- unchanged_count, applied_transforms.
308
- """
309
- reexport_map = reexport_map or {}
310
- transform_counts: collections.Counter = collections.Counter()
311
736
 
312
- baseline = _normalize_entries(baseline_entries, reexport_map, transform_counts)
313
- current = _normalize_entries(current_entries, reexport_map, transform_counts)
737
+ def _key_order(key: tuple) -> tuple:
738
+ return (key[0], key[1] or "")
739
+
314
740
 
315
- baseline_names = set(baseline.keys())
316
- current_names = set(current.keys())
741
+ def _file_line(rec: dict) -> dict:
742
+ return {"file": rec["file"], "line": rec["line"]}
317
743
 
318
- added_names = current_names - baseline_names
319
- removed_names = baseline_names - current_names
320
- common_names = baseline_names & current_names
321
744
 
322
- # Emit normalized entries (uniform name/type/signature/file/line/confidence
323
- # shape) so added[] and removed[] render into the same report table even
324
- # though they originate from the snapshot and provenance-map shapes.
325
- added = [current[n] for n in sorted(added_names)]
326
- removed = [baseline[n] for n in sorted(removed_names)]
745
+ def _diff_records(
746
+ baseline: dict[tuple, dict],
747
+ current: dict[tuple, dict],
748
+ unchanged_names: collections.Counter | None = None,
749
+ transform_counts: collections.Counter | None = None,
750
+ ) -> dict:
751
+ """Diff two (name, file)-keyed record sets: the LISTS, plus the counts
752
+ unchanged_count and changed_exports (exports with a changed field).
753
+
754
+ unchanged_names counts the names of exports left out of both sets as
755
+ unchanged (--files): each counts on both sides when a move needs a name
756
+ unique on both sides. transform_counts gains one export-type for each
757
+ matched export whose types name one kind (_export_type_collapses).
758
+ """
759
+ if transform_counts is None:
760
+ transform_counts = collections.Counter()
761
+ removed_keys = sorted((k for k in baseline if k not in current), key=_key_order)
762
+ added_keys = sorted((k for k in current if k not in baseline), key=_key_order)
763
+
764
+ # A leftover is paired as a move only when its name occurs once on each
765
+ # side; any other name left on both sides is ambiguous.
766
+ base_names = collections.Counter(name for name, _ in baseline)
767
+ curr_names = collections.Counter(name for name, _ in current)
768
+ if unchanged_names:
769
+ base_names.update(unchanged_names)
770
+ curr_names.update(unchanged_names)
771
+ removed_by_name: dict[str, list[tuple]] = collections.defaultdict(list)
772
+ added_by_name: dict[str, list[tuple]] = collections.defaultdict(list)
773
+ for key in removed_keys:
774
+ removed_by_name[key[0]].append(key)
775
+ for key in added_keys:
776
+ added_by_name[key[0]].append(key)
777
+
778
+ pairs: list[tuple[tuple, tuple]] = [(key, key) for key in baseline if key in current]
779
+ ambiguous_names: list[dict] = []
780
+ for name in sorted(set(removed_by_name) & set(added_by_name)):
781
+ if base_names[name] == 1 and curr_names[name] == 1:
782
+ pairs.append((removed_by_name[name][0], added_by_name[name][0]))
783
+ continue
784
+ ambiguous_names.append({
785
+ "name": name,
786
+ "removed": [_file_line(baseline[k]) for k in removed_by_name[name]],
787
+ "added": [_file_line(current[k]) for k in added_by_name[name]],
788
+ })
789
+ paired_base = {base_key for base_key, _ in pairs}
790
+ paired_curr = {curr_key for _, curr_key in pairs}
791
+
792
+ # Emit normalized entries (uniform shape) so added[] and removed[] render
793
+ # into the same report table even though they originate from the
794
+ # snapshot and provenance-map shapes. After the pairing, a leftover
795
+ # current entry marked off the public surface is no addition, unless its
796
+ # name is ambiguous: a reviewer may pair it as a move.
797
+ ambiguous = {item["name"] for item in ambiguous_names}
798
+ added: list[dict] = []
799
+ not_public: list[dict] = []
800
+ for key in added_keys:
801
+ if key in paired_curr:
802
+ continue
803
+ rec = current[key]
804
+ if rec.get("public") is False and rec["name"] not in ambiguous:
805
+ not_public.append(rec)
806
+ else:
807
+ added.append(rec)
808
+ removed = [baseline[k] for k in removed_keys if k not in paired_base]
327
809
 
328
810
  changed: list[dict] = []
329
811
  moved: list[dict] = []
812
+ label_changes: list[dict] = []
813
+ signature_unverified: list[dict] = []
330
814
  unchanged_count = 0
815
+ changed_exports = 0
331
816
 
332
- for name in sorted(common_names):
333
- base_rec = baseline[name]
334
- curr_rec = current[name]
817
+ for base_key, curr_key in sorted(pairs, key=lambda pair: _key_order(pair[1])):
818
+ base_rec = baseline[base_key]
819
+ curr_rec = current[curr_key]
335
820
 
336
- # File moves are tracked separately from field changes.
337
- base_file = base_rec.get("file")
338
- curr_file = curr_rec.get("file")
339
- if base_file and curr_file and base_file != curr_file:
821
+ # File moves are tracked separately from field changes, and a moved
822
+ # export's line is not compared: the move changes it.
823
+ is_move = base_key[1] is not None and curr_key[1] is not None and base_key[1] != curr_key[1]
824
+ if is_move:
340
825
  moved.append({
341
- "name": name,
342
- "previous_file": base_file,
343
- "current_file": curr_file,
826
+ "name": curr_rec["name"],
827
+ "previous_file": base_rec["file"],
828
+ "current_file": curr_rec["file"],
829
+ "previous_line": base_rec["line"],
830
+ "line": curr_rec["line"],
831
+ "confidence": curr_rec["confidence"],
344
832
  })
345
833
 
346
834
  entry_changed = False
347
835
  for field in DIFF_FIELDS:
836
+ if is_move and field == "line":
837
+ continue
348
838
  base_val = base_rec.get(field)
349
839
  curr_val = curr_rec.get(field)
350
840
  # Compare only when data is present on both sides; a missing value
@@ -352,69 +842,203 @@ def diff_inventories(
352
842
  if base_val is None or curr_val is None:
353
843
  continue
354
844
  if base_val != curr_val:
845
+ # A by-eye kind and the runner's base kind of one declaration
846
+ # are one type: compared pairwise, never rewritten.
847
+ if field == "type" and _export_type_collapses(base_rec, curr_rec):
848
+ transform_counts["export-type"] += 1
849
+ continue
355
850
  changed.append({
356
- "name": name,
851
+ "name": curr_rec["name"],
357
852
  "field": field,
358
853
  "baseline_value": base_val,
359
854
  "current_value": curr_val,
855
+ "file": curr_rec["file"],
856
+ "line": curr_rec["line"],
857
+ "confidence": curr_rec["confidence"],
360
858
  })
361
859
  entry_changed = True
362
860
 
363
- if not entry_changed:
861
+ if entry_changed:
862
+ changed_exports += 1
863
+ else:
364
864
  unchanged_count += 1
365
865
 
366
- changed_names = len({c["name"] for c in changed})
866
+ # Provenance labels are informational: reported, never counted as drift.
867
+ label_change = _label_change(base_rec, curr_rec)
868
+ if label_change is not None:
869
+ label_changes.append(label_change)
367
870
 
368
- applied_transforms = [
369
- {"transform": name, "count": transform_counts[name]}
370
- for name in sorted(transform_counts)
371
- if transform_counts[name] > 0
372
- ]
871
+ # A signature part the two sides hold in different fields was never
872
+ # compared: reported, so an empty diff does not read as verified.
873
+ unverified = _unverified_signature(base_rec, curr_rec)
874
+ if unverified is not None:
875
+ signature_unverified.append(unverified)
373
876
 
374
877
  return {
375
- "summary": {
376
- "added": len(added),
377
- "removed": len(removed),
378
- "changed": changed_names,
379
- "moved": len(moved),
380
- "unchanged": unchanged_count,
381
- },
382
878
  "added": added,
383
879
  "removed": removed,
384
880
  "changed": changed,
385
881
  "moved": moved,
882
+ "ambiguous_names": ambiguous_names,
883
+ "label_changes": label_changes,
884
+ "signature_unverified": signature_unverified,
885
+ "not_public": not_public,
386
886
  "unchanged_count": unchanged_count,
387
- "applied_transforms": applied_transforms,
887
+ "changed_exports": changed_exports,
888
+ }
889
+
890
+
891
+ def _summary(part: dict) -> dict:
892
+ return {
893
+ "added": len(part["added"]),
894
+ "removed": len(part["removed"]),
895
+ "changed": part["changed_exports"],
896
+ "moved": len(part["moved"]),
897
+ "unchanged": part["unchanged_count"],
898
+ "label_changes": len(part["label_changes"]),
899
+ "ambiguous_names": len(part["ambiguous_names"]),
900
+ "signature_unverified": len(part["signature_unverified"]),
901
+ "not_public": len(part["not_public"]),
388
902
  }
389
903
 
390
904
 
905
+ def _group_of(entry: object, group_by: str) -> str | None:
906
+ value = entry.get(group_by) if isinstance(entry, dict) else None
907
+ return value.strip() if isinstance(value, str) and value.strip() else None
908
+
909
+
910
+ def _grouped(entries: list, group_by: str) -> bool:
911
+ return any(_group_of(entry, group_by) is not None for entry in entries)
912
+
913
+
914
+ def _in_scope(entry: object, scope: set[str]) -> bool:
915
+ return isinstance(entry, dict) and _file_key(_first(entry, "file", "source_file")) in scope
916
+
917
+
918
+ def diff_inventories(
919
+ baseline_entries: list[dict],
920
+ current_entries: list[dict],
921
+ reexport_map: dict[str, str] | None = None,
922
+ group_by: str | None = None,
923
+ sources: tuple[str, str] = ("the baseline inventory", "the current inventory"),
924
+ files: list[str] | None = None,
925
+ ) -> dict:
926
+ """Compute the structural diff between two export inventories.
927
+
928
+ Returns a dict with keys: summary, added, removed, changed, moved,
929
+ ambiguous_names, label_changes, signature_unverified, not_public,
930
+ unchanged_count, applied_transforms; with group_by, group_by and
931
+ groups; and with files, file_scope (see the module docstring). files
932
+ limits the diff to the exports of those files, and every other export
933
+ is taken as unchanged.
934
+
935
+ Raises ValueError when group_by is set and one side has entries but
936
+ none with a group_by value while the other side has some; the message
937
+ names that side as sources does (the CLI passes the two file paths).
938
+ """
939
+ if group_by:
940
+ sides = (
941
+ (baseline_entries, current_entries, sources[0]),
942
+ (current_entries, baseline_entries, sources[1]),
943
+ )
944
+ for entries, other, source in sides:
945
+ if entries and not _grouped(entries, group_by) and _grouped(other, group_by):
946
+ raise ValueError(f"--group-by {group_by}: {source} has no entry with {group_by}")
947
+
948
+ reexport_map = reexport_map or {}
949
+ transform_counts: collections.Counter = collections.Counter()
950
+ scope = None if files is None else {key for key in map(_file_key, files) if key}
951
+
952
+ base_groups: dict[str | None, list] = collections.defaultdict(list)
953
+ curr_groups: dict[str | None, list] = collections.defaultdict(list)
954
+ for entry in baseline_entries:
955
+ base_groups[_group_of(entry, group_by) if group_by else None].append(entry)
956
+ for entry in current_entries:
957
+ curr_groups[_group_of(entry, group_by) if group_by else None].append(entry)
958
+ values = sorted(set(base_groups) | set(curr_groups), key=lambda v: (v is None, v or ""))
959
+
960
+ total: dict = {name: [] for name in LISTS}
961
+ total.update(unchanged_count=0, changed_exports=0)
962
+ groups: list[dict] = []
963
+ left_out = {"baseline": 0, "current": 0}
964
+ for value in values or [None]:
965
+ base_part = base_groups.get(value, [])
966
+ curr_part = curr_groups.get(value, [])
967
+ unchanged_names: collections.Counter = collections.Counter()
968
+ if scope is not None:
969
+ # Exports outside the scope are normalized apart, so a transform
970
+ # they need is not reported: they are in no list and no count.
971
+ outside = {
972
+ side: _normalize_entries(
973
+ [e for e in entries if not _in_scope(e, scope)], reexport_map, collections.Counter()
974
+ )
975
+ for side, entries in (("baseline", base_part), ("current", curr_part))
976
+ }
977
+ unchanged_names.update(name for name, _ in outside["baseline"])
978
+ for side, records in outside.items():
979
+ left_out[side] += len(records)
980
+ base_part = [e for e in base_part if _in_scope(e, scope)]
981
+ curr_part = [e for e in curr_part if _in_scope(e, scope)]
982
+ part = _diff_records(
983
+ _normalize_entries(base_part, reexport_map, transform_counts),
984
+ _normalize_entries(curr_part, reexport_map, transform_counts),
985
+ unchanged_names,
986
+ transform_counts,
987
+ )
988
+ if group_by:
989
+ for name in LISTS:
990
+ part[name] = [{group_by: value, **item} for item in part[name]]
991
+ groups.append({group_by: value, "summary": _summary(part)})
992
+ for name in LISTS:
993
+ total[name].extend(part[name])
994
+ total["unchanged_count"] += part["unchanged_count"]
995
+ total["changed_exports"] += part["changed_exports"]
996
+
997
+ applied_transforms = [
998
+ {"transform": name, "count": transform_counts[name]}
999
+ for name in sorted(transform_counts)
1000
+ if transform_counts[name] > 0
1001
+ ]
1002
+
1003
+ result = {"summary": _summary(total)}
1004
+ result.update((name, total[name]) for name in LISTS)
1005
+ result["unchanged_count"] = total["unchanged_count"]
1006
+ result["applied_transforms"] = applied_transforms
1007
+ if group_by:
1008
+ result["group_by"] = group_by
1009
+ result["groups"] = groups
1010
+ if scope is not None:
1011
+ result["file_scope"] = {
1012
+ "files": len(scope),
1013
+ "baseline_left_out": left_out["baseline"],
1014
+ "current_left_out": left_out["current"],
1015
+ }
1016
+ return result
1017
+
1018
+
391
1019
  # --------------------------------------------------------------------------
392
1020
  # CLI
393
1021
  # --------------------------------------------------------------------------
394
1022
 
395
1023
 
396
- def main() -> int:
1024
+ # Exit codes: no difference, differences found, error.
1025
+ EXIT_SAME = 0
1026
+ EXIT_DIFFERENT = 1
1027
+ EXIT_ERROR = 2
1028
+
1029
+
1030
+ def _fail(message: str) -> int:
1031
+ print(json.dumps({"status": "error", "error": message}, indent=2))
1032
+ return EXIT_ERROR
1033
+
1034
+
1035
+ def main(argv: list[str] | None = None) -> int:
1036
+ # --help prints the module docstring: the input, matching and output
1037
+ # contract a calling step cites.
397
1038
  parser = argparse.ArgumentParser(
398
1039
  prog="skf-structural-diff.py",
399
- description=(
400
- "Compare two JSON export inventories and produce a structured diff.\n"
401
- "\n"
402
- "Each inventory may be a JSON array of export entries, an object with\n"
403
- "an 'exports' key (extraction-snapshot), or an object with an 'entries'\n"
404
- "key (provenance-map.json). Field names are aliased across the two\n"
405
- "shapes, and signatures are canonicalized (quote style, stdlib module\n"
406
- "prefixes, public re-export names) symmetrically before matching.\n"
407
- "\n"
408
- "Exit code 0 means no differences were found.\n"
409
- "Exit code 1 means differences were found (or an error occurred)."
410
- ),
1040
+ description=__doc__,
411
1041
  formatter_class=argparse.RawDescriptionHelpFormatter,
412
- epilog=(
413
- "examples:\n"
414
- " python3 skf-structural-diff.py provenance-map.json extraction-snapshot.json\n"
415
- " python3 skf-structural-diff.py baseline.json current.json -o diff.json\n"
416
- " python3 skf-structural-diff.py provenance-map.json snapshot.json --reexport-map map.json\n"
417
- ),
418
1042
  )
419
1043
  parser.add_argument(
420
1044
  "baseline",
@@ -435,45 +1059,89 @@ def main() -> int:
435
1059
  "derived from the baseline provenance map itself."
436
1060
  ),
437
1061
  )
1062
+ parser.add_argument(
1063
+ "--group-by",
1064
+ choices=GROUP_FIELDS,
1065
+ help=(
1066
+ "diff each group of entries on its own (source_library: a stack's "
1067
+ "libraries) and tag every listed item with its group; an "
1068
+ "inventory whose entries all lack the field is an error when "
1069
+ "the other's have it"
1070
+ ),
1071
+ )
1072
+ parser.add_argument(
1073
+ "--files",
1074
+ metavar="FILE",
1075
+ help=(
1076
+ "diff only the exports of the files FILE names (a JSON array of "
1077
+ "paths, or a skf-classify-changed-files.py classify output) and "
1078
+ "take every other export as unchanged"
1079
+ ),
1080
+ )
1081
+ parser.add_argument(
1082
+ "--current-extra",
1083
+ metavar="FILE",
1084
+ help=(
1085
+ "an inventory merged into the current one by name and file: it fills "
1086
+ "the fields a current entry lacks, and adds the entries no current "
1087
+ "one has"
1088
+ ),
1089
+ )
438
1090
  parser.add_argument(
439
1091
  "-o",
440
1092
  "--output",
441
1093
  metavar="FILE",
442
- help="write JSON output to FILE instead of stdout",
1094
+ help="write the JSON diff to FILE and print only a summary line on stdout",
443
1095
  )
444
1096
 
445
- args = parser.parse_args()
1097
+ args = parser.parse_args(argv)
446
1098
 
447
1099
  baseline_path = Path(args.baseline)
448
1100
  current_path = Path(args.current)
449
1101
 
450
1102
  baseline_data, err = load_json(baseline_path)
451
1103
  if err:
452
- print(json.dumps({"status": "error", "error": err}, indent=2))
453
- return 1
1104
+ return _fail(err)
454
1105
  current_data, err = load_json(current_path)
455
1106
  if err:
456
- print(json.dumps({"status": "error", "error": err}, indent=2))
457
- return 1
1107
+ return _fail(err)
458
1108
 
459
1109
  baseline_entries, err = entries_from_data(baseline_data, str(baseline_path))
460
1110
  if err:
461
- print(json.dumps({"status": "error", "error": err}, indent=2))
462
- return 1
1111
+ return _fail(err)
463
1112
  current_entries, err = entries_from_data(current_data, str(current_path))
464
1113
  if err:
465
- print(json.dumps({"status": "error", "error": err}, indent=2))
466
- return 1
1114
+ return _fail(err)
1115
+ if args.current_extra:
1116
+ extra_entries, err = load_inventory(Path(args.current_extra))
1117
+ if err:
1118
+ return _fail(err)
1119
+ current_entries = merge_extra(current_entries, extra_entries)
467
1120
 
468
1121
  if args.reexport_map:
469
1122
  reexport_map, err = load_reexport_map(Path(args.reexport_map))
470
1123
  if err:
471
- print(json.dumps({"status": "error", "error": err}, indent=2))
472
- return 1
1124
+ return _fail(err)
473
1125
  else:
474
1126
  reexport_map = extract_reexport_map(baseline_data)
475
1127
 
476
- result = diff_inventories(baseline_entries, current_entries, reexport_map)
1128
+ files = None
1129
+ if args.files:
1130
+ files_data, err = load_json(Path(args.files))
1131
+ if err:
1132
+ return _fail(err)
1133
+ files, err = files_from_data(files_data, args.files)
1134
+ if err:
1135
+ return _fail(err)
1136
+
1137
+ try:
1138
+ result = diff_inventories(
1139
+ baseline_entries, current_entries, reexport_map, args.group_by,
1140
+ sources=(str(baseline_path), str(current_path)),
1141
+ files=files,
1142
+ )
1143
+ except ValueError as exc:
1144
+ return _fail(str(exc))
477
1145
  output_text = json.dumps(result, indent=2)
478
1146
 
479
1147
  if args.output:
@@ -481,16 +1149,14 @@ def main() -> int:
481
1149
  try:
482
1150
  out_path.write_text(output_text + "\n", encoding="utf-8")
483
1151
  except OSError as exc:
484
- print(
485
- json.dumps({"status": "error", "error": f"Cannot write output: {exc}"}, indent=2)
486
- )
487
- return 1
1152
+ return _fail(f"Cannot write output: {exc}")
1153
+ print(json.dumps({"status": "ok", "output": str(out_path), "summary": result["summary"]}))
488
1154
  else:
489
1155
  print(output_text)
490
1156
 
491
1157
  summary = result["summary"]
492
1158
  has_diff = summary["added"] or summary["removed"] or summary["changed"] or summary["moved"]
493
- return 1 if has_diff else 0
1159
+ return EXIT_DIFFERENT if has_diff else EXIT_SAME
494
1160
 
495
1161
 
496
1162
  if __name__ == "__main__":