bmad-module-skill-forge 1.9.0 → 2.0.1

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 (281) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.nvmrc +1 -1
  3. package/README.md +10 -5
  4. package/docs/_data/pinned.yaml +2 -2
  5. package/docs/agents.md +11 -2
  6. package/docs/architecture.md +5 -4
  7. package/docs/bmad-synergy.md +30 -7
  8. package/docs/campaign.md +172 -0
  9. package/docs/examples.md +7 -3
  10. package/docs/forge-auto.md +90 -0
  11. package/docs/getting-started.md +9 -0
  12. package/docs/how-it-works.md +6 -4
  13. package/docs/index.md +4 -3
  14. package/docs/skill-model.md +1 -1
  15. package/docs/troubleshooting.md +17 -1
  16. package/docs/verifying-a-skill.md +2 -2
  17. package/docs/why-skf.md +1 -1
  18. package/docs/workflows.md +64 -24
  19. package/package.json +2 -2
  20. package/src/knowledge/skill-lifecycle.md +1 -1
  21. package/src/module-help.csv +4 -3
  22. package/src/shared/data/language-corpora.json +32 -0
  23. package/src/shared/references/pipeline-contracts.md +6 -3
  24. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
  25. package/src/shared/scripts/skf-comention-pairs.py +350 -0
  26. package/src/shared/scripts/skf-count-tokens.py +222 -0
  27. package/src/shared/scripts/skf-derive-assembly-shape.py +107 -0
  28. package/src/shared/scripts/skf-detect-docs.py +240 -4
  29. package/src/shared/scripts/skf-detect-language.py +82 -3
  30. package/src/shared/scripts/skf-detect-tools.py +105 -2
  31. package/src/shared/scripts/skf-emit-result-envelope.py +17 -0
  32. package/src/shared/scripts/skf-enumerate-stack-skills.py +111 -2
  33. package/src/shared/scripts/skf-extract-public-api.py +53 -0
  34. package/src/shared/scripts/skf-find-cycles.py +246 -0
  35. package/src/shared/scripts/skf-hash-content.py +487 -2
  36. package/src/shared/scripts/skf-language-corpora.py +100 -0
  37. package/src/shared/scripts/skf-manifest-ops.py +67 -1
  38. package/src/shared/scripts/skf-merge-doc-urls.py +244 -0
  39. package/src/shared/scripts/skf-preapply.py +1 -1
  40. package/src/shared/scripts/skf-rebuild-managed-sections.py +180 -0
  41. package/src/shared/scripts/skf-recommend-scope-type.py +1 -2
  42. package/src/shared/scripts/skf-render-metadata-stats.py +499 -0
  43. package/src/shared/scripts/skf-rewrite-skill-name.py +308 -0
  44. package/src/shared/scripts/skf-shape-detect.py +576 -25
  45. package/src/shared/scripts/skf-shard-body.py +461 -0
  46. package/src/shared/scripts/skf-skill-inventory.py +139 -3
  47. package/src/shared/scripts/skf-structural-diff.py +307 -67
  48. package/src/shared/scripts/skf-validate-brief-schema.py +41 -6
  49. package/src/shared/scripts/skf-validate-feasibility-report.py +234 -0
  50. package/src/shared/scripts/skf-validate-frontmatter.py +17 -12
  51. package/src/shared/scripts/skf-validate-output.py +443 -10
  52. package/src/shared/scripts/skf-validate-pins.py +3 -3
  53. package/src/shared/scripts/skf-verify-no-trace.py +214 -0
  54. package/src/shared/scripts/skf-verify-provenance-completeness.py +439 -0
  55. package/src/shared/scripts/skf-write-skill-brief.py +21 -5
  56. package/src/skf-analyze-source/SKILL.md +17 -32
  57. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  58. package/src/skf-analyze-source/customize.toml +2 -0
  59. package/src/skf-analyze-source/references/auto-docs-only.md +116 -0
  60. package/src/skf-analyze-source/references/continue.md +8 -13
  61. package/src/skf-analyze-source/references/generate-briefs.md +28 -12
  62. package/src/skf-analyze-source/references/headless-contract.md +33 -0
  63. package/src/skf-analyze-source/references/health-check.md +1 -2
  64. package/src/skf-analyze-source/references/identify-units.md +25 -36
  65. package/src/skf-analyze-source/references/init.md +12 -25
  66. package/src/skf-analyze-source/references/map-and-detect.md +8 -20
  67. package/src/skf-analyze-source/references/recommend.md +4 -14
  68. package/src/skf-analyze-source/references/scan-project.md +3 -11
  69. package/src/skf-analyze-source/references/step-auto-scope.md +211 -197
  70. package/src/skf-analyze-source/references/step-shape-detect.md +20 -13
  71. package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -2
  72. package/src/skf-audit-skill/SKILL.md +9 -6
  73. package/src/skf-audit-skill/customize.toml +2 -0
  74. package/src/skf-audit-skill/references/init.md +58 -32
  75. package/src/skf-audit-skill/references/re-index.md +15 -34
  76. package/src/skf-audit-skill/references/report.md +11 -58
  77. package/src/skf-audit-skill/references/semantic-diff.md +4 -22
  78. package/src/skf-audit-skill/references/severity-classify.md +53 -87
  79. package/src/skf-audit-skill/references/step-doc-drift.md +49 -19
  80. package/src/skf-audit-skill/references/structural-diff.md +43 -68
  81. package/src/skf-brief-skill/SKILL.md +7 -36
  82. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  83. package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -1
  84. package/src/skf-brief-skill/customize.toml +14 -0
  85. package/src/skf-brief-skill/references/analyze-target.md +13 -18
  86. package/src/skf-brief-skill/references/confirm-brief.md +2 -15
  87. package/src/skf-brief-skill/references/draft-checkpoint.md +8 -8
  88. package/src/skf-brief-skill/references/gather-intent.md +27 -24
  89. package/src/skf-brief-skill/references/headless-args.md +1 -1
  90. package/src/skf-brief-skill/references/health-check.md +1 -22
  91. package/src/skf-brief-skill/references/invocation-contract.md +37 -0
  92. package/src/skf-brief-skill/references/scope-definition.md +6 -15
  93. package/src/skf-brief-skill/references/step-auto-brief.md +22 -9
  94. package/src/skf-brief-skill/references/step-auto-validate.md +3 -7
  95. package/src/skf-brief-skill/references/version-resolution.md +1 -1
  96. package/src/skf-brief-skill/references/write-brief.md +19 -13
  97. package/src/skf-campaign/SKILL.md +121 -0
  98. package/src/skf-campaign/assets/campaign-state-schema.json +191 -0
  99. package/src/skf-campaign/customize.toml +75 -0
  100. package/src/skf-campaign/manifest.yaml +15 -0
  101. package/src/skf-campaign/references/campaign-contracts.md +67 -0
  102. package/src/skf-campaign/references/campaign-directive-spec.md +32 -0
  103. package/src/skf-campaign/references/health-check.md +35 -0
  104. package/src/skf-campaign/references/step-01-setup.md +121 -0
  105. package/src/skf-campaign/references/step-02-strategy.md +102 -0
  106. package/src/skf-campaign/references/step-03-pins.md +56 -0
  107. package/src/skf-campaign/references/step-04-provenance.md +63 -0
  108. package/src/skf-campaign/references/step-05-skill-loop.md +103 -0
  109. package/src/skf-campaign/references/step-06-batch.md +88 -0
  110. package/src/skf-campaign/references/step-07-capstone.md +63 -0
  111. package/src/skf-campaign/references/step-08-verify.md +75 -0
  112. package/src/skf-campaign/references/step-09-refine.md +83 -0
  113. package/src/skf-campaign/references/step-10-export.md +106 -0
  114. package/src/skf-campaign/references/step-11-maintenance.md +84 -0
  115. package/src/skf-campaign/references/step-resume.md +115 -0
  116. package/src/skf-campaign/scripts/.gitkeep +0 -0
  117. package/src/skf-campaign/scripts/campaign-deps.py +244 -0
  118. package/src/skf-campaign/scripts/campaign-parse-manifest.py +119 -0
  119. package/src/skf-campaign/scripts/campaign-provenance.py +247 -0
  120. package/src/skf-campaign/scripts/campaign-render-batch.py +199 -0
  121. package/src/skf-campaign/scripts/campaign-render-kickoff.py +158 -0
  122. package/src/skf-campaign/scripts/campaign-report.py +249 -0
  123. package/src/skf-campaign/scripts/campaign-status.py +162 -0
  124. package/src/skf-campaign/scripts/campaign-validate-pins.py +174 -0
  125. package/src/skf-campaign/scripts/campaign-validate-state.py +207 -0
  126. package/src/skf-campaign/templates/campaign-brief-template.yaml +34 -0
  127. package/src/skf-campaign/templates/campaign-report-template.md +54 -0
  128. package/src/skf-campaign/templates/kickoff-template.md +48 -0
  129. package/src/skf-create-skill/SKILL.md +15 -4
  130. package/src/skf-create-skill/assets/compile-assembly-rules.md +35 -34
  131. package/src/skf-create-skill/assets/skill-sections.md +9 -100
  132. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +4 -13
  133. package/src/skf-create-skill/customize.toml +13 -0
  134. package/src/skf-create-skill/references/authoritative-files-protocol.md +7 -7
  135. package/src/skf-create-skill/references/compile.md +37 -47
  136. package/src/skf-create-skill/references/component-extraction.md +8 -35
  137. package/src/skf-create-skill/references/ecosystem-check.md +9 -25
  138. package/src/skf-create-skill/references/enrich.md +4 -17
  139. package/src/skf-create-skill/references/extract.md +20 -27
  140. package/src/skf-create-skill/references/extraction-patterns-tracing.md +1 -1
  141. package/src/skf-create-skill/references/extraction-patterns.md +10 -8
  142. package/src/skf-create-skill/references/generate-artifacts.md +20 -25
  143. package/src/skf-create-skill/references/health-check.md +2 -2
  144. package/src/skf-create-skill/references/load-brief.md +16 -18
  145. package/src/skf-create-skill/references/report.md +27 -14
  146. package/src/skf-create-skill/references/source-resolution-protocols.md +4 -4
  147. package/src/skf-create-skill/references/step-auto-shard.md +52 -25
  148. package/src/skf-create-skill/references/step-doc-rot.md +33 -15
  149. package/src/skf-create-skill/references/step-doc-sources.md +13 -4
  150. package/src/skf-create-skill/references/sub/ccc-discover.md +2 -13
  151. package/src/skf-create-skill/references/sub/fetch-docs.md +33 -46
  152. package/src/skf-create-skill/references/sub/fetch-temporal.md +5 -20
  153. package/src/skf-create-skill/references/tier-degradation-rules.md +6 -8
  154. package/src/skf-create-skill/references/validate.md +69 -29
  155. package/src/skf-create-skill/scripts/scan-doc-rot.py +245 -0
  156. package/src/skf-create-stack-skill/SKILL.md +15 -9
  157. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +1 -1
  158. package/src/skf-create-stack-skill/customize.toml +10 -0
  159. package/src/skf-create-stack-skill/references/compile-stack.md +7 -8
  160. package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -13
  161. package/src/skf-create-stack-skill/references/detect-integrations.md +49 -54
  162. package/src/skf-create-stack-skill/references/detect-manifests.md +37 -30
  163. package/src/skf-create-stack-skill/references/generate-output.md +24 -75
  164. package/src/skf-create-stack-skill/references/init.md +9 -81
  165. package/src/skf-create-stack-skill/references/manifest-patterns.md +5 -35
  166. package/src/skf-create-stack-skill/references/parallel-extract.md +12 -27
  167. package/src/skf-create-stack-skill/references/rank-and-confirm.md +6 -6
  168. package/src/skf-create-stack-skill/references/report.md +27 -62
  169. package/src/skf-create-stack-skill/references/validate.md +35 -31
  170. package/src/skf-drop-skill/SKILL.md +20 -23
  171. package/src/skf-drop-skill/customize.toml +10 -0
  172. package/src/skf-drop-skill/references/execute.md +67 -36
  173. package/src/skf-drop-skill/references/headless-contract.md +18 -0
  174. package/src/skf-drop-skill/references/health-check.md +1 -13
  175. package/src/skf-drop-skill/references/report.md +44 -10
  176. package/src/skf-drop-skill/references/select.md +93 -48
  177. package/src/skf-drop-skill/scripts/dir-sizes.py +116 -0
  178. package/src/skf-export-skill/SKILL.md +17 -14
  179. package/src/skf-export-skill/assets/managed-section-format.md +2 -5
  180. package/src/skf-export-skill/customize.toml +10 -6
  181. package/src/skf-export-skill/references/generate-snippet.md +21 -35
  182. package/src/skf-export-skill/references/load-skill.md +44 -42
  183. package/src/skf-export-skill/references/multi-skill-mode.md +1 -1
  184. package/src/skf-export-skill/references/orphan-context-detection.md +11 -3
  185. package/src/skf-export-skill/references/package.md +27 -33
  186. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +24 -5
  187. package/src/skf-export-skill/references/result-envelope.md +28 -0
  188. package/src/skf-export-skill/references/summary.md +11 -5
  189. package/src/skf-export-skill/references/token-report.md +42 -24
  190. package/src/skf-export-skill/references/update-context.md +32 -33
  191. package/src/skf-forger/SKILL.md +32 -39
  192. package/src/skf-forger/references/pipeline-mode.md +34 -0
  193. package/src/skf-forger/scripts/parse-pipeline.py +270 -0
  194. package/src/skf-quick-skill/SKILL.md +10 -60
  195. package/src/skf-quick-skill/customize.toml +9 -0
  196. package/src/skf-quick-skill/references/batch-mode.md +6 -4
  197. package/src/skf-quick-skill/references/compile.md +15 -52
  198. package/src/skf-quick-skill/references/ecosystem-check.md +5 -20
  199. package/src/skf-quick-skill/references/finalize.md +12 -6
  200. package/src/skf-quick-skill/references/halt-contract.md +54 -0
  201. package/src/skf-quick-skill/references/health-check.md +3 -4
  202. package/src/skf-quick-skill/references/quick-extract.md +7 -18
  203. package/src/skf-quick-skill/references/registry-resolution.md +1 -23
  204. package/src/skf-quick-skill/references/resolve-target.md +27 -34
  205. package/src/skf-quick-skill/references/write-and-validate.md +10 -25
  206. package/src/skf-refine-architecture/SKILL.md +16 -8
  207. package/src/skf-refine-architecture/customize.toml +9 -0
  208. package/src/skf-refine-architecture/references/compile.md +10 -8
  209. package/src/skf-refine-architecture/references/gap-analysis.md +21 -50
  210. package/src/skf-refine-architecture/references/health-check.md +1 -1
  211. package/src/skf-refine-architecture/references/improvements.md +9 -25
  212. package/src/skf-refine-architecture/references/init.md +9 -16
  213. package/src/skf-refine-architecture/references/issue-detection.md +10 -25
  214. package/src/skf-refine-architecture/references/refinement-rules.md +4 -16
  215. package/src/skf-refine-architecture/references/report.md +14 -11
  216. package/src/skf-rename-skill/SKILL.md +11 -21
  217. package/src/skf-rename-skill/customize.toml +10 -0
  218. package/src/skf-rename-skill/references/execute.md +112 -82
  219. package/src/skf-rename-skill/references/exit-codes.md +12 -0
  220. package/src/skf-rename-skill/references/report.md +16 -8
  221. package/src/skf-rename-skill/references/select.md +69 -42
  222. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -0
  223. package/src/skf-setup/SKILL.md +21 -13
  224. package/src/skf-setup/customize.toml +10 -0
  225. package/src/skf-setup/references/auto-index.md +11 -10
  226. package/src/skf-setup/references/ccc-index.md +14 -15
  227. package/src/skf-setup/references/detect-and-tier.md +10 -8
  228. package/src/skf-setup/references/report.md +26 -32
  229. package/src/skf-setup/references/tier-rules.md +2 -32
  230. package/src/skf-setup/references/write-config.md +9 -10
  231. package/src/skf-test-skill/SKILL.md +10 -8
  232. package/src/skf-test-skill/customize.toml +2 -0
  233. package/src/skf-test-skill/references/coherence-check.md +19 -31
  234. package/src/skf-test-skill/references/coverage-check.md +111 -67
  235. package/src/skf-test-skill/references/detect-mode.md +10 -41
  236. package/src/skf-test-skill/references/external-validators.md +11 -19
  237. package/src/skf-test-skill/references/init.md +39 -21
  238. package/src/skf-test-skill/references/migration-section-rules.md +4 -4
  239. package/src/skf-test-skill/references/report.md +60 -40
  240. package/src/skf-test-skill/references/score.md +32 -64
  241. package/src/skf-test-skill/references/scoring-rules.md +6 -62
  242. package/src/skf-test-skill/references/source-access-protocol.md +15 -5
  243. package/src/skf-test-skill/references/step-hard-gate.md +1 -3
  244. package/src/skf-test-skill/scripts/aggregate-coherence.py +276 -0
  245. package/src/skf-test-skill/scripts/check-metadata-coherence.py +359 -0
  246. package/src/skf-test-skill/scripts/combine-external-scores.py +190 -0
  247. package/src/skf-test-skill/scripts/compute-score.py +135 -4
  248. package/src/skf-test-skill/scripts/reconcile-coverage.py +327 -0
  249. package/src/skf-test-skill/scripts/validate-inventory.py +251 -0
  250. package/src/skf-test-skill/scripts/verify-declared-numerator.py +192 -0
  251. package/src/skf-update-skill/SKILL.md +17 -5
  252. package/src/skf-update-skill/customize.toml +11 -6
  253. package/src/skf-update-skill/references/detect-changes.md +48 -36
  254. package/src/skf-update-skill/references/health-check.md +5 -11
  255. package/src/skf-update-skill/references/init.md +40 -32
  256. package/src/skf-update-skill/references/manual-section-rules.md +1 -2
  257. package/src/skf-update-skill/references/merge-conflict-rules.md +1 -52
  258. package/src/skf-update-skill/references/merge.md +13 -57
  259. package/src/skf-update-skill/references/re-extract.md +27 -25
  260. package/src/skf-update-skill/references/report.md +14 -7
  261. package/src/skf-update-skill/references/validate.md +32 -30
  262. package/src/skf-update-skill/references/write.md +86 -51
  263. package/src/skf-update-skill/scripts/skf-new-file-diff.py +167 -0
  264. package/src/skf-verify-stack/SKILL.md +11 -21
  265. package/src/skf-verify-stack/customize.toml +12 -3
  266. package/src/skf-verify-stack/references/coverage-patterns.md +1 -29
  267. package/src/skf-verify-stack/references/coverage.md +21 -10
  268. package/src/skf-verify-stack/references/exit-codes.md +14 -0
  269. package/src/skf-verify-stack/references/health-check.md +1 -1
  270. package/src/skf-verify-stack/references/init.md +20 -25
  271. package/src/skf-verify-stack/references/integration-verification-rules.md +10 -19
  272. package/src/skf-verify-stack/references/integrations.md +44 -49
  273. package/src/skf-verify-stack/references/report.md +28 -34
  274. package/src/skf-verify-stack/references/requirements.md +3 -3
  275. package/src/skf-verify-stack/references/synthesize.md +36 -39
  276. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +196 -0
  277. package/src/skf-verify-stack/scripts/skf-report-delta.py +256 -0
  278. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +258 -0
  279. package/tools/cli/lib/ui.js +3 -2
  280. package/docs/deepwiki.md +0 -89
  281. package/src/skf-rename-skill/references/rebuild-context.md +0 -110
@@ -6,36 +6,76 @@
6
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
- produces a structured JSON diff showing added, removed, and changed entries.
10
- Used by audit-skill and update-skill to replace LLM-based inventory comparison.
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.
11
13
 
12
14
  CLI:
13
15
  python3 skf-structural-diff.py baseline.json current.json
14
16
  python3 skf-structural-diff.py baseline.json current.json -o diff-result.json
17
+ python3 skf-structural-diff.py provenance-map.json snapshot.json --reexport-map map.json
15
18
 
16
19
  Input:
17
- Two JSON files. Each file must be either:
18
- - An object with an "exports" array (provenance-map / extraction-snapshot format)
20
+ Two JSON files. Each file may be either:
21
+ - An object with an "exports" array (extraction-snapshot format)
22
+ - An object with an "entries" array (provenance-map.json format)
19
23
  - A plain array of export entries
20
-
21
- Each export entry must have at minimum:
22
- - name: string (primary key for matching)
23
- - type: string (function/class/type/const/interface/etc.)
24
-
25
- Optional fields used for change detection:
26
- - signature: string
27
- - file: string
28
- - line: number
29
- - confidence: string
24
+ - A name-keyed object of entry objects
25
+
26
+ Field-name aliasing (provenance-map entries[] vs extraction-snapshot
27
+ exports[]) is handled transparently, so the two sides may use different
28
+ shapes:
29
+ - name <- name | export_name
30
+ - type <- type | export_type
31
+ - file <- file | source_file
32
+ - line <- line | source_line
33
+ - signature <- signature
34
+ - confidence <- confidence
35
+
36
+ "name" is the primary match key. Nameless entries are skipped.
37
+
38
+ Canonicalization (applied symmetrically to BOTH sides before matching):
39
+ The baseline extractor (skf-create-skill) and the re-extractor (audit step 2)
40
+ can differ in cosmetic detail that would otherwise surface as false-positive
41
+ "Changed"/"Removed"/"Added" entries. These deterministic transforms collapse
42
+ the cosmetic differences:
43
+ - quote-style : normalize string-literal quotes in signatures
44
+ (double -> single), so `= "Hnsw"` == `= 'Hnsw'`.
45
+ - stdlib-prefix : strip module prefixes on well-known stdlib helpers
46
+ (typing., dataclasses., collections[.abc]., enum.),
47
+ so `typing.Optional[int]` == `Optional[int]`.
48
+ User-defined namespaces (e.g. `pkg.typing.X`) are
49
+ NOT collapsed.
50
+ - reexport-resolution : rewrite internal symbol names to their public
51
+ re-export name via the reexport map, so a renamed
52
+ public re-export (`_Impl` -> `Public`) matches the
53
+ baseline entry instead of showing up as
54
+ Removed `_Impl` + Added `Public`.
55
+
56
+ The reexport map is taken from --reexport-map when provided; otherwise it is
57
+ derived from the baseline provenance map itself (top-level `reexport_map`
58
+ plus any per-entry `reexported_as`) — the same projection produced by
59
+ `skf-load-provenance.py normalize`.
60
+
61
+ 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.
30
65
 
31
66
  Output:
32
67
  JSON object:
33
- summary: { added, removed, changed, moved, unchanged }
34
- added: list of entries present in current but not baseline
35
- removed: list of entries present in baseline but not current
36
- changed: list of { name, field, baseline_value, current_value }
37
- moved: list of { name, previous_file, current_file }
38
- unchanged_count: number of entries that matched exactly
68
+ summary: { added, removed, changed, moved, unchanged }
69
+ added: list of entries present in current but not baseline
70
+ 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 }
73
+ 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
77
+ step 6's Provenance section so a reviewer can tell
78
+ which differences the diff collapsed.
39
79
 
40
80
  Exit codes:
41
81
  0 — inventories are identical (no diff)
@@ -45,7 +85,9 @@ Exit codes:
45
85
  from __future__ import annotations
46
86
 
47
87
  import argparse
88
+ import collections
48
89
  import json
90
+ import re
49
91
  import sys
50
92
  from pathlib import Path
51
93
 
@@ -55,68 +97,220 @@ from pathlib import Path
55
97
  # "file" is excluded — file moves are tracked separately in the "moved" list.
56
98
  DIFF_FIELDS = ["type", "signature", "line", "confidence"]
57
99
 
100
+ # Well-known stdlib module prefixes whose unqualified form is importable at
101
+ # the call site. Longer prefixes must precede their own containing prefix
102
+ # (collections.abc before collections) so the alternation strips the longest.
103
+ # The negative lookbehind `(?<![\w.])` ensures we only strip a top-level module
104
+ # reference — never a user-defined namespace such as `pkg.typing.Foo`.
105
+ _STDLIB_PREFIX_RE = re.compile(
106
+ r"(?<![\w.])(?:typing|dataclasses|collections\.abc|collections|enum)\."
107
+ )
58
108
 
59
- def load_inventory(path: Path) -> tuple[list[dict], str | None]:
60
- """Load an export inventory from a JSON file.
61
109
 
62
- Accepts either:
63
- - {"exports": [...]} (provenance-map / extraction-snapshot)
64
- - [...] (plain array)
110
+ # --------------------------------------------------------------------------
111
+ # Loading
112
+ # --------------------------------------------------------------------------
65
113
 
66
- Returns (exports, error_message).
67
- """
114
+
115
+ def load_json(path: Path) -> tuple[object, str | None]:
116
+ """Read and parse a JSON file. Returns (data, error_message)."""
68
117
  try:
69
118
  text = path.read_text(encoding="utf-8")
70
119
  except OSError as exc:
71
- return [], f"Cannot read file '{path}': {exc}"
72
-
120
+ return None, f"Cannot read file '{path}': {exc}"
73
121
  try:
74
- data = json.loads(text)
122
+ return json.loads(text), None
75
123
  except json.JSONDecodeError as exc:
76
- return [], f"Invalid JSON in '{path}': {exc}"
124
+ return None, f"Invalid JSON in '{path}': {exc}"
77
125
 
126
+
127
+ def entries_from_data(data: object, source: str = "") -> tuple[list[dict], str | None]:
128
+ """Extract the export-entry list from an already-parsed inventory.
129
+
130
+ Accepts:
131
+ - [...] (plain array)
132
+ - {"exports": [...]} (extraction-snapshot format)
133
+ - {"entries": [...]} (provenance-map.json format)
134
+ - {name: {...}, ...} (name-keyed object of entry objects)
135
+
136
+ Returns (entries, error_message).
137
+ """
138
+ where = f" in '{source}'" if source else ""
78
139
  if isinstance(data, list):
79
140
  return data, None
80
141
  if isinstance(data, dict):
81
- if "exports" in data and isinstance(data["exports"], list):
142
+ if isinstance(data.get("exports"), list):
82
143
  return data["exports"], None
83
- # If it's a plain object without "exports", treat values as entries
84
- # (some inventory formats are name-keyed objects)
85
- if all(isinstance(v, dict) for v in data.values()):
86
- return list(data.values()), None
87
- return [], f"Unrecognised inventory format in '{path}': expected array or object with 'exports' key"
144
+ if isinstance(data.get("entries"), list):
145
+ return data["entries"], None
146
+ # A plain name-keyed object of entries (some inventory formats).
147
+ values = list(data.values())
148
+ if values and all(isinstance(v, dict) for v in values):
149
+ return values, None
150
+ return [], (
151
+ f"Unrecognised inventory format{where}: expected array or object "
152
+ f"with an 'exports' or 'entries' key"
153
+ )
154
+ return [], f"Unrecognised inventory format{where}: top-level value must be array or object"
155
+
88
156
 
89
- return [], f"Unrecognised inventory format in '{path}': top-level value must be array or object"
157
+ def load_inventory(path: Path) -> tuple[list[dict], str | None]:
158
+ """Load an export inventory from a JSON file (thin wrapper, back-compat).
159
+
160
+ Returns (exports, error_message).
161
+ """
162
+ data, err = load_json(path)
163
+ if err:
164
+ return [], err
165
+ return entries_from_data(data, str(path))
166
+
167
+
168
+ # --------------------------------------------------------------------------
169
+ # Re-export map
170
+ # --------------------------------------------------------------------------
171
+
172
+
173
+ def extract_reexport_map(data: object) -> dict[str, str]:
174
+ """Derive the {internal -> public} re-export map from a provenance map.
175
+
176
+ Mirrors skf-load-provenance.extract_reexport_map:
177
+ 1. top-level `reexport_map` object, plus
178
+ 2. per-entry `reexported_as` on `entries[]` items
179
+ ({"export_name": "_Impl", "reexported_as": "Public"}).
180
+ Non-string keys/values are skipped. Returns {} for non-dict input.
181
+ """
182
+ out: dict[str, str] = {}
183
+ if not isinstance(data, dict):
184
+ return out
185
+ top = data.get("reexport_map")
186
+ if isinstance(top, dict):
187
+ for k, v in top.items():
188
+ if isinstance(k, str) and isinstance(v, str):
189
+ out[k] = v
190
+ entries = data.get("entries")
191
+ if isinstance(entries, list):
192
+ for entry in entries:
193
+ if not isinstance(entry, dict):
194
+ continue
195
+ internal = entry.get("export_name")
196
+ public = entry.get("reexported_as")
197
+ if isinstance(internal, str) and isinstance(public, str):
198
+ out.setdefault(internal, public)
199
+ return out
200
+
201
+
202
+ def load_reexport_map(path: Path) -> tuple[dict[str, str], str | None]:
203
+ """Load an explicit re-export map file.
204
+
205
+ Accepts either a plain {internal: public} object or the full
206
+ `skf-load-provenance.py normalize` output (which nests it under the
207
+ `reexport_map` key). Non-string pairs are skipped.
208
+ """
209
+ data, err = load_json(path)
210
+ if err:
211
+ return {}, err
212
+ if not isinstance(data, dict):
213
+ return {}, f"reexport map '{path}' must be a JSON object"
214
+ src = data["reexport_map"] if isinstance(data.get("reexport_map"), dict) else data
215
+ return {k: v for k, v in src.items() if isinstance(k, str) and isinstance(v, str)}, None
90
216
 
91
217
 
92
- def index_by_name(entries: list[dict]) -> dict[str, dict]:
93
- """Build a name-keyed dict from an export list, skipping nameless entries."""
218
+ # --------------------------------------------------------------------------
219
+ # Canonicalization
220
+ # --------------------------------------------------------------------------
221
+
222
+
223
+ def _first(entry: dict, *keys: str) -> object:
224
+ """First non-null value among the given keys (field-name aliasing)."""
225
+ for k in keys:
226
+ v = entry.get(k)
227
+ if v is not None:
228
+ return v
229
+ return None
230
+
231
+
232
+ def canon_signature(sig: object) -> tuple[object, set[str]]:
233
+ """Canonicalize a signature string. Returns (canonical, transforms_fired).
234
+
235
+ Non-string input passes through unchanged (empty transform set).
236
+ """
237
+ if not isinstance(sig, str):
238
+ return sig, set()
239
+ applied: set[str] = set()
240
+ out = sig
241
+ if '"' in out:
242
+ out = out.replace('"', "'")
243
+ applied.add("quote-style")
244
+ stripped = _STDLIB_PREFIX_RE.sub("", out)
245
+ if stripped != out:
246
+ applied.add("stdlib-prefix")
247
+ out = stripped
248
+ return out, applied
249
+
250
+
251
+ def _normalize_entries(
252
+ entries: list[dict],
253
+ reexport_map: dict[str, str],
254
+ transform_counts: collections.Counter,
255
+ ) -> dict[str, dict]:
256
+ """Build a name-keyed dict of canonicalized records.
257
+
258
+ Applies field-name aliasing, signature canonicalization, and re-export
259
+ 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).
262
+ """
94
263
  result: dict[str, dict] = {}
95
264
  for entry in entries:
96
- name = entry.get("name", "").strip()
97
- if name:
98
- result[name] = entry
265
+ if not isinstance(entry, dict):
266
+ continue
267
+ raw_name = _first(entry, "name", "export_name")
268
+ if not isinstance(raw_name, str) or not raw_name.strip():
269
+ continue
270
+ name = raw_name.strip()
271
+ resolved = reexport_map.get(name, name)
272
+ if resolved != name:
273
+ transform_counts["reexport-resolution"] += 1
274
+ name = resolved
275
+
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
281
+ # added[]/removed[], so both sides render into one consistent table
282
+ # regardless of the input shape they came from.
283
+ result[name] = {
284
+ "name": name,
285
+ "type": _first(entry, "type", "export_type"),
286
+ "signature": canon_sig,
287
+ "file": _first(entry, "file", "source_file"),
288
+ "line": _first(entry, "line", "source_line"),
289
+ "confidence": entry.get("confidence"),
290
+ }
99
291
  return result
100
292
 
101
293
 
102
- def entry_matches(base: dict, curr: dict) -> bool:
103
- """Return True if two entries are identical across all DIFF_FIELDS."""
104
- for field in DIFF_FIELDS:
105
- if base.get(field) != curr.get(field):
106
- return False
107
- return True
294
+ # --------------------------------------------------------------------------
295
+ # Diff
296
+ # --------------------------------------------------------------------------
108
297
 
109
298
 
110
299
  def diff_inventories(
111
300
  baseline_entries: list[dict],
112
301
  current_entries: list[dict],
302
+ reexport_map: dict[str, str] | None = None,
113
303
  ) -> dict:
114
304
  """Compute the structural diff between two export inventories.
115
305
 
116
- Returns a dict with keys: summary, added, removed, changed, unchanged_count.
306
+ Returns a dict with keys: summary, added, removed, changed, moved,
307
+ unchanged_count, applied_transforms.
117
308
  """
118
- baseline = index_by_name(baseline_entries)
119
- current = index_by_name(current_entries)
309
+ reexport_map = reexport_map or {}
310
+ transform_counts: collections.Counter = collections.Counter()
311
+
312
+ baseline = _normalize_entries(baseline_entries, reexport_map, transform_counts)
313
+ current = _normalize_entries(current_entries, reexport_map, transform_counts)
120
314
 
121
315
  baseline_names = set(baseline.keys())
122
316
  current_names = set(current.keys())
@@ -125,6 +319,9 @@ def diff_inventories(
125
319
  removed_names = baseline_names - current_names
126
320
  common_names = baseline_names & current_names
127
321
 
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.
128
325
  added = [current[n] for n in sorted(added_names)]
129
326
  removed = [baseline[n] for n in sorted(removed_names)]
130
327
 
@@ -133,12 +330,12 @@ def diff_inventories(
133
330
  unchanged_count = 0
134
331
 
135
332
  for name in sorted(common_names):
136
- base_entry = baseline[name]
137
- curr_entry = current[name]
333
+ base_rec = baseline[name]
334
+ curr_rec = current[name]
138
335
 
139
- # Detect file moves separately
140
- base_file = base_entry.get("file")
141
- curr_file = curr_entry.get("file")
336
+ # File moves are tracked separately from field changes.
337
+ base_file = base_rec.get("file")
338
+ curr_file = curr_rec.get("file")
142
339
  if base_file and curr_file and base_file != curr_file:
143
340
  moved.append({
144
341
  "name": name,
@@ -148,8 +345,12 @@ def diff_inventories(
148
345
 
149
346
  entry_changed = False
150
347
  for field in DIFF_FIELDS:
151
- base_val = base_entry.get(field)
152
- curr_val = curr_entry.get(field)
348
+ base_val = base_rec.get(field)
349
+ curr_val = curr_rec.get(field)
350
+ # Compare only when data is present on both sides; a missing value
351
+ # on either side is insufficient evidence to assert a change.
352
+ if base_val is None or curr_val is None:
353
+ continue
153
354
  if base_val != curr_val:
154
355
  changed.append({
155
356
  "name": name,
@@ -162,9 +363,14 @@ def diff_inventories(
162
363
  if not entry_changed:
163
364
  unchanged_count += 1
164
365
 
165
- # Count distinct names that have at least one field change
166
366
  changed_names = len({c["name"] for c in changed})
167
367
 
368
+ applied_transforms = [
369
+ {"transform": name, "count": transform_counts[name]}
370
+ for name in sorted(transform_counts)
371
+ if transform_counts[name] > 0
372
+ ]
373
+
168
374
  return {
169
375
  "summary": {
170
376
  "added": len(added),
@@ -178,18 +384,26 @@ def diff_inventories(
178
384
  "changed": changed,
179
385
  "moved": moved,
180
386
  "unchanged_count": unchanged_count,
387
+ "applied_transforms": applied_transforms,
181
388
  }
182
389
 
183
390
 
391
+ # --------------------------------------------------------------------------
392
+ # CLI
393
+ # --------------------------------------------------------------------------
394
+
395
+
184
396
  def main() -> int:
185
397
  parser = argparse.ArgumentParser(
186
398
  prog="skf-structural-diff.py",
187
399
  description=(
188
400
  "Compare two JSON export inventories and produce a structured diff.\n"
189
401
  "\n"
190
- "Each inventory file must be either a JSON array of export entries\n"
191
- "or a JSON object with an 'exports' key containing such an array.\n"
192
- "Every entry must have at minimum 'name' and 'type' fields.\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"
193
407
  "\n"
194
408
  "Exit code 0 means no differences were found.\n"
195
409
  "Exit code 1 means differences were found (or an error occurred)."
@@ -197,8 +411,9 @@ def main() -> int:
197
411
  formatter_class=argparse.RawDescriptionHelpFormatter,
198
412
  epilog=(
199
413
  "examples:\n"
200
- " python3 skf-structural-diff.py provenance-map.json current-snapshot.json\n"
414
+ " python3 skf-structural-diff.py provenance-map.json extraction-snapshot.json\n"
201
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"
202
417
  ),
203
418
  )
204
419
  parser.add_argument(
@@ -211,6 +426,15 @@ def main() -> int:
211
426
  metavar="current.json",
212
427
  help="path to the current export inventory (e.g. extraction-snapshot.json)",
213
428
  )
429
+ parser.add_argument(
430
+ "--reexport-map",
431
+ metavar="FILE",
432
+ help=(
433
+ "path to a JSON re-export map ({internal: public}) or a "
434
+ "skf-load-provenance normalize output. When omitted, the map is "
435
+ "derived from the baseline provenance map itself."
436
+ ),
437
+ )
214
438
  parser.add_argument(
215
439
  "-o",
216
440
  "--output",
@@ -223,17 +447,33 @@ def main() -> int:
223
447
  baseline_path = Path(args.baseline)
224
448
  current_path = Path(args.current)
225
449
 
226
- baseline_entries, err = load_inventory(baseline_path)
450
+ baseline_data, err = load_json(baseline_path)
451
+ if err:
452
+ print(json.dumps({"status": "error", "error": err}, indent=2))
453
+ return 1
454
+ current_data, err = load_json(current_path)
227
455
  if err:
228
456
  print(json.dumps({"status": "error", "error": err}, indent=2))
229
457
  return 1
230
458
 
231
- current_entries, err = load_inventory(current_path)
459
+ baseline_entries, err = entries_from_data(baseline_data, str(baseline_path))
232
460
  if err:
233
461
  print(json.dumps({"status": "error", "error": err}, indent=2))
234
462
  return 1
463
+ current_entries, err = entries_from_data(current_data, str(current_path))
464
+ if err:
465
+ print(json.dumps({"status": "error", "error": err}, indent=2))
466
+ return 1
467
+
468
+ if args.reexport_map:
469
+ reexport_map, err = load_reexport_map(Path(args.reexport_map))
470
+ if err:
471
+ print(json.dumps({"status": "error", "error": err}, indent=2))
472
+ return 1
473
+ else:
474
+ reexport_map = extract_reexport_map(baseline_data)
235
475
 
236
- result = diff_inventories(baseline_entries, current_entries)
476
+ result = diff_inventories(baseline_entries, current_entries, reexport_map)
237
477
  output_text = json.dumps(result, indent=2)
238
478
 
239
479
  if args.output:
@@ -5,8 +5,8 @@
5
5
  """SKF Validate Brief Schema — schema check for a skill-brief.yaml on disk.
6
6
 
7
7
  Loads `skill-brief.yaml` from a path (or from stdin/inline-YAML), validates
8
- it against `src/shared/scripts/schemas/skill-brief.v1.json`, and applies the
9
- two conditional rules from `skf-create-skill/references/load-brief.md §3`
8
+ it against `schemas/skill-brief.v1.json` (sibling of this script), and applies the
9
+ conditional rules from `skf-create-skill/references/load-brief.md §3`
10
10
  that the JSON schema doesn't express today:
11
11
 
12
12
  - When `source_type == "docs-only"`: `doc_urls` must have ≥1 entry, and
@@ -18,6 +18,11 @@ that the JSON schema doesn't express today:
18
18
  only contains whitespace would slip past a naive presence check; we
19
19
  catch that case explicitly so the downstream directory-resolution code
20
20
  doesn't produce `{name}//` paths.
21
+ - `scope.include` must contain ≥1 glob pattern for every scope type except
22
+ `docs-only`. The schema requires the `scope.include` key but types it as
23
+ an array without a `minItems` floor; this rule enforces the non-empty
24
+ constraint conditional on `scope.type`, matching brief-skill's
25
+ consumption-time validation.
21
26
 
22
27
  The script produces skill-friendly error messages — translating raw
23
28
  jsonschema diagnostics into the "Brief validation failed: ..." form the
@@ -56,10 +61,7 @@ import yaml
56
61
  from jsonschema import Draft202012Validator
57
62
 
58
63
 
59
- REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
60
- SCHEMA_PATH = (
61
- REPO_ROOT / "src" / "shared" / "scripts" / "schemas" / "skill-brief.v1.json"
62
- )
64
+ SCHEMA_PATH = Path(__file__).resolve().parent / "schemas" / "skill-brief.v1.json"
63
65
 
64
66
 
65
67
  # --------------------------------------------------------------------------
@@ -265,6 +267,38 @@ def _version_non_empty_rule(brief: dict) -> list[dict]:
265
267
  return []
266
268
 
267
269
 
270
+ def _scope_include_non_empty_rule(brief: dict) -> list[dict]:
271
+ """`scope.include` must carry ≥1 glob pattern, except for docs-only scope.
272
+
273
+ The JSON schema requires the `scope.include` key and types it as an array of
274
+ strings, but cannot express the non-empty constraint conditional on
275
+ `scope.type`. A `docs-only` skill has no source globs to include, so it is
276
+ exempt; every other scope type must name at least one pattern or the
277
+ downstream extraction has nothing to walk. Surfacing it here keeps the
278
+ deterministic gate consistent with brief-skill's consumption-time
279
+ validation instead of paying the model to re-check structure each run.
280
+ """
281
+ scope = brief.get("scope")
282
+ if not isinstance(scope, dict):
283
+ # missing/malformed scope is the schema's `required`/`type` job
284
+ return []
285
+ if scope.get("type") == "docs-only":
286
+ return []
287
+ include = scope.get("include")
288
+ if not (isinstance(include, list) and len(include) >= 1):
289
+ return [
290
+ {
291
+ "field": "scope.include",
292
+ "message": (
293
+ "Brief validation failed: `scope.include` must contain at "
294
+ "least one glob pattern (docs-only scope excepted). "
295
+ "Update your skill-brief.yaml and re-run."
296
+ ),
297
+ }
298
+ ]
299
+ return []
300
+
301
+
268
302
  # --------------------------------------------------------------------------
269
303
  # Main validate
270
304
  # --------------------------------------------------------------------------
@@ -287,6 +321,7 @@ def validate_brief(brief: dict) -> dict:
287
321
 
288
322
  errors.extend(_version_non_empty_rule(brief))
289
323
  errors.extend(_target_version_matches_version_rule(brief))
324
+ errors.extend(_scope_include_non_empty_rule(brief))
290
325
 
291
326
  return {
292
327
  "valid": not errors,