bmad-module-skill-forge 2.2.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +49 -49
- package/docs/404.md +10 -8
- package/docs/_data/pinned.yaml +22 -13
- package/docs/_internal/STABILITY.md +58 -35
- package/docs/agents.md +39 -53
- package/docs/architecture.md +65 -51
- package/docs/bmad-synergy.md +53 -51
- package/docs/campaign.md +127 -75
- package/docs/concepts.md +34 -28
- package/docs/examples.md +70 -68
- package/docs/forge-auto.md +34 -31
- package/docs/getting-started.md +118 -57
- package/docs/how-it-works.md +25 -21
- package/docs/index.md +15 -15
- package/docs/skill-model.md +70 -59
- package/docs/troubleshooting.md +135 -18
- package/docs/verifying-a-skill.md +83 -57
- package/docs/why-skf.md +24 -22
- package/docs/workflows.md +274 -110
- package/package.json +18 -10
- package/src/forger/preferences.yaml +8 -1
- package/src/knowledge/agentskills-spec.md +2 -0
- package/src/knowledge/ccc-bridge.md +40 -24
- package/src/knowledge/confidence-tiers.md +9 -5
- package/src/knowledge/overview.md +3 -3
- package/src/knowledge/progressive-capability.md +8 -8
- package/src/knowledge/provenance-tracking.md +9 -5
- package/src/knowledge/qmd-registry.md +2 -2
- package/src/knowledge/skf-knowledge-index.csv +2 -2
- package/src/knowledge/skill-lifecycle.md +18 -11
- package/src/knowledge/split-body-strategy.md +0 -3
- package/src/knowledge/tool-resolution.md +4 -2
- package/src/knowledge/version-paths.md +101 -15
- package/src/module-help.csv +3 -3
- package/src/module.yaml +10 -0
- package/src/shared/data/ast-grep-recipes.yaml +608 -0
- package/src/shared/data/ide-context-files.json +30 -0
- package/src/shared/health-check.md +32 -12
- package/src/shared/references/description-guard-protocol.md +12 -9
- package/src/shared/references/feasibility-report-schema.md +19 -13
- package/src/shared/references/headless-gate-convention.md +55 -3
- package/src/shared/references/output-contract-schema.md +19 -8
- package/src/shared/references/pipeline-contracts.md +94 -29
- package/src/shared/references/tessl-review.md +77 -0
- package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
- package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
- package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
- package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
- package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
- package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
- package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
- package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
- package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
- package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
- package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
- package/src/shared/scripts/skf-atomic-write.py +30 -14
- package/src/shared/scripts/skf-build-change-manifest.py +2203 -24
- package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
- package/src/shared/scripts/skf-check-unit-records.py +184 -0
- package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
- package/src/shared/scripts/skf-classify-changed-files.py +694 -0
- package/src/shared/scripts/skf-comention-pairs.py +1333 -123
- package/src/shared/scripts/skf-compare-file-hashes.py +1 -1
- package/src/shared/scripts/skf-count-imports.py +1867 -0
- package/src/shared/scripts/skf-count-tokens.py +1 -1
- package/src/shared/scripts/skf-derive-assembly-shape.py +3 -2
- package/src/shared/scripts/skf-description-guard.py +99 -11
- package/src/shared/scripts/skf-detect-docs.py +694 -2
- package/src/shared/scripts/skf-detect-language.py +486 -107
- package/src/shared/scripts/skf-detect-registry.py +887 -0
- package/src/shared/scripts/skf-detect-scripts-assets.py +1 -1
- package/src/shared/scripts/skf-detect-tools.py +209 -27
- package/src/shared/scripts/skf-detect-workspaces.py +506 -30
- package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
- package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
- package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
- package/src/shared/scripts/skf-extract-public-api.py +4084 -85
- package/src/shared/scripts/skf-extraction-inventory.py +746 -0
- package/src/shared/scripts/skf-extraction-snapshot.py +539 -0
- package/src/shared/scripts/skf-fetch-temporal.py +674 -0
- package/src/shared/scripts/skf-find-cycles.py +97 -29
- package/src/shared/scripts/skf-find-test-report.py +476 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +444 -203
- package/src/shared/scripts/skf-github-fetch.py +365 -0
- package/src/shared/scripts/skf-github-probe.py +661 -0
- package/src/shared/scripts/skf-hash-content.py +166 -12
- package/src/shared/scripts/skf-language-corpora.py +1 -1
- package/src/shared/scripts/skf-load-provenance.py +159 -27
- package/src/shared/scripts/skf-manifest-ops.py +1 -1
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
- package/src/shared/scripts/skf-merge-doc-urls.py +1 -1
- package/src/shared/scripts/skf-names-present.py +221 -0
- package/src/shared/scripts/skf-pair-intersect.py +119 -28
- package/src/shared/scripts/skf-parse-gaps.py +1384 -0
- package/src/shared/scripts/skf-preapply.py +1 -1
- package/src/shared/scripts/skf-preflight.py +161 -27
- package/src/shared/scripts/skf-promote-staged.py +430 -0
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +39 -7
- package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
- package/src/shared/scripts/skf-quick-batch.py +774 -0
- package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
- package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
- package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
- package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
- package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
- package/src/shared/scripts/skf-resolve-authoritative-files.py +128 -13
- package/src/shared/scripts/skf-resolve-package.py +523 -106
- package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
- package/src/shared/scripts/skf-run-lock.py +577 -0
- package/src/shared/scripts/skf-scan-manifests.py +998 -57
- package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
- package/src/shared/scripts/skf-severity-classify.py +712 -108
- package/src/shared/scripts/skf-shape-detect.py +298 -204
- package/src/shared/scripts/skf-shard-body.py +9 -8
- package/src/shared/scripts/skf-skill-inventory.py +2412 -89
- package/src/shared/scripts/skf-skills-module.py +472 -0
- package/src/shared/scripts/skf-source-tree.py +1796 -0
- package/src/shared/scripts/skf-structural-diff.py +670 -119
- package/src/shared/scripts/skf-tessl-review.py +706 -0
- package/src/shared/scripts/skf-update-active-symlink.py +5 -5
- package/src/shared/scripts/skf-update-run-state.py +624 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
- package/src/shared/scripts/skf-validate-brief-schema.py +4 -1
- package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
- package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
- package/src/shared/scripts/skf-validate-output.py +712 -64
- package/src/shared/scripts/skf-validate-pins.py +1 -1
- package/src/shared/scripts/skf-verify-no-trace.py +116 -10
- package/src/shared/scripts/skf-verify-provenance-completeness.py +2919 -54
- package/src/shared/scripts/skf-write-skill-brief.py +367 -19
- package/src/shared/tool-requirements.yaml +201 -0
- package/src/skf-analyze-source/SKILL.md +41 -36
- package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
- package/src/skf-analyze-source/customize.toml +36 -24
- package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
- package/src/skf-analyze-source/references/continue.md +2 -13
- package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
- package/src/skf-analyze-source/references/generate-briefs.md +121 -55
- package/src/skf-analyze-source/references/headless-contract.md +42 -15
- package/src/skf-analyze-source/references/identify-units.md +96 -110
- package/src/skf-analyze-source/references/init.md +73 -71
- package/src/skf-analyze-source/references/map-and-detect.md +123 -105
- package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
- package/src/skf-analyze-source/references/recommend.md +51 -70
- package/src/skf-analyze-source/references/scan-project.md +60 -84
- package/src/skf-analyze-source/references/scan-root.md +39 -0
- package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
- package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
- package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
- package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
- package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
- package/src/skf-audit-skill/SKILL.md +43 -51
- package/src/skf-audit-skill/assets/drift-report-template.md +27 -3
- package/src/skf-audit-skill/customize.toml +41 -20
- package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
- package/src/skf-audit-skill/references/doc-drift.md +141 -0
- package/src/skf-audit-skill/references/headless-contract.md +29 -0
- package/src/skf-audit-skill/references/init.md +148 -210
- package/src/skf-audit-skill/references/re-index.md +96 -108
- package/src/skf-audit-skill/references/report.md +96 -33
- package/src/skf-audit-skill/references/semantic-diff.md +27 -18
- package/src/skf-audit-skill/references/severity-classify.md +79 -65
- package/src/skf-audit-skill/references/severity-rules.md +15 -6
- package/src/skf-audit-skill/references/structural-diff.md +75 -90
- package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
- package/src/skf-audit-skill/scripts/render-drift-tables.py +685 -0
- package/src/skf-brief-skill/SKILL.md +36 -25
- package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +17 -24
- package/src/skf-brief-skill/assets/skill-brief-schema.md +29 -15
- package/src/skf-brief-skill/customize.toml +48 -20
- package/src/skf-brief-skill/references/analyze-target.md +142 -125
- package/src/skf-brief-skill/references/confirm-brief.md +10 -8
- package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
- package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
- package/src/skf-brief-skill/references/gather-intent.md +87 -182
- package/src/skf-brief-skill/references/headless-args.md +84 -16
- package/src/skf-brief-skill/references/health-check.md +1 -1
- package/src/skf-brief-skill/references/invocation-contract.md +18 -13
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
- package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
- package/src/skf-brief-skill/references/scope-definition.md +123 -47
- package/src/skf-brief-skill/references/step-auto-brief.md +24 -80
- package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
- package/src/skf-brief-skill/references/version-resolution.md +3 -2
- package/src/skf-brief-skill/references/write-brief.md +66 -57
- package/src/skf-campaign/SKILL.md +31 -34
- package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
- package/src/skf-campaign/customize.toml +47 -16
- package/src/skf-campaign/references/campaign-contracts.md +39 -29
- package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
- package/src/skf-campaign/references/health-check.md +7 -16
- package/src/skf-campaign/references/step-01-setup.md +46 -84
- package/src/skf-campaign/references/step-02-strategy.md +31 -30
- package/src/skf-campaign/references/step-03-pins.md +26 -18
- package/src/skf-campaign/references/step-04-provenance.md +24 -23
- package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
- package/src/skf-campaign/references/step-06-batch.md +41 -34
- package/src/skf-campaign/references/step-07-capstone.md +40 -25
- package/src/skf-campaign/references/step-08-verify.md +28 -29
- package/src/skf-campaign/references/step-09-refine.md +29 -30
- package/src/skf-campaign/references/step-10-export.md +48 -36
- package/src/skf-campaign/references/step-11-maintenance.md +31 -34
- package/src/skf-campaign/references/step-resume.md +37 -68
- package/src/skf-campaign/scripts/campaign-deps.py +57 -18
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
- package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
- package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
- package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
- package/src/skf-campaign/scripts/campaign-report.py +204 -37
- package/src/skf-campaign/scripts/campaign-state.py +1128 -0
- package/src/skf-campaign/scripts/campaign-status.py +10 -74
- package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
- package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
- package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
- package/src/skf-campaign/templates/campaign-report-template.md +5 -1
- package/src/skf-campaign/templates/kickoff-template.md +0 -14
- package/src/skf-create-skill/SKILL.md +27 -20
- package/src/skf-create-skill/assets/compile-assembly-rules.md +6 -5
- package/src/skf-create-skill/assets/skill-sections.md +15 -5
- package/src/skf-create-skill/customize.toml +20 -8
- package/src/skf-create-skill/references/authoritative-files-protocol.md +41 -42
- package/src/skf-create-skill/references/batch-mode.md +81 -0
- package/src/skf-create-skill/references/ccc-index-check.md +27 -0
- package/src/skf-create-skill/references/compile.md +107 -29
- package/src/skf-create-skill/references/component-extraction.md +113 -91
- package/src/skf-create-skill/references/enrich.md +41 -23
- package/src/skf-create-skill/references/entry-points-by-hand.md +20 -0
- package/src/skf-create-skill/references/extract.md +200 -161
- package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +2 -2
- package/src/skf-create-skill/references/extraction-patterns.md +584 -196
- package/src/skf-create-skill/references/generate-artifacts.md +145 -93
- package/src/skf-create-skill/references/health-check.md +1 -1
- package/src/skf-create-skill/references/load-brief.md +53 -36
- package/src/skf-create-skill/references/relabel-rule.md +12 -0
- package/src/skf-create-skill/references/report.md +28 -59
- package/src/skf-create-skill/references/source-resolution-protocols.md +49 -155
- package/src/skf-create-skill/references/step-auto-shard.md +20 -93
- package/src/skf-create-skill/references/step-doc-rot.md +44 -60
- package/src/skf-create-skill/references/step-doc-sources.md +28 -19
- package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
- package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
- package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
- package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
- package/src/skf-create-skill/references/validate.md +173 -99
- package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
- package/src/skf-create-stack-skill/SKILL.md +29 -56
- package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
- package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
- package/src/skf-create-stack-skill/customize.toml +23 -12
- package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
- package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
- package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
- package/src/skf-create-stack-skill/references/generate-output.md +138 -128
- package/src/skf-create-stack-skill/references/init.md +90 -32
- package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
- package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
- package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
- package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
- package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
- package/src/skf-create-stack-skill/references/report.md +39 -33
- package/src/skf-create-stack-skill/references/validate.md +20 -39
- package/src/skf-drop-skill/SKILL.md +28 -51
- package/src/skf-drop-skill/customize.toml +44 -30
- package/src/skf-drop-skill/references/execute.md +79 -146
- package/src/skf-drop-skill/references/invocation-contract.md +64 -0
- package/src/skf-drop-skill/references/report.md +46 -30
- package/src/skf-drop-skill/references/select.md +118 -124
- package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
- package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
- package/src/skf-export-skill/SKILL.md +40 -62
- package/src/skf-export-skill/assets/managed-section-format.md +14 -50
- package/src/skf-export-skill/assets/snippet-format.md +3 -1
- package/src/skf-export-skill/customize.toml +42 -13
- package/src/skf-export-skill/references/generate-snippet.md +54 -46
- package/src/skf-export-skill/references/health-check.md +2 -0
- package/src/skf-export-skill/references/invocation-contract.md +15 -0
- package/src/skf-export-skill/references/load-skill.md +88 -62
- package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
- package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
- package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
- package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
- package/src/skf-export-skill/references/package.md +15 -73
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
- package/src/skf-export-skill/references/result-envelope.md +31 -16
- package/src/skf-export-skill/references/summary.md +41 -17
- package/src/skf-export-skill/references/token-report.md +4 -12
- package/src/skf-export-skill/references/update-context.md +118 -237
- package/src/skf-forger/SKILL.md +61 -37
- package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
- package/src/skf-forger/customize.toml +43 -0
- package/src/skf-forger/references/pipeline-mode.md +73 -18
- package/src/skf-forger/scripts/forge-status.py +299 -0
- package/src/skf-forger/scripts/parse-pipeline.py +322 -54
- package/src/skf-forger/scripts/pipeline-gate.py +428 -0
- package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
- package/src/skf-quick-skill/SKILL.md +54 -53
- package/src/skf-quick-skill/assets/skill-template.md +4 -2
- package/src/skf-quick-skill/customize.toml +51 -20
- package/src/skf-quick-skill/references/batch-mode.md +74 -21
- package/src/skf-quick-skill/references/compile.md +30 -31
- package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
- package/src/skf-quick-skill/references/finalize.md +69 -18
- package/src/skf-quick-skill/references/halt-contract.md +41 -29
- package/src/skf-quick-skill/references/health-check.md +5 -3
- package/src/skf-quick-skill/references/quick-extract.md +114 -45
- package/src/skf-quick-skill/references/registry-resolution.md +9 -64
- package/src/skf-quick-skill/references/resolve-target.md +116 -66
- package/src/skf-quick-skill/references/write-and-validate.md +79 -40
- package/src/skf-refine-architecture/SKILL.md +43 -53
- package/src/skf-refine-architecture/customize.toml +49 -20
- package/src/skf-refine-architecture/references/compile.md +97 -54
- package/src/skf-refine-architecture/references/exit-codes.md +30 -0
- package/src/skf-refine-architecture/references/finding-storage.md +21 -0
- package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
- package/src/skf-refine-architecture/references/improvements.md +14 -20
- package/src/skf-refine-architecture/references/init.md +98 -57
- package/src/skf-refine-architecture/references/issue-detection.md +32 -39
- package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
- package/src/skf-refine-architecture/references/report.md +65 -54
- package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
- package/src/skf-rename-skill/SKILL.md +19 -54
- package/src/skf-rename-skill/customize.toml +32 -26
- package/src/skf-rename-skill/references/execute.md +230 -184
- package/src/skf-rename-skill/references/exit-codes.md +7 -7
- package/src/skf-rename-skill/references/health-check.md +1 -7
- package/src/skf-rename-skill/references/invocation-contract.md +25 -0
- package/src/skf-rename-skill/references/report.md +63 -52
- package/src/skf-rename-skill/references/select.md +197 -115
- package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
- package/src/skf-setup/SKILL.md +26 -26
- package/src/skf-setup/customize.toml +14 -10
- package/src/skf-setup/references/auto-index.md +50 -63
- package/src/skf-setup/references/ccc-index.md +29 -90
- package/src/skf-setup/references/detect-and-tier.md +58 -67
- package/src/skf-setup/references/health-check.md +2 -0
- package/src/skf-setup/references/invocation-contract.md +14 -0
- package/src/skf-setup/references/report.md +43 -166
- package/src/skf-setup/references/tier-rules.md +5 -5
- package/src/skf-setup/references/write-config.md +34 -57
- package/src/skf-test-skill/SKILL.md +17 -57
- package/src/skf-test-skill/assets/output-section-formats.md +33 -34
- package/src/skf-test-skill/customize.toml +27 -13
- package/src/skf-test-skill/references/coherence-check.md +134 -62
- package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
- package/src/skf-test-skill/references/coverage-check.md +217 -254
- package/src/skf-test-skill/references/detect-mode.md +5 -5
- package/src/skf-test-skill/references/external-validators.md +94 -82
- package/src/skf-test-skill/references/health-check.md +6 -7
- package/src/skf-test-skill/references/init.md +163 -88
- package/src/skf-test-skill/references/invocation-contract.md +66 -0
- package/src/skf-test-skill/references/migration-section-rules.md +5 -2
- package/src/skf-test-skill/references/report.md +162 -142
- package/src/skf-test-skill/references/score.md +80 -107
- package/src/skf-test-skill/references/scoring-rules.md +41 -155
- package/src/skf-test-skill/references/source-access-protocol.md +19 -92
- package/src/skf-test-skill/references/step-hard-gate.md +51 -33
- package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
- package/src/skf-test-skill/scripts/build-result-context.py +287 -0
- package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
- package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
- package/src/skf-test-skill/scripts/compute-score.py +223 -45
- package/src/skf-test-skill/scripts/gap-ledger.py +1381 -0
- package/src/skf-test-skill/scripts/hard-gate.py +203 -0
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +760 -0
- package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
- package/src/skf-test-skill/scripts/reconcile-coverage.py +273 -79
- package/src/skf-test-skill/scripts/score-signatures.py +459 -0
- package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
- package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +127 -52
- package/src/skf-test-skill/templates/test-report-template.md +21 -15
- package/src/skf-update-skill/SKILL.md +38 -25
- package/src/skf-update-skill/customize.toml +27 -12
- package/src/skf-update-skill/references/detect-changes.md +233 -222
- package/src/skf-update-skill/references/gap-driven.md +336 -0
- package/src/skf-update-skill/references/health-check.md +31 -5
- package/src/skf-update-skill/references/init.md +250 -84
- package/src/skf-update-skill/references/invocation-contract.md +12 -0
- package/src/skf-update-skill/references/manual-section-rules.md +6 -23
- package/src/skf-update-skill/references/merge.md +180 -47
- package/src/skf-update-skill/references/re-extract.md +53 -262
- package/src/skf-update-skill/references/report.md +100 -35
- package/src/skf-update-skill/references/write.md +331 -149
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +29 -7
- package/src/skf-verify-stack/SKILL.md +23 -42
- package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
- package/src/skf-verify-stack/customize.toml +55 -22
- package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
- package/src/skf-verify-stack/references/coverage.md +67 -64
- package/src/skf-verify-stack/references/exit-codes.md +21 -8
- package/src/skf-verify-stack/references/init.md +107 -62
- package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
- package/src/skf-verify-stack/references/integrations.md +106 -65
- package/src/skf-verify-stack/references/report.md +59 -42
- package/src/skf-verify-stack/references/requirements.md +32 -9
- package/src/skf-verify-stack/references/synthesize.md +53 -39
- package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
- package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
- package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
- package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
- package/tools/cli/commands/install.js +11 -4
- package/tools/cli/commands/status.js +15 -3
- package/tools/cli/commands/update.js +11 -4
- package/tools/cli/lib/tool-check.js +320 -0
- package/tools/cli/lib/version-check.js +22 -9
- package/tools/cli/skf-cli.js +4 -1
- package/.gitattributes +0 -16
- package/.nvmrc +0 -1
- package/docs/_internal/RELEASING.md +0 -679
- package/release-audits/v1.0.0-launch-audit.md +0 -728
- package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
- package/src/skf-campaign/manifest.yaml +0 -15
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
- package/src/skf-create-skill/references/ecosystem-check.md +0 -74
- package/src/skf-drop-skill/references/headless-contract.md +0 -18
- package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
- package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
- package/src/skf-update-skill/references/validate.md +0 -124
- package/tools/validate-docs-drift.js +0 -233
- package/tools/validate-docs-links.js +0 -665
|
@@ -0,0 +1,1381 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
|
+
# dependencies = []
|
|
5
|
+
# ///
|
|
6
|
+
"""SKF test-skill gap ledger: one typed JSON record per finding.
|
|
7
|
+
|
|
8
|
+
test-skill's hard gate (step-hard-gate.md) must see every Critical and High
|
|
9
|
+
finding before scoring, and update-skill must read every gap with its source
|
|
10
|
+
and remediation after a FAIL. Markdown written by the model serves neither:
|
|
11
|
+
the coverage section holds tables and counts, the coherence formats carry no
|
|
12
|
+
severity, and the Gap Entry Format was applied only in the report step, after
|
|
13
|
+
the gate. This script keeps the findings in a JSON ledger instead. Each stage
|
|
14
|
+
appends the gaps it finds, classified against the Gap Severity table in
|
|
15
|
+
scoring-rules.md when they are written; hard-gate.py counts the blocking ones,
|
|
16
|
+
and the Gap Report is rendered from the same records on every path.
|
|
17
|
+
|
|
18
|
+
Ledger file: `{forge_version}/test-findings-{run_id}.json`
|
|
19
|
+
|
|
20
|
+
{
|
|
21
|
+
"schema_version": 1,
|
|
22
|
+
"run_id": "<run id from the file name>" | null,
|
|
23
|
+
"stages": ["coverage-check", "coherence-check", ...],
|
|
24
|
+
"records": [
|
|
25
|
+
{
|
|
26
|
+
"id": "GAP-001", # assigned here, in append order
|
|
27
|
+
"severity": "Critical" | "High" | "Medium" | "Low" | "Info",
|
|
28
|
+
"category": "<slug from CATEGORIES>",
|
|
29
|
+
"group": "Coverage" | "Coherence" | "Structural" | "Discovery" | "External",
|
|
30
|
+
"title": "<one line>",
|
|
31
|
+
"source": "<file:line or a section reference>",
|
|
32
|
+
"remediation": "<exact action that fixes the gap>",
|
|
33
|
+
"issue": "<what is wrong>", # optional
|
|
34
|
+
"export": "<export name>", # optional
|
|
35
|
+
"stage": "<the stage that appended it>"
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
`stages` lists every stage that appended, even with no records, so the gate
|
|
41
|
+
can tell a stage that found nothing from a stage that never ran. `group` is
|
|
42
|
+
derived from the category; callers never supply `id`, `group` or `stage`.
|
|
43
|
+
|
|
44
|
+
Subcommands:
|
|
45
|
+
|
|
46
|
+
append --ledger <path> --stage <stage> [--input <file>]...
|
|
47
|
+
Read records from each <file> in turn, or from stdin without --input:
|
|
48
|
+
a JSON array, one record object, or {"records": [...]}. An empty array
|
|
49
|
+
records the stage with no findings. Every record is validated first
|
|
50
|
+
and nothing is written unless all are valid. A record equal to one
|
|
51
|
+
already in the ledger (same severity, category, title, source and
|
|
52
|
+
export) is not added again, so a stage that runs twice leaves one
|
|
53
|
+
copy. Creates the ledger when it does not exist.
|
|
54
|
+
Appends may overlap (parallel tool calls): each holds an exclusive
|
|
55
|
+
lock on <ledger>.skf-lock from its read to its write, and another
|
|
56
|
+
append waits for it (up to 30 s, then LEDGER_LOCKED), so no record an
|
|
57
|
+
append reported is lost. The write is atomic: a temp file of its own
|
|
58
|
+
beside the ledger, then a rename. The lock file is removed afterwards.
|
|
59
|
+
Output: {"status": "ok", "ledger", "stage", "appended": [ids],
|
|
60
|
+
"duplicates": [ids], "record_count"}
|
|
61
|
+
|
|
62
|
+
append --ledger <path> --stage <stage> --from <kind> --input <file>...
|
|
63
|
+
[--surface <file>] [--signatures <file>] [--numerator <file>]
|
|
64
|
+
[--stale <file>] [--provenance <file>] [--skill-dir <dir>]
|
|
65
|
+
[--metadata <path>] [--served <family>]...
|
|
66
|
+
Build the records from the result file a script wrote (see Adapters
|
|
67
|
+
below) instead of reading them, then append them as above, with the
|
|
68
|
+
same output. A gap a script finds is recorded with a fixed title,
|
|
69
|
+
Source, `export` and remediation, so a rerun dedupes it. Each kind
|
|
70
|
+
reads one --input (structure reads one or more) and only the options
|
|
71
|
+
its entry names; any other option, or a file that does not hold the
|
|
72
|
+
result its kind reads, is INVALID_INPUT and nothing is written.
|
|
73
|
+
|
|
74
|
+
Adapters (--from), each record classified by its Gap Severity row
|
|
75
|
+
(scoring-rules.md). `{meta}` is --metadata, the metadata.json path a record
|
|
76
|
+
cites (never read; default `metadata.json`):
|
|
77
|
+
|
|
78
|
+
coverage --input reconcile-coverage.py's result; --surface, --signatures,
|
|
79
|
+
--numerator, --stale, --provenance, --skill-dir, --metadata.
|
|
80
|
+
Barrel branch: each `missing` name is a Medium `missing-export`
|
|
81
|
+
gap `Missing export: {name}`, or a Medium `missing-type` gap
|
|
82
|
+
`Missing type: {name}` when the --signatures result lists it in
|
|
83
|
+
`missingTypes`; its Source is the file and line the --surface
|
|
84
|
+
result's `exports[]` records for it, else `{meta}`. Each `stale`
|
|
85
|
+
name the --stale result (classify-stale) marks `fabricated` is a
|
|
86
|
+
Critical `fabricated-signature` gap `Fabricated signature:
|
|
87
|
+
{name}` at its `source` (one with no `source` is INVALID_INPUT,
|
|
88
|
+
never a lesser gap); any other is a Medium
|
|
89
|
+
`stale-documentation` gap `Stale documentation: {name}` at the
|
|
90
|
+
first line of SKILL.md, then of references/*.md in path order,
|
|
91
|
+
that writes the name (validate-inventory.py's match, with
|
|
92
|
+
--skill-dir), else `SKILL.md`. Scalar and stack branches: a
|
|
93
|
+
`missingCount` above 0 is one Medium `missing-export` gap
|
|
94
|
+
`{missingCount} of {denominator} exports not documented` at
|
|
95
|
+
`{meta}`, except that a scalar count taken from the verified
|
|
96
|
+
numerator (`numeratorSource` `verified`) is replaced by one
|
|
97
|
+
`Missing export: {name}` gap per --numerator `absent` name, at
|
|
98
|
+
the first --provenance entry of that name, else `{meta}`.
|
|
99
|
+
Every per-name gap carries `export`.
|
|
100
|
+
guards --input load-coverage-inputs.py's surface result; --metadata.
|
|
101
|
+
`guards.deflation.fires` is a Medium `metadata-drift` gap at
|
|
102
|
+
`{meta}` (else the result's `inputs.metadata`), and
|
|
103
|
+
`guards.inflation.fires` a Medium `denominator-inflation` gap at
|
|
104
|
+
the result's `inputs.brief` (else `skill-brief.yaml`), whose
|
|
105
|
+
remediation recommends `stats.effective_denominator` when
|
|
106
|
+
`guards.umbrella.umbrella` is true, else `scope.tier_a_include`.
|
|
107
|
+
numerator --input verify-declared-numerator.py's result; --metadata.
|
|
108
|
+
`inflated` true is a High `numerator-inflation` gap at `{meta}`
|
|
109
|
+
whose issue lists the `absent` names.
|
|
110
|
+
metadata-coherence
|
|
111
|
+
--input check-metadata-coherence.py's result; --metadata. Each
|
|
112
|
+
`findings[]` entry, titled as it is and with its `detail` as the
|
|
113
|
+
issue, at `{meta}`: Medium is a Medium `metadata-drift` gap, Info
|
|
114
|
+
an Info `multi-denominator` gap.
|
|
115
|
+
provenance-line
|
|
116
|
+
--input skf-verify-provenance-completeness.py verify's result.
|
|
117
|
+
Each `stale[]` item whose reason is `line-not-definition`, at
|
|
118
|
+
its `{source_file}:{source_line}` with `export`: a Low
|
|
119
|
+
`provenance-line` gap `Provenance line is not the definition of
|
|
120
|
+
{export_name}` when `definition_lines` holds a line, else an Info
|
|
121
|
+
`provenance-unverified` gap `Provenance line not verified for
|
|
122
|
+
{export_name}`. Its other items are not gaps here.
|
|
123
|
+
coherence --input aggregate-coherence.py's result. Each
|
|
124
|
+
`invalidReferences[]` entry at `SKILL.md:{line}`, by `status`:
|
|
125
|
+
`missing` is a Critical `broken-reference` gap, `inaccurate` a
|
|
126
|
+
High `inaccurate-reference` gap (its `issues` as the issue) and
|
|
127
|
+
`escapes` a High `reference-escape` gap naming its `canonical`
|
|
128
|
+
path.
|
|
129
|
+
structure --input one or more skf-scan-skill-md-structure.py results,
|
|
130
|
+
each told apart by its keys; --served. A `scan --required-sections`
|
|
131
|
+
result: each family not `satisfied` is a High `structural` gap
|
|
132
|
+
`naive-coherence: missing required section: {family}`, unless
|
|
133
|
+
--served names it (another heading serves it: the caller's
|
|
134
|
+
judgment) or it is `description` and `frontmatter_description`
|
|
135
|
+
is true. A `scan` result: unbalanced fences are a High
|
|
136
|
+
`structural` gap, and each bare opening fence and each
|
|
137
|
+
`table_drift` row a Medium `structural` gap at `SKILL.md:{line}`.
|
|
138
|
+
A `usage-scope` result: each `zero_usage` export is a Medium
|
|
139
|
+
`structural` gap with `export`. A `scan` or `reference-check`
|
|
140
|
+
result whose `scripts_assets.missing` is true is a Medium
|
|
141
|
+
`scripts-assets` gap. The titles are coherence-check.md's
|
|
142
|
+
`naive-coherence:` texts; a `reference-check` result (contextual
|
|
143
|
+
mode) titles its gap `coherence:` instead.
|
|
144
|
+
|
|
145
|
+
render --ledger <path> [--heading]
|
|
146
|
+
Print the Gap Report body in Markdown on stdout: the totals, the
|
|
147
|
+
Remediation Summary table and one Gap Entry per record, Critical first,
|
|
148
|
+
then by id. Each entry names its category as `{group} ({category})`,
|
|
149
|
+
which skf-parse-gaps.py reads back for a report whose ledger is gone.
|
|
150
|
+
--heading adds the `## Gap Report` line. With no records the body says
|
|
151
|
+
no gaps were found.
|
|
152
|
+
|
|
153
|
+
summary --ledger <path>
|
|
154
|
+
Output: {"status": "ok", "ledger", "run_id", "stages", "total",
|
|
155
|
+
"counts": {severity: n}, "blocking", "non_blocking",
|
|
156
|
+
"by_category": {category: n}}
|
|
157
|
+
|
|
158
|
+
A Critical or High record blocks only when a stage before the hard gate
|
|
159
|
+
appended it. The report stage appends after the gate (the discovery test),
|
|
160
|
+
so render and summary count its Critical and High records as non-blocking:
|
|
161
|
+
render lists them on a line of their own, after the hard gate.
|
|
162
|
+
|
|
163
|
+
categories
|
|
164
|
+
Output the category vocabulary and the severities:
|
|
165
|
+
{"status": "ok", "severities": [...],
|
|
166
|
+
"categories": [{"category", "group", "description"}]}
|
|
167
|
+
|
|
168
|
+
Exit codes:
|
|
169
|
+
0 the result was printed
|
|
170
|
+
1 the ledger is missing, unreadable, not a valid ledger, held by another
|
|
171
|
+
append past the wait, or not writable
|
|
172
|
+
2 the input records are invalid (nothing was written), or a usage error
|
|
173
|
+
(argparse: a missing or unknown argument, usage on stderr, no JSON)
|
|
174
|
+
|
|
175
|
+
Errors print {"status": "error", "code", "error", ...} on stdout, except for
|
|
176
|
+
render, which prints it on stderr so a redirected Gap Report stays clean. The
|
|
177
|
+
codes: LEDGER_MISSING, LEDGER_INVALID, LEDGER_LOCKED, WRITE_FAILED (exit 1),
|
|
178
|
+
INVALID_INPUT and INVALID_RECORD (exit 2, with `errors: [{index, error}]`).
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
from __future__ import annotations
|
|
182
|
+
|
|
183
|
+
import argparse
|
|
184
|
+
import contextlib
|
|
185
|
+
import errno
|
|
186
|
+
import importlib.util
|
|
187
|
+
import json
|
|
188
|
+
import os
|
|
189
|
+
import re
|
|
190
|
+
import secrets
|
|
191
|
+
import sys
|
|
192
|
+
import time
|
|
193
|
+
from pathlib import Path
|
|
194
|
+
|
|
195
|
+
if os.name == "nt":
|
|
196
|
+
import msvcrt
|
|
197
|
+
else:
|
|
198
|
+
import fcntl
|
|
199
|
+
|
|
200
|
+
SCHEMA_VERSION = 1
|
|
201
|
+
|
|
202
|
+
# How long an append waits for another append's lock before LEDGER_LOCKED.
|
|
203
|
+
# An append holds it for milliseconds; the wait covers a burst of parallel
|
|
204
|
+
# tool calls, not a writer that hangs.
|
|
205
|
+
LOCK_TIMEOUT_SECONDS = 30.0
|
|
206
|
+
_LOCK_POLL_SECONDS = 0.02
|
|
207
|
+
# Windows refuses to replace a file another process holds open, and a virus
|
|
208
|
+
# scanner or the search indexer may open a just-written ledger for a moment:
|
|
209
|
+
# save_ledger retries the rename there for up to this long before failing.
|
|
210
|
+
_IS_WINDOWS = os.name == "nt"
|
|
211
|
+
_REPLACE_WAIT_SECONDS = 5.0
|
|
212
|
+
|
|
213
|
+
SEVERITIES = ("Critical", "High", "Medium", "Low", "Info")
|
|
214
|
+
BLOCKING = frozenset({"Critical", "High"})
|
|
215
|
+
_SEVERITY_BY_LOWER = {s.lower(): s for s in SEVERITIES}
|
|
216
|
+
# Stages that append after the hard gate decided (report.md records the
|
|
217
|
+
# discovery test there): a Critical or High gap they record blocked nothing.
|
|
218
|
+
POST_GATE_STAGES = frozenset({"report"})
|
|
219
|
+
|
|
220
|
+
# Category slug -> (group, what the category covers). update-skill routes a
|
|
221
|
+
# gap on its category, not its severity, so the slugs are a closed set: a typo
|
|
222
|
+
# fails the append instead of reaching update-skill as an unknown category.
|
|
223
|
+
CATEGORIES: dict[str, tuple[str, str]] = {
|
|
224
|
+
"missing-export": ("Coverage", "an exported function or class the skill does not document"),
|
|
225
|
+
"signature-mismatch": ("Coverage", "a documented signature that differs from the source"),
|
|
226
|
+
"fabricated-signature": ("Coverage", "a documented export that is absent at the cited source line"),
|
|
227
|
+
"stale-documentation": ("Coverage", "documentation for an export the source no longer has"),
|
|
228
|
+
"missing-type": ("Coverage", "a type or interface the skill does not document"),
|
|
229
|
+
"provenance-completeness": ("Coverage", "an export the skill documents that the provenance map lacks"),
|
|
230
|
+
"provenance-line": ("Coverage", "a provenance line that is not the definition of its export"),
|
|
231
|
+
"provenance-unverified": ("Coverage", "a provenance line the line-check rules could not verify"),
|
|
232
|
+
"metadata-drift": ("Coverage", "export counts in the metadata that diverge from each other"),
|
|
233
|
+
"denominator-inflation": ("Coverage", "a scope include union larger than the provenance map"),
|
|
234
|
+
"numerator-inflation": (
|
|
235
|
+
"Coverage",
|
|
236
|
+
"a documented count equal to the denominator while declared exports are absent from the skill",
|
|
237
|
+
),
|
|
238
|
+
"multi-denominator": ("Coverage", "barrel and documented-surface counts that diverge by design"),
|
|
239
|
+
"migration-section": ("Coverage", "a migration section that disagrees with the annotation data"),
|
|
240
|
+
"broken-reference": ("Coherence", "a reference whose target does not exist"),
|
|
241
|
+
"inaccurate-reference": ("Coherence", "a reference whose target exists but does not match it"),
|
|
242
|
+
"reference-escape": (
|
|
243
|
+
"Coherence",
|
|
244
|
+
"a reference whose real path leaves the skill, its source and, for a stack, the skills folder",
|
|
245
|
+
),
|
|
246
|
+
"integration-pattern": ("Coherence", "an integration pattern that is incomplete"),
|
|
247
|
+
"split-body-mismatch": ("Coherence", "SKILL.md and a references/ file that disagree about an export"),
|
|
248
|
+
"scripts-assets": ("Coherence", "a Scripts & Assets section that is missing or names a missing file"),
|
|
249
|
+
"scripts-assets-provenance": ("Coherence", "a script or asset file without a provenance entry"),
|
|
250
|
+
"structural": (
|
|
251
|
+
"Structural",
|
|
252
|
+
"a section, code fence, table or usage example in SKILL.md that is missing or wrong",
|
|
253
|
+
),
|
|
254
|
+
"metadata": ("Structural", "missing optional metadata or examples"),
|
|
255
|
+
"observation": ("Structural", "a style suggestion or another non-blocking observation"),
|
|
256
|
+
"discovery": ("Discovery", "a discovery test that misrouted prompts or did not run"),
|
|
257
|
+
"description": ("Discovery", "a description whose triggers need optimizing"),
|
|
258
|
+
"external-validator": ("External", "a skill-check diagnostic or a Tessl Review suggestion"),
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
REQUIRED_FIELDS = ("severity", "category", "title", "source", "remediation")
|
|
262
|
+
OPTIONAL_FIELDS = ("issue", "export")
|
|
263
|
+
ASSIGNED_FIELDS = ("id", "group", "stage")
|
|
264
|
+
|
|
265
|
+
# The Remediation Summary's Estimated Effort column, per severity.
|
|
266
|
+
EFFORT = {
|
|
267
|
+
"Critical": "Read the source code and write or correct the documentation",
|
|
268
|
+
"High": "Read the source code and write or correct the documentation",
|
|
269
|
+
"Medium": "Add type definitions, interface docs or the missing section",
|
|
270
|
+
"Low": "Add examples or metadata",
|
|
271
|
+
"Info": "Optional improvements, no action required",
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
_ID_RE = re.compile(r"^GAP-(\d{3,})$")
|
|
275
|
+
_STAGE_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
276
|
+
_RUN_ID_FROM_NAME = re.compile(r"^test-findings-(.+)\.json$")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class LedgerError(Exception):
|
|
280
|
+
"""A ledger that is missing, unreadable or malformed."""
|
|
281
|
+
|
|
282
|
+
def __init__(self, code: str, message: str) -> None:
|
|
283
|
+
super().__init__(message)
|
|
284
|
+
self.code = code
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# --------------------------------------------------------------------------
|
|
288
|
+
# Records
|
|
289
|
+
# --------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _one_line(value: str) -> str:
|
|
293
|
+
return " ".join(value.split())
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def normalize_record(raw: object) -> tuple[dict | None, str | None]:
|
|
297
|
+
"""Validate one input record. Returns (record, None) or (None, error).
|
|
298
|
+
|
|
299
|
+
>>> rec, err = normalize_record({"severity": "high", "category": "broken-reference",
|
|
300
|
+
... "title": " Broken link ", "source": "SKILL.md:12", "remediation": "Fix it."})
|
|
301
|
+
>>> (rec["severity"], rec["title"], err)
|
|
302
|
+
('High', 'Broken link', None)
|
|
303
|
+
>>> normalize_record({"severity": "Severe"})[1]
|
|
304
|
+
"missing required field 'category'"
|
|
305
|
+
"""
|
|
306
|
+
if not isinstance(raw, dict):
|
|
307
|
+
return None, f"a record must be a JSON object, got {type(raw).__name__}"
|
|
308
|
+
for key in ASSIGNED_FIELDS:
|
|
309
|
+
if key in raw:
|
|
310
|
+
return None, f"'{key}' is assigned by the ledger; leave it out"
|
|
311
|
+
unknown = sorted(set(raw) - set(REQUIRED_FIELDS) - set(OPTIONAL_FIELDS))
|
|
312
|
+
if unknown:
|
|
313
|
+
return None, f"unknown field(s): {', '.join(unknown)}"
|
|
314
|
+
for key in REQUIRED_FIELDS:
|
|
315
|
+
if key not in raw:
|
|
316
|
+
return None, f"missing required field '{key}'"
|
|
317
|
+
if not isinstance(raw[key], str):
|
|
318
|
+
return None, f"'{key}' must be a string"
|
|
319
|
+
for key in OPTIONAL_FIELDS:
|
|
320
|
+
if raw.get(key) is not None and not isinstance(raw[key], str):
|
|
321
|
+
return None, f"'{key}' must be a string or null"
|
|
322
|
+
severity = _SEVERITY_BY_LOWER.get(raw["severity"].strip().lower())
|
|
323
|
+
if severity is None:
|
|
324
|
+
return None, (
|
|
325
|
+
f"unknown severity '{raw['severity']}' (expected one of {', '.join(SEVERITIES)})"
|
|
326
|
+
)
|
|
327
|
+
category = raw["category"].strip()
|
|
328
|
+
if category not in CATEGORIES:
|
|
329
|
+
return None, f"unknown category '{category}' (run the categories subcommand for the list)"
|
|
330
|
+
record = {
|
|
331
|
+
"severity": severity,
|
|
332
|
+
"category": category,
|
|
333
|
+
"group": CATEGORIES[category][0],
|
|
334
|
+
"title": _one_line(raw["title"]),
|
|
335
|
+
"source": _one_line(raw["source"]),
|
|
336
|
+
"remediation": raw["remediation"].strip(),
|
|
337
|
+
}
|
|
338
|
+
for key in ("title", "source", "remediation"):
|
|
339
|
+
if not record[key]:
|
|
340
|
+
return None, f"'{key}' must not be empty"
|
|
341
|
+
issue = (raw.get("issue") or "").strip()
|
|
342
|
+
if issue:
|
|
343
|
+
record["issue"] = issue
|
|
344
|
+
export = _one_line(raw.get("export") or "")
|
|
345
|
+
if export:
|
|
346
|
+
record["export"] = export
|
|
347
|
+
return record, None
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _dedupe_key(record: dict) -> tuple[str, str, str, str, str | None]:
|
|
351
|
+
"""What makes two records the same gap. The export is part of it: two
|
|
352
|
+
missing exports can share a generic title and a barrel Source."""
|
|
353
|
+
return (
|
|
354
|
+
record["severity"],
|
|
355
|
+
record["category"],
|
|
356
|
+
record["title"],
|
|
357
|
+
record["source"],
|
|
358
|
+
record.get("export") or None,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _id_number(record: dict) -> int:
|
|
363
|
+
m = _ID_RE.match(record.get("id", ""))
|
|
364
|
+
return int(m.group(1)) if m else 0
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def ordered(records: list[dict]) -> list[dict]:
|
|
368
|
+
"""Records by severity (Critical first), then by id."""
|
|
369
|
+
rank = {s: i for i, s in enumerate(SEVERITIES)}
|
|
370
|
+
return sorted(records, key=lambda r: (rank[r["severity"]], _id_number(r)))
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def count_by_severity(records: list[dict]) -> dict[str, int]:
|
|
374
|
+
counts = {s: 0 for s in SEVERITIES}
|
|
375
|
+
for record in records:
|
|
376
|
+
counts[record["severity"]] += 1
|
|
377
|
+
return counts
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def is_blocking(record: dict) -> bool:
|
|
381
|
+
"""A Critical or High gap that a stage before the hard gate recorded.
|
|
382
|
+
|
|
383
|
+
>>> is_blocking({"severity": "High", "stage": "coherence-check"})
|
|
384
|
+
True
|
|
385
|
+
>>> is_blocking({"severity": "High", "stage": "report"})
|
|
386
|
+
False
|
|
387
|
+
"""
|
|
388
|
+
return record["severity"] in BLOCKING and record.get("stage") not in POST_GATE_STAGES
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# --------------------------------------------------------------------------
|
|
392
|
+
# Ledger file
|
|
393
|
+
# --------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def new_ledger(path: Path) -> dict:
|
|
397
|
+
m = _RUN_ID_FROM_NAME.match(path.name)
|
|
398
|
+
return {
|
|
399
|
+
"schema_version": SCHEMA_VERSION,
|
|
400
|
+
"run_id": m.group(1) if m else None,
|
|
401
|
+
"stages": [],
|
|
402
|
+
"records": [],
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def validate_ledger(data: object) -> None:
|
|
407
|
+
"""Raise LedgerError unless `data` is a well-formed ledger."""
|
|
408
|
+
if not isinstance(data, dict):
|
|
409
|
+
raise LedgerError("LEDGER_INVALID", "the ledger must be a JSON object")
|
|
410
|
+
if data.get("schema_version") != SCHEMA_VERSION:
|
|
411
|
+
raise LedgerError(
|
|
412
|
+
"LEDGER_INVALID",
|
|
413
|
+
f"unsupported schema_version {data.get('schema_version')!r} (expected {SCHEMA_VERSION})",
|
|
414
|
+
)
|
|
415
|
+
stages = data.get("stages")
|
|
416
|
+
if not isinstance(stages, list) or not all(isinstance(s, str) for s in stages):
|
|
417
|
+
raise LedgerError("LEDGER_INVALID", "'stages' must be a list of strings")
|
|
418
|
+
records = data.get("records")
|
|
419
|
+
if not isinstance(records, list):
|
|
420
|
+
raise LedgerError("LEDGER_INVALID", "'records' must be a list")
|
|
421
|
+
seen: set[str] = set()
|
|
422
|
+
for index, record in enumerate(records):
|
|
423
|
+
if not isinstance(record, dict):
|
|
424
|
+
raise LedgerError("LEDGER_INVALID", f"record {index} is not an object")
|
|
425
|
+
rid = record.get("id")
|
|
426
|
+
if not isinstance(rid, str) or not _ID_RE.match(rid):
|
|
427
|
+
raise LedgerError("LEDGER_INVALID", f"record {index} has no valid id")
|
|
428
|
+
if rid in seen:
|
|
429
|
+
raise LedgerError("LEDGER_INVALID", f"duplicate id {rid}")
|
|
430
|
+
seen.add(rid)
|
|
431
|
+
if record.get("severity") not in SEVERITIES:
|
|
432
|
+
raise LedgerError("LEDGER_INVALID", f"{rid} has an unknown severity")
|
|
433
|
+
if record.get("category") not in CATEGORIES:
|
|
434
|
+
raise LedgerError("LEDGER_INVALID", f"{rid} has an unknown category")
|
|
435
|
+
for key in ("title", "source", "remediation"):
|
|
436
|
+
if not isinstance(record.get(key), str) or not record[key]:
|
|
437
|
+
raise LedgerError("LEDGER_INVALID", f"{rid} has no {key}")
|
|
438
|
+
for key in OPTIONAL_FIELDS:
|
|
439
|
+
if record.get(key) is not None and not isinstance(record[key], str):
|
|
440
|
+
raise LedgerError("LEDGER_INVALID", f"{rid} has a {key} that is not a string")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def load_ledger(path: Path) -> dict:
|
|
444
|
+
"""Read and validate a ledger. Raises LedgerError."""
|
|
445
|
+
if not path.is_file():
|
|
446
|
+
raise LedgerError("LEDGER_MISSING", f"no ledger at {path}")
|
|
447
|
+
try:
|
|
448
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
449
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
450
|
+
raise LedgerError("LEDGER_INVALID", f"cannot read {path}: {exc}") from exc
|
|
451
|
+
except json.JSONDecodeError as exc:
|
|
452
|
+
raise LedgerError("LEDGER_INVALID", f"{path} is not valid JSON: {exc}") from exc
|
|
453
|
+
validate_ledger(data)
|
|
454
|
+
return data
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def save_ledger(path: Path, data: dict) -> None:
|
|
458
|
+
"""Write the ledger atomically: a temp file of its own beside it, then a
|
|
459
|
+
rename. Call it under ledger_lock() so no other append runs between the
|
|
460
|
+
read and this write."""
|
|
461
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
462
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}-{secrets.token_hex(4)}.skf-tmp")
|
|
463
|
+
payload = (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
|
464
|
+
# O_EXCL: never write into another writer's temp file. O_BINARY (Windows
|
|
465
|
+
# only; 0 elsewhere) keeps the \n line ends.
|
|
466
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
|
|
467
|
+
fd = os.open(tmp, flags, 0o644)
|
|
468
|
+
try:
|
|
469
|
+
with open(fd, "wb") as fh:
|
|
470
|
+
fh.write(payload)
|
|
471
|
+
fh.flush()
|
|
472
|
+
os.fsync(fh.fileno())
|
|
473
|
+
deadline = time.monotonic() + _REPLACE_WAIT_SECONDS
|
|
474
|
+
while True:
|
|
475
|
+
try:
|
|
476
|
+
os.replace(tmp, path)
|
|
477
|
+
break
|
|
478
|
+
except PermissionError:
|
|
479
|
+
if not _IS_WINDOWS or time.monotonic() >= deadline:
|
|
480
|
+
raise
|
|
481
|
+
time.sleep(_LOCK_POLL_SECONDS)
|
|
482
|
+
except BaseException:
|
|
483
|
+
with contextlib.suppress(OSError):
|
|
484
|
+
tmp.unlink()
|
|
485
|
+
raise
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _try_lock(fd: int) -> bool:
|
|
489
|
+
"""Lock fd exclusively without waiting; False while another process holds it."""
|
|
490
|
+
try:
|
|
491
|
+
if os.name == "nt":
|
|
492
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
493
|
+
else:
|
|
494
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
495
|
+
except OSError as exc:
|
|
496
|
+
if exc.errno in (errno.EAGAIN, errno.EACCES, errno.EDEADLK):
|
|
497
|
+
return False
|
|
498
|
+
raise
|
|
499
|
+
return True
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _unlock(fd: int) -> None:
|
|
503
|
+
with contextlib.suppress(OSError):
|
|
504
|
+
if os.name == "nt":
|
|
505
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
506
|
+
else:
|
|
507
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _names_open_file(fd: int, path: Path) -> bool:
|
|
511
|
+
"""True when `path` still names the file open as `fd`."""
|
|
512
|
+
try:
|
|
513
|
+
return os.path.samestat(os.fstat(fd), os.stat(path))
|
|
514
|
+
except OSError:
|
|
515
|
+
return False
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@contextlib.contextmanager
|
|
519
|
+
def ledger_lock(path: Path, timeout: float | None = None):
|
|
520
|
+
"""Hold the exclusive append lock on `<ledger>.skf-lock`.
|
|
521
|
+
|
|
522
|
+
The same fcntl / msvcrt lock as skf-atomic-write.py flip-link, but this
|
|
523
|
+
one waits: up to `timeout` seconds (LOCK_TIMEOUT_SECONDS), then it raises
|
|
524
|
+
LedgerError LEDGER_LOCKED. The lock dies with its process, so a crashed
|
|
525
|
+
append leaves nothing to clean up. The lock file is removed on release.
|
|
526
|
+
On POSIX that happens while the lock is still held, and a waiter checks
|
|
527
|
+
that the file it locked is still the one at the path: otherwise a waiter
|
|
528
|
+
on the removed file and a newcomer on a new one would both get a lock.
|
|
529
|
+
Windows removes no file that is open, so there the file goes after the
|
|
530
|
+
release, unless a waiter has it open.
|
|
531
|
+
"""
|
|
532
|
+
timeout = LOCK_TIMEOUT_SECONDS if timeout is None else timeout
|
|
533
|
+
lock_path = path.with_name(path.name + ".skf-lock")
|
|
534
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
535
|
+
deadline = time.monotonic() + timeout
|
|
536
|
+
|
|
537
|
+
def refuse() -> LedgerError:
|
|
538
|
+
return LedgerError("LEDGER_LOCKED", f"another append held {lock_path} for more than {timeout:g} s")
|
|
539
|
+
|
|
540
|
+
while True:
|
|
541
|
+
fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o644)
|
|
542
|
+
try:
|
|
543
|
+
while not _try_lock(fd):
|
|
544
|
+
if time.monotonic() >= deadline:
|
|
545
|
+
raise refuse()
|
|
546
|
+
time.sleep(_LOCK_POLL_SECONDS)
|
|
547
|
+
if os.name == "nt" or _names_open_file(fd, lock_path):
|
|
548
|
+
break
|
|
549
|
+
_unlock(fd)
|
|
550
|
+
except BaseException:
|
|
551
|
+
os.close(fd)
|
|
552
|
+
raise
|
|
553
|
+
os.close(fd)
|
|
554
|
+
if time.monotonic() >= deadline:
|
|
555
|
+
raise refuse()
|
|
556
|
+
try:
|
|
557
|
+
yield
|
|
558
|
+
finally:
|
|
559
|
+
if os.name != "nt":
|
|
560
|
+
with contextlib.suppress(OSError):
|
|
561
|
+
lock_path.unlink()
|
|
562
|
+
_unlock(fd)
|
|
563
|
+
os.close(fd)
|
|
564
|
+
if os.name == "nt":
|
|
565
|
+
with contextlib.suppress(OSError):
|
|
566
|
+
lock_path.unlink()
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def append_records(ledger: dict, stage: str, records: list[dict]) -> tuple[list[str], list[str]]:
|
|
570
|
+
"""Append validated records in place. Returns (appended ids, duplicate ids)."""
|
|
571
|
+
existing = {_dedupe_key(r): r["id"] for r in ledger["records"]}
|
|
572
|
+
next_number = max((_id_number(r) for r in ledger["records"]), default=0) + 1
|
|
573
|
+
appended: list[str] = []
|
|
574
|
+
duplicates: list[str] = []
|
|
575
|
+
for record in records:
|
|
576
|
+
key = _dedupe_key(record)
|
|
577
|
+
if key in existing:
|
|
578
|
+
duplicates.append(existing[key])
|
|
579
|
+
continue
|
|
580
|
+
rid = f"GAP-{next_number:03d}"
|
|
581
|
+
next_number += 1
|
|
582
|
+
ledger["records"].append({"id": rid, **record, "stage": stage})
|
|
583
|
+
existing[key] = rid
|
|
584
|
+
appended.append(rid)
|
|
585
|
+
if stage not in ledger["stages"]:
|
|
586
|
+
ledger["stages"].append(stage)
|
|
587
|
+
return appended, duplicates
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def parse_input(text: str) -> list:
|
|
591
|
+
"""The records an append reads: an array, one object or {"records": [...]}."""
|
|
592
|
+
data = json.loads(text)
|
|
593
|
+
if isinstance(data, list):
|
|
594
|
+
return data
|
|
595
|
+
if isinstance(data, dict):
|
|
596
|
+
if set(data) == {"records"}:
|
|
597
|
+
if not isinstance(data["records"], list):
|
|
598
|
+
raise ValueError("'records' must be a list")
|
|
599
|
+
return data["records"]
|
|
600
|
+
return [data]
|
|
601
|
+
raise ValueError("input must be a JSON array or object")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
# --------------------------------------------------------------------------
|
|
605
|
+
# Adapters: a script's result file -> records (append --from)
|
|
606
|
+
# --------------------------------------------------------------------------
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
class AdapterError(Exception):
|
|
610
|
+
"""A --from input that does not hold the result its kind reads."""
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
FAMILIES = ("description", "usage", "api_surface")
|
|
614
|
+
# The options (argparse dests) each kind reads, besides --input. A kind
|
|
615
|
+
# refuses any other, so a call that passes a file it would ignore fails.
|
|
616
|
+
ADAPTER_OPTIONS: dict[str, frozenset[str]] = {
|
|
617
|
+
"coverage": frozenset(
|
|
618
|
+
{"surface", "signatures", "numerator", "stale", "provenance", "skill_dir", "metadata"}
|
|
619
|
+
),
|
|
620
|
+
"guards": frozenset({"metadata"}),
|
|
621
|
+
"numerator": frozenset({"metadata"}),
|
|
622
|
+
"metadata-coherence": frozenset({"metadata"}),
|
|
623
|
+
"provenance-line": frozenset(),
|
|
624
|
+
"coherence": frozenset(),
|
|
625
|
+
"structure": frozenset({"served"}),
|
|
626
|
+
}
|
|
627
|
+
ALL_ADAPTER_OPTIONS = frozenset().union(*ADAPTER_OPTIONS.values())
|
|
628
|
+
_DEFAULT_METADATA = "metadata.json"
|
|
629
|
+
_DEFAULT_BRIEF = "skill-brief.yaml"
|
|
630
|
+
# The remediation of a missing required section: the SKF template heading
|
|
631
|
+
# that serves the family.
|
|
632
|
+
_SECTION_FIX = {
|
|
633
|
+
"description": (
|
|
634
|
+
"Add a `## Overview` section to SKILL.md that says what the library does and when "
|
|
635
|
+
"to use it, or a non-empty `description` to its frontmatter."
|
|
636
|
+
),
|
|
637
|
+
"usage": (
|
|
638
|
+
"Add a `## Quick Start` section to SKILL.md with a runnable example of the "
|
|
639
|
+
"library's most common calls."
|
|
640
|
+
),
|
|
641
|
+
"api_surface": (
|
|
642
|
+
"Add a `## Key API Summary` section to SKILL.md that lists the library's exports "
|
|
643
|
+
"with their purpose."
|
|
644
|
+
),
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
_SIBLINGS: dict[str, object] = {}
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _sibling(filename: str):
|
|
651
|
+
"""A script beside this one, loaded once: validate-inventory.py (the
|
|
652
|
+
documented-name match the stale-documentation Source uses)."""
|
|
653
|
+
module = _SIBLINGS.get(filename)
|
|
654
|
+
if module is None:
|
|
655
|
+
path = Path(__file__).resolve().parent / filename
|
|
656
|
+
spec = importlib.util.spec_from_file_location("skf_" + filename[:-3].replace("-", "_"), path)
|
|
657
|
+
if spec is None or spec.loader is None or not path.is_file():
|
|
658
|
+
raise AdapterError(f"{filename} not found beside {Path(__file__).name}")
|
|
659
|
+
module = importlib.util.module_from_spec(spec)
|
|
660
|
+
spec.loader.exec_module(module)
|
|
661
|
+
_SIBLINGS[filename] = module
|
|
662
|
+
return module
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def read_result(path: str, flag: str):
|
|
666
|
+
"""The JSON a script wrote. Raises AdapterError."""
|
|
667
|
+
try:
|
|
668
|
+
return json.loads(Path(path).read_bytes().decode("utf-8-sig"))
|
|
669
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
670
|
+
raise AdapterError(f"cannot read {flag} {path}: {exc}") from exc
|
|
671
|
+
except json.JSONDecodeError as exc:
|
|
672
|
+
raise AdapterError(f"{flag} {path} is not JSON: {exc.msg}") from exc
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _object(data: object, label: str, *keys: str) -> dict:
|
|
676
|
+
"""`data` as the object a script writes, with each of `keys`."""
|
|
677
|
+
if not isinstance(data, dict) or any(key not in data for key in keys):
|
|
678
|
+
needs = ", ".join(f"'{key}'" for key in keys)
|
|
679
|
+
raise AdapterError(f"{label} is not that script's result: it needs an object with {needs}")
|
|
680
|
+
return data
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _list(data: dict, key: str, label: str) -> list:
|
|
684
|
+
value = data.get(key)
|
|
685
|
+
if value is None:
|
|
686
|
+
return []
|
|
687
|
+
if not isinstance(value, list):
|
|
688
|
+
raise AdapterError(f"{label}: '{key}' must be a list")
|
|
689
|
+
return value
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _names(data: dict, key: str, label: str) -> list[str]:
|
|
693
|
+
return [n for n in _list(data, key, label) if isinstance(n, str) and n]
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _count(value: object) -> int | None:
|
|
697
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _code(names: list[str]) -> str:
|
|
701
|
+
return ", ".join(f"`{n}`" for n in names)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _gap(severity: str, category: str, title: str, source: str, remediation: str,
|
|
705
|
+
issue: str | None = None, export: str | None = None) -> dict:
|
|
706
|
+
record = {"severity": severity, "category": category, "title": title, "source": source,
|
|
707
|
+
"remediation": remediation}
|
|
708
|
+
if issue:
|
|
709
|
+
record["issue"] = issue
|
|
710
|
+
if export:
|
|
711
|
+
record["export"] = export
|
|
712
|
+
return record
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _surface_sources(surface: dict | None) -> dict[str, str]:
|
|
716
|
+
"""name -> `file:line` (or `file`) from a surface result's `exports[]`:
|
|
717
|
+
the first record with a file, one with a line preferred."""
|
|
718
|
+
sources: dict[str, tuple[bool, str]] = {}
|
|
719
|
+
for rec in (surface or {}).get("exports") or []:
|
|
720
|
+
if not isinstance(rec, dict) or not isinstance(rec.get("name"), str):
|
|
721
|
+
continue
|
|
722
|
+
file = rec.get("file")
|
|
723
|
+
if not isinstance(file, str) or not file:
|
|
724
|
+
continue
|
|
725
|
+
line = _count(rec.get("line"))
|
|
726
|
+
where = (line is not None, f"{file}:{line}" if line is not None else file)
|
|
727
|
+
known = sources.get(rec["name"])
|
|
728
|
+
if known is None or (where[0] and not known[0]):
|
|
729
|
+
sources[rec["name"]] = where
|
|
730
|
+
return {name: where for name, (_, where) in sources.items()}
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _provenance_sources(provenance: dict | None) -> dict[str, str]:
|
|
734
|
+
"""export_name -> `source_file:source_line` of its first provenance entry."""
|
|
735
|
+
sources: dict[str, str] = {}
|
|
736
|
+
for entry in (provenance or {}).get("entries") or []:
|
|
737
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("export_name"), str):
|
|
738
|
+
continue
|
|
739
|
+
file = entry.get("source_file")
|
|
740
|
+
if entry["export_name"] in sources or not isinstance(file, str) or not file:
|
|
741
|
+
continue
|
|
742
|
+
line = _count(entry.get("source_line"))
|
|
743
|
+
sources[entry["export_name"]] = f"{file}:{line}" if line is not None else file
|
|
744
|
+
return sources
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _documenting_line(skill_dir: Path, name: str) -> str | None:
|
|
748
|
+
"""The first line of SKILL.md, then of references/*.md in path order,
|
|
749
|
+
that writes `name` (validate-inventory.py's match), as `file:line`."""
|
|
750
|
+
pattern = _sibling("validate-inventory.py").name_pattern(name)
|
|
751
|
+
refs = skill_dir / "references"
|
|
752
|
+
files = [skill_dir / "SKILL.md"] + (sorted(refs.glob("*.md")) if refs.is_dir() else [])
|
|
753
|
+
for path in files:
|
|
754
|
+
try:
|
|
755
|
+
text = path.read_bytes().decode("utf-8-sig", errors="replace")
|
|
756
|
+
except OSError:
|
|
757
|
+
continue
|
|
758
|
+
for number, line in enumerate(text.split("\n"), start=1):
|
|
759
|
+
if pattern.search(line):
|
|
760
|
+
return f"{path.relative_to(skill_dir).as_posix()}:{number}"
|
|
761
|
+
return None
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _missing_export(name: str, where: str | None, meta: str, kind: str = "export") -> dict:
|
|
765
|
+
if where:
|
|
766
|
+
read = f"read its definition at `{where}`"
|
|
767
|
+
else:
|
|
768
|
+
read = f"find its definition in the source (`{meta}` lists it)"
|
|
769
|
+
if kind == "type":
|
|
770
|
+
return _gap("Medium", "missing-type", f"Missing type: {name}", where or meta,
|
|
771
|
+
f"Document the type `{name}` in SKILL.md: {read} and list its fields or members.",
|
|
772
|
+
issue=f"the type `{name}` is on the source API surface, and the skill does not document it",
|
|
773
|
+
export=name)
|
|
774
|
+
return _gap("Medium", "missing-export", f"Missing export: {name}", where or meta,
|
|
775
|
+
f"Document `{name}` in SKILL.md: {read} and add its signature, its purpose and a usage "
|
|
776
|
+
"example.",
|
|
777
|
+
issue=f"`{name}` is on the source API surface, and SKILL.md and references/ do not document it",
|
|
778
|
+
export=name)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def from_coverage(cov: object, surface: object = None, signatures: object = None, numerator: object = None,
|
|
782
|
+
stale: object = None, provenance: object = None, skill_dir: str | None = None,
|
|
783
|
+
metadata: str | None = None) -> list[dict]:
|
|
784
|
+
"""reconcile-coverage.py's result: missing and stale names, or the missing count."""
|
|
785
|
+
cov = _object(cov, "--input (reconcile-coverage.py)", "branch")
|
|
786
|
+
meta = metadata or _DEFAULT_METADATA
|
|
787
|
+
branch = cov["branch"]
|
|
788
|
+
records: list[dict] = []
|
|
789
|
+
if branch == "enumerated":
|
|
790
|
+
types = set()
|
|
791
|
+
if signatures is not None:
|
|
792
|
+
types = set(_names(_object(signatures, "--signatures (score-signatures.py)", "missingTypes"),
|
|
793
|
+
"missingTypes", "--signatures"))
|
|
794
|
+
where = _surface_sources(_object(surface, "--surface (load-coverage-inputs.py surface)", "exports")
|
|
795
|
+
if surface is not None else None)
|
|
796
|
+
for name in _names(cov, "missing", "--input"):
|
|
797
|
+
records.append(_missing_export(name, where.get(name), meta, "type" if name in types else "export"))
|
|
798
|
+
classified: dict[str, dict] = {}
|
|
799
|
+
if stale is not None:
|
|
800
|
+
if not isinstance(stale, list):
|
|
801
|
+
raise AdapterError("--stale is not classify-stale JSON (a list)")
|
|
802
|
+
classified = {i["name"]: i for i in stale if isinstance(i, dict) and isinstance(i.get("name"), str)}
|
|
803
|
+
for name in _names(cov, "stale", "--input"):
|
|
804
|
+
item = classified.get(name) or {}
|
|
805
|
+
cited = item.get("source")
|
|
806
|
+
if item.get("fabricated") is True:
|
|
807
|
+
if not isinstance(cited, str) or not cited:
|
|
808
|
+
# a Critical gap needs the line it cites: never downgrade it
|
|
809
|
+
raise AdapterError(f"--stale: {name!r} is fabricated with no source")
|
|
810
|
+
records.append(_gap(
|
|
811
|
+
"Critical", "fabricated-signature", f"Fabricated signature: {name}", cited,
|
|
812
|
+
f"Remove `{name}` from SKILL.md and references/, or correct it to the export the source "
|
|
813
|
+
f"defines: no file its provenance cites defines it (`{cited}`).",
|
|
814
|
+
issue=f"SKILL.md documents `{name}`, which the source API surface lacks and the cited "
|
|
815
|
+
"file does not define",
|
|
816
|
+
export=name))
|
|
817
|
+
continue
|
|
818
|
+
doc = _documenting_line(Path(skill_dir), name) if skill_dir else None
|
|
819
|
+
records.append(_gap(
|
|
820
|
+
"Medium", "stale-documentation", f"Stale documentation: {name}", doc or "SKILL.md",
|
|
821
|
+
f"Check `{name}` against the source and update or remove its documentation at "
|
|
822
|
+
f"{doc or 'SKILL.md'}: the enumerated source surface does not export it.",
|
|
823
|
+
issue=f"SKILL.md documents `{name}`, which the enumerated source API surface lacks",
|
|
824
|
+
export=name))
|
|
825
|
+
elif branch in ("scalar", "stack"):
|
|
826
|
+
if branch == "scalar" and cov.get("numeratorSource") == "verified":
|
|
827
|
+
if numerator is None:
|
|
828
|
+
raise AdapterError("the scalar count used the verified numerator: pass its result with "
|
|
829
|
+
"--numerator")
|
|
830
|
+
absent = _names(_object(numerator, "--numerator (verify-declared-numerator.py)", "absent"),
|
|
831
|
+
"absent", "--numerator")
|
|
832
|
+
where = _provenance_sources(_object(provenance, "--provenance (provenance map)", "entries")
|
|
833
|
+
if provenance is not None else None)
|
|
834
|
+
records += [_missing_export(name, where.get(name), meta) for name in absent]
|
|
835
|
+
else:
|
|
836
|
+
missing, denominator = _count(cov.get("missingCount")), _count(cov.get("denominator"))
|
|
837
|
+
if missing:
|
|
838
|
+
records.append(_gap(
|
|
839
|
+
"Medium", "missing-export", f"{missing} of {denominator} exports not documented", meta,
|
|
840
|
+
f"Find the {missing} exports the denominator counts that SKILL.md and references/ do not "
|
|
841
|
+
f"name (`{meta}` lists the exports) and document each with its signature.",
|
|
842
|
+
issue=f"SKILL.md and references/ name {cov.get('documented')} of the {denominator} exports "
|
|
843
|
+
f"the {branch} denominator counts"))
|
|
844
|
+
elif branch != "docsOnly":
|
|
845
|
+
raise AdapterError(f"--input (reconcile-coverage.py): unknown branch {branch!r}")
|
|
846
|
+
return records
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def from_guards(surface: object, metadata: str | None = None) -> list[dict]:
|
|
850
|
+
"""load-coverage-inputs.py surface's deflation and inflation guards."""
|
|
851
|
+
surface = _object(surface, "--input (load-coverage-inputs.py surface)", "guards", "inputs")
|
|
852
|
+
guards = surface["guards"]
|
|
853
|
+
if guards is None:
|
|
854
|
+
return []
|
|
855
|
+
if not isinstance(guards, dict):
|
|
856
|
+
raise AdapterError("--input (load-coverage-inputs.py surface): 'guards' must be an object or null")
|
|
857
|
+
inputs = surface["inputs"] if isinstance(surface["inputs"], dict) else {}
|
|
858
|
+
meta = metadata or inputs.get("metadata") or _DEFAULT_METADATA
|
|
859
|
+
brief = inputs.get("brief") or _DEFAULT_BRIEF
|
|
860
|
+
records: list[dict] = []
|
|
861
|
+
deflation = guards.get("deflation") or {}
|
|
862
|
+
if deflation.get("fires") is True:
|
|
863
|
+
records.append(_gap(
|
|
864
|
+
"Medium", "metadata-drift",
|
|
865
|
+
"denominator deflation: effective_denominator below source public surface without tier_a_include",
|
|
866
|
+
meta,
|
|
867
|
+
f"Set `stats.effective_denominator` in `{meta}` to the public surface the source re-derives, or "
|
|
868
|
+
"add `scope.tier_a_include` to the brief to name the authored tier the smaller count measures.",
|
|
869
|
+
issue=f"the re-derived surface counts {deflation.get('rederived')} exports, "
|
|
870
|
+
f"{deflation.get('pct')}% above `stats.effective_denominator` "
|
|
871
|
+
f"({deflation.get('effectiveDenominator')}), and the brief has no `scope.tier_a_include`"))
|
|
872
|
+
inflation = guards.get("inflation") or {}
|
|
873
|
+
if inflation.get("fires") is True:
|
|
874
|
+
if (guards.get("umbrella") or {}).get("umbrella") is True:
|
|
875
|
+
fix = (f"Set `stats.effective_denominator` in `{meta}` to the authored surface: the root barrel "
|
|
876
|
+
"is an umbrella of re-exports, so a `scope.tier_a_include` glob would still count them.")
|
|
877
|
+
else:
|
|
878
|
+
fix = (f"Add `scope.tier_a_include` to `{brief}`, listing the files of the authored surface, so "
|
|
879
|
+
"the denominator counts it instead of the coarse `scope.include` union.")
|
|
880
|
+
records.append(_gap(
|
|
881
|
+
"Medium", "denominator-inflation",
|
|
882
|
+
"denominator inflation: coarse scope.include union exceeds authored surface", brief, fix,
|
|
883
|
+
issue=f"the `scope.include` union counts {inflation.get('scopeIncludeUnion')} exports, "
|
|
884
|
+
f"{inflation.get('pct')}% above the {inflation.get('provenanceEntries')} provenance entries, "
|
|
885
|
+
"and the brief has no `scope.tier_a_include`"))
|
|
886
|
+
return records
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def from_numerator(numerator: object, metadata: str | None = None) -> list[dict]:
|
|
890
|
+
"""verify-declared-numerator.py's result: the numerator inflation gap."""
|
|
891
|
+
numerator = _object(numerator, "--input (verify-declared-numerator.py)", "inflated")
|
|
892
|
+
if numerator["inflated"] is not True:
|
|
893
|
+
return []
|
|
894
|
+
meta = metadata or _DEFAULT_METADATA
|
|
895
|
+
declared, verified = _count(numerator.get("declared")) or 0, _count(numerator.get("verified")) or 0
|
|
896
|
+
absent = _names(numerator, "absent", "--input")
|
|
897
|
+
return [_gap(
|
|
898
|
+
"High", "numerator-inflation",
|
|
899
|
+
f"numerator inflation: {declared - verified} of {declared} declared exports absent from "
|
|
900
|
+
"SKILL.md/references",
|
|
901
|
+
meta,
|
|
902
|
+
f"Document the absent exports in SKILL.md, or set `stats.exports_documented` in `{meta}` to the "
|
|
903
|
+
f"{verified} exports the skill documents.",
|
|
904
|
+
issue=f"`stats.exports_documented` equals the denominator, and these declared exports are absent "
|
|
905
|
+
f"from SKILL.md and references/: {_code(absent)}")]
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def from_metadata_coherence(result: object, metadata: str | None = None) -> list[dict]:
|
|
909
|
+
"""check-metadata-coherence.py's findings: count drift and multi-denominator notes."""
|
|
910
|
+
result = _object(result, "--input (check-metadata-coherence.py)", "findings")
|
|
911
|
+
meta = metadata or _DEFAULT_METADATA
|
|
912
|
+
records: list[dict] = []
|
|
913
|
+
for index, finding in enumerate(_list(result, "findings", "--input")):
|
|
914
|
+
if not isinstance(finding, dict) or not isinstance(finding.get("title"), str):
|
|
915
|
+
raise AdapterError(f"--input: findings[{index}] has no title")
|
|
916
|
+
detail = finding.get("detail") if isinstance(finding.get("detail"), str) else None
|
|
917
|
+
if finding.get("severity") == "Medium":
|
|
918
|
+
records.append(_gap(
|
|
919
|
+
"Medium", "metadata-drift", finding["title"], meta,
|
|
920
|
+
f"Recount the exports and correct the diverging counts in `{meta}`; update-skill applies it as "
|
|
921
|
+
"a metadata update.",
|
|
922
|
+
issue=detail))
|
|
923
|
+
elif finding.get("severity") == "Info":
|
|
924
|
+
records.append(_gap(
|
|
925
|
+
"Info", "multi-denominator", finding["title"], meta,
|
|
926
|
+
"No action required: the barrel and the documented surface measure different sets by design, "
|
|
927
|
+
"and the report records both counts.",
|
|
928
|
+
issue=detail))
|
|
929
|
+
else:
|
|
930
|
+
raise AdapterError(f"--input: findings[{index}] has severity {finding.get('severity')!r}, "
|
|
931
|
+
"neither Medium nor Info")
|
|
932
|
+
return records
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def from_provenance_line(result: object) -> list[dict]:
|
|
936
|
+
"""verify's `line-not-definition` items: provenance line gaps."""
|
|
937
|
+
result = _object(result, "--input (skf-verify-provenance-completeness.py verify)", "stale")
|
|
938
|
+
records: list[dict] = []
|
|
939
|
+
for item in _list(result, "stale", "--input"):
|
|
940
|
+
if not isinstance(item, dict) or item.get("reason") != "line-not-definition":
|
|
941
|
+
continue
|
|
942
|
+
name, file, line = item.get("export_name"), item.get("source_file"), item.get("source_line")
|
|
943
|
+
defs = [n for n in item.get("definition_lines") or [] if _count(n) is not None]
|
|
944
|
+
where = f"{file}:{line}"
|
|
945
|
+
if defs:
|
|
946
|
+
lines = ", ".join(str(n) for n in defs)
|
|
947
|
+
records.append(_gap(
|
|
948
|
+
"Low", "provenance-line", f"Provenance line is not the definition of {name}", where,
|
|
949
|
+
f"Set the provenance `source_line` of `{name}` in `{file}` to its definition line ({lines}) "
|
|
950
|
+
"and move its citations to that line; update-skill `--from-test-report` applies this when the "
|
|
951
|
+
"file defines it on one line.",
|
|
952
|
+
issue=f"the provenance map records `{name}` at `{where}`, which is not the line that defines "
|
|
953
|
+
f"it; the lines that define it are {lines}.",
|
|
954
|
+
export=name))
|
|
955
|
+
else:
|
|
956
|
+
records.append(_gap(
|
|
957
|
+
"Info", "provenance-unverified", f"Provenance line not verified for {name}", where,
|
|
958
|
+
f"Open `{file}` and confirm that line {line} defines `{name}`; if another line does, set the "
|
|
959
|
+
"provenance `source_line` to it.",
|
|
960
|
+
issue=f"the line-check rules found no definition line for `{name}` in `{file}`; check by hand",
|
|
961
|
+
export=name))
|
|
962
|
+
return records
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def from_coherence(result: object) -> list[dict]:
|
|
966
|
+
"""aggregate-coherence.py's invalid references."""
|
|
967
|
+
result = _object(result, "--input (aggregate-coherence.py)", "invalidReferences")
|
|
968
|
+
records: list[dict] = []
|
|
969
|
+
for index, ref in enumerate(_list(result, "invalidReferences", "--input")):
|
|
970
|
+
if not isinstance(ref, dict):
|
|
971
|
+
raise AdapterError(f"--input: invalidReferences[{index}] is not an object")
|
|
972
|
+
target, line = ref.get("target"), _count(ref.get("line"))
|
|
973
|
+
where = f"SKILL.md:{line}" if line is not None else "SKILL.md"
|
|
974
|
+
status = ref.get("status")
|
|
975
|
+
if status == "missing":
|
|
976
|
+
records.append(_gap(
|
|
977
|
+
"Critical", "broken-reference", f"coherence: broken reference: {target}", where,
|
|
978
|
+
f"Point the reference to `{target}` at {where} at a target that exists, or remove it: "
|
|
979
|
+
"nothing exists at its target.",
|
|
980
|
+
issue="; ".join(i for i in ref.get("issues") or [] if isinstance(i, str)) or None))
|
|
981
|
+
elif status == "inaccurate":
|
|
982
|
+
issues = "; ".join(i for i in ref.get("issues") or [] if isinstance(i, str))
|
|
983
|
+
records.append(_gap(
|
|
984
|
+
"High", "inaccurate-reference", f"coherence: inaccurate reference: {target}", where,
|
|
985
|
+
f"Correct the reference to `{target}` at {where} so it matches its target"
|
|
986
|
+
+ (f": {issues}." if issues else "."),
|
|
987
|
+
issue=issues or None))
|
|
988
|
+
elif status == "escapes":
|
|
989
|
+
canonical = ref.get("canonical")
|
|
990
|
+
records.append(_gap(
|
|
991
|
+
"High", "reference-escape",
|
|
992
|
+
f"coherence: reference escapes skill/source sandbox: {target} → {canonical}", where,
|
|
993
|
+
f"Point the reference to `{target}` at {where} inside the skill package or its source tree, "
|
|
994
|
+
f"or remove it: it resolves to `{canonical}`."))
|
|
995
|
+
else:
|
|
996
|
+
raise AdapterError(f"--input: invalidReferences[{index}] has an unknown status {status!r}")
|
|
997
|
+
return records
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def _structure_shape(data: object) -> str | None:
|
|
1001
|
+
"""Which skf-scan-skill-md-structure.py result `data` is."""
|
|
1002
|
+
if not isinstance(data, dict):
|
|
1003
|
+
return None
|
|
1004
|
+
if all(isinstance(data.get(f), dict) and "satisfied" in data[f] for f in FAMILIES):
|
|
1005
|
+
return "required-sections"
|
|
1006
|
+
if "zero_usage" in data:
|
|
1007
|
+
return "usage-scope"
|
|
1008
|
+
if "unbalanced_fences" in data:
|
|
1009
|
+
return "scan"
|
|
1010
|
+
if "references" in data and "counts" in data:
|
|
1011
|
+
return "reference-check"
|
|
1012
|
+
return None
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def _scripts_assets_gap(data: dict, prefix: str) -> list[dict]:
|
|
1016
|
+
section = data.get("scripts_assets")
|
|
1017
|
+
if not isinstance(section, dict) or section.get("missing") is not True:
|
|
1018
|
+
return []
|
|
1019
|
+
folders = [f for f in section.get("folders") or [] if isinstance(f, str)]
|
|
1020
|
+
named = " and ".join(f"`{f}/`" for f in folders) or "`scripts/` and `assets/`"
|
|
1021
|
+
return [_gap(
|
|
1022
|
+
"Medium", "scripts-assets",
|
|
1023
|
+
f"{prefix}: scripts/assets directory exists but Scripts & Assets section missing", "SKILL.md",
|
|
1024
|
+
f"Add a `## Scripts & Assets` section to SKILL.md that lists each file in {named} with its purpose.")]
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def from_structure(results: list[object], served: list[str] | None = None) -> list[dict]:
|
|
1028
|
+
"""skf-scan-skill-md-structure.py results: the naive structural gaps, and
|
|
1029
|
+
the Scripts & Assets gap of either mode."""
|
|
1030
|
+
served_set = set(served or [])
|
|
1031
|
+
records: list[dict] = []
|
|
1032
|
+
for index, data in enumerate(results):
|
|
1033
|
+
shape = _structure_shape(data)
|
|
1034
|
+
if shape is None:
|
|
1035
|
+
raise AdapterError(f"--input #{index + 1} is not a scan, scan --required-sections, usage-scope or "
|
|
1036
|
+
"reference-check result")
|
|
1037
|
+
if shape == "required-sections":
|
|
1038
|
+
for family in FAMILIES:
|
|
1039
|
+
entry = data[family]
|
|
1040
|
+
if entry.get("satisfied") is True or family in served_set:
|
|
1041
|
+
continue
|
|
1042
|
+
if family == "description" and data.get("frontmatter_description") is True:
|
|
1043
|
+
continue
|
|
1044
|
+
tried = [t for t in entry.get("tried") or [] if isinstance(t, str)]
|
|
1045
|
+
records.append(_gap(
|
|
1046
|
+
"High", "structural", f"naive-coherence: missing required section: {family}", "SKILL.md",
|
|
1047
|
+
_SECTION_FIX[family],
|
|
1048
|
+
issue=f"no heading of SKILL.md serves the {family} family (synonyms tried: "
|
|
1049
|
+
f"{', '.join(tried)})"))
|
|
1050
|
+
elif shape == "scan":
|
|
1051
|
+
if data.get("unbalanced_fences") is True:
|
|
1052
|
+
records.append(_gap(
|
|
1053
|
+
"High", "structural", "naive-coherence: unbalanced code fence (unclosed block)", "SKILL.md",
|
|
1054
|
+
"Close the code block SKILL.md leaves open with a fence line of three backticks.",
|
|
1055
|
+
issue=f"SKILL.md has {data.get('fence_count')} fence lines, so one block is never closed"))
|
|
1056
|
+
for fence in _list(data, "bare_opening_fences", "--input"):
|
|
1057
|
+
line = _count(fence.get("line")) if isinstance(fence, dict) else None
|
|
1058
|
+
records.append(_gap(
|
|
1059
|
+
"Medium", "structural",
|
|
1060
|
+
f"naive-coherence: opening code fence at line {line} missing language tag",
|
|
1061
|
+
f"SKILL.md:{line}",
|
|
1062
|
+
f"Add a language tag (such as `bash` or `python`) to the opening fence at SKILL.md line "
|
|
1063
|
+
f"{line}."))
|
|
1064
|
+
for row in _list(data, "table_drift", "--input"):
|
|
1065
|
+
if not isinstance(row, dict):
|
|
1066
|
+
continue
|
|
1067
|
+
line, expected = _count(row.get("line")), _count(row.get("expected_cols"))
|
|
1068
|
+
where = f"section `{row['section']}`: " if row.get("section") else ""
|
|
1069
|
+
records.append(_gap(
|
|
1070
|
+
"Medium", "structural",
|
|
1071
|
+
f"naive-coherence: table row at line {line} has {row.get('actual_cols')} columns; "
|
|
1072
|
+
f"header has {expected}",
|
|
1073
|
+
f"SKILL.md:{line}",
|
|
1074
|
+
f"Give the row at SKILL.md line {line} the {expected} columns its header has (write a pipe "
|
|
1075
|
+
"inside a cell as `\\|`).",
|
|
1076
|
+
issue=f"{where}{row.get('row')}" if row.get("row") else None))
|
|
1077
|
+
records += _scripts_assets_gap(data, "naive-coherence")
|
|
1078
|
+
elif shape == "usage-scope":
|
|
1079
|
+
for export in _list(data, "zero_usage", "--input"):
|
|
1080
|
+
if not isinstance(export, dict) or not isinstance(export.get("name"), str):
|
|
1081
|
+
continue
|
|
1082
|
+
name, kind = export["name"], export.get("kind") or "export"
|
|
1083
|
+
records.append(_gap(
|
|
1084
|
+
"Medium", "structural",
|
|
1085
|
+
f"naive-coherence: exported {kind} `{name}` is not referenced in any usage-family "
|
|
1086
|
+
"section or reference file",
|
|
1087
|
+
"SKILL.md",
|
|
1088
|
+
f"Add an example that calls `{name}` to SKILL.md's usage section (or to a references/ file "
|
|
1089
|
+
"for a split body).",
|
|
1090
|
+
export=name))
|
|
1091
|
+
else:
|
|
1092
|
+
records += _scripts_assets_gap(data, "coherence")
|
|
1093
|
+
return records
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def build_records(args: argparse.Namespace) -> list[dict]:
|
|
1097
|
+
"""The records `append --from` appends. Raises AdapterError."""
|
|
1098
|
+
kind = args.adapter
|
|
1099
|
+
stray = sorted(o for o in ALL_ADAPTER_OPTIONS - ADAPTER_OPTIONS[kind] if getattr(args, o) is not None)
|
|
1100
|
+
if stray:
|
|
1101
|
+
flags = ", ".join("--" + o.replace("_", "-") for o in stray)
|
|
1102
|
+
raise AdapterError(f"--from {kind} does not read {flags}")
|
|
1103
|
+
inputs = args.input or []
|
|
1104
|
+
if not inputs or (kind != "structure" and len(inputs) > 1):
|
|
1105
|
+
wanted = "one or more --input files" if kind == "structure" else "one --input file"
|
|
1106
|
+
raise AdapterError(f"--from {kind} reads {wanted}")
|
|
1107
|
+
if kind == "structure":
|
|
1108
|
+
return from_structure([read_result(p, "--input") for p in inputs], args.served)
|
|
1109
|
+
data = read_result(inputs[0], "--input")
|
|
1110
|
+
if kind == "coverage":
|
|
1111
|
+
files = {o: read_result(getattr(args, o), "--" + o) if getattr(args, o) is not None else None
|
|
1112
|
+
for o in ("surface", "signatures", "numerator", "stale", "provenance")}
|
|
1113
|
+
return from_coverage(data, skill_dir=args.skill_dir, metadata=args.metadata, **files)
|
|
1114
|
+
if kind == "guards":
|
|
1115
|
+
return from_guards(data, args.metadata)
|
|
1116
|
+
if kind == "numerator":
|
|
1117
|
+
return from_numerator(data, args.metadata)
|
|
1118
|
+
if kind == "metadata-coherence":
|
|
1119
|
+
return from_metadata_coherence(data, args.metadata)
|
|
1120
|
+
if kind == "provenance-line":
|
|
1121
|
+
return from_provenance_line(data)
|
|
1122
|
+
return from_coherence(data)
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
# --------------------------------------------------------------------------
|
|
1126
|
+
# Gap Report
|
|
1127
|
+
# --------------------------------------------------------------------------
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def render_gap_report(ledger: dict, heading: bool = False) -> str:
|
|
1131
|
+
"""The Gap Report section body, from the ledger (report.md §4c writes it)."""
|
|
1132
|
+
records = ordered(ledger["records"])
|
|
1133
|
+
counts = count_by_severity(records)
|
|
1134
|
+
blocking = sum(1 for r in records if is_blocking(r))
|
|
1135
|
+
after_gate = counts["Critical"] + counts["High"] - blocking
|
|
1136
|
+
lines: list[str] = []
|
|
1137
|
+
if heading:
|
|
1138
|
+
lines += ["## Gap Report", ""]
|
|
1139
|
+
lines += [
|
|
1140
|
+
f"**Total Gaps:** {len(records)}",
|
|
1141
|
+
f"**Blocking (Critical + High):** {blocking}",
|
|
1142
|
+
f"**Non-blocking (Medium + Low + Info):** {len(records) - blocking - after_gate}",
|
|
1143
|
+
]
|
|
1144
|
+
if after_gate:
|
|
1145
|
+
lines.append(f"**Found after the hard gate (Critical + High, non-blocking):** {after_gate}")
|
|
1146
|
+
lines.append("")
|
|
1147
|
+
if not records:
|
|
1148
|
+
lines += ["No gaps found.", ""]
|
|
1149
|
+
return "\n".join(lines)
|
|
1150
|
+
lines += [
|
|
1151
|
+
"### Remediation Summary",
|
|
1152
|
+
"",
|
|
1153
|
+
"| Severity | Count | Estimated Effort |",
|
|
1154
|
+
"|----------|-------|------------------|",
|
|
1155
|
+
]
|
|
1156
|
+
for severity in SEVERITIES:
|
|
1157
|
+
effort = EFFORT[severity] if counts[severity] else "None"
|
|
1158
|
+
lines.append(f"| {severity} | {counts[severity]} | {effort} |")
|
|
1159
|
+
lines += [f"| **Total** | **{len(records)}** | |", ""]
|
|
1160
|
+
for record in records:
|
|
1161
|
+
lines += [
|
|
1162
|
+
f"### {record['id']}: {record['title']}",
|
|
1163
|
+
"",
|
|
1164
|
+
f"**Severity:** {record['severity']}",
|
|
1165
|
+
# The group comes from the category, as at append time: a ledger
|
|
1166
|
+
# written by hand may lack it.
|
|
1167
|
+
f"**Category:** {CATEGORIES[record['category']][0]} ({record['category']})",
|
|
1168
|
+
f"**Source:** {record['source']}",
|
|
1169
|
+
]
|
|
1170
|
+
if record.get("export"):
|
|
1171
|
+
lines.append(f"**Export:** {record['export']}")
|
|
1172
|
+
lines.append("")
|
|
1173
|
+
if record.get("issue"):
|
|
1174
|
+
lines += [f"**Issue:** {record['issue']}", ""]
|
|
1175
|
+
lines += [f"**Remediation:** {record['remediation']}", ""]
|
|
1176
|
+
return "\n".join(lines)
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def summarize(path: Path, ledger: dict) -> dict:
|
|
1180
|
+
records = ledger["records"]
|
|
1181
|
+
counts = count_by_severity(records)
|
|
1182
|
+
by_category: dict[str, int] = {}
|
|
1183
|
+
for record in records:
|
|
1184
|
+
by_category[record["category"]] = by_category.get(record["category"], 0) + 1
|
|
1185
|
+
blocking = sum(1 for r in records if is_blocking(r))
|
|
1186
|
+
return {
|
|
1187
|
+
"status": "ok",
|
|
1188
|
+
"ledger": str(path),
|
|
1189
|
+
"run_id": ledger.get("run_id"),
|
|
1190
|
+
"stages": list(ledger["stages"]),
|
|
1191
|
+
"total": len(records),
|
|
1192
|
+
"counts": counts,
|
|
1193
|
+
"blocking": blocking,
|
|
1194
|
+
"non_blocking": len(records) - blocking,
|
|
1195
|
+
"by_category": dict(sorted(by_category.items())),
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
# --------------------------------------------------------------------------
|
|
1200
|
+
# CLI
|
|
1201
|
+
# --------------------------------------------------------------------------
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _emit(payload: dict, stream=None) -> None:
|
|
1205
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False), file=stream or sys.stdout)
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def _error(code: str, message: str, **extra) -> dict:
|
|
1209
|
+
return {"status": "error", "code": code, "error": message, **extra}
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def _read_records(args: argparse.Namespace) -> list:
|
|
1213
|
+
"""The records an append reads: built by --from, else from each --input
|
|
1214
|
+
file in turn, else from stdin. Raises AdapterError, OSError or ValueError."""
|
|
1215
|
+
if args.adapter:
|
|
1216
|
+
return build_records(args)
|
|
1217
|
+
given = sorted(o for o in ALL_ADAPTER_OPTIONS if getattr(args, o) is not None)
|
|
1218
|
+
if given:
|
|
1219
|
+
flags = ", ".join("--" + o.replace("_", "-") for o in given)
|
|
1220
|
+
raise AdapterError(f"{flags} work only with --from")
|
|
1221
|
+
if not args.input:
|
|
1222
|
+
# A byte order mark on stdin (a Windows editor or shell) is not JSON.
|
|
1223
|
+
return parse_input(sys.stdin.read().removeprefix("\ufeff"))
|
|
1224
|
+
records: list = []
|
|
1225
|
+
for path in args.input:
|
|
1226
|
+
records += parse_input(Path(path).read_text(encoding="utf-8-sig"))
|
|
1227
|
+
return records
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def _cmd_append(args: argparse.Namespace) -> int:
|
|
1231
|
+
path = Path(args.ledger)
|
|
1232
|
+
if not _STAGE_RE.match(args.stage):
|
|
1233
|
+
_emit(_error("INVALID_INPUT", f"--stage must be a lowercase slug, got '{args.stage}'"))
|
|
1234
|
+
return 2
|
|
1235
|
+
try:
|
|
1236
|
+
raw_records = _read_records(args)
|
|
1237
|
+
except AdapterError as exc:
|
|
1238
|
+
_emit(_error("INVALID_INPUT", str(exc)))
|
|
1239
|
+
return 2
|
|
1240
|
+
except (OSError, UnicodeDecodeError, ValueError) as exc:
|
|
1241
|
+
# json.JSONDecodeError is a ValueError.
|
|
1242
|
+
_emit(_error("INVALID_INPUT", f"cannot read the records: {exc}"))
|
|
1243
|
+
return 2
|
|
1244
|
+
records: list[dict] = []
|
|
1245
|
+
errors: list[dict] = []
|
|
1246
|
+
for index, raw in enumerate(raw_records):
|
|
1247
|
+
record, err = normalize_record(raw)
|
|
1248
|
+
if err:
|
|
1249
|
+
errors.append({"index": index, "error": err})
|
|
1250
|
+
else:
|
|
1251
|
+
records.append(record)
|
|
1252
|
+
if errors:
|
|
1253
|
+
_emit(_error("INVALID_RECORD", f"{len(errors)} invalid record(s); nothing was written", errors=errors))
|
|
1254
|
+
return 2
|
|
1255
|
+
try:
|
|
1256
|
+
# Read, append and write as one step: an append that overlaps this
|
|
1257
|
+
# one waits here instead of writing over the records it adds.
|
|
1258
|
+
with ledger_lock(path):
|
|
1259
|
+
ledger = load_ledger(path) if path.exists() else new_ledger(path)
|
|
1260
|
+
appended, duplicates = append_records(ledger, args.stage, records)
|
|
1261
|
+
save_ledger(path, ledger)
|
|
1262
|
+
except LedgerError as exc:
|
|
1263
|
+
_emit(_error(exc.code, str(exc)))
|
|
1264
|
+
return 1
|
|
1265
|
+
except OSError as exc:
|
|
1266
|
+
_emit(_error("WRITE_FAILED", f"cannot write {path}: {exc}"))
|
|
1267
|
+
return 1
|
|
1268
|
+
_emit(
|
|
1269
|
+
{
|
|
1270
|
+
"status": "ok",
|
|
1271
|
+
"ledger": str(path),
|
|
1272
|
+
"stage": args.stage,
|
|
1273
|
+
"appended": appended,
|
|
1274
|
+
"duplicates": duplicates,
|
|
1275
|
+
"record_count": len(ledger["records"]),
|
|
1276
|
+
}
|
|
1277
|
+
)
|
|
1278
|
+
return 0
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def _cmd_render(args: argparse.Namespace) -> int:
|
|
1282
|
+
path = Path(args.ledger)
|
|
1283
|
+
try:
|
|
1284
|
+
ledger = load_ledger(path)
|
|
1285
|
+
except LedgerError as exc:
|
|
1286
|
+
_emit(_error(exc.code, str(exc)), stream=sys.stderr)
|
|
1287
|
+
return 1
|
|
1288
|
+
sys.stdout.write(render_gap_report(ledger, heading=args.heading))
|
|
1289
|
+
return 0
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
def _cmd_summary(args: argparse.Namespace) -> int:
|
|
1293
|
+
path = Path(args.ledger)
|
|
1294
|
+
try:
|
|
1295
|
+
ledger = load_ledger(path)
|
|
1296
|
+
except LedgerError as exc:
|
|
1297
|
+
_emit(_error(exc.code, str(exc)))
|
|
1298
|
+
return 1
|
|
1299
|
+
_emit(summarize(path, ledger))
|
|
1300
|
+
return 0
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def _cmd_categories(args: argparse.Namespace) -> int:
|
|
1304
|
+
_emit(
|
|
1305
|
+
{
|
|
1306
|
+
"status": "ok",
|
|
1307
|
+
"severities": list(SEVERITIES),
|
|
1308
|
+
"categories": [
|
|
1309
|
+
{"category": slug, "group": group, "description": description}
|
|
1310
|
+
for slug, (group, description) in CATEGORIES.items()
|
|
1311
|
+
],
|
|
1312
|
+
}
|
|
1313
|
+
)
|
|
1314
|
+
return 0
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
1318
|
+
parser = argparse.ArgumentParser(
|
|
1319
|
+
prog="gap-ledger",
|
|
1320
|
+
description="Append, render and summarize the test-skill gap ledger.",
|
|
1321
|
+
)
|
|
1322
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
1323
|
+
|
|
1324
|
+
p = sub.add_parser("append", help="append records from stdin, --input or a script's result (--from)")
|
|
1325
|
+
p.add_argument("--ledger", required=True, help="{forge_version}/test-findings-{run_id}.json")
|
|
1326
|
+
p.add_argument("--stage", required=True, help="the stage appending, e.g. coverage-check")
|
|
1327
|
+
p.add_argument("--input", action="append",
|
|
1328
|
+
help="a JSON file with the records (default: stdin); with --from, the script result to read "
|
|
1329
|
+
"(structure takes it more than once)")
|
|
1330
|
+
p.add_argument("--from", dest="adapter", choices=tuple(ADAPTER_OPTIONS),
|
|
1331
|
+
help="build the records from the --input result of a script (see the docstring's Adapters)")
|
|
1332
|
+
p.add_argument("--surface", help="coverage: load-coverage-inputs.py surface's result (Sources, kinds)")
|
|
1333
|
+
p.add_argument("--signatures", help="coverage: score-signatures.py score's result (missingTypes)")
|
|
1334
|
+
p.add_argument("--numerator", help="coverage: verify-declared-numerator.py's result (absent names)")
|
|
1335
|
+
p.add_argument("--stale", help="coverage: classify-stale's result (fabricated signatures)")
|
|
1336
|
+
p.add_argument("--provenance", help="coverage: the provenance map (an absent name's Source)")
|
|
1337
|
+
p.add_argument("--skill-dir", help="coverage: the skill package (a stale name's documenting line)")
|
|
1338
|
+
p.add_argument("--metadata", help="the metadata.json path a record cites as its Source (not read)")
|
|
1339
|
+
p.add_argument("--served", action="append", choices=FAMILIES,
|
|
1340
|
+
help="structure: a family another heading serves (the caller's judgment); repeatable")
|
|
1341
|
+
p.set_defaults(func=_cmd_append)
|
|
1342
|
+
|
|
1343
|
+
p = sub.add_parser("render", help="print the Gap Report in Markdown")
|
|
1344
|
+
p.add_argument("--ledger", required=True)
|
|
1345
|
+
p.add_argument("--heading", action="store_true", help="start with the ## Gap Report line")
|
|
1346
|
+
p.set_defaults(func=_cmd_render)
|
|
1347
|
+
|
|
1348
|
+
p = sub.add_parser("summary", help="counts by severity and category")
|
|
1349
|
+
p.add_argument("--ledger", required=True)
|
|
1350
|
+
p.set_defaults(func=_cmd_summary)
|
|
1351
|
+
|
|
1352
|
+
p = sub.add_parser("categories", help="list the category vocabulary")
|
|
1353
|
+
p.set_defaults(func=_cmd_categories)
|
|
1354
|
+
|
|
1355
|
+
return parser
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def _force_utf8(*streams) -> None:
|
|
1359
|
+
"""Reconfigure the JSON and Markdown streams to UTF-8.
|
|
1360
|
+
|
|
1361
|
+
A default Windows console uses cp1252, which cannot carry a non-ASCII
|
|
1362
|
+
title or remediation. Keeps each stream's error handler; skips test
|
|
1363
|
+
doubles without reconfigure(). For stdin this must run before the read.
|
|
1364
|
+
"""
|
|
1365
|
+
for stream in streams:
|
|
1366
|
+
if hasattr(stream, "reconfigure"):
|
|
1367
|
+
errors = getattr(stream, "errors", None)
|
|
1368
|
+
if errors is None:
|
|
1369
|
+
stream.reconfigure(encoding="utf-8")
|
|
1370
|
+
else:
|
|
1371
|
+
stream.reconfigure(encoding="utf-8", errors=errors)
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1375
|
+
_force_utf8(sys.stdin, sys.stdout, sys.stderr)
|
|
1376
|
+
args = _build_parser().parse_args(argv)
|
|
1377
|
+
return args.func(args)
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
if __name__ == "__main__":
|
|
1381
|
+
raise SystemExit(main())
|