bmad-module-skill-forge 2.0.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.nvmrc +1 -1
- package/docs/_data/pinned.yaml +1 -1
- package/package.json +2 -2
- package/src/knowledge/skill-lifecycle.md +1 -1
- package/src/module-help.csv +2 -2
- package/src/shared/scripts/skf-comention-pairs.py +350 -0
- package/src/shared/scripts/skf-count-tokens.py +222 -0
- package/src/shared/scripts/skf-detect-docs.py +232 -1
- package/src/shared/scripts/skf-detect-language.py +77 -0
- package/src/shared/scripts/skf-detect-tools.py +105 -2
- package/src/shared/scripts/skf-enumerate-stack-skills.py +111 -2
- package/src/shared/scripts/skf-find-cycles.py +246 -0
- package/src/shared/scripts/skf-hash-content.py +487 -2
- package/src/shared/scripts/skf-manifest-ops.py +67 -1
- package/src/shared/scripts/skf-rebuild-managed-sections.py +180 -0
- package/src/shared/scripts/skf-recommend-scope-type.py +1 -2
- package/src/shared/scripts/skf-render-metadata-stats.py +499 -0
- package/src/shared/scripts/skf-rewrite-skill-name.py +308 -0
- package/src/shared/scripts/skf-shard-body.py +461 -0
- package/src/shared/scripts/skf-skill-inventory.py +139 -3
- package/src/shared/scripts/skf-structural-diff.py +307 -67
- package/src/shared/scripts/skf-validate-brief-schema.py +39 -1
- package/src/shared/scripts/skf-validate-feasibility-report.py +234 -0
- package/src/shared/scripts/skf-validate-frontmatter.py +17 -12
- package/src/shared/scripts/skf-validate-output.py +443 -10
- package/src/shared/scripts/skf-validate-pins.py +1 -1
- package/src/shared/scripts/skf-verify-no-trace.py +214 -0
- package/src/shared/scripts/skf-verify-provenance-completeness.py +439 -0
- package/src/skf-analyze-source/SKILL.md +17 -32
- package/src/skf-analyze-source/customize.toml +2 -0
- package/src/skf-analyze-source/references/auto-docs-only.md +116 -0
- package/src/skf-analyze-source/references/continue.md +8 -13
- package/src/skf-analyze-source/references/generate-briefs.md +28 -12
- package/src/skf-analyze-source/references/headless-contract.md +33 -0
- package/src/skf-analyze-source/references/health-check.md +1 -2
- package/src/skf-analyze-source/references/identify-units.md +25 -36
- package/src/skf-analyze-source/references/init.md +12 -25
- package/src/skf-analyze-source/references/map-and-detect.md +8 -20
- package/src/skf-analyze-source/references/recommend.md +4 -14
- package/src/skf-analyze-source/references/scan-project.md +3 -11
- package/src/skf-analyze-source/references/step-auto-scope.md +42 -180
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -2
- package/src/skf-audit-skill/SKILL.md +9 -6
- package/src/skf-audit-skill/customize.toml +2 -0
- package/src/skf-audit-skill/references/init.md +58 -32
- package/src/skf-audit-skill/references/re-index.md +15 -34
- package/src/skf-audit-skill/references/report.md +11 -58
- package/src/skf-audit-skill/references/semantic-diff.md +4 -22
- package/src/skf-audit-skill/references/severity-classify.md +53 -87
- package/src/skf-audit-skill/references/step-doc-drift.md +49 -19
- package/src/skf-audit-skill/references/structural-diff.md +43 -68
- package/src/skf-brief-skill/SKILL.md +7 -36
- package/src/skf-brief-skill/assets/scope-templates.md +1 -1
- package/src/skf-brief-skill/customize.toml +14 -0
- package/src/skf-brief-skill/references/analyze-target.md +6 -14
- package/src/skf-brief-skill/references/confirm-brief.md +2 -14
- package/src/skf-brief-skill/references/draft-checkpoint.md +8 -8
- package/src/skf-brief-skill/references/gather-intent.md +19 -22
- package/src/skf-brief-skill/references/headless-args.md +1 -1
- package/src/skf-brief-skill/references/health-check.md +1 -22
- package/src/skf-brief-skill/references/invocation-contract.md +37 -0
- package/src/skf-brief-skill/references/scope-definition.md +4 -14
- package/src/skf-brief-skill/references/step-auto-brief.md +1 -5
- package/src/skf-brief-skill/references/step-auto-validate.md +1 -5
- package/src/skf-brief-skill/references/version-resolution.md +1 -1
- package/src/skf-brief-skill/references/write-brief.md +17 -10
- package/src/skf-campaign/SKILL.md +15 -84
- package/src/skf-campaign/customize.toml +2 -0
- package/src/skf-campaign/manifest.yaml +4 -0
- package/src/skf-campaign/references/campaign-contracts.md +67 -0
- package/src/skf-campaign/references/campaign-directive-spec.md +14 -103
- package/src/skf-campaign/references/step-01-setup.md +4 -5
- package/src/skf-campaign/references/step-02-strategy.md +8 -3
- package/src/skf-campaign/references/step-03-pins.md +1 -1
- package/src/skf-campaign/references/step-04-provenance.md +1 -1
- package/src/skf-campaign/references/step-05-skill-loop.md +1 -1
- package/src/skf-campaign/references/step-06-batch.md +7 -6
- package/src/skf-campaign/references/step-11-maintenance.md +2 -2
- package/src/skf-campaign/references/step-resume.md +9 -8
- package/src/skf-campaign/scripts/campaign-deps.py +10 -1
- package/src/skf-campaign/scripts/campaign-render-batch.py +199 -0
- package/src/skf-campaign/scripts/campaign-status.py +162 -0
- package/src/skf-campaign/templates/campaign-brief-template.yaml +1 -1
- package/src/skf-create-skill/SKILL.md +14 -3
- package/src/skf-create-skill/assets/compile-assembly-rules.md +17 -38
- package/src/skf-create-skill/assets/skill-sections.md +9 -100
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +4 -13
- package/src/skf-create-skill/customize.toml +13 -0
- package/src/skf-create-skill/references/authoritative-files-protocol.md +7 -7
- package/src/skf-create-skill/references/compile.md +34 -47
- package/src/skf-create-skill/references/component-extraction.md +8 -35
- package/src/skf-create-skill/references/ecosystem-check.md +9 -25
- package/src/skf-create-skill/references/enrich.md +4 -17
- package/src/skf-create-skill/references/extract.md +14 -29
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +1 -1
- package/src/skf-create-skill/references/extraction-patterns.md +7 -7
- package/src/skf-create-skill/references/generate-artifacts.md +9 -23
- package/src/skf-create-skill/references/health-check.md +1 -1
- package/src/skf-create-skill/references/load-brief.md +16 -18
- package/src/skf-create-skill/references/report.md +12 -15
- package/src/skf-create-skill/references/source-resolution-protocols.md +3 -3
- package/src/skf-create-skill/references/step-auto-shard.md +52 -25
- package/src/skf-create-skill/references/step-doc-rot.md +30 -12
- package/src/skf-create-skill/references/step-doc-sources.md +2 -2
- package/src/skf-create-skill/references/sub/ccc-discover.md +2 -13
- package/src/skf-create-skill/references/sub/fetch-docs.md +8 -50
- package/src/skf-create-skill/references/sub/fetch-temporal.md +5 -20
- package/src/skf-create-skill/references/tier-degradation-rules.md +6 -8
- package/src/skf-create-skill/references/validate.md +59 -28
- package/src/skf-create-skill/scripts/scan-doc-rot.py +245 -0
- package/src/skf-create-stack-skill/SKILL.md +15 -9
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +1 -1
- package/src/skf-create-stack-skill/customize.toml +10 -0
- package/src/skf-create-stack-skill/references/compile-stack.md +7 -8
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -13
- package/src/skf-create-stack-skill/references/detect-integrations.md +49 -54
- package/src/skf-create-stack-skill/references/detect-manifests.md +37 -30
- package/src/skf-create-stack-skill/references/generate-output.md +24 -75
- package/src/skf-create-stack-skill/references/init.md +9 -81
- package/src/skf-create-stack-skill/references/manifest-patterns.md +5 -35
- package/src/skf-create-stack-skill/references/parallel-extract.md +12 -27
- package/src/skf-create-stack-skill/references/rank-and-confirm.md +6 -6
- package/src/skf-create-stack-skill/references/report.md +27 -62
- package/src/skf-create-stack-skill/references/validate.md +35 -31
- package/src/skf-drop-skill/SKILL.md +20 -23
- package/src/skf-drop-skill/customize.toml +10 -0
- package/src/skf-drop-skill/references/execute.md +51 -31
- package/src/skf-drop-skill/references/headless-contract.md +18 -0
- package/src/skf-drop-skill/references/health-check.md +1 -13
- package/src/skf-drop-skill/references/report.md +42 -10
- package/src/skf-drop-skill/references/select.md +88 -48
- package/src/skf-drop-skill/scripts/dir-sizes.py +116 -0
- package/src/skf-export-skill/SKILL.md +17 -14
- package/src/skf-export-skill/assets/managed-section-format.md +2 -5
- package/src/skf-export-skill/customize.toml +10 -6
- package/src/skf-export-skill/references/generate-snippet.md +21 -35
- package/src/skf-export-skill/references/load-skill.md +44 -42
- package/src/skf-export-skill/references/multi-skill-mode.md +1 -1
- package/src/skf-export-skill/references/orphan-context-detection.md +11 -3
- package/src/skf-export-skill/references/package.md +27 -33
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +24 -5
- package/src/skf-export-skill/references/result-envelope.md +28 -0
- package/src/skf-export-skill/references/summary.md +11 -5
- package/src/skf-export-skill/references/token-report.md +42 -24
- package/src/skf-export-skill/references/update-context.md +32 -33
- package/src/skf-forger/SKILL.md +27 -39
- package/src/skf-forger/references/pipeline-mode.md +34 -0
- package/src/skf-forger/scripts/parse-pipeline.py +270 -0
- package/src/skf-quick-skill/SKILL.md +10 -60
- package/src/skf-quick-skill/customize.toml +9 -0
- package/src/skf-quick-skill/references/batch-mode.md +6 -4
- package/src/skf-quick-skill/references/compile.md +15 -52
- package/src/skf-quick-skill/references/ecosystem-check.md +5 -20
- package/src/skf-quick-skill/references/finalize.md +12 -6
- package/src/skf-quick-skill/references/halt-contract.md +54 -0
- package/src/skf-quick-skill/references/health-check.md +3 -4
- package/src/skf-quick-skill/references/quick-extract.md +7 -18
- package/src/skf-quick-skill/references/registry-resolution.md +1 -23
- package/src/skf-quick-skill/references/resolve-target.md +27 -34
- package/src/skf-quick-skill/references/write-and-validate.md +10 -25
- package/src/skf-refine-architecture/SKILL.md +14 -7
- package/src/skf-refine-architecture/customize.toml +9 -0
- package/src/skf-refine-architecture/references/compile.md +9 -7
- package/src/skf-refine-architecture/references/gap-analysis.md +21 -50
- package/src/skf-refine-architecture/references/health-check.md +1 -1
- package/src/skf-refine-architecture/references/improvements.md +9 -25
- package/src/skf-refine-architecture/references/init.md +9 -16
- package/src/skf-refine-architecture/references/issue-detection.md +10 -25
- package/src/skf-refine-architecture/references/refinement-rules.md +4 -16
- package/src/skf-refine-architecture/references/report.md +13 -10
- package/src/skf-rename-skill/SKILL.md +11 -21
- package/src/skf-rename-skill/customize.toml +10 -0
- package/src/skf-rename-skill/references/execute.md +107 -78
- package/src/skf-rename-skill/references/exit-codes.md +12 -0
- package/src/skf-rename-skill/references/report.md +16 -8
- package/src/skf-rename-skill/references/select.md +69 -42
- package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -0
- package/src/skf-setup/SKILL.md +20 -12
- package/src/skf-setup/customize.toml +10 -0
- package/src/skf-setup/references/auto-index.md +9 -10
- package/src/skf-setup/references/ccc-index.md +14 -15
- package/src/skf-setup/references/detect-and-tier.md +6 -8
- package/src/skf-setup/references/report.md +22 -31
- package/src/skf-setup/references/tier-rules.md +2 -32
- package/src/skf-setup/references/write-config.md +9 -10
- package/src/skf-test-skill/SKILL.md +10 -8
- package/src/skf-test-skill/customize.toml +2 -0
- package/src/skf-test-skill/references/coherence-check.md +19 -31
- package/src/skf-test-skill/references/coverage-check.md +109 -75
- package/src/skf-test-skill/references/detect-mode.md +10 -41
- package/src/skf-test-skill/references/external-validators.md +11 -19
- package/src/skf-test-skill/references/init.md +38 -20
- package/src/skf-test-skill/references/migration-section-rules.md +4 -4
- package/src/skf-test-skill/references/report.md +60 -40
- package/src/skf-test-skill/references/score.md +32 -64
- package/src/skf-test-skill/references/scoring-rules.md +6 -62
- package/src/skf-test-skill/references/source-access-protocol.md +6 -6
- package/src/skf-test-skill/references/step-hard-gate.md +1 -3
- package/src/skf-test-skill/scripts/aggregate-coherence.py +276 -0
- package/src/skf-test-skill/scripts/check-metadata-coherence.py +359 -0
- package/src/skf-test-skill/scripts/combine-external-scores.py +190 -0
- package/src/skf-test-skill/scripts/compute-score.py +131 -1
- package/src/skf-test-skill/scripts/reconcile-coverage.py +327 -0
- package/src/skf-test-skill/scripts/validate-inventory.py +251 -0
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +192 -0
- package/src/skf-update-skill/SKILL.md +17 -5
- package/src/skf-update-skill/customize.toml +14 -0
- package/src/skf-update-skill/references/detect-changes.md +19 -37
- package/src/skf-update-skill/references/health-check.md +3 -9
- package/src/skf-update-skill/references/init.md +39 -32
- package/src/skf-update-skill/references/manual-section-rules.md +1 -2
- package/src/skf-update-skill/references/merge-conflict-rules.md +1 -52
- package/src/skf-update-skill/references/merge.md +13 -57
- package/src/skf-update-skill/references/re-extract.md +12 -24
- package/src/skf-update-skill/references/report.md +14 -7
- package/src/skf-update-skill/references/validate.md +32 -30
- package/src/skf-update-skill/references/write.md +86 -51
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +167 -0
- package/src/skf-verify-stack/SKILL.md +11 -21
- package/src/skf-verify-stack/customize.toml +12 -3
- package/src/skf-verify-stack/references/coverage-patterns.md +1 -29
- package/src/skf-verify-stack/references/coverage.md +21 -10
- package/src/skf-verify-stack/references/exit-codes.md +14 -0
- package/src/skf-verify-stack/references/health-check.md +1 -1
- package/src/skf-verify-stack/references/init.md +20 -25
- package/src/skf-verify-stack/references/integration-verification-rules.md +10 -19
- package/src/skf-verify-stack/references/integrations.md +44 -49
- package/src/skf-verify-stack/references/report.md +28 -34
- package/src/skf-verify-stack/references/requirements.md +3 -3
- package/src/skf-verify-stack/references/synthesize.md +36 -39
- package/src/skf-verify-stack/scripts/skf-coverage-tally.py +196 -0
- package/src/skf-verify-stack/scripts/skf-report-delta.py +256 -0
- package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +258 -0
- 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())
|