bmad-module-skill-forge 2.2.0 → 4.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 +143 -18
- package/docs/verifying-a-skill.md +87 -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 +3 -3
- 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 +31 -59
- 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 +233 -64
- package/src/shared/scripts/skf-build-change-manifest.py +2753 -28
- 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 +772 -0
- package/src/shared/scripts/skf-comention-pairs.py +1333 -123
- package/src/shared/scripts/skf-compare-file-hashes.py +267 -105
- 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 +21 -3
- 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 +197 -55
- 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 +5247 -96
- package/src/shared/scripts/skf-extraction-inventory.py +1280 -0
- package/src/shared/scripts/skf-extraction-snapshot.py +664 -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 +469 -205
- package/src/shared/scripts/skf-github-fetch.py +365 -0
- package/src/shared/scripts/skf-github-probe.py +700 -0
- package/src/shared/scripts/skf-hash-content.py +180 -20
- package/src/shared/scripts/skf-language-corpora.py +1 -1
- package/src/shared/scripts/skf-load-provenance.py +161 -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 +143 -20
- 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 +176 -28
- package/src/shared/scripts/skf-promote-staged.py +430 -0
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +64 -8
- 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 +155 -14
- 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 +787 -121
- package/src/shared/scripts/skf-tessl-review.py +706 -0
- package/src/shared/scripts/skf-update-active-symlink.py +120 -67
- 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 +23 -2
- 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 +3945 -152
- package/src/shared/scripts/skf-write-skill-brief.py +406 -25
- 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 +28 -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/health-check.md +2 -0
- package/src/skf-audit-skill/references/init.md +149 -210
- package/src/skf-audit-skill/references/re-index.md +103 -110
- package/src/skf-audit-skill/references/report.md +96 -33
- package/src/skf-audit-skill/references/semantic-diff.md +34 -25
- 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 +80 -90
- package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
- package/src/skf-audit-skill/scripts/render-drift-tables.py +712 -0
- package/src/skf-brief-skill/SKILL.md +37 -26
- package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +18 -25
- package/src/skf-brief-skill/assets/skill-brief-schema.md +30 -23
- package/src/skf-brief-skill/customize.toml +48 -20
- package/src/skf-brief-skill/references/analyze-target.md +144 -127
- package/src/skf-brief-skill/references/confirm-brief.md +11 -9
- 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 +26 -100
- package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
- package/src/skf-brief-skill/references/version-resolution.md +7 -10
- 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 +11 -8
- package/src/skf-create-skill/assets/skill-sections.md +21 -11
- package/src/skf-create-skill/customize.toml +20 -8
- package/src/skf-create-skill/references/authoritative-files-protocol.md +36 -58
- 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 +119 -70
- 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 +37 -0
- package/src/skf-create-skill/references/extract.md +204 -162
- package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +11 -11
- package/src/skf-create-skill/references/extraction-patterns.md +588 -201
- 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 +37 -57
- package/src/skf-create-skill/references/source-resolution-protocols.md +58 -169
- 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 +83 -146
- package/src/skf-drop-skill/references/health-check.md +2 -0
- 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 +122 -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 +209 -264
- 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 +164 -91
- 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 +164 -142
- package/src/skf-test-skill/references/score.md +80 -107
- package/src/skf-test-skill/references/scoring-rules.md +42 -155
- package/src/skf-test-skill/references/source-access-protocol.md +18 -94
- 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 +380 -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 +1666 -0
- package/src/skf-test-skill/scripts/hard-gate.py +203 -0
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +1198 -0
- package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
- package/src/skf-test-skill/scripts/reconcile-coverage.py +547 -79
- package/src/skf-test-skill/scripts/score-signatures.py +479 -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 +133 -56
- 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 +253 -223
- 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 +254 -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 +182 -49
- package/src/skf-update-skill/references/re-extract.md +53 -262
- package/src/skf-update-skill/references/report.md +102 -35
- package/src/skf-update-skill/references/write.md +357 -155
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +183 -46
- 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,1666 @@
|
|
|
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). One whose `defined_at` (where the source
|
|
89
|
+
declares it) is a Python or TS/JS `file:line` is a documented
|
|
90
|
+
extra when its name has no dot and the --surface result was
|
|
91
|
+
built from an extraction that read the whole scope (an
|
|
92
|
+
`extraction` object, not `no-ast-grep`, `truncated` or
|
|
93
|
+
`fallback.needed`: only such an extraction fills
|
|
94
|
+
`excluded.outsideScope`, the names a brief scoped out) and its
|
|
95
|
+
`excluded.outsideScope` list does not list
|
|
96
|
+
it: an Info `observation` gap `Documented extra: {name}` at
|
|
97
|
+
`defined_at`. With no `defined_at`, a homonym (its `declared_in`
|
|
98
|
+
lists several declaring files) is one too, with --skill-dir,
|
|
99
|
+
when the skill's `[AST:path:L<n>]` and `[SRC:path:L<n>]`
|
|
100
|
+
citations on the lines of SKILL.md and references/**/*.md that
|
|
101
|
+
name it (outside the citations), or on the next non-blank line
|
|
102
|
+
after one, cite exactly one of those files: the gap
|
|
103
|
+
is at that file's `declared_in` item, and two cited or none
|
|
104
|
+
leave it Medium. The documented extra's issue names the line
|
|
105
|
+
that documents it (with --skill-dir) and its remediation asks no
|
|
106
|
+
change (update-skill never routes it) and says whether signature
|
|
107
|
+
scoring compared its documented signature (a name the
|
|
108
|
+
--signatures result lists in `comparedNames`). `excluded` is
|
|
109
|
+
read once a stale name that is not fabricated needs it, and a
|
|
110
|
+
malformed one is then INVALID_INPUT. Any other is a Medium `stale-documentation` gap
|
|
111
|
+
`Stale documentation: {name}` at the first line of SKILL.md,
|
|
112
|
+
then of references/*.md in path order, that writes the name
|
|
113
|
+
(validate-inventory.py's match, with --skill-dir), else
|
|
114
|
+
`SKILL.md`. Scalar and stack branches: a
|
|
115
|
+
`missingCount` above 0 is one Medium `missing-export` gap
|
|
116
|
+
`{missingCount} of {denominator} exports not documented` at
|
|
117
|
+
`{meta}`, except that a scalar count taken from the verified
|
|
118
|
+
numerator (`numeratorSource` `verified`) is replaced by one
|
|
119
|
+
`Missing export: {name}` gap per --numerator `absent` name, at
|
|
120
|
+
the first --provenance entry of that name, else `{meta}`.
|
|
121
|
+
Every per-name gap carries `export`.
|
|
122
|
+
guards --input load-coverage-inputs.py's surface result; --metadata.
|
|
123
|
+
`guards.deflation.fires` is a Medium `metadata-drift` gap at
|
|
124
|
+
`{meta}` (else the result's `inputs.metadata`), and
|
|
125
|
+
`guards.inflation.fires` a Medium `denominator-inflation` gap at
|
|
126
|
+
the result's `inputs.brief` (else `skill-brief.yaml`), whose
|
|
127
|
+
remediation recommends `stats.effective_denominator` when
|
|
128
|
+
`guards.umbrella.umbrella` is true, else `scope.tier_a_include`.
|
|
129
|
+
`guards.staleScope.fires` is one Medium `brief-scope-stale` gap
|
|
130
|
+
at that brief, whose issue names each `unmatchedInclude` glob,
|
|
131
|
+
each `restored` name with its file and each
|
|
132
|
+
`unmatchedTierAInclude` glob. The deflation and inflation gaps
|
|
133
|
+
say the brief has no `scope.tier_a_include` when
|
|
134
|
+
`guards.tierAInclude` lists no glob, that no glob matches a file
|
|
135
|
+
when each is an `unmatchedTierAInclude` glob, and else that no
|
|
136
|
+
glob counts a public name (a surface without
|
|
137
|
+
`guards.tierAInclude` reads the `unmatchedTierAInclude` globs
|
|
138
|
+
alone). A stored `stats.effective_denominator` of 0 (`pct`
|
|
139
|
+
null) is written as a count against 0, not a percentage, and
|
|
140
|
+
its fix re-runs Create Skill to count it again.
|
|
141
|
+
numerator --input verify-declared-numerator.py's result; --metadata.
|
|
142
|
+
`inflated` true is a High `numerator-inflation` gap at `{meta}`
|
|
143
|
+
whose issue lists the `absent` names.
|
|
144
|
+
metadata-coherence
|
|
145
|
+
--input check-metadata-coherence.py's result; --metadata. Each
|
|
146
|
+
`findings[]` entry, titled as it is and with its `detail` as the
|
|
147
|
+
issue, at `{meta}`: Medium is a Medium `metadata-drift` gap, Info
|
|
148
|
+
an Info `multi-denominator` gap.
|
|
149
|
+
provenance-line
|
|
150
|
+
--input skf-verify-provenance-completeness.py verify's result.
|
|
151
|
+
Each `stale[]` item whose reason is `line-not-definition`, at
|
|
152
|
+
its `{source_file}:{source_line}` with `export`: a Low
|
|
153
|
+
`provenance-line` gap `Provenance line is not the definition of
|
|
154
|
+
{export_name}` when `definition_lines` holds a line, else an Info
|
|
155
|
+
`provenance-unverified` gap `Provenance line not verified for
|
|
156
|
+
{export_name}`. Its other items are not gaps here.
|
|
157
|
+
coherence --input aggregate-coherence.py's result. Each
|
|
158
|
+
`invalidReferences[]` entry at `SKILL.md:{line}`, by `status`:
|
|
159
|
+
`missing` is a Critical `broken-reference` gap, `inaccurate` a
|
|
160
|
+
High `inaccurate-reference` gap (its `issues` as the issue) and
|
|
161
|
+
`escapes` a High `reference-escape` gap naming its `canonical`
|
|
162
|
+
path.
|
|
163
|
+
structure --input one or more skf-scan-skill-md-structure.py results,
|
|
164
|
+
each told apart by its keys; --served. A `scan --required-sections`
|
|
165
|
+
result: each family not `satisfied` is a High `structural` gap
|
|
166
|
+
`naive-coherence: missing required section: {family}`, unless
|
|
167
|
+
--served names it (another heading serves it: the caller's
|
|
168
|
+
judgment) or it is `description` and `frontmatter_description`
|
|
169
|
+
is true. A `scan` result: unbalanced fences are a High
|
|
170
|
+
`structural` gap, and each bare opening fence and each
|
|
171
|
+
`table_drift` row a Medium `structural` gap at `SKILL.md:{line}`.
|
|
172
|
+
A `usage-scope` result: each `zero_usage` export is a Medium
|
|
173
|
+
`structural` gap with `export`. A `scan` or `reference-check`
|
|
174
|
+
result whose `scripts_assets.missing` is true is a Medium
|
|
175
|
+
`scripts-assets` gap. The titles are coherence-check.md's
|
|
176
|
+
`naive-coherence:` texts; a `reference-check` result (contextual
|
|
177
|
+
mode) titles its gap `coherence:` instead.
|
|
178
|
+
|
|
179
|
+
render --ledger <path> [--heading]
|
|
180
|
+
Print the Gap Report body in Markdown on stdout: the totals, the
|
|
181
|
+
Remediation Summary table and one Gap Entry per record, Critical first,
|
|
182
|
+
then by id. Each entry names its category as `{group} ({category})`,
|
|
183
|
+
which skf-parse-gaps.py reads back for a report whose ledger is gone.
|
|
184
|
+
--heading adds the `## Gap Report` line. With no records the body says
|
|
185
|
+
no gaps were found.
|
|
186
|
+
|
|
187
|
+
summary --ledger <path>
|
|
188
|
+
Output: {"status": "ok", "ledger", "run_id", "stages", "total",
|
|
189
|
+
"counts": {severity: n}, "blocking", "non_blocking",
|
|
190
|
+
"by_category": {category: n}}
|
|
191
|
+
|
|
192
|
+
A Critical or High record blocks only when a stage before the hard gate
|
|
193
|
+
appended it. The report stage appends after the gate (the discovery test),
|
|
194
|
+
so render and summary count its Critical and High records as non-blocking:
|
|
195
|
+
render lists them on a line of their own, after the hard gate.
|
|
196
|
+
|
|
197
|
+
categories
|
|
198
|
+
Output the category vocabulary and the severities:
|
|
199
|
+
{"status": "ok", "severities": [...],
|
|
200
|
+
"categories": [{"category", "group", "description"}]}
|
|
201
|
+
|
|
202
|
+
Exit codes:
|
|
203
|
+
0 the result was printed
|
|
204
|
+
1 the ledger is missing, unreadable, not a valid ledger, held by another
|
|
205
|
+
append past the wait, or not writable
|
|
206
|
+
2 the input records are invalid (nothing was written), or a usage error
|
|
207
|
+
(argparse: a missing or unknown argument, usage on stderr, no JSON)
|
|
208
|
+
|
|
209
|
+
Errors print {"status": "error", "code", "error", ...} on stdout, except for
|
|
210
|
+
render, which prints it on stderr so a redirected Gap Report stays clean. The
|
|
211
|
+
codes: LEDGER_MISSING, LEDGER_INVALID, LEDGER_LOCKED, WRITE_FAILED (exit 1),
|
|
212
|
+
INVALID_INPUT and INVALID_RECORD (exit 2, with `errors: [{index, error}]`).
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
from __future__ import annotations
|
|
216
|
+
|
|
217
|
+
import argparse
|
|
218
|
+
import contextlib
|
|
219
|
+
import errno
|
|
220
|
+
import importlib.util
|
|
221
|
+
import json
|
|
222
|
+
import os
|
|
223
|
+
import posixpath
|
|
224
|
+
import re
|
|
225
|
+
import secrets
|
|
226
|
+
import sys
|
|
227
|
+
import time
|
|
228
|
+
from pathlib import Path
|
|
229
|
+
|
|
230
|
+
if os.name == "nt":
|
|
231
|
+
import msvcrt
|
|
232
|
+
else:
|
|
233
|
+
import fcntl
|
|
234
|
+
|
|
235
|
+
SCHEMA_VERSION = 1
|
|
236
|
+
|
|
237
|
+
# How long an append waits for another append's lock before LEDGER_LOCKED.
|
|
238
|
+
# An append holds it for milliseconds; the wait covers a burst of parallel
|
|
239
|
+
# tool calls, not a writer that hangs.
|
|
240
|
+
LOCK_TIMEOUT_SECONDS = 30.0
|
|
241
|
+
_LOCK_POLL_SECONDS = 0.02
|
|
242
|
+
# Windows refuses to replace a file another process holds open, and a virus
|
|
243
|
+
# scanner or the search indexer may open a just-written ledger for a moment:
|
|
244
|
+
# save_ledger retries the rename there for up to this long before failing.
|
|
245
|
+
_IS_WINDOWS = os.name == "nt"
|
|
246
|
+
_REPLACE_WAIT_SECONDS = 5.0
|
|
247
|
+
|
|
248
|
+
SEVERITIES = ("Critical", "High", "Medium", "Low", "Info")
|
|
249
|
+
BLOCKING = frozenset({"Critical", "High"})
|
|
250
|
+
_SEVERITY_BY_LOWER = {s.lower(): s for s in SEVERITIES}
|
|
251
|
+
# Stages that append after the hard gate decided (report.md records the
|
|
252
|
+
# discovery test there): a Critical or High gap they record blocked nothing.
|
|
253
|
+
POST_GATE_STAGES = frozenset({"report"})
|
|
254
|
+
|
|
255
|
+
# Category slug -> (group, what the category covers). update-skill routes a
|
|
256
|
+
# gap on its category, not its severity, so the slugs are a closed set: a typo
|
|
257
|
+
# fails the append instead of reaching update-skill as an unknown category.
|
|
258
|
+
CATEGORIES: dict[str, tuple[str, str]] = {
|
|
259
|
+
"missing-export": ("Coverage", "an exported function or class the skill does not document"),
|
|
260
|
+
"signature-mismatch": ("Coverage", "a documented signature that differs from the source"),
|
|
261
|
+
"fabricated-signature": ("Coverage", "a documented export that is absent at the cited source line"),
|
|
262
|
+
"stale-documentation": ("Coverage", "documentation for an export the enumerated source surface lacks"),
|
|
263
|
+
"missing-type": ("Coverage", "a type or interface the skill does not document"),
|
|
264
|
+
"provenance-completeness": ("Coverage", "an export the skill documents that the provenance map lacks"),
|
|
265
|
+
"provenance-line": ("Coverage", "a provenance line that is not the definition of its export"),
|
|
266
|
+
"provenance-unverified": ("Coverage", "a provenance line the line-check rules could not verify"),
|
|
267
|
+
"metadata-drift": ("Coverage", "export counts in the metadata that diverge from each other"),
|
|
268
|
+
"denominator-inflation": ("Coverage", "a scope include union larger than the provenance map"),
|
|
269
|
+
"brief-scope-stale": (
|
|
270
|
+
"Coverage", "a scope.include or scope.tier_a_include glob in the brief that matches no source file"),
|
|
271
|
+
"numerator-inflation": (
|
|
272
|
+
"Coverage",
|
|
273
|
+
"a documented count equal to the denominator while declared exports are absent from the skill",
|
|
274
|
+
),
|
|
275
|
+
"multi-denominator": ("Coverage", "barrel and documented-surface counts that diverge by design"),
|
|
276
|
+
"migration-section": ("Coverage", "a migration section that disagrees with the annotation data"),
|
|
277
|
+
"broken-reference": ("Coherence", "a reference whose target does not exist"),
|
|
278
|
+
"inaccurate-reference": ("Coherence", "a reference whose target exists but does not match it"),
|
|
279
|
+
"reference-escape": (
|
|
280
|
+
"Coherence",
|
|
281
|
+
"a reference whose real path leaves the skill, its source and, for a stack, the skills folder",
|
|
282
|
+
),
|
|
283
|
+
"integration-pattern": ("Coherence", "an integration pattern that is incomplete"),
|
|
284
|
+
"split-body-mismatch": ("Coherence", "SKILL.md and a references/ file that disagree about an export"),
|
|
285
|
+
"scripts-assets": ("Coherence", "a Scripts & Assets section that is missing or names a missing file"),
|
|
286
|
+
"scripts-assets-provenance": ("Coherence", "a script or asset file without a provenance entry"),
|
|
287
|
+
"structural": (
|
|
288
|
+
"Structural",
|
|
289
|
+
"a section, code fence, table or usage example in SKILL.md that is missing or wrong",
|
|
290
|
+
),
|
|
291
|
+
"metadata": ("Structural", "missing optional metadata or examples"),
|
|
292
|
+
"observation": ("Structural", "a style suggestion, a documented extra or another non-blocking observation"),
|
|
293
|
+
"discovery": ("Discovery", "a discovery test that misrouted prompts or did not run"),
|
|
294
|
+
"description": ("Discovery", "a description whose triggers need optimizing"),
|
|
295
|
+
"external-validator": ("External", "a skill-check diagnostic or a Tessl Review suggestion"),
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
REQUIRED_FIELDS = ("severity", "category", "title", "source", "remediation")
|
|
299
|
+
OPTIONAL_FIELDS = ("issue", "export")
|
|
300
|
+
ASSIGNED_FIELDS = ("id", "group", "stage")
|
|
301
|
+
|
|
302
|
+
# The Remediation Summary's Estimated Effort column, per severity.
|
|
303
|
+
EFFORT = {
|
|
304
|
+
"Critical": "Read the source code and write or correct the documentation",
|
|
305
|
+
"High": "Read the source code and write or correct the documentation",
|
|
306
|
+
"Medium": "Add type definitions, interface docs or the missing section",
|
|
307
|
+
"Low": "Add examples or metadata",
|
|
308
|
+
"Info": "Optional improvements, no action required",
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
_ID_RE = re.compile(r"^GAP-(\d{3,})$")
|
|
312
|
+
_STAGE_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
313
|
+
_RUN_ID_FROM_NAME = re.compile(r"^test-findings-(.+)\.json$")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class LedgerError(Exception):
|
|
317
|
+
"""A ledger that is missing, unreadable or malformed."""
|
|
318
|
+
|
|
319
|
+
def __init__(self, code: str, message: str) -> None:
|
|
320
|
+
super().__init__(message)
|
|
321
|
+
self.code = code
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# --------------------------------------------------------------------------
|
|
325
|
+
# Records
|
|
326
|
+
# --------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _one_line(value: str) -> str:
|
|
330
|
+
return " ".join(value.split())
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def normalize_record(raw: object) -> tuple[dict | None, str | None]:
|
|
334
|
+
"""Validate one input record. Returns (record, None) or (None, error).
|
|
335
|
+
|
|
336
|
+
>>> rec, err = normalize_record({"severity": "high", "category": "broken-reference",
|
|
337
|
+
... "title": " Broken link ", "source": "SKILL.md:12", "remediation": "Fix it."})
|
|
338
|
+
>>> (rec["severity"], rec["title"], err)
|
|
339
|
+
('High', 'Broken link', None)
|
|
340
|
+
>>> normalize_record({"severity": "Severe"})[1]
|
|
341
|
+
"missing required field 'category'"
|
|
342
|
+
"""
|
|
343
|
+
if not isinstance(raw, dict):
|
|
344
|
+
return None, f"a record must be a JSON object, got {type(raw).__name__}"
|
|
345
|
+
for key in ASSIGNED_FIELDS:
|
|
346
|
+
if key in raw:
|
|
347
|
+
return None, f"'{key}' is assigned by the ledger; leave it out"
|
|
348
|
+
unknown = sorted(set(raw) - set(REQUIRED_FIELDS) - set(OPTIONAL_FIELDS))
|
|
349
|
+
if unknown:
|
|
350
|
+
return None, f"unknown field(s): {', '.join(unknown)}"
|
|
351
|
+
for key in REQUIRED_FIELDS:
|
|
352
|
+
if key not in raw:
|
|
353
|
+
return None, f"missing required field '{key}'"
|
|
354
|
+
if not isinstance(raw[key], str):
|
|
355
|
+
return None, f"'{key}' must be a string"
|
|
356
|
+
for key in OPTIONAL_FIELDS:
|
|
357
|
+
if raw.get(key) is not None and not isinstance(raw[key], str):
|
|
358
|
+
return None, f"'{key}' must be a string or null"
|
|
359
|
+
severity = _SEVERITY_BY_LOWER.get(raw["severity"].strip().lower())
|
|
360
|
+
if severity is None:
|
|
361
|
+
return None, (
|
|
362
|
+
f"unknown severity '{raw['severity']}' (expected one of {', '.join(SEVERITIES)})"
|
|
363
|
+
)
|
|
364
|
+
category = raw["category"].strip()
|
|
365
|
+
if category not in CATEGORIES:
|
|
366
|
+
return None, f"unknown category '{category}' (run the categories subcommand for the list)"
|
|
367
|
+
record = {
|
|
368
|
+
"severity": severity,
|
|
369
|
+
"category": category,
|
|
370
|
+
"group": CATEGORIES[category][0],
|
|
371
|
+
"title": _one_line(raw["title"]),
|
|
372
|
+
"source": _one_line(raw["source"]),
|
|
373
|
+
"remediation": raw["remediation"].strip(),
|
|
374
|
+
}
|
|
375
|
+
for key in ("title", "source", "remediation"):
|
|
376
|
+
if not record[key]:
|
|
377
|
+
return None, f"'{key}' must not be empty"
|
|
378
|
+
issue = (raw.get("issue") or "").strip()
|
|
379
|
+
if issue:
|
|
380
|
+
record["issue"] = issue
|
|
381
|
+
export = _one_line(raw.get("export") or "")
|
|
382
|
+
if export:
|
|
383
|
+
record["export"] = export
|
|
384
|
+
return record, None
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _dedupe_key(record: dict) -> tuple[str, str, str, str, str | None]:
|
|
388
|
+
"""What makes two records the same gap. The export is part of it: two
|
|
389
|
+
missing exports can share a generic title and a barrel Source."""
|
|
390
|
+
return (
|
|
391
|
+
record["severity"],
|
|
392
|
+
record["category"],
|
|
393
|
+
record["title"],
|
|
394
|
+
record["source"],
|
|
395
|
+
record.get("export") or None,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _id_number(record: dict) -> int:
|
|
400
|
+
m = _ID_RE.match(record.get("id", ""))
|
|
401
|
+
return int(m.group(1)) if m else 0
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def ordered(records: list[dict]) -> list[dict]:
|
|
405
|
+
"""Records by severity (Critical first), then by id."""
|
|
406
|
+
rank = {s: i for i, s in enumerate(SEVERITIES)}
|
|
407
|
+
return sorted(records, key=lambda r: (rank[r["severity"]], _id_number(r)))
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def count_by_severity(records: list[dict]) -> dict[str, int]:
|
|
411
|
+
counts = {s: 0 for s in SEVERITIES}
|
|
412
|
+
for record in records:
|
|
413
|
+
counts[record["severity"]] += 1
|
|
414
|
+
return counts
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def is_blocking(record: dict) -> bool:
|
|
418
|
+
"""A Critical or High gap that a stage before the hard gate recorded.
|
|
419
|
+
|
|
420
|
+
>>> is_blocking({"severity": "High", "stage": "coherence-check"})
|
|
421
|
+
True
|
|
422
|
+
>>> is_blocking({"severity": "High", "stage": "report"})
|
|
423
|
+
False
|
|
424
|
+
"""
|
|
425
|
+
return record["severity"] in BLOCKING and record.get("stage") not in POST_GATE_STAGES
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# --------------------------------------------------------------------------
|
|
429
|
+
# Ledger file
|
|
430
|
+
# --------------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def new_ledger(path: Path) -> dict:
|
|
434
|
+
m = _RUN_ID_FROM_NAME.match(path.name)
|
|
435
|
+
return {
|
|
436
|
+
"schema_version": SCHEMA_VERSION,
|
|
437
|
+
"run_id": m.group(1) if m else None,
|
|
438
|
+
"stages": [],
|
|
439
|
+
"records": [],
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def validate_ledger(data: object) -> None:
|
|
444
|
+
"""Raise LedgerError unless `data` is a well-formed ledger."""
|
|
445
|
+
if not isinstance(data, dict):
|
|
446
|
+
raise LedgerError("LEDGER_INVALID", "the ledger must be a JSON object")
|
|
447
|
+
if data.get("schema_version") != SCHEMA_VERSION:
|
|
448
|
+
raise LedgerError(
|
|
449
|
+
"LEDGER_INVALID",
|
|
450
|
+
f"unsupported schema_version {data.get('schema_version')!r} (expected {SCHEMA_VERSION})",
|
|
451
|
+
)
|
|
452
|
+
stages = data.get("stages")
|
|
453
|
+
if not isinstance(stages, list) or not all(isinstance(s, str) for s in stages):
|
|
454
|
+
raise LedgerError("LEDGER_INVALID", "'stages' must be a list of strings")
|
|
455
|
+
records = data.get("records")
|
|
456
|
+
if not isinstance(records, list):
|
|
457
|
+
raise LedgerError("LEDGER_INVALID", "'records' must be a list")
|
|
458
|
+
seen: set[str] = set()
|
|
459
|
+
for index, record in enumerate(records):
|
|
460
|
+
if not isinstance(record, dict):
|
|
461
|
+
raise LedgerError("LEDGER_INVALID", f"record {index} is not an object")
|
|
462
|
+
rid = record.get("id")
|
|
463
|
+
if not isinstance(rid, str) or not _ID_RE.match(rid):
|
|
464
|
+
raise LedgerError("LEDGER_INVALID", f"record {index} has no valid id")
|
|
465
|
+
if rid in seen:
|
|
466
|
+
raise LedgerError("LEDGER_INVALID", f"duplicate id {rid}")
|
|
467
|
+
seen.add(rid)
|
|
468
|
+
if record.get("severity") not in SEVERITIES:
|
|
469
|
+
raise LedgerError("LEDGER_INVALID", f"{rid} has an unknown severity")
|
|
470
|
+
if record.get("category") not in CATEGORIES:
|
|
471
|
+
raise LedgerError("LEDGER_INVALID", f"{rid} has an unknown category")
|
|
472
|
+
for key in ("title", "source", "remediation"):
|
|
473
|
+
if not isinstance(record.get(key), str) or not record[key]:
|
|
474
|
+
raise LedgerError("LEDGER_INVALID", f"{rid} has no {key}")
|
|
475
|
+
for key in OPTIONAL_FIELDS:
|
|
476
|
+
if record.get(key) is not None and not isinstance(record[key], str):
|
|
477
|
+
raise LedgerError("LEDGER_INVALID", f"{rid} has a {key} that is not a string")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def load_ledger(path: Path) -> dict:
|
|
481
|
+
"""Read and validate a ledger. Raises LedgerError."""
|
|
482
|
+
if not path.is_file():
|
|
483
|
+
raise LedgerError("LEDGER_MISSING", f"no ledger at {path}")
|
|
484
|
+
try:
|
|
485
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
486
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
487
|
+
raise LedgerError("LEDGER_INVALID", f"cannot read {path}: {exc}") from exc
|
|
488
|
+
except json.JSONDecodeError as exc:
|
|
489
|
+
raise LedgerError("LEDGER_INVALID", f"{path} is not valid JSON: {exc}") from exc
|
|
490
|
+
validate_ledger(data)
|
|
491
|
+
return data
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def save_ledger(path: Path, data: dict) -> None:
|
|
495
|
+
"""Write the ledger atomically: a temp file of its own beside it, then a
|
|
496
|
+
rename. Call it under ledger_lock() so no other append runs between the
|
|
497
|
+
read and this write."""
|
|
498
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
499
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}-{secrets.token_hex(4)}.skf-tmp")
|
|
500
|
+
payload = (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
|
501
|
+
# O_EXCL: never write into another writer's temp file. O_BINARY (Windows
|
|
502
|
+
# only; 0 elsewhere) keeps the \n line ends.
|
|
503
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
|
|
504
|
+
fd = os.open(tmp, flags, 0o644)
|
|
505
|
+
try:
|
|
506
|
+
with open(fd, "wb") as fh:
|
|
507
|
+
fh.write(payload)
|
|
508
|
+
fh.flush()
|
|
509
|
+
os.fsync(fh.fileno())
|
|
510
|
+
deadline = time.monotonic() + _REPLACE_WAIT_SECONDS
|
|
511
|
+
while True:
|
|
512
|
+
try:
|
|
513
|
+
os.replace(tmp, path)
|
|
514
|
+
break
|
|
515
|
+
except PermissionError:
|
|
516
|
+
if not _IS_WINDOWS or time.monotonic() >= deadline:
|
|
517
|
+
raise
|
|
518
|
+
time.sleep(_LOCK_POLL_SECONDS)
|
|
519
|
+
except BaseException:
|
|
520
|
+
with contextlib.suppress(OSError):
|
|
521
|
+
tmp.unlink()
|
|
522
|
+
raise
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _try_lock(fd: int) -> bool:
|
|
526
|
+
"""Lock fd exclusively without waiting; False while another process holds it."""
|
|
527
|
+
try:
|
|
528
|
+
if os.name == "nt":
|
|
529
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
530
|
+
else:
|
|
531
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
532
|
+
except OSError as exc:
|
|
533
|
+
if exc.errno in (errno.EAGAIN, errno.EACCES, errno.EDEADLK):
|
|
534
|
+
return False
|
|
535
|
+
raise
|
|
536
|
+
return True
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _unlock(fd: int) -> None:
|
|
540
|
+
with contextlib.suppress(OSError):
|
|
541
|
+
if os.name == "nt":
|
|
542
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
543
|
+
else:
|
|
544
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _names_open_file(fd: int, path: Path) -> bool:
|
|
548
|
+
"""True when `path` still names the file open as `fd`."""
|
|
549
|
+
try:
|
|
550
|
+
return os.path.samestat(os.fstat(fd), os.stat(path))
|
|
551
|
+
except OSError:
|
|
552
|
+
return False
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@contextlib.contextmanager
|
|
556
|
+
def ledger_lock(path: Path, timeout: float | None = None):
|
|
557
|
+
"""Hold the exclusive append lock on `<ledger>.skf-lock`.
|
|
558
|
+
|
|
559
|
+
The same fcntl / msvcrt lock as skf-atomic-write.py flip-link, but this
|
|
560
|
+
one waits: up to `timeout` seconds (LOCK_TIMEOUT_SECONDS), then it raises
|
|
561
|
+
LedgerError LEDGER_LOCKED. The lock dies with its process, so a crashed
|
|
562
|
+
append leaves nothing to clean up. The lock file is removed on release.
|
|
563
|
+
On POSIX that happens while the lock is still held, and a waiter checks
|
|
564
|
+
that the file it locked is still the one at the path: otherwise a waiter
|
|
565
|
+
on the removed file and a newcomer on a new one would both get a lock.
|
|
566
|
+
Windows removes no file that is open, so there the file goes after the
|
|
567
|
+
release, unless a waiter has it open. While the removal is under way (the
|
|
568
|
+
remover's own delete handle is open, or a virus scanner's or the search
|
|
569
|
+
indexer's), Windows refuses a new open of the file with PermissionError:
|
|
570
|
+
an append takes that for a held lock and tries again until the same
|
|
571
|
+
deadline, then raises LEDGER_LOCKED naming the last refusal. A refusal
|
|
572
|
+
with a folder at the path, or with nothing there on an immediate second
|
|
573
|
+
try, is a lasting denial and is raised as is.
|
|
574
|
+
"""
|
|
575
|
+
timeout = LOCK_TIMEOUT_SECONDS if timeout is None else timeout
|
|
576
|
+
lock_path = path.with_name(path.name + ".skf-lock")
|
|
577
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
578
|
+
deadline = time.monotonic() + timeout
|
|
579
|
+
|
|
580
|
+
def refuse(cause: OSError | None = None) -> LedgerError:
|
|
581
|
+
message = f"another append held {lock_path} for more than {timeout:g} s"
|
|
582
|
+
if cause is not None:
|
|
583
|
+
message += f" (last open refused: {cause})"
|
|
584
|
+
return LedgerError("LEDGER_LOCKED", message)
|
|
585
|
+
|
|
586
|
+
vanished = False
|
|
587
|
+
while True:
|
|
588
|
+
try:
|
|
589
|
+
fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o644)
|
|
590
|
+
except PermissionError as exc:
|
|
591
|
+
# Windows refuses to open a file whose removal is pending. A folder
|
|
592
|
+
# at the path, or nothing there twice running (once can be the
|
|
593
|
+
# removal ending just now), is a lasting denial: raise it.
|
|
594
|
+
if not _IS_WINDOWS or os.path.isdir(lock_path):
|
|
595
|
+
raise
|
|
596
|
+
if not os.path.lexists(lock_path):
|
|
597
|
+
if vanished:
|
|
598
|
+
raise
|
|
599
|
+
vanished = True
|
|
600
|
+
continue
|
|
601
|
+
vanished = False
|
|
602
|
+
if time.monotonic() >= deadline:
|
|
603
|
+
raise refuse(exc) from exc
|
|
604
|
+
time.sleep(_LOCK_POLL_SECONDS)
|
|
605
|
+
continue
|
|
606
|
+
try:
|
|
607
|
+
while not _try_lock(fd):
|
|
608
|
+
if time.monotonic() >= deadline:
|
|
609
|
+
raise refuse()
|
|
610
|
+
time.sleep(_LOCK_POLL_SECONDS)
|
|
611
|
+
if os.name == "nt" or _names_open_file(fd, lock_path):
|
|
612
|
+
break
|
|
613
|
+
_unlock(fd)
|
|
614
|
+
except BaseException:
|
|
615
|
+
os.close(fd)
|
|
616
|
+
raise
|
|
617
|
+
os.close(fd)
|
|
618
|
+
if time.monotonic() >= deadline:
|
|
619
|
+
raise refuse()
|
|
620
|
+
try:
|
|
621
|
+
yield
|
|
622
|
+
finally:
|
|
623
|
+
if os.name != "nt":
|
|
624
|
+
with contextlib.suppress(OSError):
|
|
625
|
+
lock_path.unlink()
|
|
626
|
+
_unlock(fd)
|
|
627
|
+
os.close(fd)
|
|
628
|
+
if os.name == "nt":
|
|
629
|
+
with contextlib.suppress(OSError):
|
|
630
|
+
lock_path.unlink()
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def append_records(ledger: dict, stage: str, records: list[dict]) -> tuple[list[str], list[str]]:
|
|
634
|
+
"""Append validated records in place. Returns (appended ids, duplicate ids)."""
|
|
635
|
+
existing = {_dedupe_key(r): r["id"] for r in ledger["records"]}
|
|
636
|
+
next_number = max((_id_number(r) for r in ledger["records"]), default=0) + 1
|
|
637
|
+
appended: list[str] = []
|
|
638
|
+
duplicates: list[str] = []
|
|
639
|
+
for record in records:
|
|
640
|
+
key = _dedupe_key(record)
|
|
641
|
+
if key in existing:
|
|
642
|
+
duplicates.append(existing[key])
|
|
643
|
+
continue
|
|
644
|
+
rid = f"GAP-{next_number:03d}"
|
|
645
|
+
next_number += 1
|
|
646
|
+
ledger["records"].append({"id": rid, **record, "stage": stage})
|
|
647
|
+
existing[key] = rid
|
|
648
|
+
appended.append(rid)
|
|
649
|
+
if stage not in ledger["stages"]:
|
|
650
|
+
ledger["stages"].append(stage)
|
|
651
|
+
return appended, duplicates
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def parse_input(text: str) -> list:
|
|
655
|
+
"""The records an append reads: an array, one object or {"records": [...]}."""
|
|
656
|
+
data = json.loads(text)
|
|
657
|
+
if isinstance(data, list):
|
|
658
|
+
return data
|
|
659
|
+
if isinstance(data, dict):
|
|
660
|
+
if set(data) == {"records"}:
|
|
661
|
+
if not isinstance(data["records"], list):
|
|
662
|
+
raise ValueError("'records' must be a list")
|
|
663
|
+
return data["records"]
|
|
664
|
+
return [data]
|
|
665
|
+
raise ValueError("input must be a JSON array or object")
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
# --------------------------------------------------------------------------
|
|
669
|
+
# Adapters: a script's result file -> records (append --from)
|
|
670
|
+
# --------------------------------------------------------------------------
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
class AdapterError(Exception):
|
|
674
|
+
"""A --from input that does not hold the result its kind reads."""
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
FAMILIES = ("description", "usage", "api_surface")
|
|
678
|
+
# The options (argparse dests) each kind reads, besides --input. A kind
|
|
679
|
+
# refuses any other, so a call that passes a file it would ignore fails.
|
|
680
|
+
ADAPTER_OPTIONS: dict[str, frozenset[str]] = {
|
|
681
|
+
"coverage": frozenset(
|
|
682
|
+
{"surface", "signatures", "numerator", "stale", "provenance", "skill_dir", "metadata"}
|
|
683
|
+
),
|
|
684
|
+
"guards": frozenset({"metadata"}),
|
|
685
|
+
"numerator": frozenset({"metadata"}),
|
|
686
|
+
"metadata-coherence": frozenset({"metadata"}),
|
|
687
|
+
"provenance-line": frozenset(),
|
|
688
|
+
"coherence": frozenset(),
|
|
689
|
+
"structure": frozenset({"served"}),
|
|
690
|
+
}
|
|
691
|
+
ALL_ADAPTER_OPTIONS = frozenset().union(*ADAPTER_OPTIONS.values())
|
|
692
|
+
_DEFAULT_METADATA = "metadata.json"
|
|
693
|
+
_DEFAULT_BRIEF = "skill-brief.yaml"
|
|
694
|
+
# The remediation of a missing required section: the SKF template heading
|
|
695
|
+
# that serves the family.
|
|
696
|
+
_SECTION_FIX = {
|
|
697
|
+
"description": (
|
|
698
|
+
"Add a `## Overview` section to SKILL.md that says what the library does and when "
|
|
699
|
+
"to use it, or a non-empty `description` to its frontmatter."
|
|
700
|
+
),
|
|
701
|
+
"usage": (
|
|
702
|
+
"Add a `## Quick Start` section to SKILL.md with a runnable example of the "
|
|
703
|
+
"library's most common calls."
|
|
704
|
+
),
|
|
705
|
+
"api_surface": (
|
|
706
|
+
"Add a `## Key API Summary` section to SKILL.md that lists the library's exports "
|
|
707
|
+
"with their purpose."
|
|
708
|
+
),
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
_SIBLINGS: dict[str, object] = {}
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _sibling(filename: str):
|
|
715
|
+
"""A script beside this one, loaded once: validate-inventory.py (the
|
|
716
|
+
documented-name match the stale-documentation Source uses)."""
|
|
717
|
+
module = _SIBLINGS.get(filename)
|
|
718
|
+
if module is None:
|
|
719
|
+
path = Path(__file__).resolve().parent / filename
|
|
720
|
+
spec = importlib.util.spec_from_file_location("skf_" + filename[:-3].replace("-", "_"), path)
|
|
721
|
+
if spec is None or spec.loader is None or not path.is_file():
|
|
722
|
+
raise AdapterError(f"{filename} not found beside {Path(__file__).name}")
|
|
723
|
+
module = importlib.util.module_from_spec(spec)
|
|
724
|
+
spec.loader.exec_module(module)
|
|
725
|
+
_SIBLINGS[filename] = module
|
|
726
|
+
return module
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def read_result(path: str, flag: str):
|
|
730
|
+
"""The JSON a script wrote. Raises AdapterError."""
|
|
731
|
+
try:
|
|
732
|
+
return json.loads(Path(path).read_bytes().decode("utf-8-sig"))
|
|
733
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
734
|
+
raise AdapterError(f"cannot read {flag} {path}: {exc}") from exc
|
|
735
|
+
except json.JSONDecodeError as exc:
|
|
736
|
+
raise AdapterError(f"{flag} {path} is not JSON: {exc.msg}") from exc
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _object(data: object, label: str, *keys: str) -> dict:
|
|
740
|
+
"""`data` as the object a script writes, with each of `keys`."""
|
|
741
|
+
if not isinstance(data, dict) or any(key not in data for key in keys):
|
|
742
|
+
needs = ", ".join(f"'{key}'" for key in keys)
|
|
743
|
+
raise AdapterError(f"{label} is not that script's result: it needs an object with {needs}")
|
|
744
|
+
return data
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _list(data: dict, key: str, label: str) -> list:
|
|
748
|
+
value = data.get(key)
|
|
749
|
+
if value is None:
|
|
750
|
+
return []
|
|
751
|
+
if not isinstance(value, list):
|
|
752
|
+
raise AdapterError(f"{label}: '{key}' must be a list")
|
|
753
|
+
return value
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _names(data: dict, key: str, label: str) -> list[str]:
|
|
757
|
+
return [n for n in _list(data, key, label) if isinstance(n, str) and n]
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _count(value: object) -> int | None:
|
|
761
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _code(names: list[str]) -> str:
|
|
765
|
+
return ", ".join(f"`{n}`" for n in names)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _gap(severity: str, category: str, title: str, source: str, remediation: str,
|
|
769
|
+
issue: str | None = None, export: str | None = None) -> dict:
|
|
770
|
+
record = {"severity": severity, "category": category, "title": title, "source": source,
|
|
771
|
+
"remediation": remediation}
|
|
772
|
+
if issue:
|
|
773
|
+
record["issue"] = issue
|
|
774
|
+
if export:
|
|
775
|
+
record["export"] = export
|
|
776
|
+
return record
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _surface_sources(surface: dict | None) -> dict[str, str]:
|
|
780
|
+
"""name -> `file:line` (or `file`) from a surface result's `exports[]`:
|
|
781
|
+
the first record with a file, one with a line preferred."""
|
|
782
|
+
sources: dict[str, tuple[bool, str]] = {}
|
|
783
|
+
for rec in (surface or {}).get("exports") or []:
|
|
784
|
+
if not isinstance(rec, dict) or not isinstance(rec.get("name"), str):
|
|
785
|
+
continue
|
|
786
|
+
file = rec.get("file")
|
|
787
|
+
if not isinstance(file, str) or not file:
|
|
788
|
+
continue
|
|
789
|
+
line = _count(rec.get("line"))
|
|
790
|
+
where = (line is not None, f"{file}:{line}" if line is not None else file)
|
|
791
|
+
known = sources.get(rec["name"])
|
|
792
|
+
if known is None or (where[0] and not known[0]):
|
|
793
|
+
sources[rec["name"]] = where
|
|
794
|
+
return {name: where for name, (_, where) in sources.items()}
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _provenance_sources(provenance: dict | None) -> dict[str, str]:
|
|
798
|
+
"""export_name -> `source_file:source_line` of its first provenance entry."""
|
|
799
|
+
sources: dict[str, str] = {}
|
|
800
|
+
for entry in (provenance or {}).get("entries") or []:
|
|
801
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("export_name"), str):
|
|
802
|
+
continue
|
|
803
|
+
file = entry.get("source_file")
|
|
804
|
+
if entry["export_name"] in sources or not isinstance(file, str) or not file:
|
|
805
|
+
continue
|
|
806
|
+
line = _count(entry.get("source_line"))
|
|
807
|
+
sources[entry["export_name"]] = f"{file}:{line}" if line is not None else file
|
|
808
|
+
return sources
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _documenting_line(skill_dir: Path, name: str) -> str | None:
|
|
812
|
+
"""The first line of SKILL.md, then of references/*.md in path order,
|
|
813
|
+
that writes `name` (validate-inventory.py's match), as `file:line`."""
|
|
814
|
+
pattern = _sibling("validate-inventory.py").name_pattern(name)
|
|
815
|
+
refs = skill_dir / "references"
|
|
816
|
+
files = [skill_dir / "SKILL.md"] + (sorted(refs.glob("*.md")) if refs.is_dir() else [])
|
|
817
|
+
for path in files:
|
|
818
|
+
try:
|
|
819
|
+
text = path.read_bytes().decode("utf-8-sig", errors="replace")
|
|
820
|
+
except OSError:
|
|
821
|
+
continue
|
|
822
|
+
for number, line in enumerate(text.split("\n"), start=1):
|
|
823
|
+
if pattern.search(line):
|
|
824
|
+
return f"{path.relative_to(skill_dir).as_posix()}:{number}"
|
|
825
|
+
return None
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _outside_scope(surface: dict | None) -> set[str] | None:
|
|
829
|
+
"""The names a surface result's `excluded.outsideScope` lists (defined in
|
|
830
|
+
a file the brief scopes out). None with no surface, no such list, or a
|
|
831
|
+
surface built without an extraction (no `extraction` object) or from
|
|
832
|
+
one that did not read the whole scope (a `no-ast-grep` status, a
|
|
833
|
+
`truncated` run, or `fallback.needed`): only an extraction fills that
|
|
834
|
+
list, so the names a brief scoped out are then unknown."""
|
|
835
|
+
excluded = (surface or {}).get("excluded")
|
|
836
|
+
if excluded is None:
|
|
837
|
+
return None
|
|
838
|
+
if not isinstance(excluded, dict):
|
|
839
|
+
raise AdapterError("--surface: 'excluded' must be an object")
|
|
840
|
+
rows = excluded.get("outsideScope")
|
|
841
|
+
if rows is None:
|
|
842
|
+
return None
|
|
843
|
+
if not isinstance(rows, list):
|
|
844
|
+
raise AdapterError("--surface: 'excluded.outsideScope' must be a list")
|
|
845
|
+
extraction = (surface or {}).get("extraction")
|
|
846
|
+
if not isinstance(extraction, dict):
|
|
847
|
+
return None
|
|
848
|
+
fallback = extraction.get("fallback")
|
|
849
|
+
if (extraction.get("status") == "no-ast-grep" or extraction.get("truncated")
|
|
850
|
+
or (isinstance(fallback, dict) and fallback.get("needed"))):
|
|
851
|
+
return None
|
|
852
|
+
return {row["name"] for row in rows if isinstance(row, dict) and isinstance(row.get("name"), str)}
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
# The extensions classify-stale reads a `defined_at` in.
|
|
856
|
+
# Keep identical to PYTHON_EXTENSIONS | TSJS_EXTENSIONS in skf-verify-provenance-completeness.py.
|
|
857
|
+
_DEFINED_AT_EXTENSIONS = frozenset({".py", ".pyi", ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"})
|
|
858
|
+
# classify-stale's `defined_at`: a Python or TS/JS file and a line.
|
|
859
|
+
_DEFINED_AT_RE = re.compile(
|
|
860
|
+
r"^.+(?:" + "|".join(re.escape(e) for e in sorted(_DEFINED_AT_EXTENSIONS)) + r"):[1-9]\d*$", re.IGNORECASE)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
# A source citation the skill writes, `[AST:path:L12]`, `[SRC:path:L12-34]` or `[SRC:path:L12-L34]` (one with no
|
|
864
|
+
# `:L<n>` part is ignored). Groups: prefix, path, first line, the `L` of a range's end, the range's end.
|
|
865
|
+
# Keep identical to _SKILL_CITATION_RE in skf-verify-provenance-completeness.py.
|
|
866
|
+
_SKILL_CITATION_RE = re.compile(
|
|
867
|
+
r"\[(AST|SRC):([^\[\]\n]+?):L(\d+)(?:-(L?)(\d+))?\]"
|
|
868
|
+
)
|
|
869
|
+
# Any bracketed citation (`[AST:...]`, `[SRC:...]`, `[EXT:...]`): a line names a documented name outside them.
|
|
870
|
+
_ANY_CITATION_RE = re.compile(r"\[[A-Z]+:[^\[\]\n]*\]")
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _cited_path(path: str) -> str:
|
|
874
|
+
"""A cited or `declared_in` path as both compare: forward slashes, no `./`."""
|
|
875
|
+
path = path.strip().replace("\\", "/")
|
|
876
|
+
while path.startswith("./"):
|
|
877
|
+
path = path[2:]
|
|
878
|
+
return posixpath.normpath(path) if path else path
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def skill_markdown_files(skill_dir: Path) -> list[Path]:
|
|
882
|
+
"""`SKILL.md` then every `references/**/*.md`, in a stable order.
|
|
883
|
+
Keep identical to skill_markdown_files in skf-verify-provenance-completeness.py."""
|
|
884
|
+
files: list[Path] = []
|
|
885
|
+
skill_md = skill_dir / "SKILL.md"
|
|
886
|
+
if skill_md.is_file():
|
|
887
|
+
files.append(skill_md)
|
|
888
|
+
refs = skill_dir / "references"
|
|
889
|
+
if refs.is_dir():
|
|
890
|
+
files.extend(sorted(p for p in refs.rglob("*.md") if p.is_file()))
|
|
891
|
+
return files
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _skill_cited_files(skill_dir: Path, name: str) -> set[str]:
|
|
895
|
+
"""The files the skill's `[AST:]` and `[SRC:]` citations name on a line of
|
|
896
|
+
SKILL.md or references/**/*.md that writes `name` outside its citations
|
|
897
|
+
(validate-inventory.py's match), or on the next non-blank line (a
|
|
898
|
+
heading, then its citation)."""
|
|
899
|
+
pattern = _sibling("validate-inventory.py").name_pattern(name)
|
|
900
|
+
cited: set[str] = set()
|
|
901
|
+
for path in skill_markdown_files(skill_dir):
|
|
902
|
+
try:
|
|
903
|
+
text = path.read_bytes().decode("utf-8-sig", errors="replace")
|
|
904
|
+
except OSError:
|
|
905
|
+
continue
|
|
906
|
+
lines = text.split("\n")
|
|
907
|
+
for number, line in enumerate(lines):
|
|
908
|
+
if not pattern.search(_ANY_CITATION_RE.sub(" ", line)):
|
|
909
|
+
continue
|
|
910
|
+
following = next((ln for ln in lines[number + 1:] if ln.strip()), "")
|
|
911
|
+
for cited_line in (line, following):
|
|
912
|
+
cited.update(_cited_path(m.group(2)) for m in _SKILL_CITATION_RE.finditer(cited_line))
|
|
913
|
+
return cited
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _documented_extra_at(name: str, item: dict, outside: set[str] | None,
|
|
917
|
+
skill_dir: str | None = None) -> tuple[str, int] | None:
|
|
918
|
+
"""Where the source declares a stale name that is a documented extra, given
|
|
919
|
+
a surface built from an extraction whose `excluded.outsideScope` does not
|
|
920
|
+
list the name: (the item's `defined_at`, 0), or, with no `defined_at`,
|
|
921
|
+
(the one `declared_in` item whose file the skill cites on a line that
|
|
922
|
+
names it, the number of files `declared_in` lists) when it lists several.
|
|
923
|
+
None for anything else, which stays a Medium gap: no `defined_at` and no
|
|
924
|
+
single cited declaring file (two or more cited, none, or no --skill-dir),
|
|
925
|
+
a `defined_at` or `declared_in` item not a Python or TS/JS `file:line`, a
|
|
926
|
+
dotted name, no surface or one without an extraction, or a name a brief
|
|
927
|
+
scoped out."""
|
|
928
|
+
if outside is None or name in outside or "." in name:
|
|
929
|
+
return None
|
|
930
|
+
defined_at = item.get("defined_at")
|
|
931
|
+
if defined_at is not None:
|
|
932
|
+
if not isinstance(defined_at, str) or not _DEFINED_AT_RE.match(defined_at):
|
|
933
|
+
return None
|
|
934
|
+
return defined_at, 0
|
|
935
|
+
declared_in = item.get("declared_in")
|
|
936
|
+
if not skill_dir or not isinstance(declared_in, list) or not all(
|
|
937
|
+
isinstance(d, str) and _DEFINED_AT_RE.match(d) for d in declared_in):
|
|
938
|
+
return None
|
|
939
|
+
by_file = {_cited_path(d.rsplit(":", 1)[0]): d for d in declared_in}
|
|
940
|
+
if len(by_file) < 2:
|
|
941
|
+
return None
|
|
942
|
+
cited = _skill_cited_files(Path(skill_dir), name)
|
|
943
|
+
chosen = [d for f, d in sorted(by_file.items()) if f in cited]
|
|
944
|
+
return (chosen[0], len(by_file)) if len(chosen) == 1 else None
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _missing_export(name: str, where: str | None, meta: str, kind: str = "export") -> dict:
|
|
948
|
+
if where:
|
|
949
|
+
read = f"read its definition at `{where}`"
|
|
950
|
+
else:
|
|
951
|
+
read = f"find its definition in the source (`{meta}` lists it)"
|
|
952
|
+
if kind == "type":
|
|
953
|
+
return _gap("Medium", "missing-type", f"Missing type: {name}", where or meta,
|
|
954
|
+
f"Document the type `{name}` in SKILL.md: {read} and list its fields or members.",
|
|
955
|
+
issue=f"the type `{name}` is on the source API surface, and the skill does not document it",
|
|
956
|
+
export=name)
|
|
957
|
+
return _gap("Medium", "missing-export", f"Missing export: {name}", where or meta,
|
|
958
|
+
f"Document `{name}` in SKILL.md: {read} and add its signature, its purpose and a usage "
|
|
959
|
+
"example.",
|
|
960
|
+
issue=f"`{name}` is on the source API surface, and SKILL.md and references/ do not document it",
|
|
961
|
+
export=name)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def from_coverage(cov: object, surface: object = None, signatures: object = None, numerator: object = None,
|
|
965
|
+
stale: object = None, provenance: object = None, skill_dir: str | None = None,
|
|
966
|
+
metadata: str | None = None) -> list[dict]:
|
|
967
|
+
"""reconcile-coverage.py's result: missing and stale names, or the missing count."""
|
|
968
|
+
cov = _object(cov, "--input (reconcile-coverage.py)", "branch")
|
|
969
|
+
meta = metadata or _DEFAULT_METADATA
|
|
970
|
+
branch = cov["branch"]
|
|
971
|
+
records: list[dict] = []
|
|
972
|
+
if branch == "enumerated":
|
|
973
|
+
types: set[str] = set()
|
|
974
|
+
# the names whose documented signature signature scoring compared
|
|
975
|
+
scored: set[str] = set()
|
|
976
|
+
if signatures is not None:
|
|
977
|
+
signatures = _object(signatures, "--signatures (score-signatures.py)", "missingTypes")
|
|
978
|
+
types = set(_names(signatures, "missingTypes", "--signatures"))
|
|
979
|
+
scored = set(_names(signatures, "comparedNames", "--signatures"))
|
|
980
|
+
if surface is not None:
|
|
981
|
+
surface = _object(surface, "--surface (load-coverage-inputs.py surface)", "exports")
|
|
982
|
+
where = _surface_sources(surface)
|
|
983
|
+
# `excluded` is read once a stale name may be a documented extra
|
|
984
|
+
outside: set[str] | None = None
|
|
985
|
+
outside_read = False
|
|
986
|
+
for name in _names(cov, "missing", "--input"):
|
|
987
|
+
records.append(_missing_export(name, where.get(name), meta, "type" if name in types else "export"))
|
|
988
|
+
classified: dict[str, dict] = {}
|
|
989
|
+
if stale is not None:
|
|
990
|
+
if not isinstance(stale, list):
|
|
991
|
+
raise AdapterError("--stale is not classify-stale JSON (a list)")
|
|
992
|
+
classified = {i["name"]: i for i in stale if isinstance(i, dict) and isinstance(i.get("name"), str)}
|
|
993
|
+
for name in _names(cov, "stale", "--input"):
|
|
994
|
+
item = classified.get(name) or {}
|
|
995
|
+
cited = item.get("source")
|
|
996
|
+
if item.get("fabricated") is True:
|
|
997
|
+
if not isinstance(cited, str) or not cited:
|
|
998
|
+
# a Critical gap needs the line it cites: never downgrade it
|
|
999
|
+
raise AdapterError(f"--stale: {name!r} is fabricated with no source")
|
|
1000
|
+
records.append(_gap(
|
|
1001
|
+
"Critical", "fabricated-signature", f"Fabricated signature: {name}", cited,
|
|
1002
|
+
f"Remove `{name}` from SKILL.md and references/, or correct it to the export the source "
|
|
1003
|
+
f"defines: no file its provenance cites defines it (`{cited}`).",
|
|
1004
|
+
issue=f"SKILL.md documents `{name}`, which the source API surface lacks and the cited "
|
|
1005
|
+
"file does not define",
|
|
1006
|
+
export=name))
|
|
1007
|
+
continue
|
|
1008
|
+
if not outside_read:
|
|
1009
|
+
outside, outside_read = _outside_scope(surface), True
|
|
1010
|
+
extra = _documented_extra_at(name, item, outside, skill_dir)
|
|
1011
|
+
doc = _documenting_line(Path(skill_dir), name) if skill_dir else None
|
|
1012
|
+
if extra:
|
|
1013
|
+
declared, among = extra
|
|
1014
|
+
# a homonym: the one declaring file the skill's own citations name
|
|
1015
|
+
picked = (f"; of the {among} files that declare it, the skill cites only "
|
|
1016
|
+
f"`{declared.rsplit(':', 1)[0]}`") if among else ""
|
|
1017
|
+
# never routed: update-skill leaves an `observation` alone. Signature scoring compares a
|
|
1018
|
+
# documented signature of a name on the surface's records (a nested sub-package's) alone.
|
|
1019
|
+
checked = ("Signature scoring compares its documented signature with the surface's record of it."
|
|
1020
|
+
if name in scored else
|
|
1021
|
+
"Its documented signature was not checked against that declaration.")
|
|
1022
|
+
records.append(_gap(
|
|
1023
|
+
"Info", "observation", f"Documented extra: {name}", declared,
|
|
1024
|
+
f"No change: `{name}` is a documented extra, a name the source still declares at "
|
|
1025
|
+
f"`{declared}` outside the enumerated surface, and its documentation stays. {checked}",
|
|
1026
|
+
issue=f"{f'`{doc}`' if doc else 'the skill'} documents `{name}`, which the enumerated source API "
|
|
1027
|
+
f"surface lacks and the source still declares{picked}",
|
|
1028
|
+
export=name))
|
|
1029
|
+
continue
|
|
1030
|
+
records.append(_gap(
|
|
1031
|
+
"Medium", "stale-documentation", f"Stale documentation: {name}", doc or "SKILL.md",
|
|
1032
|
+
f"Check `{name}` against the source and update or remove its documentation at "
|
|
1033
|
+
f"{doc or 'SKILL.md'}: the enumerated source surface does not export it.",
|
|
1034
|
+
issue=f"SKILL.md documents `{name}`, which the enumerated source API surface lacks",
|
|
1035
|
+
export=name))
|
|
1036
|
+
elif branch in ("scalar", "stack"):
|
|
1037
|
+
if branch == "scalar" and cov.get("numeratorSource") == "verified":
|
|
1038
|
+
if numerator is None:
|
|
1039
|
+
raise AdapterError("the scalar count used the verified numerator: pass its result with "
|
|
1040
|
+
"--numerator")
|
|
1041
|
+
absent = _names(_object(numerator, "--numerator (verify-declared-numerator.py)", "absent"),
|
|
1042
|
+
"absent", "--numerator")
|
|
1043
|
+
where = _provenance_sources(_object(provenance, "--provenance (provenance map)", "entries")
|
|
1044
|
+
if provenance is not None else None)
|
|
1045
|
+
records += [_missing_export(name, where.get(name), meta) for name in absent]
|
|
1046
|
+
else:
|
|
1047
|
+
missing, denominator = _count(cov.get("missingCount")), _count(cov.get("denominator"))
|
|
1048
|
+
if missing:
|
|
1049
|
+
records.append(_gap(
|
|
1050
|
+
"Medium", "missing-export", f"{missing} of {denominator} exports not documented", meta,
|
|
1051
|
+
f"Find the {missing} exports the denominator counts that SKILL.md and references/ do not "
|
|
1052
|
+
f"name (`{meta}` lists the exports) and document each with its signature.",
|
|
1053
|
+
issue=f"SKILL.md and references/ name {cov.get('documented')} of the {denominator} exports "
|
|
1054
|
+
f"the {branch} denominator counts"))
|
|
1055
|
+
elif branch != "docsOnly":
|
|
1056
|
+
raise AdapterError(f"--input (reconcile-coverage.py): unknown branch {branch!r}")
|
|
1057
|
+
return records
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def from_guards(surface: object, metadata: str | None = None) -> list[dict]:
|
|
1061
|
+
"""load-coverage-inputs.py surface's deflation, inflation and stale-scope guards."""
|
|
1062
|
+
surface = _object(surface, "--input (load-coverage-inputs.py surface)", "guards", "inputs")
|
|
1063
|
+
guards = surface["guards"]
|
|
1064
|
+
if guards is None:
|
|
1065
|
+
return []
|
|
1066
|
+
if not isinstance(guards, dict):
|
|
1067
|
+
raise AdapterError("--input (load-coverage-inputs.py surface): 'guards' must be an object or null")
|
|
1068
|
+
inputs = surface["inputs"] if isinstance(surface["inputs"], dict) else {}
|
|
1069
|
+
meta = metadata or inputs.get("metadata") or _DEFAULT_METADATA
|
|
1070
|
+
brief = inputs.get("brief") or _DEFAULT_BRIEF
|
|
1071
|
+
records: list[dict] = []
|
|
1072
|
+
stale = guards.get("staleScope") or {}
|
|
1073
|
+
label = "--input (load-coverage-inputs.py surface): guards.staleScope"
|
|
1074
|
+
# A tier A glob that matches no file counts no name, and so does one that
|
|
1075
|
+
# matches only excluded files or files that define no name: when the
|
|
1076
|
+
# guards fire, the brief has no tier A glob, or none of its globs counts a
|
|
1077
|
+
# name. `tierAInclude` (the brief's tier A globs) is absent from a surface
|
|
1078
|
+
# older than #712, where only the stale globs say the brief has some.
|
|
1079
|
+
tier_a_globs = _names(stale, "unmatchedTierAInclude", label)
|
|
1080
|
+
brief_tier_a = list(dict.fromkeys(
|
|
1081
|
+
_names(guards, "tierAInclude", "--input (load-coverage-inputs.py surface): guards") + tier_a_globs))
|
|
1082
|
+
if not brief_tier_a:
|
|
1083
|
+
no_tier_a = "the brief has no `scope.tier_a_include`"
|
|
1084
|
+
elif set(brief_tier_a) <= set(tier_a_globs):
|
|
1085
|
+
no_tier_a = "no `scope.tier_a_include` glob in the brief matches a file"
|
|
1086
|
+
else:
|
|
1087
|
+
no_tier_a = "no `scope.tier_a_include` glob in the brief counts a public name"
|
|
1088
|
+
deflation = guards.get("deflation") or {}
|
|
1089
|
+
if deflation.get("fires") is True:
|
|
1090
|
+
if deflation.get("pct") is None: # a stored 0: no percentage above it
|
|
1091
|
+
above = (f" against a `stats.effective_denominator` of "
|
|
1092
|
+
f"{_count(deflation.get('effectiveDenominator')) or 0}")
|
|
1093
|
+
else:
|
|
1094
|
+
above = (f", {deflation.get('pct')}% above `stats.effective_denominator` "
|
|
1095
|
+
f"({deflation.get('effectiveDenominator')})")
|
|
1096
|
+
if deflation.get("pct") is None: # a 0 measures no tier: count it again
|
|
1097
|
+
first = ("Edit `scope.tier_a_include` in the brief so its globs match the files that define the "
|
|
1098
|
+
"authored tier" if brief_tier_a else "Check `scope.include` in the brief")
|
|
1099
|
+
fix = (f"{first}, then re-run Create Skill so `stats.effective_denominator` in `{meta}` is counted "
|
|
1100
|
+
"again.")
|
|
1101
|
+
elif brief_tier_a:
|
|
1102
|
+
fix = (f"Set `stats.effective_denominator` in `{meta}` to the public surface the source re-derives, "
|
|
1103
|
+
"or edit `scope.tier_a_include` in the brief so its globs match the files of the authored tier "
|
|
1104
|
+
"the smaller count measures.")
|
|
1105
|
+
else:
|
|
1106
|
+
fix = (f"Set `stats.effective_denominator` in `{meta}` to the public surface the source re-derives, "
|
|
1107
|
+
"or add `scope.tier_a_include` to the brief to name the authored tier the smaller count "
|
|
1108
|
+
"measures.")
|
|
1109
|
+
records.append(_gap(
|
|
1110
|
+
"Medium", "metadata-drift",
|
|
1111
|
+
"denominator deflation: effective_denominator below source public surface without tier_a_include",
|
|
1112
|
+
meta, fix,
|
|
1113
|
+
issue=f"the re-derived surface counts {deflation.get('rederived')} exports{above}, and {no_tier_a}"))
|
|
1114
|
+
inflation = guards.get("inflation") or {}
|
|
1115
|
+
if inflation.get("fires") is True:
|
|
1116
|
+
if (guards.get("umbrella") or {}).get("umbrella") is True:
|
|
1117
|
+
fix = (f"Set `stats.effective_denominator` in `{meta}` to the authored surface: the root barrel "
|
|
1118
|
+
"is an umbrella of re-exports, so a `scope.tier_a_include` glob would still count them.")
|
|
1119
|
+
elif brief_tier_a:
|
|
1120
|
+
fix = (f"Edit `scope.tier_a_include` in `{brief}` so its globs match the files of the authored "
|
|
1121
|
+
"surface: the denominator then counts it instead of the coarse `scope.include` union.")
|
|
1122
|
+
else:
|
|
1123
|
+
fix = (f"Add `scope.tier_a_include` to `{brief}`, listing the files of the authored surface, so "
|
|
1124
|
+
"the denominator counts it instead of the coarse `scope.include` union.")
|
|
1125
|
+
records.append(_gap(
|
|
1126
|
+
"Medium", "denominator-inflation",
|
|
1127
|
+
"denominator inflation: coarse scope.include union exceeds authored surface", brief, fix,
|
|
1128
|
+
issue=f"the `scope.include` union counts {inflation.get('scopeIncludeUnion')} exports, "
|
|
1129
|
+
f"{inflation.get('pct')}% above the {inflation.get('provenanceEntries')} provenance entries, "
|
|
1130
|
+
f"and {no_tier_a}"))
|
|
1131
|
+
if stale.get("fires") is True:
|
|
1132
|
+
globs = _names(stale, "unmatchedInclude", label)
|
|
1133
|
+
restored = sorted({(r["name"], r["file"] if isinstance(r.get("file"), str) else "")
|
|
1134
|
+
for r in _list(stale, "restored", label)
|
|
1135
|
+
if isinstance(r, dict) and isinstance(r.get("name"), str) and r["name"]})
|
|
1136
|
+
names = ", ".join(f"`{name}` in `{file}`" if file else f"`{name}`" for name, file in restored)
|
|
1137
|
+
if len(globs) == 1:
|
|
1138
|
+
unmatched = f"the `scope.include` glob {_code(globs)} matches no file in the source tested"
|
|
1139
|
+
else:
|
|
1140
|
+
unmatched = f"the `scope.include` globs {_code(globs)} match no file in the source tested"
|
|
1141
|
+
if not restored:
|
|
1142
|
+
back = "no root export was restored"
|
|
1143
|
+
elif len(restored) == 1:
|
|
1144
|
+
back = f"the `all` set restores 1 root export, defined in a file no include glob covers: {names}"
|
|
1145
|
+
else:
|
|
1146
|
+
back = (f"the `all` set restores {len(restored)} root exports, defined in files no include glob "
|
|
1147
|
+
f"covers: {names}")
|
|
1148
|
+
if len(tier_a_globs) == 1:
|
|
1149
|
+
tier_a = (f"the `scope.tier_a_include` glob {_code(tier_a_globs)} matches no file in the source "
|
|
1150
|
+
"tested, so it counts no name")
|
|
1151
|
+
else:
|
|
1152
|
+
tier_a = (f"the `scope.tier_a_include` globs {_code(tier_a_globs)} match no file in the source "
|
|
1153
|
+
"tested, so they count no name")
|
|
1154
|
+
exclude = ", and add to `scope.exclude` any restored file the brief meant to leave out"
|
|
1155
|
+
if not tier_a_globs: # the include globs alone, as #677 wrote the gap
|
|
1156
|
+
lists, rewrite, clauses = ["scope.include"], "an include glob", [unmatched, back]
|
|
1157
|
+
elif not globs: # no include glob is stale, so nothing was restored
|
|
1158
|
+
lists, rewrite, clauses, exclude = ["scope.tier_a_include"], "a tier A glob", [tier_a], ""
|
|
1159
|
+
else:
|
|
1160
|
+
lists = ["scope.include", "scope.tier_a_include"]
|
|
1161
|
+
rewrite, clauses = "an include or tier A glob", [unmatched, back, tier_a]
|
|
1162
|
+
edit = " and ".join(f"`{key}`" for key in lists)
|
|
1163
|
+
records.append(_gap(
|
|
1164
|
+
"Medium", "brief-scope-stale", f"stale brief scope: {' and '.join(lists)} globs match no source file",
|
|
1165
|
+
brief,
|
|
1166
|
+
f"Edit {edit} in `{brief}` by hand so each glob matches the files it meant in this version of the "
|
|
1167
|
+
f"source (update-skill does not rewrite {rewrite}){exclude}.",
|
|
1168
|
+
issue="; ".join(clauses)))
|
|
1169
|
+
return records
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def from_numerator(numerator: object, metadata: str | None = None) -> list[dict]:
|
|
1173
|
+
"""verify-declared-numerator.py's result: the numerator inflation gap."""
|
|
1174
|
+
numerator = _object(numerator, "--input (verify-declared-numerator.py)", "inflated")
|
|
1175
|
+
if numerator["inflated"] is not True:
|
|
1176
|
+
return []
|
|
1177
|
+
meta = metadata or _DEFAULT_METADATA
|
|
1178
|
+
declared, verified = _count(numerator.get("declared")) or 0, _count(numerator.get("verified")) or 0
|
|
1179
|
+
absent = _names(numerator, "absent", "--input")
|
|
1180
|
+
return [_gap(
|
|
1181
|
+
"High", "numerator-inflation",
|
|
1182
|
+
f"numerator inflation: {declared - verified} of {declared} declared exports absent from "
|
|
1183
|
+
"SKILL.md/references",
|
|
1184
|
+
meta,
|
|
1185
|
+
f"Document the absent exports in SKILL.md or references/: the skill documents {verified} of its "
|
|
1186
|
+
f"{declared} declared exports.",
|
|
1187
|
+
issue=f"a declared count (`stats.exports_documented`, or the provenance map's distinct entry names) "
|
|
1188
|
+
f"equals `stats.effective_denominator`, and these declared exports are absent from SKILL.md and "
|
|
1189
|
+
f"references/: {_code(absent)}")]
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def from_metadata_coherence(result: object, metadata: str | None = None) -> list[dict]:
|
|
1193
|
+
"""check-metadata-coherence.py's findings: count drift and multi-denominator notes."""
|
|
1194
|
+
result = _object(result, "--input (check-metadata-coherence.py)", "findings")
|
|
1195
|
+
meta = metadata or _DEFAULT_METADATA
|
|
1196
|
+
records: list[dict] = []
|
|
1197
|
+
for index, finding in enumerate(_list(result, "findings", "--input")):
|
|
1198
|
+
if not isinstance(finding, dict) or not isinstance(finding.get("title"), str):
|
|
1199
|
+
raise AdapterError(f"--input: findings[{index}] has no title")
|
|
1200
|
+
detail = finding.get("detail") if isinstance(finding.get("detail"), str) else None
|
|
1201
|
+
if finding.get("severity") == "Medium":
|
|
1202
|
+
records.append(_gap(
|
|
1203
|
+
"Medium", "metadata-drift", finding["title"], meta,
|
|
1204
|
+
f"Recount the exports and correct the diverging counts in `{meta}`; update-skill applies it as "
|
|
1205
|
+
"a metadata update.",
|
|
1206
|
+
issue=detail))
|
|
1207
|
+
elif finding.get("severity") == "Info":
|
|
1208
|
+
records.append(_gap(
|
|
1209
|
+
"Info", "multi-denominator", finding["title"], meta,
|
|
1210
|
+
"No action required: the barrel and the documented surface measure different sets by design, "
|
|
1211
|
+
"and the report records both counts.",
|
|
1212
|
+
issue=detail))
|
|
1213
|
+
else:
|
|
1214
|
+
raise AdapterError(f"--input: findings[{index}] has severity {finding.get('severity')!r}, "
|
|
1215
|
+
"neither Medium nor Info")
|
|
1216
|
+
return records
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def from_provenance_line(result: object) -> list[dict]:
|
|
1220
|
+
"""verify's `line-not-definition` items: provenance line gaps."""
|
|
1221
|
+
result = _object(result, "--input (skf-verify-provenance-completeness.py verify)", "stale")
|
|
1222
|
+
records: list[dict] = []
|
|
1223
|
+
for item in _list(result, "stale", "--input"):
|
|
1224
|
+
if not isinstance(item, dict) or item.get("reason") != "line-not-definition":
|
|
1225
|
+
continue
|
|
1226
|
+
name, file, line = item.get("export_name"), item.get("source_file"), item.get("source_line")
|
|
1227
|
+
defs = [n for n in item.get("definition_lines") or [] if _count(n) is not None]
|
|
1228
|
+
where = f"{file}:{line}"
|
|
1229
|
+
if defs:
|
|
1230
|
+
lines = ", ".join(str(n) for n in defs)
|
|
1231
|
+
records.append(_gap(
|
|
1232
|
+
"Low", "provenance-line", f"Provenance line is not the definition of {name}", where,
|
|
1233
|
+
f"Set the provenance `source_line` of `{name}` in `{file}` to its definition line ({lines}) "
|
|
1234
|
+
"and move its citations to that line; update-skill `--from-test-report` applies this when the "
|
|
1235
|
+
"file defines it on one line.",
|
|
1236
|
+
issue=f"the provenance map records `{name}` at `{where}`, which is not the line that defines "
|
|
1237
|
+
f"it; the lines that define it are {lines}.",
|
|
1238
|
+
export=name))
|
|
1239
|
+
else:
|
|
1240
|
+
records.append(_gap(
|
|
1241
|
+
"Info", "provenance-unverified", f"Provenance line not verified for {name}", where,
|
|
1242
|
+
f"Open `{file}` and confirm that line {line} defines `{name}`; if another line does, set the "
|
|
1243
|
+
"provenance `source_line` to it.",
|
|
1244
|
+
issue=f"the line-check rules found no definition line for `{name}` in `{file}`; check by hand",
|
|
1245
|
+
export=name))
|
|
1246
|
+
return records
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def from_coherence(result: object) -> list[dict]:
|
|
1250
|
+
"""aggregate-coherence.py's invalid references."""
|
|
1251
|
+
result = _object(result, "--input (aggregate-coherence.py)", "invalidReferences")
|
|
1252
|
+
records: list[dict] = []
|
|
1253
|
+
for index, ref in enumerate(_list(result, "invalidReferences", "--input")):
|
|
1254
|
+
if not isinstance(ref, dict):
|
|
1255
|
+
raise AdapterError(f"--input: invalidReferences[{index}] is not an object")
|
|
1256
|
+
target, line = ref.get("target"), _count(ref.get("line"))
|
|
1257
|
+
where = f"SKILL.md:{line}" if line is not None else "SKILL.md"
|
|
1258
|
+
status = ref.get("status")
|
|
1259
|
+
if status == "missing":
|
|
1260
|
+
records.append(_gap(
|
|
1261
|
+
"Critical", "broken-reference", f"coherence: broken reference: {target}", where,
|
|
1262
|
+
f"Point the reference to `{target}` at {where} at a target that exists, or remove it: "
|
|
1263
|
+
"nothing exists at its target.",
|
|
1264
|
+
issue="; ".join(i for i in ref.get("issues") or [] if isinstance(i, str)) or None))
|
|
1265
|
+
elif status == "inaccurate":
|
|
1266
|
+
issues = "; ".join(i for i in ref.get("issues") or [] if isinstance(i, str))
|
|
1267
|
+
records.append(_gap(
|
|
1268
|
+
"High", "inaccurate-reference", f"coherence: inaccurate reference: {target}", where,
|
|
1269
|
+
f"Correct the reference to `{target}` at {where} so it matches its target"
|
|
1270
|
+
+ (f": {issues}." if issues else "."),
|
|
1271
|
+
issue=issues or None))
|
|
1272
|
+
elif status == "escapes":
|
|
1273
|
+
canonical = ref.get("canonical")
|
|
1274
|
+
records.append(_gap(
|
|
1275
|
+
"High", "reference-escape",
|
|
1276
|
+
f"coherence: reference escapes skill/source sandbox: {target} → {canonical}", where,
|
|
1277
|
+
f"Point the reference to `{target}` at {where} inside the skill package or its source tree, "
|
|
1278
|
+
f"or remove it: it resolves to `{canonical}`."))
|
|
1279
|
+
else:
|
|
1280
|
+
raise AdapterError(f"--input: invalidReferences[{index}] has an unknown status {status!r}")
|
|
1281
|
+
return records
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _structure_shape(data: object) -> str | None:
|
|
1285
|
+
"""Which skf-scan-skill-md-structure.py result `data` is."""
|
|
1286
|
+
if not isinstance(data, dict):
|
|
1287
|
+
return None
|
|
1288
|
+
if all(isinstance(data.get(f), dict) and "satisfied" in data[f] for f in FAMILIES):
|
|
1289
|
+
return "required-sections"
|
|
1290
|
+
if "zero_usage" in data:
|
|
1291
|
+
return "usage-scope"
|
|
1292
|
+
if "unbalanced_fences" in data:
|
|
1293
|
+
return "scan"
|
|
1294
|
+
if "references" in data and "counts" in data:
|
|
1295
|
+
return "reference-check"
|
|
1296
|
+
return None
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
def _scripts_assets_gap(data: dict, prefix: str) -> list[dict]:
|
|
1300
|
+
section = data.get("scripts_assets")
|
|
1301
|
+
if not isinstance(section, dict) or section.get("missing") is not True:
|
|
1302
|
+
return []
|
|
1303
|
+
folders = [f for f in section.get("folders") or [] if isinstance(f, str)]
|
|
1304
|
+
named = " and ".join(f"`{f}/`" for f in folders) or "`scripts/` and `assets/`"
|
|
1305
|
+
return [_gap(
|
|
1306
|
+
"Medium", "scripts-assets",
|
|
1307
|
+
f"{prefix}: scripts/assets directory exists but Scripts & Assets section missing", "SKILL.md",
|
|
1308
|
+
f"Add a `## Scripts & Assets` section to SKILL.md that lists each file in {named} with its purpose.")]
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def from_structure(results: list[object], served: list[str] | None = None) -> list[dict]:
|
|
1312
|
+
"""skf-scan-skill-md-structure.py results: the naive structural gaps, and
|
|
1313
|
+
the Scripts & Assets gap of either mode."""
|
|
1314
|
+
served_set = set(served or [])
|
|
1315
|
+
records: list[dict] = []
|
|
1316
|
+
for index, data in enumerate(results):
|
|
1317
|
+
shape = _structure_shape(data)
|
|
1318
|
+
if shape is None:
|
|
1319
|
+
raise AdapterError(f"--input #{index + 1} is not a scan, scan --required-sections, usage-scope or "
|
|
1320
|
+
"reference-check result")
|
|
1321
|
+
if shape == "required-sections":
|
|
1322
|
+
for family in FAMILIES:
|
|
1323
|
+
entry = data[family]
|
|
1324
|
+
if entry.get("satisfied") is True or family in served_set:
|
|
1325
|
+
continue
|
|
1326
|
+
if family == "description" and data.get("frontmatter_description") is True:
|
|
1327
|
+
continue
|
|
1328
|
+
tried = [t for t in entry.get("tried") or [] if isinstance(t, str)]
|
|
1329
|
+
records.append(_gap(
|
|
1330
|
+
"High", "structural", f"naive-coherence: missing required section: {family}", "SKILL.md",
|
|
1331
|
+
_SECTION_FIX[family],
|
|
1332
|
+
issue=f"no heading of SKILL.md serves the {family} family (synonyms tried: "
|
|
1333
|
+
f"{', '.join(tried)})"))
|
|
1334
|
+
elif shape == "scan":
|
|
1335
|
+
if data.get("unbalanced_fences") is True:
|
|
1336
|
+
records.append(_gap(
|
|
1337
|
+
"High", "structural", "naive-coherence: unbalanced code fence (unclosed block)", "SKILL.md",
|
|
1338
|
+
"Close the code block SKILL.md leaves open with a fence line of three backticks.",
|
|
1339
|
+
issue=f"SKILL.md has {data.get('fence_count')} fence lines, so one block is never closed"))
|
|
1340
|
+
for fence in _list(data, "bare_opening_fences", "--input"):
|
|
1341
|
+
line = _count(fence.get("line")) if isinstance(fence, dict) else None
|
|
1342
|
+
records.append(_gap(
|
|
1343
|
+
"Medium", "structural",
|
|
1344
|
+
f"naive-coherence: opening code fence at line {line} missing language tag",
|
|
1345
|
+
f"SKILL.md:{line}",
|
|
1346
|
+
f"Add a language tag (such as `bash` or `python`) to the opening fence at SKILL.md line "
|
|
1347
|
+
f"{line}."))
|
|
1348
|
+
for row in _list(data, "table_drift", "--input"):
|
|
1349
|
+
if not isinstance(row, dict):
|
|
1350
|
+
continue
|
|
1351
|
+
line, expected = _count(row.get("line")), _count(row.get("expected_cols"))
|
|
1352
|
+
where = f"section `{row['section']}`: " if row.get("section") else ""
|
|
1353
|
+
records.append(_gap(
|
|
1354
|
+
"Medium", "structural",
|
|
1355
|
+
f"naive-coherence: table row at line {line} has {row.get('actual_cols')} columns; "
|
|
1356
|
+
f"header has {expected}",
|
|
1357
|
+
f"SKILL.md:{line}",
|
|
1358
|
+
f"Give the row at SKILL.md line {line} the {expected} columns its header has (write a pipe "
|
|
1359
|
+
"inside a cell as `\\|`).",
|
|
1360
|
+
issue=f"{where}{row.get('row')}" if row.get("row") else None))
|
|
1361
|
+
records += _scripts_assets_gap(data, "naive-coherence")
|
|
1362
|
+
elif shape == "usage-scope":
|
|
1363
|
+
for export in _list(data, "zero_usage", "--input"):
|
|
1364
|
+
if not isinstance(export, dict) or not isinstance(export.get("name"), str):
|
|
1365
|
+
continue
|
|
1366
|
+
name, kind = export["name"], export.get("kind") or "export"
|
|
1367
|
+
records.append(_gap(
|
|
1368
|
+
"Medium", "structural",
|
|
1369
|
+
f"naive-coherence: exported {kind} `{name}` is not referenced in any usage-family "
|
|
1370
|
+
"section or reference file",
|
|
1371
|
+
"SKILL.md",
|
|
1372
|
+
f"Add an example that calls `{name}` to SKILL.md's usage section (or to a references/ file "
|
|
1373
|
+
"for a split body).",
|
|
1374
|
+
export=name))
|
|
1375
|
+
else:
|
|
1376
|
+
records += _scripts_assets_gap(data, "coherence")
|
|
1377
|
+
return records
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
def build_records(args: argparse.Namespace) -> list[dict]:
|
|
1381
|
+
"""The records `append --from` appends. Raises AdapterError."""
|
|
1382
|
+
kind = args.adapter
|
|
1383
|
+
stray = sorted(o for o in ALL_ADAPTER_OPTIONS - ADAPTER_OPTIONS[kind] if getattr(args, o) is not None)
|
|
1384
|
+
if stray:
|
|
1385
|
+
flags = ", ".join("--" + o.replace("_", "-") for o in stray)
|
|
1386
|
+
raise AdapterError(f"--from {kind} does not read {flags}")
|
|
1387
|
+
inputs = args.input or []
|
|
1388
|
+
if not inputs or (kind != "structure" and len(inputs) > 1):
|
|
1389
|
+
wanted = "one or more --input files" if kind == "structure" else "one --input file"
|
|
1390
|
+
raise AdapterError(f"--from {kind} reads {wanted}")
|
|
1391
|
+
if kind == "structure":
|
|
1392
|
+
return from_structure([read_result(p, "--input") for p in inputs], args.served)
|
|
1393
|
+
data = read_result(inputs[0], "--input")
|
|
1394
|
+
if kind == "coverage":
|
|
1395
|
+
files = {o: read_result(getattr(args, o), "--" + o) if getattr(args, o) is not None else None
|
|
1396
|
+
for o in ("surface", "signatures", "numerator", "stale", "provenance")}
|
|
1397
|
+
return from_coverage(data, skill_dir=args.skill_dir, metadata=args.metadata, **files)
|
|
1398
|
+
if kind == "guards":
|
|
1399
|
+
return from_guards(data, args.metadata)
|
|
1400
|
+
if kind == "numerator":
|
|
1401
|
+
return from_numerator(data, args.metadata)
|
|
1402
|
+
if kind == "metadata-coherence":
|
|
1403
|
+
return from_metadata_coherence(data, args.metadata)
|
|
1404
|
+
if kind == "provenance-line":
|
|
1405
|
+
return from_provenance_line(data)
|
|
1406
|
+
return from_coherence(data)
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
# --------------------------------------------------------------------------
|
|
1410
|
+
# Gap Report
|
|
1411
|
+
# --------------------------------------------------------------------------
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
def render_gap_report(ledger: dict, heading: bool = False) -> str:
|
|
1415
|
+
"""The Gap Report section body, from the ledger (report.md §4c writes it)."""
|
|
1416
|
+
records = ordered(ledger["records"])
|
|
1417
|
+
counts = count_by_severity(records)
|
|
1418
|
+
blocking = sum(1 for r in records if is_blocking(r))
|
|
1419
|
+
after_gate = counts["Critical"] + counts["High"] - blocking
|
|
1420
|
+
lines: list[str] = []
|
|
1421
|
+
if heading:
|
|
1422
|
+
lines += ["## Gap Report", ""]
|
|
1423
|
+
lines += [
|
|
1424
|
+
f"**Total Gaps:** {len(records)}",
|
|
1425
|
+
f"**Blocking (Critical + High):** {blocking}",
|
|
1426
|
+
f"**Non-blocking (Medium + Low + Info):** {len(records) - blocking - after_gate}",
|
|
1427
|
+
]
|
|
1428
|
+
if after_gate:
|
|
1429
|
+
lines.append(f"**Found after the hard gate (Critical + High, non-blocking):** {after_gate}")
|
|
1430
|
+
lines.append("")
|
|
1431
|
+
if not records:
|
|
1432
|
+
lines += ["No gaps found.", ""]
|
|
1433
|
+
return "\n".join(lines)
|
|
1434
|
+
lines += [
|
|
1435
|
+
"### Remediation Summary",
|
|
1436
|
+
"",
|
|
1437
|
+
"| Severity | Count | Estimated Effort |",
|
|
1438
|
+
"|----------|-------|------------------|",
|
|
1439
|
+
]
|
|
1440
|
+
for severity in SEVERITIES:
|
|
1441
|
+
effort = EFFORT[severity] if counts[severity] else "None"
|
|
1442
|
+
lines.append(f"| {severity} | {counts[severity]} | {effort} |")
|
|
1443
|
+
lines += [f"| **Total** | **{len(records)}** | |", ""]
|
|
1444
|
+
for record in records:
|
|
1445
|
+
lines += [
|
|
1446
|
+
f"### {record['id']}: {record['title']}",
|
|
1447
|
+
"",
|
|
1448
|
+
f"**Severity:** {record['severity']}",
|
|
1449
|
+
# The group comes from the category, as at append time: a ledger
|
|
1450
|
+
# written by hand may lack it.
|
|
1451
|
+
f"**Category:** {CATEGORIES[record['category']][0]} ({record['category']})",
|
|
1452
|
+
f"**Source:** {record['source']}",
|
|
1453
|
+
]
|
|
1454
|
+
if record.get("export"):
|
|
1455
|
+
lines.append(f"**Export:** {record['export']}")
|
|
1456
|
+
lines.append("")
|
|
1457
|
+
if record.get("issue"):
|
|
1458
|
+
lines += [f"**Issue:** {record['issue']}", ""]
|
|
1459
|
+
lines += [f"**Remediation:** {record['remediation']}", ""]
|
|
1460
|
+
return "\n".join(lines)
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def summarize(path: Path, ledger: dict) -> dict:
|
|
1464
|
+
records = ledger["records"]
|
|
1465
|
+
counts = count_by_severity(records)
|
|
1466
|
+
by_category: dict[str, int] = {}
|
|
1467
|
+
for record in records:
|
|
1468
|
+
by_category[record["category"]] = by_category.get(record["category"], 0) + 1
|
|
1469
|
+
blocking = sum(1 for r in records if is_blocking(r))
|
|
1470
|
+
return {
|
|
1471
|
+
"status": "ok",
|
|
1472
|
+
"ledger": str(path),
|
|
1473
|
+
"run_id": ledger.get("run_id"),
|
|
1474
|
+
"stages": list(ledger["stages"]),
|
|
1475
|
+
"total": len(records),
|
|
1476
|
+
"counts": counts,
|
|
1477
|
+
"blocking": blocking,
|
|
1478
|
+
"non_blocking": len(records) - blocking,
|
|
1479
|
+
"by_category": dict(sorted(by_category.items())),
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
# --------------------------------------------------------------------------
|
|
1484
|
+
# CLI
|
|
1485
|
+
# --------------------------------------------------------------------------
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def _emit(payload: dict, stream=None) -> None:
|
|
1489
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False), file=stream or sys.stdout)
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
def _error(code: str, message: str, **extra) -> dict:
|
|
1493
|
+
return {"status": "error", "code": code, "error": message, **extra}
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
def _read_records(args: argparse.Namespace) -> list:
|
|
1497
|
+
"""The records an append reads: built by --from, else from each --input
|
|
1498
|
+
file in turn, else from stdin. Raises AdapterError, OSError or ValueError."""
|
|
1499
|
+
if args.adapter:
|
|
1500
|
+
return build_records(args)
|
|
1501
|
+
given = sorted(o for o in ALL_ADAPTER_OPTIONS if getattr(args, o) is not None)
|
|
1502
|
+
if given:
|
|
1503
|
+
flags = ", ".join("--" + o.replace("_", "-") for o in given)
|
|
1504
|
+
raise AdapterError(f"{flags} work only with --from")
|
|
1505
|
+
if not args.input:
|
|
1506
|
+
# A byte order mark on stdin (a Windows editor or shell) is not JSON.
|
|
1507
|
+
return parse_input(sys.stdin.read().removeprefix("\ufeff"))
|
|
1508
|
+
records: list = []
|
|
1509
|
+
for path in args.input:
|
|
1510
|
+
records += parse_input(Path(path).read_text(encoding="utf-8-sig"))
|
|
1511
|
+
return records
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
def _cmd_append(args: argparse.Namespace) -> int:
|
|
1515
|
+
path = Path(args.ledger)
|
|
1516
|
+
if not _STAGE_RE.match(args.stage):
|
|
1517
|
+
_emit(_error("INVALID_INPUT", f"--stage must be a lowercase slug, got '{args.stage}'"))
|
|
1518
|
+
return 2
|
|
1519
|
+
try:
|
|
1520
|
+
raw_records = _read_records(args)
|
|
1521
|
+
except AdapterError as exc:
|
|
1522
|
+
_emit(_error("INVALID_INPUT", str(exc)))
|
|
1523
|
+
return 2
|
|
1524
|
+
except (OSError, UnicodeDecodeError, ValueError) as exc:
|
|
1525
|
+
# json.JSONDecodeError is a ValueError.
|
|
1526
|
+
_emit(_error("INVALID_INPUT", f"cannot read the records: {exc}"))
|
|
1527
|
+
return 2
|
|
1528
|
+
records: list[dict] = []
|
|
1529
|
+
errors: list[dict] = []
|
|
1530
|
+
for index, raw in enumerate(raw_records):
|
|
1531
|
+
record, err = normalize_record(raw)
|
|
1532
|
+
if err:
|
|
1533
|
+
errors.append({"index": index, "error": err})
|
|
1534
|
+
else:
|
|
1535
|
+
records.append(record)
|
|
1536
|
+
if errors:
|
|
1537
|
+
_emit(_error("INVALID_RECORD", f"{len(errors)} invalid record(s); nothing was written", errors=errors))
|
|
1538
|
+
return 2
|
|
1539
|
+
try:
|
|
1540
|
+
# Read, append and write as one step: an append that overlaps this
|
|
1541
|
+
# one waits here instead of writing over the records it adds.
|
|
1542
|
+
with ledger_lock(path):
|
|
1543
|
+
ledger = load_ledger(path) if path.exists() else new_ledger(path)
|
|
1544
|
+
appended, duplicates = append_records(ledger, args.stage, records)
|
|
1545
|
+
save_ledger(path, ledger)
|
|
1546
|
+
except LedgerError as exc:
|
|
1547
|
+
_emit(_error(exc.code, str(exc)))
|
|
1548
|
+
return 1
|
|
1549
|
+
except OSError as exc:
|
|
1550
|
+
_emit(_error("WRITE_FAILED", f"cannot write {path}: {exc}"))
|
|
1551
|
+
return 1
|
|
1552
|
+
_emit(
|
|
1553
|
+
{
|
|
1554
|
+
"status": "ok",
|
|
1555
|
+
"ledger": str(path),
|
|
1556
|
+
"stage": args.stage,
|
|
1557
|
+
"appended": appended,
|
|
1558
|
+
"duplicates": duplicates,
|
|
1559
|
+
"record_count": len(ledger["records"]),
|
|
1560
|
+
}
|
|
1561
|
+
)
|
|
1562
|
+
return 0
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
def _cmd_render(args: argparse.Namespace) -> int:
|
|
1566
|
+
path = Path(args.ledger)
|
|
1567
|
+
try:
|
|
1568
|
+
ledger = load_ledger(path)
|
|
1569
|
+
except LedgerError as exc:
|
|
1570
|
+
_emit(_error(exc.code, str(exc)), stream=sys.stderr)
|
|
1571
|
+
return 1
|
|
1572
|
+
sys.stdout.write(render_gap_report(ledger, heading=args.heading))
|
|
1573
|
+
return 0
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
def _cmd_summary(args: argparse.Namespace) -> int:
|
|
1577
|
+
path = Path(args.ledger)
|
|
1578
|
+
try:
|
|
1579
|
+
ledger = load_ledger(path)
|
|
1580
|
+
except LedgerError as exc:
|
|
1581
|
+
_emit(_error(exc.code, str(exc)))
|
|
1582
|
+
return 1
|
|
1583
|
+
_emit(summarize(path, ledger))
|
|
1584
|
+
return 0
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
def _cmd_categories(args: argparse.Namespace) -> int:
|
|
1588
|
+
_emit(
|
|
1589
|
+
{
|
|
1590
|
+
"status": "ok",
|
|
1591
|
+
"severities": list(SEVERITIES),
|
|
1592
|
+
"categories": [
|
|
1593
|
+
{"category": slug, "group": group, "description": description}
|
|
1594
|
+
for slug, (group, description) in CATEGORIES.items()
|
|
1595
|
+
],
|
|
1596
|
+
}
|
|
1597
|
+
)
|
|
1598
|
+
return 0
|
|
1599
|
+
|
|
1600
|
+
|
|
1601
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
1602
|
+
parser = argparse.ArgumentParser(
|
|
1603
|
+
prog="gap-ledger",
|
|
1604
|
+
description="Append, render and summarize the test-skill gap ledger.",
|
|
1605
|
+
)
|
|
1606
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
1607
|
+
|
|
1608
|
+
p = sub.add_parser("append", help="append records from stdin, --input or a script's result (--from)")
|
|
1609
|
+
p.add_argument("--ledger", required=True, help="{forge_version}/test-findings-{run_id}.json")
|
|
1610
|
+
p.add_argument("--stage", required=True, help="the stage appending, e.g. coverage-check")
|
|
1611
|
+
p.add_argument("--input", action="append",
|
|
1612
|
+
help="a JSON file with the records (default: stdin); with --from, the script result to read "
|
|
1613
|
+
"(structure takes it more than once)")
|
|
1614
|
+
p.add_argument("--from", dest="adapter", choices=tuple(ADAPTER_OPTIONS),
|
|
1615
|
+
help="build the records from the --input result of a script (see the docstring's Adapters)")
|
|
1616
|
+
p.add_argument("--surface", help="coverage: load-coverage-inputs.py surface's result (Sources, kinds, "
|
|
1617
|
+
"the names outside scope)")
|
|
1618
|
+
p.add_argument("--signatures", help="coverage: score-signatures.py score's result (missingTypes)")
|
|
1619
|
+
p.add_argument("--numerator", help="coverage: verify-declared-numerator.py's result (absent names)")
|
|
1620
|
+
p.add_argument("--stale", help="coverage: classify-stale's result (fabricated signatures, documented extras)")
|
|
1621
|
+
p.add_argument("--provenance", help="coverage: the provenance map (an absent name's Source)")
|
|
1622
|
+
p.add_argument("--skill-dir", help="coverage: the skill package (a stale name's documenting line)")
|
|
1623
|
+
p.add_argument("--metadata", help="the metadata.json path a record cites as its Source (not read)")
|
|
1624
|
+
p.add_argument("--served", action="append", choices=FAMILIES,
|
|
1625
|
+
help="structure: a family another heading serves (the caller's judgment); repeatable")
|
|
1626
|
+
p.set_defaults(func=_cmd_append)
|
|
1627
|
+
|
|
1628
|
+
p = sub.add_parser("render", help="print the Gap Report in Markdown")
|
|
1629
|
+
p.add_argument("--ledger", required=True)
|
|
1630
|
+
p.add_argument("--heading", action="store_true", help="start with the ## Gap Report line")
|
|
1631
|
+
p.set_defaults(func=_cmd_render)
|
|
1632
|
+
|
|
1633
|
+
p = sub.add_parser("summary", help="counts by severity and category")
|
|
1634
|
+
p.add_argument("--ledger", required=True)
|
|
1635
|
+
p.set_defaults(func=_cmd_summary)
|
|
1636
|
+
|
|
1637
|
+
p = sub.add_parser("categories", help="list the category vocabulary")
|
|
1638
|
+
p.set_defaults(func=_cmd_categories)
|
|
1639
|
+
|
|
1640
|
+
return parser
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
def _force_utf8(*streams) -> None:
|
|
1644
|
+
"""Reconfigure the JSON and Markdown streams to UTF-8.
|
|
1645
|
+
|
|
1646
|
+
A default Windows console uses cp1252, which cannot carry a non-ASCII
|
|
1647
|
+
title or remediation. Keeps each stream's error handler; skips test
|
|
1648
|
+
doubles without reconfigure(). For stdin this must run before the read.
|
|
1649
|
+
"""
|
|
1650
|
+
for stream in streams:
|
|
1651
|
+
if hasattr(stream, "reconfigure"):
|
|
1652
|
+
errors = getattr(stream, "errors", None)
|
|
1653
|
+
if errors is None:
|
|
1654
|
+
stream.reconfigure(encoding="utf-8")
|
|
1655
|
+
else:
|
|
1656
|
+
stream.reconfigure(encoding="utf-8", errors=errors)
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1660
|
+
_force_utf8(sys.stdin, sys.stdout, sys.stderr)
|
|
1661
|
+
args = _build_parser().parse_args(argv)
|
|
1662
|
+
return args.func(args)
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
if __name__ == "__main__":
|
|
1666
|
+
raise SystemExit(main())
|