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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.nvmrc +1 -1
- package/README.md +10 -5
- package/docs/_data/pinned.yaml +2 -2
- package/docs/agents.md +11 -2
- package/docs/architecture.md +5 -4
- package/docs/bmad-synergy.md +30 -7
- package/docs/campaign.md +172 -0
- package/docs/examples.md +7 -3
- package/docs/forge-auto.md +90 -0
- package/docs/getting-started.md +9 -0
- package/docs/how-it-works.md +6 -4
- package/docs/index.md +4 -3
- package/docs/skill-model.md +1 -1
- package/docs/troubleshooting.md +17 -1
- package/docs/verifying-a-skill.md +2 -2
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +64 -24
- package/package.json +2 -2
- package/src/knowledge/skill-lifecycle.md +1 -1
- package/src/module-help.csv +4 -3
- package/src/shared/data/language-corpora.json +32 -0
- package/src/shared/references/pipeline-contracts.md +6 -3
- package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
- 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-derive-assembly-shape.py +107 -0
- package/src/shared/scripts/skf-detect-docs.py +240 -4
- package/src/shared/scripts/skf-detect-language.py +82 -3
- package/src/shared/scripts/skf-detect-tools.py +105 -2
- package/src/shared/scripts/skf-emit-result-envelope.py +17 -0
- package/src/shared/scripts/skf-enumerate-stack-skills.py +111 -2
- package/src/shared/scripts/skf-extract-public-api.py +53 -0
- 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-language-corpora.py +100 -0
- package/src/shared/scripts/skf-manifest-ops.py +67 -1
- package/src/shared/scripts/skf-merge-doc-urls.py +244 -0
- package/src/shared/scripts/skf-preapply.py +1 -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-shape-detect.py +576 -25
- 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 +41 -6
- 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 +3 -3
- package/src/shared/scripts/skf-verify-no-trace.py +214 -0
- package/src/shared/scripts/skf-verify-provenance-completeness.py +439 -0
- package/src/shared/scripts/skf-write-skill-brief.py +21 -5
- package/src/skf-analyze-source/SKILL.md +17 -32
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- 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 +211 -197
- package/src/skf-analyze-source/references/step-shape-detect.md +20 -13
- 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/assets/skill-brief-schema.md +1 -1
- package/src/skf-brief-skill/customize.toml +14 -0
- package/src/skf-brief-skill/references/analyze-target.md +13 -18
- package/src/skf-brief-skill/references/confirm-brief.md +2 -15
- package/src/skf-brief-skill/references/draft-checkpoint.md +8 -8
- package/src/skf-brief-skill/references/gather-intent.md +27 -24
- 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 +6 -15
- package/src/skf-brief-skill/references/step-auto-brief.md +22 -9
- package/src/skf-brief-skill/references/step-auto-validate.md +3 -7
- package/src/skf-brief-skill/references/version-resolution.md +1 -1
- package/src/skf-brief-skill/references/write-brief.md +19 -13
- package/src/skf-campaign/SKILL.md +121 -0
- package/src/skf-campaign/assets/campaign-state-schema.json +191 -0
- package/src/skf-campaign/customize.toml +75 -0
- package/src/skf-campaign/manifest.yaml +15 -0
- package/src/skf-campaign/references/campaign-contracts.md +67 -0
- package/src/skf-campaign/references/campaign-directive-spec.md +32 -0
- package/src/skf-campaign/references/health-check.md +35 -0
- package/src/skf-campaign/references/step-01-setup.md +121 -0
- package/src/skf-campaign/references/step-02-strategy.md +102 -0
- package/src/skf-campaign/references/step-03-pins.md +56 -0
- package/src/skf-campaign/references/step-04-provenance.md +63 -0
- package/src/skf-campaign/references/step-05-skill-loop.md +103 -0
- package/src/skf-campaign/references/step-06-batch.md +88 -0
- package/src/skf-campaign/references/step-07-capstone.md +63 -0
- package/src/skf-campaign/references/step-08-verify.md +75 -0
- package/src/skf-campaign/references/step-09-refine.md +83 -0
- package/src/skf-campaign/references/step-10-export.md +106 -0
- package/src/skf-campaign/references/step-11-maintenance.md +84 -0
- package/src/skf-campaign/references/step-resume.md +115 -0
- package/src/skf-campaign/scripts/.gitkeep +0 -0
- package/src/skf-campaign/scripts/campaign-deps.py +244 -0
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +119 -0
- package/src/skf-campaign/scripts/campaign-provenance.py +247 -0
- package/src/skf-campaign/scripts/campaign-render-batch.py +199 -0
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +158 -0
- package/src/skf-campaign/scripts/campaign-report.py +249 -0
- package/src/skf-campaign/scripts/campaign-status.py +162 -0
- package/src/skf-campaign/scripts/campaign-validate-pins.py +174 -0
- package/src/skf-campaign/scripts/campaign-validate-state.py +207 -0
- package/src/skf-campaign/templates/campaign-brief-template.yaml +34 -0
- package/src/skf-campaign/templates/campaign-report-template.md +54 -0
- package/src/skf-campaign/templates/kickoff-template.md +48 -0
- package/src/skf-create-skill/SKILL.md +15 -4
- package/src/skf-create-skill/assets/compile-assembly-rules.md +35 -34
- 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 +37 -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 +20 -27
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +1 -1
- package/src/skf-create-skill/references/extraction-patterns.md +10 -8
- package/src/skf-create-skill/references/generate-artifacts.md +20 -25
- package/src/skf-create-skill/references/health-check.md +2 -2
- package/src/skf-create-skill/references/load-brief.md +16 -18
- package/src/skf-create-skill/references/report.md +27 -14
- package/src/skf-create-skill/references/source-resolution-protocols.md +4 -4
- package/src/skf-create-skill/references/step-auto-shard.md +52 -25
- package/src/skf-create-skill/references/step-doc-rot.md +33 -15
- package/src/skf-create-skill/references/step-doc-sources.md +13 -4
- package/src/skf-create-skill/references/sub/ccc-discover.md +2 -13
- package/src/skf-create-skill/references/sub/fetch-docs.md +33 -46
- 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 +69 -29
- 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 +67 -36
- 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 +44 -10
- package/src/skf-drop-skill/references/select.md +93 -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 +32 -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 +16 -8
- package/src/skf-refine-architecture/customize.toml +9 -0
- package/src/skf-refine-architecture/references/compile.md +10 -8
- 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 +14 -11
- 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 +112 -82
- 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 +21 -13
- package/src/skf-setup/customize.toml +10 -0
- package/src/skf-setup/references/auto-index.md +11 -10
- package/src/skf-setup/references/ccc-index.md +14 -15
- package/src/skf-setup/references/detect-and-tier.md +10 -8
- package/src/skf-setup/references/report.md +26 -32
- 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 +111 -67
- 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 +39 -21
- 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 +15 -5
- 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 +135 -4
- 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 +11 -6
- package/src/skf-update-skill/references/detect-changes.md +48 -36
- package/src/skf-update-skill/references/health-check.md +5 -11
- package/src/skf-update-skill/references/init.md +40 -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 +27 -25
- 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/tools/cli/lib/ui.js +3 -2
- package/docs/deepwiki.md +0 -89
- package/src/skf-rename-skill/references/rebuild-context.md +0 -110
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.10"
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Verify No Trace — post-rename commit gate.
|
|
6
|
+
|
|
7
|
+
Confirms that no structural reference to the old skill name survives inside the
|
|
8
|
+
freshly-materialized new location before the rename is committed (before the old
|
|
9
|
+
directories are deleted). Every check is a fixed-file-set, case-sensitive scan
|
|
10
|
+
plus a region partition — fully deterministic, so the prompt keeps only the
|
|
11
|
+
roll-back-or-commit decision.
|
|
12
|
+
|
|
13
|
+
The scan matches the old name only as a complete skill-name token: an occurrence
|
|
14
|
+
NOT adjacent to another skill-name character `[a-z0-9-]` on either side. This is
|
|
15
|
+
what a literal substring grep intends but gets wrong when the old name is a
|
|
16
|
+
substring of the new name (rename -> rename-skill): a bare substring scan would
|
|
17
|
+
flag every correctly-renamed `rename-skill` as a leftover and make the rename
|
|
18
|
+
un-committable. Token matching flags a genuine leftover path segment
|
|
19
|
+
(`.claude/skills/rename/`), JSON value (`"rename"`), or header (`[rename v...]`)
|
|
20
|
+
while ignoring `rename-skill` and `renamed`.
|
|
21
|
+
|
|
22
|
+
For each version under --versions it scans:
|
|
23
|
+
|
|
24
|
+
{skill_group}/{v}/{new_name}/SKILL.md -> frontmatter matches are HARD;
|
|
25
|
+
body matches (below the closing
|
|
26
|
+
`---`) are advisory warnings
|
|
27
|
+
{skill_group}/{v}/{new_name}/metadata.json -> any match is HARD
|
|
28
|
+
{skill_group}/{v}/{new_name}/context-snippet.md-> any match is HARD
|
|
29
|
+
{forge_group}/{v}/provenance-map.json -> any match is HARD
|
|
30
|
+
|
|
31
|
+
and the directory listing:
|
|
32
|
+
|
|
33
|
+
{skill_group}/{v}/ MUST contain {new_name}/ and MUST NOT contain {old_name}/
|
|
34
|
+
|
|
35
|
+
The SKILL.md frontmatter/body split uses the same closing-`---`-on-its-own-line
|
|
36
|
+
rule as skf-validate-output.py, so a legitimate body mention of the old name (a
|
|
37
|
+
changelog line, a cross-reference) is a warning, never a blocker.
|
|
38
|
+
|
|
39
|
+
Output (stdout, always JSON):
|
|
40
|
+
{hard_matches[], body_warnings[], dir_violations[], skipped[], clean}
|
|
41
|
+
|
|
42
|
+
Exit codes:
|
|
43
|
+
0 clean (no hard_matches and no dir_violations)
|
|
44
|
+
1 not clean (at least one hard match or dir violation)
|
|
45
|
+
2 operation error (bad args)
|
|
46
|
+
|
|
47
|
+
CLI example:
|
|
48
|
+
python3 skf-verify-no-trace.py /out/rename-skill \
|
|
49
|
+
--forge-group /forge/rename-skill \
|
|
50
|
+
--old-name rename --new-name rename-skill --versions 1.0.0,0.9.0
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import argparse
|
|
56
|
+
import json
|
|
57
|
+
import re
|
|
58
|
+
import sys
|
|
59
|
+
from pathlib import Path
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _name_token_re(old_name: str) -> "re.Pattern[str]":
|
|
63
|
+
"""Match old_name only where it is a complete skill-name token.
|
|
64
|
+
|
|
65
|
+
Negative lookbehind/lookahead on `[a-z0-9-]` so `rename` matches inside
|
|
66
|
+
`/rename/` or `"rename"` but not inside `rename-skill` or `renamed`.
|
|
67
|
+
"""
|
|
68
|
+
return re.compile(r"(?<![a-z0-9-])" + re.escape(old_name) + r"(?![a-z0-9-])")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _split_frontmatter_body(content: str):
|
|
72
|
+
"""Return (frontmatter_lines, body_lines) as lists of (lineno, text).
|
|
73
|
+
|
|
74
|
+
Uses the closing-`---`-on-its-own-line rule (matching skf-validate-output.py).
|
|
75
|
+
When there is no valid frontmatter block, the whole file is treated as body —
|
|
76
|
+
a SKILL.md without frontmatter has no "structural" region to protect, so any
|
|
77
|
+
old-name match there is advisory, and the separate metadata/snippet/provenance
|
|
78
|
+
scans still gate the commit.
|
|
79
|
+
"""
|
|
80
|
+
lines = content.split("\n")
|
|
81
|
+
numbered = list(enumerate(lines, start=1))
|
|
82
|
+
if not content.startswith("---\n"):
|
|
83
|
+
return [], numbered
|
|
84
|
+
close_idx = None # 0-based index into `lines` of the closing '---'
|
|
85
|
+
for i in range(1, len(lines)):
|
|
86
|
+
if lines[i].rstrip() == "---":
|
|
87
|
+
close_idx = i
|
|
88
|
+
break
|
|
89
|
+
if close_idx is None:
|
|
90
|
+
return [], numbered
|
|
91
|
+
# Frontmatter = lines strictly between the opening (line 1) and closing '---'.
|
|
92
|
+
frontmatter = numbered[1:close_idx]
|
|
93
|
+
body = numbered[close_idx + 1 :]
|
|
94
|
+
return frontmatter, body
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _scan_lines(numbered, pattern):
|
|
98
|
+
"""Return [(lineno, text)] for every line where pattern matches (name token)."""
|
|
99
|
+
return [(n, text) for (n, text) in numbered if pattern.search(text)]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def verify(skill_group: Path, forge_group: Path, old_name: str, new_name: str, versions):
|
|
103
|
+
hard_matches = []
|
|
104
|
+
body_warnings = []
|
|
105
|
+
dir_violations = []
|
|
106
|
+
skipped = []
|
|
107
|
+
pattern = _name_token_re(old_name)
|
|
108
|
+
|
|
109
|
+
def scan_file(path: Path, region: str, version: str):
|
|
110
|
+
if not path.exists():
|
|
111
|
+
skipped.append({"version": version, "file": str(path), "reason": "missing"})
|
|
112
|
+
return None
|
|
113
|
+
try:
|
|
114
|
+
return path.read_text(encoding="utf-8")
|
|
115
|
+
except OSError as e:
|
|
116
|
+
skipped.append({"version": version, "file": str(path), "reason": f"read-error: {e}"})
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
for v in versions:
|
|
120
|
+
inner = skill_group / v / new_name
|
|
121
|
+
|
|
122
|
+
# SKILL.md — region-partitioned (frontmatter=hard, body=warning).
|
|
123
|
+
skill_md = inner / "SKILL.md"
|
|
124
|
+
content = scan_file(skill_md, "skill-md", v)
|
|
125
|
+
if content is not None:
|
|
126
|
+
fm, body = _split_frontmatter_body(content)
|
|
127
|
+
for n, text in _scan_lines(fm, pattern):
|
|
128
|
+
hard_matches.append(
|
|
129
|
+
{"version": v, "file": str(skill_md), "region": "skill-frontmatter",
|
|
130
|
+
"line": n, "text": text.strip()}
|
|
131
|
+
)
|
|
132
|
+
for n, text in _scan_lines(body, pattern):
|
|
133
|
+
body_warnings.append(
|
|
134
|
+
{"version": v, "file": str(skill_md), "region": "skill-body",
|
|
135
|
+
"line": n, "text": text.strip()}
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Whole-file hard scans.
|
|
139
|
+
for rel, region, base in (
|
|
140
|
+
(inner / "metadata.json", "metadata-json", skill_group),
|
|
141
|
+
(inner / "context-snippet.md", "context-snippet", skill_group),
|
|
142
|
+
(forge_group / v / "provenance-map.json", "provenance-json", forge_group),
|
|
143
|
+
):
|
|
144
|
+
content = scan_file(rel, region, v)
|
|
145
|
+
if content is None:
|
|
146
|
+
continue
|
|
147
|
+
for n, text in _scan_lines(list(enumerate(content.split("\n"), start=1)), pattern):
|
|
148
|
+
hard_matches.append(
|
|
149
|
+
{"version": v, "file": str(rel), "region": region,
|
|
150
|
+
"line": n, "text": text.strip()}
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Directory listing: must contain {new_name}/, must not contain {old_name}/.
|
|
154
|
+
version_dir = skill_group / v
|
|
155
|
+
old_dir = version_dir / old_name
|
|
156
|
+
new_dir = version_dir / new_name
|
|
157
|
+
if old_dir.exists():
|
|
158
|
+
dir_violations.append(
|
|
159
|
+
{"version": v, "path": str(version_dir),
|
|
160
|
+
"issue": "old-name-dir-present", "detail": f"{old_name}/"}
|
|
161
|
+
)
|
|
162
|
+
if not new_dir.exists():
|
|
163
|
+
dir_violations.append(
|
|
164
|
+
{"version": v, "path": str(version_dir),
|
|
165
|
+
"issue": "new-name-dir-missing", "detail": f"{new_name}/"}
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
clean = not hard_matches and not dir_violations
|
|
169
|
+
return {
|
|
170
|
+
"status": "ok",
|
|
171
|
+
"old_name": old_name,
|
|
172
|
+
"new_name": new_name,
|
|
173
|
+
"versions": list(versions),
|
|
174
|
+
"hard_matches": hard_matches,
|
|
175
|
+
"body_warnings": body_warnings,
|
|
176
|
+
"dir_violations": dir_violations,
|
|
177
|
+
"skipped": skipped,
|
|
178
|
+
"clean": clean,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def main() -> None:
|
|
183
|
+
parser = argparse.ArgumentParser(
|
|
184
|
+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
|
185
|
+
)
|
|
186
|
+
parser.add_argument("skill_group", type=Path, help="new_skill_group ({skills_output_folder}/{new_name})")
|
|
187
|
+
parser.add_argument("--forge-group", type=Path, required=True, help="new_forge_group ({forge_data_folder}/{new_name})")
|
|
188
|
+
parser.add_argument("--old-name", required=True, help="Old skill name to scan for")
|
|
189
|
+
parser.add_argument("--new-name", required=True, help="New skill name")
|
|
190
|
+
parser.add_argument("--versions", required=True, help="Comma-separated version list (affected_versions)")
|
|
191
|
+
parser.add_argument("--verbose", action="store_true", help="Diagnostics to stderr")
|
|
192
|
+
args = parser.parse_args()
|
|
193
|
+
|
|
194
|
+
versions = [v for v in (s.strip() for s in args.versions.split(",")) if v]
|
|
195
|
+
if not versions:
|
|
196
|
+
print(json.dumps({"status": "error", "message": "no versions provided"}), file=sys.stderr)
|
|
197
|
+
sys.exit(2)
|
|
198
|
+
|
|
199
|
+
result = verify(args.skill_group, args.forge_group, args.old_name, args.new_name, versions)
|
|
200
|
+
|
|
201
|
+
if args.verbose:
|
|
202
|
+
print(
|
|
203
|
+
f"[skf-verify-no-trace] hard={len(result['hard_matches'])} "
|
|
204
|
+
f"warn={len(result['body_warnings'])} dir={len(result['dir_violations'])} "
|
|
205
|
+
f"clean={result['clean']}",
|
|
206
|
+
file=sys.stderr,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
print(json.dumps(result, indent=2))
|
|
210
|
+
sys.exit(0 if result["clean"] else 1)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
if __name__ == "__main__":
|
|
214
|
+
main()
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.10"
|
|
4
|
+
# dependencies = []
|
|
5
|
+
# ///
|
|
6
|
+
"""SKF Verify Provenance Completeness — deterministic Check D for update-skill.
|
|
7
|
+
|
|
8
|
+
`skf-update-skill/references/validate.md §2 Check D (Provenance Completeness)`
|
|
9
|
+
previously asked the model to perform three deterministic set/citation
|
|
10
|
+
operations by eye, against in-context data:
|
|
11
|
+
|
|
12
|
+
1. **Completeness** — verify every documented export has a provenance-map
|
|
13
|
+
entry (metadata `exports[]` \\ provenance `entries[].export_name`).
|
|
14
|
+
2. **Orphans** — flag provenance entries whose export was removed
|
|
15
|
+
(provenance `entries[].export_name` \\ metadata `exports[]`).
|
|
16
|
+
3. **Stale citations** — flag entries whose `source_file:source_line`
|
|
17
|
+
no longer resolves (file gone, or line past end of file).
|
|
18
|
+
|
|
19
|
+
An LLM set-diff can silently pass a dropped or orphaned entry, and eyeballing
|
|
20
|
+
whether a cited line still exists is not something the model can do reliably.
|
|
21
|
+
Both inputs are machine-readable — `metadata.json` (`exports[]`, a list of
|
|
22
|
+
export-name strings) and `provenance-map.json` (`entries[]` with
|
|
23
|
+
`export_name` / `source_file` / `source_line`, plus an optional
|
|
24
|
+
`reexport_map` for stack-skill barrel renames) are written by
|
|
25
|
+
`write.md` §2 and §3. This script diffs the two sets and re-checks the
|
|
26
|
+
citations exactly, so Check D consumes JSON rather than computing it.
|
|
27
|
+
|
|
28
|
+
Timing: this runs **post-write** (`write.md` §6), after `metadata.json`
|
|
29
|
+
(§2) and `provenance-map.json` (§3) are on disk. `validate.md` Check D
|
|
30
|
+
defers here — the provenance map does not exist yet at validate time.
|
|
31
|
+
|
|
32
|
+
Determinism:
|
|
33
|
+
- Set operations are pure over the two JSON inputs.
|
|
34
|
+
- Citation resolution reads the source tree at `source_root` (from the
|
|
35
|
+
provenance map, or `--source-root`). When no source root resolves on
|
|
36
|
+
disk, the stale check is SKIPPED (`summary.stale_check` reports it) and
|
|
37
|
+
only completeness + orphan diffs run — those need no source tree. Same
|
|
38
|
+
input tree → same output.
|
|
39
|
+
|
|
40
|
+
Stack-skill canonicalization: a provenance entry's `export_name` may be the
|
|
41
|
+
internal name of a barrel-renamed export (e.g. `_Impl` re-exported as
|
|
42
|
+
`Public`). Names are canonicalized through `reexport_map`
|
|
43
|
+
(`{internal: public}`) before the set-diff, mirroring
|
|
44
|
+
`skf-load-provenance.extract_reexport_map`, so an internal-named entry does
|
|
45
|
+
not read as a missing public export nor as an orphan.
|
|
46
|
+
|
|
47
|
+
Subcommand:
|
|
48
|
+
verify --metadata <metadata.json> --provenance <provenance-map.json>
|
|
49
|
+
[--source-root <path>] [-o <out.json>]
|
|
50
|
+
|
|
51
|
+
Emit JSON:
|
|
52
|
+
{
|
|
53
|
+
"status": "pass" | "findings",
|
|
54
|
+
"missing": ["<export documented but not in provenance>", ...],
|
|
55
|
+
"orphaned": ["<provenance entry with no documented export>", ...],
|
|
56
|
+
"stale": [
|
|
57
|
+
{"export_name": "<raw entry name>",
|
|
58
|
+
"source_file": "<rel path>",
|
|
59
|
+
"source_line": <int|null>,
|
|
60
|
+
"reason": "file-missing" | "line-out-of-bounds" | "line-invalid"},
|
|
61
|
+
...
|
|
62
|
+
],
|
|
63
|
+
"summary": {
|
|
64
|
+
"exports_checked": <int>, # documented exports (metadata)
|
|
65
|
+
"entries_checked": <int>, # provenance entries with export_name
|
|
66
|
+
"missing_count": <int>,
|
|
67
|
+
"orphaned_count": <int>,
|
|
68
|
+
"stale_count": <int>,
|
|
69
|
+
"citations_checked": <int>, # entries whose citation was resolved
|
|
70
|
+
"stale_check": "checked" | "skipped-no-source-root"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
`missing` / `orphaned` are sorted, deduplicated public names.
|
|
75
|
+
`stale` is sorted by (export_name, source_file).
|
|
76
|
+
|
|
77
|
+
CLI examples:
|
|
78
|
+
uv run skf-verify-provenance-completeness.py verify \\
|
|
79
|
+
--metadata {skill_package}/metadata.json \\
|
|
80
|
+
--provenance {forge_version}/provenance-map.json \\
|
|
81
|
+
--source-root {source_root}
|
|
82
|
+
|
|
83
|
+
Exit codes:
|
|
84
|
+
0 — verification ran and found nothing (status "pass")
|
|
85
|
+
1 — verification ran and found missing / orphaned / stale entries
|
|
86
|
+
(status "findings"); advisory — the caller decides how to surface it
|
|
87
|
+
2 — error (input file not found, malformed JSON, invalid structure)
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
from __future__ import annotations
|
|
91
|
+
|
|
92
|
+
import argparse
|
|
93
|
+
import json
|
|
94
|
+
import sys
|
|
95
|
+
from pathlib import Path
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
STALE_FILE_MISSING = "file-missing"
|
|
99
|
+
STALE_LINE_OOB = "line-out-of-bounds"
|
|
100
|
+
STALE_LINE_INVALID = "line-invalid"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --------------------------------------------------------------------------
|
|
104
|
+
# I/O
|
|
105
|
+
# --------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def load_json_object(path: Path, label: str) -> dict:
|
|
109
|
+
"""Read a JSON file and require a top-level object. Raises ValueError."""
|
|
110
|
+
try:
|
|
111
|
+
text = path.read_text(encoding="utf-8")
|
|
112
|
+
except OSError as exc:
|
|
113
|
+
raise ValueError(f"failed to read {label} {path}: {exc}") from exc
|
|
114
|
+
try:
|
|
115
|
+
data = json.loads(text)
|
|
116
|
+
except json.JSONDecodeError as exc:
|
|
117
|
+
raise ValueError(f"malformed JSON in {label} {path}: {exc}") from exc
|
|
118
|
+
if not isinstance(data, dict):
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"{label} {path} must be a JSON object at top level; "
|
|
121
|
+
f"got {type(data).__name__}"
|
|
122
|
+
)
|
|
123
|
+
return data
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --------------------------------------------------------------------------
|
|
127
|
+
# Extraction / canonicalization
|
|
128
|
+
# --------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def extract_export_names(metadata: dict) -> list[str]:
|
|
132
|
+
"""Pull documented export names from metadata.exports.
|
|
133
|
+
|
|
134
|
+
`exports` is a list of export-name strings (per the skill templates).
|
|
135
|
+
Defensively also accept dict items carrying `name` / `export_name`.
|
|
136
|
+
Non-string / unnamed items are skipped. Order preserved; caller dedupes.
|
|
137
|
+
"""
|
|
138
|
+
names: list[str] = []
|
|
139
|
+
exports = metadata.get("exports")
|
|
140
|
+
if not isinstance(exports, list):
|
|
141
|
+
return names
|
|
142
|
+
for item in exports:
|
|
143
|
+
if isinstance(item, str) and item:
|
|
144
|
+
names.append(item)
|
|
145
|
+
elif isinstance(item, dict):
|
|
146
|
+
nm = item.get("name") or item.get("export_name")
|
|
147
|
+
if isinstance(nm, str) and nm:
|
|
148
|
+
names.append(nm)
|
|
149
|
+
return names
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def extract_reexport_map(prov: dict) -> dict[str, str]:
|
|
153
|
+
"""Build the `{internal: public}` re-export map.
|
|
154
|
+
|
|
155
|
+
Mirrors `skf-load-provenance.extract_reexport_map`: top-level
|
|
156
|
+
`reexport_map` is authoritative; per-entry `reexported_as` fills gaps.
|
|
157
|
+
Non-string keys/values skipped.
|
|
158
|
+
"""
|
|
159
|
+
out: dict[str, str] = {}
|
|
160
|
+
top = prov.get("reexport_map")
|
|
161
|
+
if isinstance(top, dict):
|
|
162
|
+
for k, v in top.items():
|
|
163
|
+
if isinstance(k, str) and isinstance(v, str):
|
|
164
|
+
out[k] = v
|
|
165
|
+
entries = prov.get("entries")
|
|
166
|
+
if isinstance(entries, list):
|
|
167
|
+
for entry in entries:
|
|
168
|
+
if not isinstance(entry, dict):
|
|
169
|
+
continue
|
|
170
|
+
internal = entry.get("export_name")
|
|
171
|
+
public = entry.get("reexported_as")
|
|
172
|
+
if isinstance(internal, str) and isinstance(public, str):
|
|
173
|
+
out.setdefault(internal, public)
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def canon(name: str, reexport_map: dict[str, str]) -> str:
|
|
178
|
+
"""Canonicalize an export name to its public form via reexport_map.
|
|
179
|
+
|
|
180
|
+
Idempotent for names that are not internal (public names are not keys).
|
|
181
|
+
"""
|
|
182
|
+
return reexport_map.get(name, name)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def provenance_entry_names(prov: dict) -> list[str]:
|
|
186
|
+
"""Raw `export_name` of every well-formed provenance entry (order kept)."""
|
|
187
|
+
names: list[str] = []
|
|
188
|
+
entries = prov.get("entries")
|
|
189
|
+
if not isinstance(entries, list):
|
|
190
|
+
return names
|
|
191
|
+
for entry in entries:
|
|
192
|
+
if not isinstance(entry, dict):
|
|
193
|
+
continue
|
|
194
|
+
nm = entry.get("export_name")
|
|
195
|
+
if isinstance(nm, str) and nm:
|
|
196
|
+
names.append(nm)
|
|
197
|
+
return names
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --------------------------------------------------------------------------
|
|
201
|
+
# Citation resolution
|
|
202
|
+
# --------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _coerce_line(value: object) -> int | None | str:
|
|
206
|
+
"""Return an int line number, None (tolerated), or a sentinel str for
|
|
207
|
+
an un-coercible non-null value (reported as line-invalid)."""
|
|
208
|
+
if value is None:
|
|
209
|
+
return None
|
|
210
|
+
if isinstance(value, bool):
|
|
211
|
+
# bool is an int subclass — a boolean line number is nonsense
|
|
212
|
+
return "invalid"
|
|
213
|
+
if isinstance(value, int):
|
|
214
|
+
return value
|
|
215
|
+
if isinstance(value, str):
|
|
216
|
+
s = value.strip()
|
|
217
|
+
if not s:
|
|
218
|
+
return None
|
|
219
|
+
try:
|
|
220
|
+
return int(s)
|
|
221
|
+
except ValueError:
|
|
222
|
+
return "invalid"
|
|
223
|
+
return "invalid"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def check_citation(
|
|
227
|
+
source_file: object, source_line: object, source_root: Path
|
|
228
|
+
) -> str | None:
|
|
229
|
+
"""Resolve a single `source_file:source_line` citation under source_root.
|
|
230
|
+
|
|
231
|
+
Returns a stale reason (one of the STALE_* constants) when the citation
|
|
232
|
+
does not resolve, or None when it is valid OR tolerated.
|
|
233
|
+
|
|
234
|
+
Tolerated (returns None): source_file missing/empty, or source_line null
|
|
235
|
+
or empty — `unknown`-outcome entries legitimately carry null citations
|
|
236
|
+
(write.md §3), so an absent citation is not "stale".
|
|
237
|
+
"""
|
|
238
|
+
if not isinstance(source_file, str) or not source_file.strip():
|
|
239
|
+
return None
|
|
240
|
+
line = _coerce_line(source_line)
|
|
241
|
+
if line is None:
|
|
242
|
+
return None
|
|
243
|
+
if isinstance(line, str): # sentinel for un-coercible non-null value
|
|
244
|
+
return STALE_LINE_INVALID
|
|
245
|
+
|
|
246
|
+
rel = source_file.strip().replace("\\", "/")
|
|
247
|
+
target = (source_root / rel).resolve()
|
|
248
|
+
if not target.is_file():
|
|
249
|
+
return STALE_FILE_MISSING
|
|
250
|
+
|
|
251
|
+
if line < 1:
|
|
252
|
+
return STALE_LINE_OOB
|
|
253
|
+
try:
|
|
254
|
+
with target.open("r", encoding="utf-8", errors="replace") as fh:
|
|
255
|
+
line_count = sum(1 for _ in fh)
|
|
256
|
+
except OSError:
|
|
257
|
+
return STALE_FILE_MISSING
|
|
258
|
+
if line > line_count:
|
|
259
|
+
return STALE_LINE_OOB
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# --------------------------------------------------------------------------
|
|
264
|
+
# Verification
|
|
265
|
+
# --------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def resolve_source_root(prov: dict, override: str | None) -> Path | None:
|
|
269
|
+
"""Pick the source root for citation resolution.
|
|
270
|
+
|
|
271
|
+
`--source-root` override wins; otherwise the provenance map's top-level
|
|
272
|
+
`source_root`. Returns None when neither resolves to an on-disk dir.
|
|
273
|
+
"""
|
|
274
|
+
candidate: str | None = None
|
|
275
|
+
if override:
|
|
276
|
+
candidate = override
|
|
277
|
+
else:
|
|
278
|
+
sr = prov.get("source_root")
|
|
279
|
+
if isinstance(sr, str) and sr:
|
|
280
|
+
candidate = sr
|
|
281
|
+
if not candidate:
|
|
282
|
+
return None
|
|
283
|
+
root = Path(candidate)
|
|
284
|
+
return root if root.is_dir() else None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def verify(metadata: dict, prov: dict, source_root: Path | None) -> dict:
|
|
288
|
+
"""Compute the completeness / orphan / stale report over the two maps."""
|
|
289
|
+
reexport_map = extract_reexport_map(prov)
|
|
290
|
+
|
|
291
|
+
documented = extract_export_names(metadata)
|
|
292
|
+
documented_canon = {canon(n, reexport_map) for n in documented}
|
|
293
|
+
|
|
294
|
+
raw_entry_names = provenance_entry_names(prov)
|
|
295
|
+
entry_canon = {canon(n, reexport_map) for n in raw_entry_names}
|
|
296
|
+
|
|
297
|
+
missing = sorted(documented_canon - entry_canon)
|
|
298
|
+
orphaned = sorted(entry_canon - documented_canon)
|
|
299
|
+
|
|
300
|
+
stale: list[dict] = []
|
|
301
|
+
citations_checked = 0
|
|
302
|
+
if source_root is not None:
|
|
303
|
+
entries = prov.get("entries")
|
|
304
|
+
if isinstance(entries, list):
|
|
305
|
+
for entry in entries:
|
|
306
|
+
if not isinstance(entry, dict):
|
|
307
|
+
continue
|
|
308
|
+
name = entry.get("export_name")
|
|
309
|
+
if not isinstance(name, str) or not name:
|
|
310
|
+
continue
|
|
311
|
+
sf = entry.get("source_file")
|
|
312
|
+
sl = entry.get("source_line")
|
|
313
|
+
# Only count a resolvable (non-null) citation as "checked".
|
|
314
|
+
if isinstance(sf, str) and sf.strip() and _coerce_line(sl) is not None:
|
|
315
|
+
citations_checked += 1
|
|
316
|
+
reason = check_citation(sf, sl, source_root)
|
|
317
|
+
if reason is not None:
|
|
318
|
+
stale.append(
|
|
319
|
+
{
|
|
320
|
+
"export_name": name,
|
|
321
|
+
"source_file": sf if isinstance(sf, str) else None,
|
|
322
|
+
"source_line": sl if isinstance(sl, int)
|
|
323
|
+
and not isinstance(sl, bool) else sl,
|
|
324
|
+
"reason": reason,
|
|
325
|
+
}
|
|
326
|
+
)
|
|
327
|
+
stale.sort(key=lambda s: (s["export_name"], s.get("source_file") or ""))
|
|
328
|
+
|
|
329
|
+
status = "pass" if not (missing or orphaned or stale) else "findings"
|
|
330
|
+
return {
|
|
331
|
+
"status": status,
|
|
332
|
+
"missing": missing,
|
|
333
|
+
"orphaned": orphaned,
|
|
334
|
+
"stale": stale,
|
|
335
|
+
"summary": {
|
|
336
|
+
"exports_checked": len(documented_canon),
|
|
337
|
+
"entries_checked": len(raw_entry_names),
|
|
338
|
+
"missing_count": len(missing),
|
|
339
|
+
"orphaned_count": len(orphaned),
|
|
340
|
+
"stale_count": len(stale),
|
|
341
|
+
"citations_checked": citations_checked,
|
|
342
|
+
"stale_check": "checked"
|
|
343
|
+
if source_root is not None
|
|
344
|
+
else "skipped-no-source-root",
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# --------------------------------------------------------------------------
|
|
350
|
+
# CLI
|
|
351
|
+
# --------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _cmd_verify(args: argparse.Namespace) -> int:
|
|
355
|
+
meta_path = Path(args.metadata)
|
|
356
|
+
prov_path = Path(args.provenance)
|
|
357
|
+
if not meta_path.is_file():
|
|
358
|
+
print(f"error: metadata not found: {meta_path}", file=sys.stderr)
|
|
359
|
+
return 2
|
|
360
|
+
if not prov_path.is_file():
|
|
361
|
+
print(f"error: provenance map not found: {prov_path}", file=sys.stderr)
|
|
362
|
+
return 2
|
|
363
|
+
try:
|
|
364
|
+
metadata = load_json_object(meta_path, "metadata")
|
|
365
|
+
prov = load_json_object(prov_path, "provenance map")
|
|
366
|
+
except ValueError as exc:
|
|
367
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
368
|
+
return 2
|
|
369
|
+
|
|
370
|
+
source_root = resolve_source_root(prov, args.source_root)
|
|
371
|
+
if args.verbose:
|
|
372
|
+
if source_root is None:
|
|
373
|
+
print(
|
|
374
|
+
"verbose: no source root resolved — skipping stale-citation "
|
|
375
|
+
"check (completeness + orphan diffs only)",
|
|
376
|
+
file=sys.stderr,
|
|
377
|
+
)
|
|
378
|
+
else:
|
|
379
|
+
print(f"verbose: resolving citations under {source_root}", file=sys.stderr)
|
|
380
|
+
|
|
381
|
+
result = verify(metadata, prov, source_root)
|
|
382
|
+
|
|
383
|
+
out_text = json.dumps(result, indent=2) + "\n"
|
|
384
|
+
if args.output and args.output != "-":
|
|
385
|
+
Path(args.output).write_text(out_text, encoding="utf-8")
|
|
386
|
+
else:
|
|
387
|
+
sys.stdout.write(out_text)
|
|
388
|
+
|
|
389
|
+
return 0 if result["status"] == "pass" else 1
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
393
|
+
parser = argparse.ArgumentParser(
|
|
394
|
+
prog="skf-verify-provenance-completeness",
|
|
395
|
+
description=(
|
|
396
|
+
"Cross-reference documented exports (metadata.json) against "
|
|
397
|
+
"provenance-map entries — completeness, orphans, and stale "
|
|
398
|
+
"file:line citations — emitting findings as JSON."
|
|
399
|
+
),
|
|
400
|
+
)
|
|
401
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
402
|
+
p = sub.add_parser(
|
|
403
|
+
"verify",
|
|
404
|
+
help="emit provenance completeness / orphan / stale-citation findings",
|
|
405
|
+
)
|
|
406
|
+
p.add_argument("--metadata", required=True, help="path to metadata.json")
|
|
407
|
+
p.add_argument(
|
|
408
|
+
"--provenance", required=True, help="path to provenance-map.json"
|
|
409
|
+
)
|
|
410
|
+
p.add_argument(
|
|
411
|
+
"--source-root",
|
|
412
|
+
default=None,
|
|
413
|
+
help=(
|
|
414
|
+
"source tree root for citation resolution; overrides the "
|
|
415
|
+
"provenance map's source_root. Stale check is skipped when no "
|
|
416
|
+
"root resolves on disk."
|
|
417
|
+
),
|
|
418
|
+
)
|
|
419
|
+
p.add_argument(
|
|
420
|
+
"-o",
|
|
421
|
+
"--output",
|
|
422
|
+
default=None,
|
|
423
|
+
help="write JSON to this file instead of stdout ('-' for stdout)",
|
|
424
|
+
)
|
|
425
|
+
p.add_argument(
|
|
426
|
+
"--verbose", action="store_true", help="diagnostics to stderr"
|
|
427
|
+
)
|
|
428
|
+
p.set_defaults(func=_cmd_verify)
|
|
429
|
+
return parser
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def main(argv: list[str] | None = None) -> int:
|
|
433
|
+
parser = _build_parser()
|
|
434
|
+
args = parser.parse_args(argv)
|
|
435
|
+
return args.func(args)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
if __name__ == "__main__":
|
|
439
|
+
raise SystemExit(main())
|