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
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # ///
5
+ """Deterministic metadata export-count coherence cross-check.
6
+
7
+ Count-drift helper for the SKF test-skill workflow (coverage-check.md §4b).
8
+ Distinct from reconcile-coverage.py (which owns the coverage *numerator*
9
+ intersection) and compute-score.py (weights + INCONCLUSIVE floor): this script
10
+ performs the §4b *count-drift* arithmetic — binning the collected export counts
11
+ into the two canonical clusters and computing the intra-cluster / cross-cluster
12
+ percentage divergences — so the "13% drift → emit / 4% → skip" decision no
13
+ longer swings between runs on an in-prompt eyeball.
14
+
15
+ It reproduces exactly the §4b operationalized checks:
16
+
17
+ * Cluster A — public-barrel surface (`__init__.py` / `index.ts` / `lib.rs`
18
+ re-exports): `stats.exports_public_api`, `exports[].length`.
19
+
20
+ * Cluster B — documented surface (extracted + documented, incl. methods and
21
+ submodule members): `stats.exports_documented`, the provenance-map
22
+ **named-export count** (entries whose `export_name` contains `::` are
23
+ excluded — impl-block methods roll up under an already-counted type), and
24
+ the `confidence_distribution` sum (`t1 + t1_low + t2 + t3`).
25
+
26
+ * Intra-cluster divergence (Medium): within a cluster with >=2 present counts,
27
+ if the largest and smallest disagree by more than the drift threshold
28
+ (default 10%) of the larger, emit `metadata drift — {barrel|documented-surface}
29
+ export counts diverge`.
30
+
31
+ * Cross-cluster divergence (Info): if both clusters resolved to a
32
+ representative count (the higher of each cluster's present counts) and they
33
+ differ by more than the threshold, emit `multi-denominator reporting —
34
+ barrel vs documented surface` (expected for skills whose documented surface
35
+ intentionally exceeds the barrel — not drift, just made auditable).
36
+
37
+ Stack skills (`skill_type == "stack"`) and reference apps
38
+ (`scope_type == "reference-app"`) are skipped: their three counts measure
39
+ intentionally *different* surfaces, so comparing them yields only false drift.
40
+
41
+ CLI usage:
42
+ uv run check-metadata-coherence.py '<JSON>' # positional
43
+ uv run check-metadata-coherence.py --json-input '<JSON>' # explicit flag
44
+ cat input.json | uv run check-metadata-coherence.py --stdin # piped
45
+
46
+ Input schema (one object; omit any count that is absent for the skill):
47
+ {
48
+ "skillType": "stack" | ... | null, # metadata.json.skill_type
49
+ "scopeType": "reference-app" | ... | null, # metadata.json.scope_type
50
+ "clusterA": {
51
+ "exports_public_api": <int|null>,
52
+ "exports_length": <int|null>
53
+ },
54
+ "clusterB": {
55
+ "exports_documented": <int|null>
56
+ },
57
+ "provenanceExportNames": ["Type::method", "foo", ...] | null, # raw entry names
58
+ "confidenceDistribution": {"t1": N, "t1_low": N, "t2": N, "t3": N} | null,
59
+ "driftThresholdPct": 10 # optional, default 10
60
+ }
61
+
62
+ Output (stdout, one object):
63
+ {
64
+ "skipped": <bool>,
65
+ "skipReason": <str|null>,
66
+ "driftThresholdPct": <int>,
67
+ "clusterACounts": {label: count, ...}, # present counts only
68
+ "clusterBCounts": {label: count, ...}, # present counts only, incl. derived
69
+ "findings": [
70
+ {"severity": "Medium|Info", "title": "...", "detail": "...",
71
+ "category": "structural/metadata coherence", "driftPct": <int>}
72
+ ]
73
+ }
74
+
75
+ Exit codes:
76
+ 0 — cross-check emitted successfully (findings may be empty)
77
+ 1 — no input / input could not be parsed as JSON
78
+ 2 — input parsed but schema/semantics invalid (error object emitted as JSON)
79
+ """
80
+
81
+ from __future__ import annotations
82
+
83
+ import argparse
84
+ import json
85
+ import sys
86
+
87
+ DEFAULT_DRIFT_PCT = 10
88
+ CATEGORY = "structural/metadata coherence"
89
+
90
+ # Stable source labels used in finding bodies (match coverage-check.md §4b examples).
91
+ LABEL_EXPORTS_PUBLIC_API = "stats.exports_public_api"
92
+ LABEL_EXPORTS_LENGTH = "exports[].length"
93
+ LABEL_EXPORTS_DOCUMENTED = "stats.exports_documented"
94
+ LABEL_PROVENANCE_NAMED = "provenance named-exports"
95
+ LABEL_CONFIDENCE_SUM = "confidence_distribution sum"
96
+
97
+
98
+ def make_error(message):
99
+ return {"error": message, "code": "INVALID_INPUT"}
100
+
101
+
102
+ def _as_count(value, label, errors):
103
+ """Return value if it is a non-negative int (not bool), else record an error."""
104
+ if value is None:
105
+ return None
106
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
107
+ errors.append(f"{label} must be a non-negative integer (got {value!r})")
108
+ return None
109
+ return value
110
+
111
+
112
+ def _provenance_named_count(names, errors):
113
+ """Count provenance entry names, excluding `::` impl-block methods."""
114
+ if names is None:
115
+ return None
116
+ if not isinstance(names, list):
117
+ errors.append("provenanceExportNames must be a list of strings")
118
+ return None
119
+ return sum(1 for n in names if isinstance(n, str) and n and "::" not in n)
120
+
121
+
122
+ def _confidence_sum(dist, errors):
123
+ """Sum t1 + t1_low + t2 + t3 (each defaulting to 0 when absent)."""
124
+ if dist is None:
125
+ return None
126
+ if not isinstance(dist, dict):
127
+ errors.append("confidenceDistribution must be an object")
128
+ return None
129
+ total = 0
130
+ for tier in ("t1", "t1_low", "t2", "t3"):
131
+ v = dist.get(tier, 0)
132
+ if v is None:
133
+ v = 0
134
+ if isinstance(v, bool) or not isinstance(v, int) or v < 0:
135
+ errors.append(
136
+ f"confidenceDistribution.{tier} must be a non-negative integer (got {v!r})"
137
+ )
138
+ continue
139
+ total += v
140
+ return total
141
+
142
+
143
+ def drift_pct(counts):
144
+ """Percentage divergence of the extreme counts: (max - min) / max * 100."""
145
+ hi = max(counts)
146
+ lo = min(counts)
147
+ if hi == 0:
148
+ return 0.0
149
+ return (hi - lo) / hi * 100.0
150
+
151
+
152
+ def _intra_cluster_finding(present, cluster_label, title, threshold):
153
+ """Emit an intra-cluster Medium finding when the extremes diverge > threshold."""
154
+ if len(present) < 2:
155
+ return None
156
+ values = [c for _, c in present]
157
+ pct = drift_pct(values)
158
+ if pct <= threshold:
159
+ return None
160
+ enumerated = ", ".join(f"{label}={count}" for label, count in present)
161
+ pct_display = round(pct)
162
+ return {
163
+ "severity": "Medium",
164
+ "title": title,
165
+ "detail": f"{enumerated} → {pct_display}% drift",
166
+ "category": CATEGORY,
167
+ "driftPct": pct_display,
168
+ }
169
+
170
+
171
+ def check(inp):
172
+ """Pure count-drift cross-check. Returns the result object (or an error object)."""
173
+ if inp is None or not isinstance(inp, dict):
174
+ return make_error("Input must be a JSON object")
175
+
176
+ threshold = inp.get("driftThresholdPct", DEFAULT_DRIFT_PCT)
177
+ if isinstance(threshold, bool) or not isinstance(threshold, (int, float)) or threshold < 0:
178
+ return make_error("driftThresholdPct must be a non-negative number")
179
+
180
+ skill_type = inp.get("skillType")
181
+ scope_type = inp.get("scopeType")
182
+ if skill_type == "stack":
183
+ return {
184
+ "skipped": True,
185
+ "skipReason": "stack skill — the three counts measure intentionally "
186
+ "different surfaces (empty own barrel, cited constituent contracts, "
187
+ "per-constituent confidence bins); comparing them yields only false drift",
188
+ "driftThresholdPct": threshold,
189
+ "clusterACounts": {},
190
+ "clusterBCounts": {},
191
+ "findings": [],
192
+ }
193
+ if scope_type == "reference-app":
194
+ return {
195
+ "skipped": True,
196
+ "skipReason": "reference app — counts measure pattern surfaces vs "
197
+ "per-citation provenance, not a shared export barrel; comparing them "
198
+ "yields only false drift",
199
+ "driftThresholdPct": threshold,
200
+ "clusterACounts": {},
201
+ "clusterBCounts": {},
202
+ "findings": [],
203
+ }
204
+
205
+ cluster_a_in = inp.get("clusterA") or {}
206
+ cluster_b_in = inp.get("clusterB") or {}
207
+ if not isinstance(cluster_a_in, dict) or not isinstance(cluster_b_in, dict):
208
+ return make_error("clusterA and clusterB must be objects when present")
209
+
210
+ errors: list[str] = []
211
+
212
+ exports_public_api = _as_count(
213
+ cluster_a_in.get("exports_public_api"), LABEL_EXPORTS_PUBLIC_API, errors
214
+ )
215
+ exports_length = _as_count(
216
+ cluster_a_in.get("exports_length"), LABEL_EXPORTS_LENGTH, errors
217
+ )
218
+ exports_documented = _as_count(
219
+ cluster_b_in.get("exports_documented"), LABEL_EXPORTS_DOCUMENTED, errors
220
+ )
221
+ provenance_named = _provenance_named_count(inp.get("provenanceExportNames"), errors)
222
+ confidence_sum = _confidence_sum(inp.get("confidenceDistribution"), errors)
223
+
224
+ if errors:
225
+ return make_error("; ".join(errors))
226
+
227
+ # Present-count lists preserve a stable cluster-canonical order for reporting.
228
+ cluster_a = [
229
+ (LABEL_EXPORTS_PUBLIC_API, exports_public_api),
230
+ (LABEL_EXPORTS_LENGTH, exports_length),
231
+ ]
232
+ cluster_a = [(label, c) for label, c in cluster_a if c is not None]
233
+
234
+ cluster_b = [
235
+ (LABEL_EXPORTS_DOCUMENTED, exports_documented),
236
+ (LABEL_PROVENANCE_NAMED, provenance_named),
237
+ (LABEL_CONFIDENCE_SUM, confidence_sum),
238
+ ]
239
+ cluster_b = [(label, c) for label, c in cluster_b if c is not None]
240
+
241
+ findings = []
242
+
243
+ a_finding = _intra_cluster_finding(
244
+ cluster_a, "barrel", "metadata drift — barrel export counts diverge", threshold
245
+ )
246
+ if a_finding:
247
+ findings.append(a_finding)
248
+
249
+ b_finding = _intra_cluster_finding(
250
+ cluster_b,
251
+ "documented-surface",
252
+ "metadata drift — documented-surface export counts diverge",
253
+ threshold,
254
+ )
255
+ if b_finding:
256
+ findings.append(b_finding)
257
+
258
+ # Cross-cluster: representative = the higher of each cluster's present counts.
259
+ if cluster_a and cluster_b:
260
+ repr_a = max(c for _, c in cluster_a)
261
+ repr_b = max(c for _, c in cluster_b)
262
+ pct = drift_pct([repr_a, repr_b])
263
+ if pct > threshold:
264
+ findings.append(
265
+ {
266
+ "severity": "Info",
267
+ "title": "multi-denominator reporting — barrel vs documented surface",
268
+ "detail": f"barrel={repr_a}, documented={repr_b}",
269
+ "category": CATEGORY,
270
+ "driftPct": round(pct),
271
+ }
272
+ )
273
+
274
+ return {
275
+ "skipped": False,
276
+ "skipReason": None,
277
+ "driftThresholdPct": threshold,
278
+ "clusterACounts": {label: count for label, count in cluster_a},
279
+ "clusterBCounts": {label: count for label, count in cluster_b},
280
+ "findings": findings,
281
+ }
282
+
283
+
284
+ # --- CLI --------------------------------------------------------------------
285
+
286
+
287
+ def _build_parser():
288
+ parser = argparse.ArgumentParser(
289
+ prog="check-metadata-coherence",
290
+ description=(
291
+ "Deterministic metadata export-count coherence cross-check "
292
+ "(coverage-check.md §4b). Bins the collected export counts into the "
293
+ "two canonical clusters and emits the intra-cluster / cross-cluster "
294
+ "drift findings so the count-drift decision is reproducible."
295
+ ),
296
+ formatter_class=argparse.RawDescriptionHelpFormatter,
297
+ epilog=(
298
+ "Example:\n"
299
+ " uv run check-metadata-coherence.py "
300
+ "'{\"clusterA\":{\"exports_public_api\":55,\"exports_length\":48},"
301
+ "\"clusterB\":{\"exports_documented\":114}}'"
302
+ ),
303
+ )
304
+ src = parser.add_mutually_exclusive_group()
305
+ src.add_argument(
306
+ "json_input",
307
+ nargs="?",
308
+ help="JSON object as a positional argument (single-quote it on the shell).",
309
+ )
310
+ src.add_argument(
311
+ "--json-input",
312
+ dest="json_input_flag",
313
+ help="JSON object passed via flag (overrides positional).",
314
+ )
315
+ src.add_argument(
316
+ "--stdin",
317
+ action="store_true",
318
+ help="Read the JSON object from stdin.",
319
+ )
320
+ return parser
321
+
322
+
323
+ def _resolve_input(args):
324
+ if args.stdin:
325
+ return sys.stdin.read()
326
+ if args.json_input_flag is not None:
327
+ return args.json_input_flag
328
+ if args.json_input is not None:
329
+ return args.json_input
330
+ return ""
331
+
332
+
333
+ def main(argv=None):
334
+ parser = _build_parser()
335
+ args = parser.parse_args(argv)
336
+ raw = _resolve_input(args)
337
+ if not raw.strip():
338
+ parser.print_usage(file=sys.stderr)
339
+ print(
340
+ "error: no input provided (positional arg, --json-input, or --stdin)",
341
+ file=sys.stderr,
342
+ )
343
+ return 1
344
+
345
+ try:
346
+ data = json.loads(raw)
347
+ except json.JSONDecodeError as exc:
348
+ print(json.dumps(make_error(f"Invalid JSON: {exc.msg}"), indent=2))
349
+ return 1
350
+
351
+ result = check(data)
352
+ print(json.dumps(result, indent=2))
353
+ if isinstance(result, dict) and result.get("code") == "INVALID_INPUT":
354
+ return 2
355
+ return 0
356
+
357
+
358
+ if __name__ == "__main__":
359
+ raise SystemExit(main())
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # dependencies = []
5
+ # ///
6
+ """Deterministic combined external-validation score.
7
+
8
+ Arithmetic helper for the SKF test-skill workflow (external-validators.md §4).
9
+ The combined external score feeds compute-score.py as the `externalValidation`
10
+ category input, so — like every other scoring input in this skill — the mean is
11
+ computed once by a script rather than by hand, keeping the verdict reproducible
12
+ run-to-run (an odd-sum average such as (80 + 73) / 2 = 76.5 is exactly where an
13
+ in-prompt round swings).
14
+
15
+ Rule (external-validators.md §4):
16
+ * both tools ran -> mean of skill-check + tessl review scores
17
+ * one tool ran -> that tool's score
18
+ * neither ran -> null (scoring step redistributes the external weight)
19
+
20
+ Both scores are on the same 0-100 scale (skill-check quality score; tessl review
21
+ percentage). Rounding matches compute-score.py (JS-compatible half-up) so the
22
+ number this script emits and the one compute-score.py weights agree to the digit.
23
+
24
+ Input schema (one JSON object; a tool that did not run is null or omitted):
25
+ {
26
+ "skillCheckScore": <0-100 | null>,
27
+ "tesslReviewScore": <0-100 | null>
28
+ }
29
+
30
+ Output (stdout, one object):
31
+ {
32
+ "externalScore": <0-100 float | null>, # null when neither tool ran
33
+ "toolsUsed": ["skill-check", "tessl"], # tools that contributed
34
+ "available": <bool> # at least one tool ran
35
+ }
36
+ or {"error": ..., "code": "INVALID_INPUT"} on a schema violation.
37
+
38
+ CLI usage (mirrors compute-score.py):
39
+ uv run combine-external-scores.py '<JSON>' # positional
40
+ uv run combine-external-scores.py --json-input '<JSON>' # explicit flag
41
+ cat input.json | uv run combine-external-scores.py --stdin # piped input
42
+
43
+ Exit codes:
44
+ 0 — score emitted successfully
45
+ 1 — no input / input could not be parsed as JSON
46
+ 2 — input parsed but schema/semantics invalid (error object emitted as JSON)
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import argparse
52
+ import json
53
+ import math
54
+ import sys
55
+
56
+ SKILL_CHECK = "skill-check"
57
+ TESSL = "tessl"
58
+
59
+
60
+ def round2(value):
61
+ """Round to 2 decimals with JS-compatible half-up rounding (matches compute-score.py)."""
62
+ return math.floor(value * 100 + 0.5) / 100
63
+
64
+
65
+ def make_error(message):
66
+ return {"error": message, "code": "INVALID_INPUT"}
67
+
68
+
69
+ def _as_score(value, label, errors):
70
+ """Return value if it is a 0-100 number (not bool), None if absent, else record an error."""
71
+ if value is None:
72
+ return None
73
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
74
+ errors.append(f"{label} must be a number between 0 and 100 or null (got {value!r})")
75
+ return None
76
+ if value < 0 or value > 100:
77
+ errors.append(f"{label} must be between 0 and 100 (got {value})")
78
+ return None
79
+ return value
80
+
81
+
82
+ def combine(inp):
83
+ """Pure combined-external-score computation. Returns the result (or error) object."""
84
+ if inp is None or not isinstance(inp, dict):
85
+ return make_error("Input must be a JSON object")
86
+
87
+ errors: list[str] = []
88
+ skill_check = _as_score(inp.get("skillCheckScore"), "skillCheckScore", errors)
89
+ tessl = _as_score(inp.get("tesslReviewScore"), "tesslReviewScore", errors)
90
+ if errors:
91
+ return make_error("; ".join(errors))
92
+
93
+ tools_used = []
94
+ present = []
95
+ if skill_check is not None:
96
+ tools_used.append(SKILL_CHECK)
97
+ present.append(skill_check)
98
+ if tessl is not None:
99
+ tools_used.append(TESSL)
100
+ present.append(tessl)
101
+
102
+ if not present:
103
+ external_score = None
104
+ elif len(present) == 1:
105
+ external_score = round2(present[0])
106
+ else:
107
+ external_score = round2(sum(present) / len(present))
108
+
109
+ return {
110
+ "externalScore": external_score,
111
+ "toolsUsed": tools_used,
112
+ "available": bool(present),
113
+ }
114
+
115
+
116
+ # --- CLI --------------------------------------------------------------------
117
+
118
+
119
+ def _build_parser():
120
+ parser = argparse.ArgumentParser(
121
+ prog="combine-external-scores",
122
+ description=(
123
+ "Deterministic combined external-validation score "
124
+ "(external-validators.md §4). Averages the skill-check and tessl "
125
+ "review scores (or passes a single available score through) into the "
126
+ "`externalValidation` scoring input."
127
+ ),
128
+ formatter_class=argparse.RawDescriptionHelpFormatter,
129
+ epilog=(
130
+ "Example:\n"
131
+ " uv run combine-external-scores.py "
132
+ "'{\"skillCheckScore\":80,\"tesslReviewScore\":73}'"
133
+ ),
134
+ )
135
+ src = parser.add_mutually_exclusive_group()
136
+ src.add_argument(
137
+ "json_input",
138
+ nargs="?",
139
+ help="JSON object as a positional argument (single-quote it on the shell).",
140
+ )
141
+ src.add_argument(
142
+ "--json-input",
143
+ dest="json_input_flag",
144
+ help="JSON object passed via flag (overrides positional).",
145
+ )
146
+ src.add_argument(
147
+ "--stdin",
148
+ action="store_true",
149
+ help="Read the JSON object from stdin.",
150
+ )
151
+ return parser
152
+
153
+
154
+ def _resolve_input(args):
155
+ if args.stdin:
156
+ return sys.stdin.read()
157
+ if args.json_input_flag is not None:
158
+ return args.json_input_flag
159
+ if args.json_input is not None:
160
+ return args.json_input
161
+ return ""
162
+
163
+
164
+ def main(argv=None):
165
+ parser = _build_parser()
166
+ args = parser.parse_args(argv)
167
+ raw = _resolve_input(args)
168
+ if not raw.strip():
169
+ parser.print_usage(file=sys.stderr)
170
+ print(
171
+ "error: no input provided (positional arg, --json-input, or --stdin)",
172
+ file=sys.stderr,
173
+ )
174
+ return 1
175
+
176
+ try:
177
+ data = json.loads(raw)
178
+ except json.JSONDecodeError as exc:
179
+ print(json.dumps(make_error(f"Invalid JSON: {exc.msg}"), indent=2))
180
+ return 1
181
+
182
+ result = combine(data)
183
+ print(json.dumps(result, indent=2))
184
+ if isinstance(result, dict) and result.get("code") == "INVALID_INPUT":
185
+ return 2
186
+ return 0
187
+
188
+
189
+ if __name__ == "__main__":
190
+ raise SystemExit(main())