bmad-module-skill-forge 2.2.0 → 3.0.0

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