bmad-module-skill-forge 2.2.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +49 -49
- package/docs/404.md +10 -8
- package/docs/_data/pinned.yaml +22 -13
- package/docs/_internal/STABILITY.md +58 -35
- package/docs/agents.md +39 -53
- package/docs/architecture.md +65 -51
- package/docs/bmad-synergy.md +53 -51
- package/docs/campaign.md +127 -75
- package/docs/concepts.md +34 -28
- package/docs/examples.md +70 -68
- package/docs/forge-auto.md +34 -31
- package/docs/getting-started.md +118 -57
- package/docs/how-it-works.md +25 -21
- package/docs/index.md +15 -15
- package/docs/skill-model.md +70 -59
- package/docs/troubleshooting.md +135 -18
- package/docs/verifying-a-skill.md +83 -57
- package/docs/why-skf.md +24 -22
- package/docs/workflows.md +274 -110
- package/package.json +18 -10
- package/src/forger/preferences.yaml +8 -1
- package/src/knowledge/agentskills-spec.md +2 -0
- package/src/knowledge/ccc-bridge.md +40 -24
- package/src/knowledge/confidence-tiers.md +9 -5
- package/src/knowledge/overview.md +3 -3
- package/src/knowledge/progressive-capability.md +8 -8
- package/src/knowledge/provenance-tracking.md +9 -5
- package/src/knowledge/qmd-registry.md +2 -2
- package/src/knowledge/skf-knowledge-index.csv +2 -2
- package/src/knowledge/skill-lifecycle.md +18 -11
- package/src/knowledge/split-body-strategy.md +0 -3
- package/src/knowledge/tool-resolution.md +4 -2
- package/src/knowledge/version-paths.md +101 -15
- package/src/module-help.csv +3 -3
- package/src/module.yaml +10 -0
- package/src/shared/data/ast-grep-recipes.yaml +608 -0
- package/src/shared/data/ide-context-files.json +30 -0
- package/src/shared/health-check.md +32 -12
- package/src/shared/references/description-guard-protocol.md +12 -9
- package/src/shared/references/feasibility-report-schema.md +19 -13
- package/src/shared/references/headless-gate-convention.md +55 -3
- package/src/shared/references/output-contract-schema.md +19 -8
- package/src/shared/references/pipeline-contracts.md +94 -29
- package/src/shared/references/tessl-review.md +77 -0
- package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
- package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
- package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
- package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
- package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
- package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
- package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
- package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
- package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
- package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
- package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
- package/src/shared/scripts/skf-atomic-write.py +30 -14
- package/src/shared/scripts/skf-build-change-manifest.py +2203 -24
- package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
- package/src/shared/scripts/skf-check-unit-records.py +184 -0
- package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
- package/src/shared/scripts/skf-classify-changed-files.py +694 -0
- package/src/shared/scripts/skf-comention-pairs.py +1333 -123
- package/src/shared/scripts/skf-compare-file-hashes.py +1 -1
- package/src/shared/scripts/skf-count-imports.py +1867 -0
- package/src/shared/scripts/skf-count-tokens.py +1 -1
- package/src/shared/scripts/skf-derive-assembly-shape.py +3 -2
- package/src/shared/scripts/skf-description-guard.py +99 -11
- package/src/shared/scripts/skf-detect-docs.py +694 -2
- package/src/shared/scripts/skf-detect-language.py +486 -107
- package/src/shared/scripts/skf-detect-registry.py +887 -0
- package/src/shared/scripts/skf-detect-scripts-assets.py +1 -1
- package/src/shared/scripts/skf-detect-tools.py +209 -27
- package/src/shared/scripts/skf-detect-workspaces.py +506 -30
- package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
- package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
- package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
- package/src/shared/scripts/skf-extract-public-api.py +4084 -85
- package/src/shared/scripts/skf-extraction-inventory.py +746 -0
- package/src/shared/scripts/skf-extraction-snapshot.py +539 -0
- package/src/shared/scripts/skf-fetch-temporal.py +674 -0
- package/src/shared/scripts/skf-find-cycles.py +97 -29
- package/src/shared/scripts/skf-find-test-report.py +476 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +444 -203
- package/src/shared/scripts/skf-github-fetch.py +365 -0
- package/src/shared/scripts/skf-github-probe.py +661 -0
- package/src/shared/scripts/skf-hash-content.py +166 -12
- package/src/shared/scripts/skf-language-corpora.py +1 -1
- package/src/shared/scripts/skf-load-provenance.py +159 -27
- package/src/shared/scripts/skf-manifest-ops.py +1 -1
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
- package/src/shared/scripts/skf-merge-doc-urls.py +1 -1
- package/src/shared/scripts/skf-names-present.py +221 -0
- package/src/shared/scripts/skf-pair-intersect.py +119 -28
- package/src/shared/scripts/skf-parse-gaps.py +1384 -0
- package/src/shared/scripts/skf-preapply.py +1 -1
- package/src/shared/scripts/skf-preflight.py +161 -27
- package/src/shared/scripts/skf-promote-staged.py +430 -0
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +39 -7
- package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
- package/src/shared/scripts/skf-quick-batch.py +774 -0
- package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
- package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
- package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
- package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
- package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
- package/src/shared/scripts/skf-resolve-authoritative-files.py +128 -13
- package/src/shared/scripts/skf-resolve-package.py +523 -106
- package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
- package/src/shared/scripts/skf-run-lock.py +577 -0
- package/src/shared/scripts/skf-scan-manifests.py +998 -57
- package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
- package/src/shared/scripts/skf-severity-classify.py +712 -108
- package/src/shared/scripts/skf-shape-detect.py +298 -204
- package/src/shared/scripts/skf-shard-body.py +9 -8
- package/src/shared/scripts/skf-skill-inventory.py +2412 -89
- package/src/shared/scripts/skf-skills-module.py +472 -0
- package/src/shared/scripts/skf-source-tree.py +1796 -0
- package/src/shared/scripts/skf-structural-diff.py +670 -119
- package/src/shared/scripts/skf-tessl-review.py +706 -0
- package/src/shared/scripts/skf-update-active-symlink.py +5 -5
- package/src/shared/scripts/skf-update-run-state.py +624 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
- package/src/shared/scripts/skf-validate-brief-schema.py +4 -1
- package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
- package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
- package/src/shared/scripts/skf-validate-output.py +712 -64
- package/src/shared/scripts/skf-validate-pins.py +1 -1
- package/src/shared/scripts/skf-verify-no-trace.py +116 -10
- package/src/shared/scripts/skf-verify-provenance-completeness.py +2919 -54
- package/src/shared/scripts/skf-write-skill-brief.py +367 -19
- package/src/shared/tool-requirements.yaml +201 -0
- package/src/skf-analyze-source/SKILL.md +41 -36
- package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
- package/src/skf-analyze-source/customize.toml +36 -24
- package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
- package/src/skf-analyze-source/references/continue.md +2 -13
- package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
- package/src/skf-analyze-source/references/generate-briefs.md +121 -55
- package/src/skf-analyze-source/references/headless-contract.md +42 -15
- package/src/skf-analyze-source/references/identify-units.md +96 -110
- package/src/skf-analyze-source/references/init.md +73 -71
- package/src/skf-analyze-source/references/map-and-detect.md +123 -105
- package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
- package/src/skf-analyze-source/references/recommend.md +51 -70
- package/src/skf-analyze-source/references/scan-project.md +60 -84
- package/src/skf-analyze-source/references/scan-root.md +39 -0
- package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
- package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
- package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
- package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
- package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
- package/src/skf-audit-skill/SKILL.md +43 -51
- package/src/skf-audit-skill/assets/drift-report-template.md +27 -3
- package/src/skf-audit-skill/customize.toml +41 -20
- package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
- package/src/skf-audit-skill/references/doc-drift.md +141 -0
- package/src/skf-audit-skill/references/headless-contract.md +29 -0
- package/src/skf-audit-skill/references/init.md +148 -210
- package/src/skf-audit-skill/references/re-index.md +96 -108
- package/src/skf-audit-skill/references/report.md +96 -33
- package/src/skf-audit-skill/references/semantic-diff.md +27 -18
- package/src/skf-audit-skill/references/severity-classify.md +79 -65
- package/src/skf-audit-skill/references/severity-rules.md +15 -6
- package/src/skf-audit-skill/references/structural-diff.md +75 -90
- package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
- package/src/skf-audit-skill/scripts/render-drift-tables.py +685 -0
- package/src/skf-brief-skill/SKILL.md +36 -25
- package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +17 -24
- package/src/skf-brief-skill/assets/skill-brief-schema.md +29 -15
- package/src/skf-brief-skill/customize.toml +48 -20
- package/src/skf-brief-skill/references/analyze-target.md +142 -125
- package/src/skf-brief-skill/references/confirm-brief.md +10 -8
- package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
- package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
- package/src/skf-brief-skill/references/gather-intent.md +87 -182
- package/src/skf-brief-skill/references/headless-args.md +84 -16
- package/src/skf-brief-skill/references/health-check.md +1 -1
- package/src/skf-brief-skill/references/invocation-contract.md +18 -13
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
- package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
- package/src/skf-brief-skill/references/scope-definition.md +123 -47
- package/src/skf-brief-skill/references/step-auto-brief.md +24 -80
- package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
- package/src/skf-brief-skill/references/version-resolution.md +3 -2
- package/src/skf-brief-skill/references/write-brief.md +66 -57
- package/src/skf-campaign/SKILL.md +31 -34
- package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
- package/src/skf-campaign/customize.toml +47 -16
- package/src/skf-campaign/references/campaign-contracts.md +39 -29
- package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
- package/src/skf-campaign/references/health-check.md +7 -16
- package/src/skf-campaign/references/step-01-setup.md +46 -84
- package/src/skf-campaign/references/step-02-strategy.md +31 -30
- package/src/skf-campaign/references/step-03-pins.md +26 -18
- package/src/skf-campaign/references/step-04-provenance.md +24 -23
- package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
- package/src/skf-campaign/references/step-06-batch.md +41 -34
- package/src/skf-campaign/references/step-07-capstone.md +40 -25
- package/src/skf-campaign/references/step-08-verify.md +28 -29
- package/src/skf-campaign/references/step-09-refine.md +29 -30
- package/src/skf-campaign/references/step-10-export.md +48 -36
- package/src/skf-campaign/references/step-11-maintenance.md +31 -34
- package/src/skf-campaign/references/step-resume.md +37 -68
- package/src/skf-campaign/scripts/campaign-deps.py +57 -18
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
- package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
- package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
- package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
- package/src/skf-campaign/scripts/campaign-report.py +204 -37
- package/src/skf-campaign/scripts/campaign-state.py +1128 -0
- package/src/skf-campaign/scripts/campaign-status.py +10 -74
- package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
- package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
- package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
- package/src/skf-campaign/templates/campaign-report-template.md +5 -1
- package/src/skf-campaign/templates/kickoff-template.md +0 -14
- package/src/skf-create-skill/SKILL.md +27 -20
- package/src/skf-create-skill/assets/compile-assembly-rules.md +6 -5
- package/src/skf-create-skill/assets/skill-sections.md +15 -5
- package/src/skf-create-skill/customize.toml +20 -8
- package/src/skf-create-skill/references/authoritative-files-protocol.md +41 -42
- package/src/skf-create-skill/references/batch-mode.md +81 -0
- package/src/skf-create-skill/references/ccc-index-check.md +27 -0
- package/src/skf-create-skill/references/compile.md +107 -29
- package/src/skf-create-skill/references/component-extraction.md +113 -91
- package/src/skf-create-skill/references/enrich.md +41 -23
- package/src/skf-create-skill/references/entry-points-by-hand.md +20 -0
- package/src/skf-create-skill/references/extract.md +200 -161
- package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +2 -2
- package/src/skf-create-skill/references/extraction-patterns.md +584 -196
- package/src/skf-create-skill/references/generate-artifacts.md +145 -93
- package/src/skf-create-skill/references/health-check.md +1 -1
- package/src/skf-create-skill/references/load-brief.md +53 -36
- package/src/skf-create-skill/references/relabel-rule.md +12 -0
- package/src/skf-create-skill/references/report.md +28 -59
- package/src/skf-create-skill/references/source-resolution-protocols.md +49 -155
- package/src/skf-create-skill/references/step-auto-shard.md +20 -93
- package/src/skf-create-skill/references/step-doc-rot.md +44 -60
- package/src/skf-create-skill/references/step-doc-sources.md +28 -19
- package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
- package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
- package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
- package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
- package/src/skf-create-skill/references/validate.md +173 -99
- package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
- package/src/skf-create-stack-skill/SKILL.md +29 -56
- package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
- package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
- package/src/skf-create-stack-skill/customize.toml +23 -12
- package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
- package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
- package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
- package/src/skf-create-stack-skill/references/generate-output.md +138 -128
- package/src/skf-create-stack-skill/references/init.md +90 -32
- package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
- package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
- package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
- package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
- package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
- package/src/skf-create-stack-skill/references/report.md +39 -33
- package/src/skf-create-stack-skill/references/validate.md +20 -39
- package/src/skf-drop-skill/SKILL.md +28 -51
- package/src/skf-drop-skill/customize.toml +44 -30
- package/src/skf-drop-skill/references/execute.md +79 -146
- package/src/skf-drop-skill/references/invocation-contract.md +64 -0
- package/src/skf-drop-skill/references/report.md +46 -30
- package/src/skf-drop-skill/references/select.md +118 -124
- package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
- package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
- package/src/skf-export-skill/SKILL.md +40 -62
- package/src/skf-export-skill/assets/managed-section-format.md +14 -50
- package/src/skf-export-skill/assets/snippet-format.md +3 -1
- package/src/skf-export-skill/customize.toml +42 -13
- package/src/skf-export-skill/references/generate-snippet.md +54 -46
- package/src/skf-export-skill/references/health-check.md +2 -0
- package/src/skf-export-skill/references/invocation-contract.md +15 -0
- package/src/skf-export-skill/references/load-skill.md +88 -62
- package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
- package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
- package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
- package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
- package/src/skf-export-skill/references/package.md +15 -73
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
- package/src/skf-export-skill/references/result-envelope.md +31 -16
- package/src/skf-export-skill/references/summary.md +41 -17
- package/src/skf-export-skill/references/token-report.md +4 -12
- package/src/skf-export-skill/references/update-context.md +118 -237
- package/src/skf-forger/SKILL.md +61 -37
- package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
- package/src/skf-forger/customize.toml +43 -0
- package/src/skf-forger/references/pipeline-mode.md +73 -18
- package/src/skf-forger/scripts/forge-status.py +299 -0
- package/src/skf-forger/scripts/parse-pipeline.py +322 -54
- package/src/skf-forger/scripts/pipeline-gate.py +428 -0
- package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
- package/src/skf-quick-skill/SKILL.md +54 -53
- package/src/skf-quick-skill/assets/skill-template.md +4 -2
- package/src/skf-quick-skill/customize.toml +51 -20
- package/src/skf-quick-skill/references/batch-mode.md +74 -21
- package/src/skf-quick-skill/references/compile.md +30 -31
- package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
- package/src/skf-quick-skill/references/finalize.md +69 -18
- package/src/skf-quick-skill/references/halt-contract.md +41 -29
- package/src/skf-quick-skill/references/health-check.md +5 -3
- package/src/skf-quick-skill/references/quick-extract.md +114 -45
- package/src/skf-quick-skill/references/registry-resolution.md +9 -64
- package/src/skf-quick-skill/references/resolve-target.md +116 -66
- package/src/skf-quick-skill/references/write-and-validate.md +79 -40
- package/src/skf-refine-architecture/SKILL.md +43 -53
- package/src/skf-refine-architecture/customize.toml +49 -20
- package/src/skf-refine-architecture/references/compile.md +97 -54
- package/src/skf-refine-architecture/references/exit-codes.md +30 -0
- package/src/skf-refine-architecture/references/finding-storage.md +21 -0
- package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
- package/src/skf-refine-architecture/references/improvements.md +14 -20
- package/src/skf-refine-architecture/references/init.md +98 -57
- package/src/skf-refine-architecture/references/issue-detection.md +32 -39
- package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
- package/src/skf-refine-architecture/references/report.md +65 -54
- package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
- package/src/skf-rename-skill/SKILL.md +19 -54
- package/src/skf-rename-skill/customize.toml +32 -26
- package/src/skf-rename-skill/references/execute.md +230 -184
- package/src/skf-rename-skill/references/exit-codes.md +7 -7
- package/src/skf-rename-skill/references/health-check.md +1 -7
- package/src/skf-rename-skill/references/invocation-contract.md +25 -0
- package/src/skf-rename-skill/references/report.md +63 -52
- package/src/skf-rename-skill/references/select.md +197 -115
- package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
- package/src/skf-setup/SKILL.md +26 -26
- package/src/skf-setup/customize.toml +14 -10
- package/src/skf-setup/references/auto-index.md +50 -63
- package/src/skf-setup/references/ccc-index.md +29 -90
- package/src/skf-setup/references/detect-and-tier.md +58 -67
- package/src/skf-setup/references/health-check.md +2 -0
- package/src/skf-setup/references/invocation-contract.md +14 -0
- package/src/skf-setup/references/report.md +43 -166
- package/src/skf-setup/references/tier-rules.md +5 -5
- package/src/skf-setup/references/write-config.md +34 -57
- package/src/skf-test-skill/SKILL.md +17 -57
- package/src/skf-test-skill/assets/output-section-formats.md +33 -34
- package/src/skf-test-skill/customize.toml +27 -13
- package/src/skf-test-skill/references/coherence-check.md +134 -62
- package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
- package/src/skf-test-skill/references/coverage-check.md +217 -254
- package/src/skf-test-skill/references/detect-mode.md +5 -5
- package/src/skf-test-skill/references/external-validators.md +94 -82
- package/src/skf-test-skill/references/health-check.md +6 -7
- package/src/skf-test-skill/references/init.md +163 -88
- package/src/skf-test-skill/references/invocation-contract.md +66 -0
- package/src/skf-test-skill/references/migration-section-rules.md +5 -2
- package/src/skf-test-skill/references/report.md +162 -142
- package/src/skf-test-skill/references/score.md +80 -107
- package/src/skf-test-skill/references/scoring-rules.md +41 -155
- package/src/skf-test-skill/references/source-access-protocol.md +19 -92
- package/src/skf-test-skill/references/step-hard-gate.md +51 -33
- package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
- package/src/skf-test-skill/scripts/build-result-context.py +287 -0
- package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
- package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
- package/src/skf-test-skill/scripts/compute-score.py +223 -45
- package/src/skf-test-skill/scripts/gap-ledger.py +1381 -0
- package/src/skf-test-skill/scripts/hard-gate.py +203 -0
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +760 -0
- package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
- package/src/skf-test-skill/scripts/reconcile-coverage.py +273 -79
- package/src/skf-test-skill/scripts/score-signatures.py +459 -0
- package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
- package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +127 -52
- package/src/skf-test-skill/templates/test-report-template.md +21 -15
- package/src/skf-update-skill/SKILL.md +38 -25
- package/src/skf-update-skill/customize.toml +27 -12
- package/src/skf-update-skill/references/detect-changes.md +233 -222
- package/src/skf-update-skill/references/gap-driven.md +336 -0
- package/src/skf-update-skill/references/health-check.md +31 -5
- package/src/skf-update-skill/references/init.md +250 -84
- package/src/skf-update-skill/references/invocation-contract.md +12 -0
- package/src/skf-update-skill/references/manual-section-rules.md +6 -23
- package/src/skf-update-skill/references/merge.md +180 -47
- package/src/skf-update-skill/references/re-extract.md +53 -262
- package/src/skf-update-skill/references/report.md +100 -35
- package/src/skf-update-skill/references/write.md +331 -149
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +29 -7
- package/src/skf-verify-stack/SKILL.md +23 -42
- package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
- package/src/skf-verify-stack/customize.toml +55 -22
- package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
- package/src/skf-verify-stack/references/coverage.md +67 -64
- package/src/skf-verify-stack/references/exit-codes.md +21 -8
- package/src/skf-verify-stack/references/init.md +107 -62
- package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
- package/src/skf-verify-stack/references/integrations.md +106 -65
- package/src/skf-verify-stack/references/report.md +59 -42
- package/src/skf-verify-stack/references/requirements.md +32 -9
- package/src/skf-verify-stack/references/synthesize.md +53 -39
- package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
- package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
- package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
- package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
- package/tools/cli/commands/install.js +11 -4
- package/tools/cli/commands/status.js +15 -3
- package/tools/cli/commands/update.js +11 -4
- package/tools/cli/lib/tool-check.js +320 -0
- package/tools/cli/lib/version-check.js +22 -9
- package/tools/cli/skf-cli.js +4 -1
- package/.gitattributes +0 -16
- package/.nvmrc +0 -1
- package/docs/_internal/RELEASING.md +0 -679
- package/release-audits/v1.0.0-launch-audit.md +0 -728
- package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
- package/src/skf-campaign/manifest.yaml +0 -15
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
- package/src/skf-create-skill/references/ecosystem-check.md +0 -74
- package/src/skf-drop-skill/references/headless-contract.md +0 -18
- package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
- package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
- package/src/skf-update-skill/references/validate.md +0 -124
- package/tools/validate-docs-drift.js +0 -233
- package/tools/validate-docs-links.js +0 -665
|
@@ -0,0 +1,976 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Render Stack Metadata: a stack's export labels, library tiers, pair tiers, metadata.json fields and provenance entries.
|
|
6
|
+
|
|
7
|
+
create-stack-skill relabels its code-mode export records and sets each
|
|
8
|
+
library's tier from them (parallel-extract section 3a), gives each
|
|
9
|
+
integration the tier of its pair (detect-integrations section 3), writes the
|
|
10
|
+
counted and ranked fields of metadata.json (generate-output section 6) and
|
|
11
|
+
writes the provenance map from the run's files in either mode: its entries,
|
|
12
|
+
its integrations and, in compose mode, its constituents (generate-output
|
|
13
|
+
section 7). This helper holds those rules once, so no step file restates
|
|
14
|
+
them, and skf-validate-output.py --skill-type stack recomputes the dominant
|
|
15
|
+
tier with dominant_tier here.
|
|
16
|
+
|
|
17
|
+
Rules:
|
|
18
|
+
Tiers, from the strongest: T1, T1-low, T2, T3.
|
|
19
|
+
|
|
20
|
+
library_tier(records)
|
|
21
|
+
A code-mode library is T1 when it has export records and every one
|
|
22
|
+
names extraction_method ast_bridge (an ast-grep rule matched it), and
|
|
23
|
+
T1-low otherwise: one export read by eye, or none recorded at all.
|
|
24
|
+
|
|
25
|
+
combine_pair_tier(a, b, mode)
|
|
26
|
+
An integration takes the weaker of its two libraries' tiers, one rule
|
|
27
|
+
in code mode and compose mode: T1 + T1 is T1; T1 + T1-low and
|
|
28
|
+
T1-low + T1-low are T1-low; T1 + T2, T1-low + T2 and T2 + T2 are T2; a
|
|
29
|
+
pair with a T3 member is T3. A T2 member lowers the pair like any
|
|
30
|
+
other tier: its temporal annotations never keep a stronger one.
|
|
31
|
+
`mode` must name a mode, and both modes take this rule.
|
|
32
|
+
|
|
33
|
+
dominant_tier(distribution)
|
|
34
|
+
The tier of the largest bin of a confidence_distribution. A tie goes
|
|
35
|
+
to the weaker tier, so the tier never overstates confidence, and a
|
|
36
|
+
distribution with no positive bin (no library) reads T1-low. This is
|
|
37
|
+
the stack's confidence_tier.
|
|
38
|
+
|
|
39
|
+
confidence_distribution
|
|
40
|
+
Each library once, in the bin of its tier, so the four bins sum to
|
|
41
|
+
library_count. The evidence report bins the provenance entries
|
|
42
|
+
instead (skf-render-metadata-stats.py).
|
|
43
|
+
|
|
44
|
+
source_authority
|
|
45
|
+
The lowest authority among the libraries: official, then community,
|
|
46
|
+
then internal, so a community and an internal library give internal.
|
|
47
|
+
A library that records none counts as community, so a constituent
|
|
48
|
+
without one never lifts a stack to official, and a code-mode stack,
|
|
49
|
+
whose libraries are dependencies rather than skills with an
|
|
50
|
+
authority, is community.
|
|
51
|
+
|
|
52
|
+
relabel(records)
|
|
53
|
+
The label check is skf-render-metadata-stats.py's, run beside this
|
|
54
|
+
script on the records as its stack shape: each violation's field
|
|
55
|
+
takes the `expected` value the check gives, never the reverse, and the
|
|
56
|
+
check runs again until it passes. Three violations name no value to
|
|
57
|
+
copy, so the rule here sets one: an unknown or missing
|
|
58
|
+
extraction_method becomes its canonical spelling when it is a known
|
|
59
|
+
method in another case, and source_reading otherwise, since nothing
|
|
60
|
+
shows an ast-grep rule matched it; an ast-grep record with no node
|
|
61
|
+
kind becomes ast_bridge, the stack's name for that tool, which pairs
|
|
62
|
+
with no kind; and a record with no valid signature_source takes T1
|
|
63
|
+
when an ast-grep rule matched it (ast_bridge or ast-grep) and T1-low
|
|
64
|
+
otherwise.
|
|
65
|
+
|
|
66
|
+
Input (--input <json-file-or-'-'>, where '-' reads stdin):
|
|
67
|
+
{
|
|
68
|
+
"mode": "code" | "compose",
|
|
69
|
+
"libraries": [
|
|
70
|
+
{"name": "<library>",
|
|
71
|
+
"confidence": "T1|T1-low|T2|T3", # per_library_extractions[].confidence
|
|
72
|
+
"source_authority": "official|community|internal"}, # optional
|
|
73
|
+
...
|
|
74
|
+
],
|
|
75
|
+
"integrations": [{"a": "<library>", "b": "<library>"}, ...] # optional
|
|
76
|
+
}
|
|
77
|
+
Tier, mode and authority tokens compare case-insensitively and are printed
|
|
78
|
+
in their canonical spelling. Each library is named once, and each pair
|
|
79
|
+
names two different libraries of `libraries`, once in either order.
|
|
80
|
+
|
|
81
|
+
Export records (--records <json-file>, or '-' for library-tiers): the
|
|
82
|
+
file parallel-extract section 3a writes and relabels,
|
|
83
|
+
{"entries": [{"source_library": "<library>",
|
|
84
|
+
"extraction_method": "ast_bridge|source_reading", ...}, ...]}
|
|
85
|
+
and, for library-tiers, --libraries, the stack's libraries, comma-separated.
|
|
86
|
+
Every record names one of them; a library with no record is T1-low.
|
|
87
|
+
|
|
88
|
+
Subcommands:
|
|
89
|
+
relabel relabels the records against the label check, rewrites the
|
|
90
|
+
file atomically when a label changed (parallel-extract
|
|
91
|
+
section 3a), and prints each relabeled export and the
|
|
92
|
+
records' label counts (one per record, by signature_source):
|
|
93
|
+
{"relabeled": [{"entry_index": I, "export_name": "<name>",
|
|
94
|
+
"source_library": "<library>",
|
|
95
|
+
"changes": [{"field": "<field>",
|
|
96
|
+
"from": <old>, "to": <new>}]}, ...],
|
|
97
|
+
"confidence_distribution": {"t1": 0, "t1_low": 0, "t2": 0, "t3": 0},
|
|
98
|
+
"coherence": {"ok": true, "violations": []}}
|
|
99
|
+
A removed ast_node_type shows "to": null.
|
|
100
|
+
library-tiers
|
|
101
|
+
each library's tier, in the order --libraries gives
|
|
102
|
+
(parallel-extract section 3a):
|
|
103
|
+
{"libraries": [{"name": "<library>", "tier": "T1|T1-low",
|
|
104
|
+
"export_count": N, "ast_bridge_count": M}, ...]}
|
|
105
|
+
pair-tiers the tier of each integration (detect-integrations section 3):
|
|
106
|
+
{"mode": "<mode>",
|
|
107
|
+
"integrations": [{"a": "<a>", "b": "<b>", "tier": "<tier>"}, ...]}
|
|
108
|
+
metadata the computed fields of metadata.json (generate-output section
|
|
109
|
+
6), with the libraries and pairs in the order given:
|
|
110
|
+
{"mode": "<mode>",
|
|
111
|
+
"library_count": N,
|
|
112
|
+
"integration_count": M,
|
|
113
|
+
"libraries": ["<library>", ...],
|
|
114
|
+
"integration_pairs": [["<a>", "<b>"], ...],
|
|
115
|
+
"confidence_distribution": {"t1": 0, "t1_low": 0, "t2": 0, "t3": 0},
|
|
116
|
+
"confidence_tier": "<dominant tier>",
|
|
117
|
+
"source_authority": "<authority>",
|
|
118
|
+
"integrations": [{"a": "<a>", "b": "<b>", "tier": "<tier>"}, ...]}
|
|
119
|
+
provenance writes provenance-map.json to --target atomically
|
|
120
|
+
(generate-output section 7), in the mode the extraction
|
|
121
|
+
bundle --bundle names: the object --input holds (the map's
|
|
122
|
+
provenance_version, skill_name, skill_type, source_repo,
|
|
123
|
+
source_commit, source_ref and generated_at) with `entries` set
|
|
124
|
+
to the records' entries (as they stand in code mode) and
|
|
125
|
+
`integrations` built
|
|
126
|
+
from the bundle, one per pair of its `integrations`: libraries
|
|
127
|
+
[a, b], pattern_type from its type, its detection_method,
|
|
128
|
+
co_import_files and confidence from its tier. Each replaces
|
|
129
|
+
the field of that name the input holds.
|
|
130
|
+
code mode takes --pairs, the skf-pair-intersect.py result
|
|
131
|
+
(detect-integrations section 1): a pair's co_import_files are
|
|
132
|
+
the files of its entry there, {path, line_a, line_b}, matched
|
|
133
|
+
on the two libraries in either order (line_a and line_b swap
|
|
134
|
+
when the bundle names the pair the other way round); a bundle
|
|
135
|
+
pair with no entry is an input error.
|
|
136
|
+
compose mode takes --inventory, step 4's
|
|
137
|
+
skf-enumerate-stack-skills.py result, --skills-root and
|
|
138
|
+
--project-root. Each entry takes its constituent's tier, the
|
|
139
|
+
confidence of the per_library_extractions[] entry whose library
|
|
140
|
+
is its source_library, as its confidence and signature_source,
|
|
141
|
+
and extraction_method compose-from-skill (an entry naming no
|
|
142
|
+
library of the bundle is an input error), so the records hold
|
|
143
|
+
only the cited fields. Its co_import_files are [], and it adds
|
|
144
|
+
`constituents`, one per per_library_extractions[] entry in its
|
|
145
|
+
order: skill_name its library, skill_path the folder
|
|
146
|
+
{skills-root}/{skill_dir} relative to the project root with a
|
|
147
|
+
trailing slash (absolute when no relative path reaches it; a
|
|
148
|
+
folder that does not exist is an input error), its version,
|
|
149
|
+
composed_at the input's generated_at, and metadata_hash copied
|
|
150
|
+
from the inventory's `skills[]` entry whose name is its
|
|
151
|
+
skill_dir, never computed. It prints
|
|
152
|
+
{"target": "<path>", "entry_count": N, "integration_count": M}
|
|
153
|
+
with "constituent_count": K added in compose mode.
|
|
154
|
+
|
|
155
|
+
CLI:
|
|
156
|
+
uv run skf-render-stack-metadata.py relabel --records export-records.json
|
|
157
|
+
uv run skf-render-stack-metadata.py library-tiers --records export-records.json --libraries "react,zod"
|
|
158
|
+
uv run skf-render-stack-metadata.py pair-tiers --input -
|
|
159
|
+
uv run skf-render-stack-metadata.py metadata --input stack.json
|
|
160
|
+
uv run skf-render-stack-metadata.py provenance --records export-records.json --bundle extraction-bundle.json \\
|
|
161
|
+
--pairs pair-intersect.json --input - --target provenance-map.json
|
|
162
|
+
uv run skf-render-stack-metadata.py provenance --records compose-entries.json --bundle extraction-bundle.json \\
|
|
163
|
+
--inventory stack-inventory.json --skills-root skills --project-root . --input - --target provenance-map.json
|
|
164
|
+
|
|
165
|
+
Exit codes:
|
|
166
|
+
0 the JSON is printed (relabel: the records pass the label check)
|
|
167
|
+
1 relabel only: a violation no rule fixes is left; the JSON lists it in
|
|
168
|
+
coherence.violations, and the file holds every label that was fixed
|
|
169
|
+
2 a usage, read, write or input error, or skf-render-metadata-stats.py
|
|
170
|
+
missing beside this script for relabel: one line on stderr, no JSON,
|
|
171
|
+
nothing written
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
from __future__ import annotations
|
|
175
|
+
|
|
176
|
+
import argparse
|
|
177
|
+
import contextlib
|
|
178
|
+
import importlib.util
|
|
179
|
+
import json
|
|
180
|
+
import math
|
|
181
|
+
import os
|
|
182
|
+
import re
|
|
183
|
+
import secrets
|
|
184
|
+
import sys
|
|
185
|
+
import time
|
|
186
|
+
from pathlib import Path
|
|
187
|
+
|
|
188
|
+
# Tiers from the strongest to the weakest: T1-low is weaker than T1, T2 than
|
|
189
|
+
# T1-low, T3 than T2.
|
|
190
|
+
TIERS = ("T1", "T1-low", "T2", "T3")
|
|
191
|
+
MODES = ("code", "compose")
|
|
192
|
+
# The extraction_method of an export an ast-grep rule matched: the only one
|
|
193
|
+
# that keeps a code-mode library at T1.
|
|
194
|
+
AST_METHOD = "ast_bridge"
|
|
195
|
+
# The extraction_method of every compose-mode entry: copied from a constituent skill.
|
|
196
|
+
COMPOSE_METHOD = "compose-from-skill"
|
|
197
|
+
# Authorities from the highest to the lowest.
|
|
198
|
+
AUTHORITIES = ("official", "community", "internal")
|
|
199
|
+
# The authority of a library that records none.
|
|
200
|
+
DEFAULT_AUTHORITY = "community"
|
|
201
|
+
|
|
202
|
+
# The confidence_distribution bins of metadata.json, from the strongest tier
|
|
203
|
+
# to the weakest. dominant_tier reads them in this order and lets a later bin
|
|
204
|
+
# win a tie, so a tie resolves toward the weaker tier.
|
|
205
|
+
_DISTRIBUTION_BINS = (("t1", "T1"), ("t1_low", "T1-low"), ("t2", "T2"), ("t3", "T3"))
|
|
206
|
+
|
|
207
|
+
# The tier of a distribution that records no evidence (the conservative default).
|
|
208
|
+
_NO_EVIDENCE_TIER = "T1-low"
|
|
209
|
+
|
|
210
|
+
# The label check relabel runs: skf-render-metadata-stats.py beside this
|
|
211
|
+
# script (src/shared/scripts/ and _bmad/skf/shared/scripts/ alike).
|
|
212
|
+
_STATS_HELPER = "skf-render-metadata-stats.py"
|
|
213
|
+
# The stats helper's shape for a stack's records.
|
|
214
|
+
_STATS_SHAPE = "stack"
|
|
215
|
+
# The method a record takes when nothing shows an ast-grep rule matched it.
|
|
216
|
+
_READ_BY_EYE_METHOD = "source_reading"
|
|
217
|
+
# The extraction_method values that name an ast-grep match, and the label a
|
|
218
|
+
# record of each kind takes when it has no valid signature_source.
|
|
219
|
+
_AST_METHODS = ("ast_bridge", "ast-grep")
|
|
220
|
+
# The violation field of a record: provenance.entries[<index>].<field>.
|
|
221
|
+
_RECORD_FIELD_RE = re.compile(r"^provenance\.entries\[(\d+)\]\.(\w+)$")
|
|
222
|
+
# The `expected` value of an ast-grep record's missing node kind.
|
|
223
|
+
_NODE_KIND_EXPECTED = "non-null"
|
|
224
|
+
# Relabeling passes before relabel reports what is left: each pass fixes
|
|
225
|
+
# every violation it finds, so two or three passes settle every record.
|
|
226
|
+
_RELABEL_PASSES = 5
|
|
227
|
+
# Windows refuses to replace a file another process holds open, and a virus
|
|
228
|
+
# scanner or the search indexer may open a just-written file for a moment:
|
|
229
|
+
# _write_atomic retries the rename there for up to this long before failing.
|
|
230
|
+
_IS_WINDOWS = os.name == "nt"
|
|
231
|
+
_REPLACE_WAIT_SECONDS = 5.0
|
|
232
|
+
_REPLACE_POLL_SECONDS = 0.02
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class InputError(ValueError):
|
|
236
|
+
"""An input the caller must fix: exit 2 with one line on stderr."""
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class WriteError(OSError):
|
|
240
|
+
"""A file that could not be written: exit 2 with one line on stderr."""
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _token(value, allowed: tuple[str, ...], what: str) -> str:
|
|
244
|
+
"""`value` in its canonical spelling from `allowed`, compared case-insensitively."""
|
|
245
|
+
if isinstance(value, str):
|
|
246
|
+
for token in allowed:
|
|
247
|
+
if value.strip().lower() == token.lower():
|
|
248
|
+
return token
|
|
249
|
+
raise InputError(f"{what} must be one of {', '.join(allowed)}; got {value!r}")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# --------------------------------------------------------------------------
|
|
253
|
+
# Rules
|
|
254
|
+
# --------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def library_tier(methods: list[str]) -> str:
|
|
258
|
+
"""A code-mode library's tier from the extraction_method of each of its export records.
|
|
259
|
+
|
|
260
|
+
T1 when there is at least one record and every one is ast_bridge, T1-low
|
|
261
|
+
otherwise: an export read by eye, or no export recorded, never earns T1.
|
|
262
|
+
"""
|
|
263
|
+
return "T1" if methods and all(method == AST_METHOD for method in methods) else "T1-low"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def combine_pair_tier(a: str, b: str, mode: str) -> str:
|
|
267
|
+
"""The tier of an integration between a library of tier `a` and one of tier `b`.
|
|
268
|
+
|
|
269
|
+
The weaker of the two, in both modes. Raises InputError on an unknown tier
|
|
270
|
+
or mode.
|
|
271
|
+
"""
|
|
272
|
+
_token(mode, MODES, "mode")
|
|
273
|
+
return max(_token(a, TIERS, "tier"), _token(b, TIERS, "tier"), key=TIERS.index)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# Keep identical to _bin_count and dominant_tier in skf-enumerate-stack-skills.py
|
|
277
|
+
# (test/test-skf-render-stack-metadata.py pins the copies).
|
|
278
|
+
def _bin_count(value):
|
|
279
|
+
"""A confidence_distribution bin's count: a positive finite number, else 0."""
|
|
280
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
281
|
+
return 0
|
|
282
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
283
|
+
return 0
|
|
284
|
+
return value if value > 0 else 0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def dominant_tier(distribution) -> str:
|
|
288
|
+
"""The tier of the largest bin of a metadata.json confidence_distribution.
|
|
289
|
+
|
|
290
|
+
The bins are read from the strongest tier to the weakest and a later bin
|
|
291
|
+
wins a tie, so a tie resolves toward the weaker tier (T1-low over T1, T2
|
|
292
|
+
over T1-low, T3 over T2) and the tier never overstates confidence. A bin
|
|
293
|
+
counts only when it holds a positive number. T1-low when none does: the
|
|
294
|
+
distribution is absent, not an object or all zero.
|
|
295
|
+
"""
|
|
296
|
+
tier, best = _NO_EVIDENCE_TIER, 0
|
|
297
|
+
if isinstance(distribution, dict):
|
|
298
|
+
for key, bin_tier in _DISTRIBUTION_BINS:
|
|
299
|
+
count = _bin_count(distribution.get(key))
|
|
300
|
+
if count and count >= best:
|
|
301
|
+
tier, best = bin_tier, count
|
|
302
|
+
return tier
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def library_distribution(tiers: list[str]) -> dict[str, int]:
|
|
306
|
+
"""confidence_distribution with each library once, in the bin of its tier."""
|
|
307
|
+
bin_of = {tier: key for key, tier in _DISTRIBUTION_BINS}
|
|
308
|
+
distribution = {key: 0 for key, _ in _DISTRIBUTION_BINS}
|
|
309
|
+
for tier in tiers:
|
|
310
|
+
distribution[bin_of[tier]] += 1
|
|
311
|
+
return distribution
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def lowest_authority(authorities: list[str | None]) -> str:
|
|
315
|
+
"""The lowest of the authorities, a library that records none (None)
|
|
316
|
+
counting as DEFAULT_AUTHORITY; DEFAULT_AUTHORITY for no library."""
|
|
317
|
+
return max((authority or DEFAULT_AUTHORITY for authority in authorities),
|
|
318
|
+
key=AUTHORITIES.index, default=DEFAULT_AUTHORITY)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# --------------------------------------------------------------------------
|
|
322
|
+
# Input and projections
|
|
323
|
+
# --------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def parse_stack(data) -> tuple[str, list[dict], list[tuple[str, str]]]:
|
|
327
|
+
"""(mode, libraries, pairs) from the input JSON. Raises InputError."""
|
|
328
|
+
if not isinstance(data, dict):
|
|
329
|
+
raise InputError(f"the input must be a JSON object; got {type(data).__name__}")
|
|
330
|
+
mode = _token(data.get("mode"), MODES, "mode")
|
|
331
|
+
|
|
332
|
+
raw_libraries = data.get("libraries")
|
|
333
|
+
if not isinstance(raw_libraries, list):
|
|
334
|
+
raise InputError("`libraries` must be an array of {name, confidence} objects")
|
|
335
|
+
libraries: list[dict] = []
|
|
336
|
+
tiers: dict[str, str] = {}
|
|
337
|
+
for i, item in enumerate(raw_libraries):
|
|
338
|
+
where = f"libraries[{i}]"
|
|
339
|
+
if not isinstance(item, dict):
|
|
340
|
+
raise InputError(f"{where} must be an object with `name` and `confidence`; got {item!r}")
|
|
341
|
+
name = item.get("name")
|
|
342
|
+
if not isinstance(name, str) or not name.strip():
|
|
343
|
+
raise InputError(f"{where}.name must be a non-empty string; got {name!r}")
|
|
344
|
+
if name in tiers:
|
|
345
|
+
raise InputError(f"library {name!r} is listed twice")
|
|
346
|
+
tier = _token(item.get("confidence"), TIERS, f"{where}.confidence")
|
|
347
|
+
authority = item.get("source_authority")
|
|
348
|
+
if authority is not None:
|
|
349
|
+
authority = _token(authority, AUTHORITIES, f"{where}.source_authority")
|
|
350
|
+
tiers[name] = tier
|
|
351
|
+
libraries.append({"name": name, "confidence": tier, "source_authority": authority})
|
|
352
|
+
|
|
353
|
+
raw_pairs = data.get("integrations", [])
|
|
354
|
+
if not isinstance(raw_pairs, list):
|
|
355
|
+
raise InputError("`integrations` must be an array of {a, b} objects")
|
|
356
|
+
pairs: list[tuple[str, str]] = []
|
|
357
|
+
seen: set[frozenset[str]] = set()
|
|
358
|
+
for i, item in enumerate(raw_pairs):
|
|
359
|
+
where = f"integrations[{i}]"
|
|
360
|
+
if not isinstance(item, dict):
|
|
361
|
+
raise InputError(f"{where} must be an object with `a` and `b`; got {item!r}")
|
|
362
|
+
a, b = item.get("a"), item.get("b")
|
|
363
|
+
for side, name in (("a", a), ("b", b)):
|
|
364
|
+
if not isinstance(name, str) or name not in tiers:
|
|
365
|
+
raise InputError(f"{where}.{side} {name!r} is not one of the libraries")
|
|
366
|
+
if a == b:
|
|
367
|
+
raise InputError(f"{where} pairs {a!r} with itself")
|
|
368
|
+
key = frozenset((a, b))
|
|
369
|
+
if key in seen:
|
|
370
|
+
raise InputError(f"the pair {a!r} + {b!r} is listed twice")
|
|
371
|
+
seen.add(key)
|
|
372
|
+
pairs.append((a, b))
|
|
373
|
+
return mode, libraries, pairs
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def parse_library_names(value: str) -> list[str]:
|
|
377
|
+
"""The comma-separated --libraries list, each name once. Raises InputError."""
|
|
378
|
+
names = [name.strip() for name in value.split(",") if name.strip()]
|
|
379
|
+
if not names:
|
|
380
|
+
raise InputError("--libraries must name at least one library")
|
|
381
|
+
seen: set[str] = set()
|
|
382
|
+
for name in names:
|
|
383
|
+
if name in seen:
|
|
384
|
+
raise InputError(f"library {name!r} is listed twice in --libraries")
|
|
385
|
+
seen.add(name)
|
|
386
|
+
return names
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def library_tiers(data, names: list[str]) -> list[dict]:
|
|
390
|
+
"""Each library of `names` with its tier and record counts, from the export records.
|
|
391
|
+
|
|
392
|
+
`data` is the parsed records file, {"entries": [...]}. Raises InputError
|
|
393
|
+
on a record that names no library, or one --libraries does not list.
|
|
394
|
+
"""
|
|
395
|
+
methods: dict[str, list[str]] = {name: [] for name in names}
|
|
396
|
+
for i, record in enumerate(parse_records(data)):
|
|
397
|
+
where = f"entries[{i}]"
|
|
398
|
+
library = record.get("source_library")
|
|
399
|
+
if not isinstance(library, str) or library not in methods:
|
|
400
|
+
raise InputError(f"{where}.source_library {library!r} is not one of --libraries")
|
|
401
|
+
method = record.get("extraction_method")
|
|
402
|
+
methods[library].append(method.strip().lower() if isinstance(method, str) else "")
|
|
403
|
+
return [
|
|
404
|
+
{"name": name, "tier": library_tier(found), "export_count": len(found),
|
|
405
|
+
"ast_bridge_count": sum(method == AST_METHOD for method in found)}
|
|
406
|
+
for name, found in methods.items()
|
|
407
|
+
]
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def parse_records(data) -> list[dict]:
|
|
411
|
+
"""The `entries` of an export records file, each an object. Raises InputError."""
|
|
412
|
+
if not isinstance(data, dict) or not isinstance(data.get("entries"), list):
|
|
413
|
+
raise InputError("the export records must be a JSON object with an `entries` array")
|
|
414
|
+
for i, record in enumerate(data["entries"]):
|
|
415
|
+
if not isinstance(record, dict):
|
|
416
|
+
raise InputError(f"entries[{i}] must be an object; got {record!r}")
|
|
417
|
+
return data["entries"]
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# --------------------------------------------------------------------------
|
|
421
|
+
# Relabeling
|
|
422
|
+
# --------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _load_stats_rules():
|
|
426
|
+
"""skf-render-metadata-stats.py beside this script, as a module. Raises ImportError."""
|
|
427
|
+
path = Path(__file__).resolve().parent / _STATS_HELPER
|
|
428
|
+
if not path.is_file():
|
|
429
|
+
raise ImportError(f"{_STATS_HELPER} not found beside {Path(__file__).name}: no label check can run")
|
|
430
|
+
spec = importlib.util.spec_from_file_location("skf_render_metadata_stats_rules", path)
|
|
431
|
+
if spec is None or spec.loader is None:
|
|
432
|
+
raise ImportError(f"{_STATS_HELPER} beside {Path(__file__).name} cannot be loaded")
|
|
433
|
+
module = importlib.util.module_from_spec(spec)
|
|
434
|
+
try:
|
|
435
|
+
spec.loader.exec_module(module)
|
|
436
|
+
except (OSError, SyntaxError) as exc:
|
|
437
|
+
raise ImportError(f"{_STATS_HELPER} beside {Path(__file__).name} cannot be loaded: {exc}") from exc
|
|
438
|
+
return module
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def label_check(stats, entries: list[dict]) -> tuple[dict, dict]:
|
|
442
|
+
"""(confidence_distribution, coherence) of the stats helper's stack check on the records."""
|
|
443
|
+
prov = {"entries": entries}
|
|
444
|
+
derived = stats.derive_stats(prov, {}, _STATS_SHAPE)
|
|
445
|
+
return derived["confidence_distribution"], stats.coherence_compute(derived, prov)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _set(changes: dict, entries: list[dict], index: int, field: str, value) -> None:
|
|
449
|
+
"""Set (or, for None on ast_node_type, remove) one field of a record and note the change."""
|
|
450
|
+
record = entries[index]
|
|
451
|
+
old = record.get(field)
|
|
452
|
+
if field == "ast_node_type" and value is None:
|
|
453
|
+
record.pop(field, None)
|
|
454
|
+
else:
|
|
455
|
+
record[field] = value
|
|
456
|
+
changes.setdefault(index, []).append({"field": field, "from": old, "to": value})
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _known_method(value, known: tuple[str, ...]) -> str | None:
|
|
460
|
+
"""`value` as a known extraction_method in its canonical spelling, or None."""
|
|
461
|
+
if isinstance(value, str):
|
|
462
|
+
token = value.strip().lower()
|
|
463
|
+
if token in known:
|
|
464
|
+
return token
|
|
465
|
+
return None
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def relabel_pass(stats, entries: list[dict], violations: list[dict], changes: dict) -> bool:
|
|
469
|
+
"""Apply one fix per violation to the records in place. True when a label changed."""
|
|
470
|
+
changed = False
|
|
471
|
+
for violation in violations:
|
|
472
|
+
field = violation.get("field")
|
|
473
|
+
if field == "confidence_distribution":
|
|
474
|
+
# Records with no valid signature_source: each takes the label its method implies.
|
|
475
|
+
for index, record in enumerate(entries):
|
|
476
|
+
signature = record.get("signature_source")
|
|
477
|
+
if isinstance(signature, str) and signature.strip().upper() in stats._SIG_MAP:
|
|
478
|
+
continue
|
|
479
|
+
method = _known_method(record.get("extraction_method"), _AST_METHODS)
|
|
480
|
+
_set(changes, entries, index, "signature_source", "T1" if method else "T1-low")
|
|
481
|
+
changed = True
|
|
482
|
+
continue
|
|
483
|
+
match = _RECORD_FIELD_RE.match(field) if isinstance(field, str) else None
|
|
484
|
+
if match is None:
|
|
485
|
+
continue # no rule for it: it stays in coherence.violations
|
|
486
|
+
index, name = int(match.group(1)), match.group(2)
|
|
487
|
+
expected = violation.get("expected")
|
|
488
|
+
if name == "extraction_method":
|
|
489
|
+
method = _known_method(entries[index].get("extraction_method"), stats._KNOWN_METHODS)
|
|
490
|
+
_set(changes, entries, index, name, method or _READ_BY_EYE_METHOD)
|
|
491
|
+
elif name == "ast_node_type" and expected == _NODE_KIND_EXPECTED:
|
|
492
|
+
_set(changes, entries, index, "extraction_method", _AST_METHODS[0])
|
|
493
|
+
else:
|
|
494
|
+
_set(changes, entries, index, name, expected)
|
|
495
|
+
changed = True
|
|
496
|
+
return changed
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def relabel(stats, entries: list[dict]) -> tuple[list[dict], dict, dict]:
|
|
500
|
+
"""Relabel the records in place until they pass the label check.
|
|
501
|
+
|
|
502
|
+
Returns (relabeled, confidence_distribution, coherence), `relabeled`
|
|
503
|
+
holding each changed record once, in entries[] order, with its changes.
|
|
504
|
+
"""
|
|
505
|
+
changes: dict[int, list[dict]] = {}
|
|
506
|
+
for _ in range(_RELABEL_PASSES):
|
|
507
|
+
_distribution, coherence = label_check(stats, entries)
|
|
508
|
+
if coherence["ok"] or not relabel_pass(stats, entries, coherence["violations"], changes):
|
|
509
|
+
break
|
|
510
|
+
distribution, coherence = label_check(stats, entries)
|
|
511
|
+
relabeled = [
|
|
512
|
+
{"entry_index": index, "export_name": entries[index].get("export_name"),
|
|
513
|
+
"source_library": entries[index].get("source_library"), "changes": found}
|
|
514
|
+
for index, found in sorted(changes.items())
|
|
515
|
+
]
|
|
516
|
+
return relabeled, distribution, coherence
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# --------------------------------------------------------------------------
|
|
520
|
+
# Provenance map
|
|
521
|
+
# --------------------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def bundle_mode(bundle) -> str:
|
|
525
|
+
"""The extraction bundle's `mode`, code or compose. Raises InputError."""
|
|
526
|
+
if not isinstance(bundle, dict):
|
|
527
|
+
raise InputError(f"the extraction bundle must be a JSON object; got {type(bundle).__name__}")
|
|
528
|
+
return _token(bundle.get("mode"), MODES, "the bundle's mode")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def pair_files(data) -> dict[frozenset[str], tuple[str, list[dict]]]:
|
|
532
|
+
"""Each pair of a skf-pair-intersect.py result, keyed by its two libraries:
|
|
533
|
+
its `a` and its co-import files, each {path, line_a, line_b}.
|
|
534
|
+
|
|
535
|
+
Raises InputError on a result with no `pairs` array, or a pair that names
|
|
536
|
+
no two libraries or whose files are not {path, ...} objects.
|
|
537
|
+
"""
|
|
538
|
+
if not isinstance(data, dict) or not isinstance(data.get("pairs"), list):
|
|
539
|
+
raise InputError("the pair-intersect result (--pairs) must be a JSON object with a `pairs` array")
|
|
540
|
+
out: dict[frozenset[str], tuple[str, list[dict]]] = {}
|
|
541
|
+
for i, pair in enumerate(data["pairs"]):
|
|
542
|
+
where = f"the pair-intersect result's pairs[{i}]"
|
|
543
|
+
if not isinstance(pair, dict):
|
|
544
|
+
raise InputError(f"{where} must be an object; got {pair!r}")
|
|
545
|
+
for key in ("a", "b"):
|
|
546
|
+
if not isinstance(pair.get(key), str) or not pair[key].strip():
|
|
547
|
+
raise InputError(f"{where}.{key} must be a non-empty string; got {pair.get(key)!r}")
|
|
548
|
+
files = pair.get("files")
|
|
549
|
+
if not isinstance(files, list) or not all(
|
|
550
|
+
isinstance(item, dict) and isinstance(item.get("path"), str) for item in files
|
|
551
|
+
):
|
|
552
|
+
raise InputError(f"{where}.files must be an array of {{path, line_a, line_b}} objects")
|
|
553
|
+
out[frozenset((pair["a"], pair["b"]))] = (pair["a"], files)
|
|
554
|
+
return out
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _co_import_files(a: str, found: tuple[str, list[dict]]) -> list[dict]:
|
|
558
|
+
"""A pair's co-import files with `line_a` the line that imports `a`: the
|
|
559
|
+
pair-intersect files as they stand, or with the two lines swapped when
|
|
560
|
+
that result names the pair the other way round."""
|
|
561
|
+
first, files = found
|
|
562
|
+
if first == a:
|
|
563
|
+
return files
|
|
564
|
+
return [dict(item, line_a=item.get("line_b"), line_b=item.get("line_a")) for item in files]
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def bundle_integrations(bundle, pairs: dict | None = None) -> list[dict]:
|
|
568
|
+
"""The provenance map's integrations[] from the extraction bundle's, in its order.
|
|
569
|
+
|
|
570
|
+
Each bundle pair {a, b, type, tier, detection_method, ...} gives
|
|
571
|
+
{libraries: [a, b], pattern_type, detection_method, co_import_files,
|
|
572
|
+
confidence}. With `pairs` (code mode, from pair_files) its co-import files
|
|
573
|
+
are its entry's there; without (compose mode) they are []. Raises
|
|
574
|
+
InputError on a bundle with no `integrations` array, a pair missing one of
|
|
575
|
+
those fields, or a code-mode pair `pairs` has no entry for.
|
|
576
|
+
"""
|
|
577
|
+
if not isinstance(bundle, dict) or not isinstance(bundle.get("integrations"), list):
|
|
578
|
+
raise InputError("the extraction bundle must be a JSON object with an `integrations` array")
|
|
579
|
+
out = []
|
|
580
|
+
for i, pair in enumerate(bundle["integrations"]):
|
|
581
|
+
where = f"the bundle's integrations[{i}]"
|
|
582
|
+
if not isinstance(pair, dict):
|
|
583
|
+
raise InputError(f"{where} must be an object; got {pair!r}")
|
|
584
|
+
for key in ("a", "b", "type", "detection_method"):
|
|
585
|
+
if not isinstance(pair.get(key), str) or not pair[key].strip():
|
|
586
|
+
raise InputError(f"{where}.{key} must be a non-empty string; got {pair.get(key)!r}")
|
|
587
|
+
co_import_files: list[dict] = []
|
|
588
|
+
if pairs is not None:
|
|
589
|
+
found = pairs.get(frozenset((pair["a"], pair["b"])))
|
|
590
|
+
if found is None:
|
|
591
|
+
raise InputError(
|
|
592
|
+
f"{where} ({pair['a']} + {pair['b']}) has no entry in the pair-intersect result (--pairs)")
|
|
593
|
+
co_import_files = _co_import_files(pair["a"], found)
|
|
594
|
+
out.append({
|
|
595
|
+
"libraries": [pair["a"], pair["b"]],
|
|
596
|
+
"pattern_type": pair["type"],
|
|
597
|
+
"detection_method": pair["detection_method"],
|
|
598
|
+
"co_import_files": co_import_files,
|
|
599
|
+
"confidence": _token(pair.get("tier"), TIERS, f"{where}.tier"),
|
|
600
|
+
})
|
|
601
|
+
return out
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def inventory_hashes(data) -> dict[str, str]:
|
|
605
|
+
"""Each skill's metadata_hash in a skf-enumerate-stack-skills.py result, by name.
|
|
606
|
+
|
|
607
|
+
Raises InputError on a result with no `skills` array. A skill without a
|
|
608
|
+
string hash is left out, so a constituent naming it is refused.
|
|
609
|
+
"""
|
|
610
|
+
if not isinstance(data, dict) or not isinstance(data.get("skills"), list):
|
|
611
|
+
raise InputError("the stack inventory (--inventory) must be a JSON object with a `skills` array")
|
|
612
|
+
return {
|
|
613
|
+
skill["name"]: skill["metadata_hash"]
|
|
614
|
+
for skill in data["skills"]
|
|
615
|
+
if isinstance(skill, dict) and isinstance(skill.get("name"), str)
|
|
616
|
+
and isinstance(skill.get("metadata_hash"), str) and skill["metadata_hash"].strip()
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _skill_path(skills_root: str, skill_dir: str, project_root: str) -> str:
|
|
621
|
+
"""The skill's folder, {skills_root}/{skill_dir}, relative to the project
|
|
622
|
+
root, forward-slash, with a trailing slash; absolute when no relative
|
|
623
|
+
path reaches it (another drive on Windows)."""
|
|
624
|
+
folder = os.path.abspath(os.path.join(skills_root, skill_dir))
|
|
625
|
+
try:
|
|
626
|
+
path = os.path.relpath(folder, os.path.abspath(project_root))
|
|
627
|
+
except ValueError:
|
|
628
|
+
path = folder
|
|
629
|
+
return Path(path).as_posix().rstrip("/") + "/"
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def compose_constituents(bundle, hashes: dict[str, str], skills_root: str, project_root: str,
|
|
633
|
+
composed_at) -> list[dict]:
|
|
634
|
+
"""The compose-mode map's constituents[], one per per_library_extractions[] entry, in its order.
|
|
635
|
+
|
|
636
|
+
skill_name is the entry's library, skill_path its skill folder (see
|
|
637
|
+
_skill_path), version its version, composed_at the map's generated_at
|
|
638
|
+
and metadata_hash the hash step 4's inventory recorded for its skill_dir,
|
|
639
|
+
copied, never computed. Raises InputError on a missing field, a skill
|
|
640
|
+
folder that does not exist, or a skill_dir the inventory does not hold.
|
|
641
|
+
"""
|
|
642
|
+
if not isinstance(composed_at, str) or not composed_at.strip():
|
|
643
|
+
raise InputError(f"compose mode takes composed_at from the input's generated_at, "
|
|
644
|
+
f"a non-empty string; got {composed_at!r}")
|
|
645
|
+
entries = bundle.get("per_library_extractions")
|
|
646
|
+
if not isinstance(entries, list):
|
|
647
|
+
raise InputError("the extraction bundle must have a `per_library_extractions` array")
|
|
648
|
+
out = []
|
|
649
|
+
for i, entry in enumerate(entries):
|
|
650
|
+
where = f"the bundle's per_library_extractions[{i}]"
|
|
651
|
+
if not isinstance(entry, dict):
|
|
652
|
+
raise InputError(f"{where} must be an object; got {entry!r}")
|
|
653
|
+
for key in ("library", "skill_dir"):
|
|
654
|
+
if not isinstance(entry.get(key), str) or not entry[key].strip():
|
|
655
|
+
raise InputError(f"{where}.{key} must be a non-empty string; got {entry.get(key)!r}")
|
|
656
|
+
skill_dir, version = entry["skill_dir"], entry.get("version")
|
|
657
|
+
if skill_dir in (".", "..") or "/" in skill_dir or "\\" in skill_dir:
|
|
658
|
+
raise InputError(f"{where}.skill_dir must be a folder name; got {skill_dir!r}")
|
|
659
|
+
if version is not None and not isinstance(version, str):
|
|
660
|
+
raise InputError(f"{where}.version must be a string or null; got {version!r}")
|
|
661
|
+
if not os.path.isdir(os.path.join(skills_root, skill_dir)):
|
|
662
|
+
raise InputError(f"{where}: no skill folder {skill_dir!r} under --skills-root {skills_root}")
|
|
663
|
+
if skill_dir not in hashes:
|
|
664
|
+
raise InputError(f"{where}: the stack inventory (--inventory) holds no metadata_hash for {skill_dir!r}")
|
|
665
|
+
out.append({
|
|
666
|
+
"skill_name": entry["library"],
|
|
667
|
+
"skill_path": _skill_path(skills_root, skill_dir, project_root),
|
|
668
|
+
"version": version,
|
|
669
|
+
"composed_at": composed_at,
|
|
670
|
+
"metadata_hash": hashes[skill_dir],
|
|
671
|
+
})
|
|
672
|
+
return out
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def compose_entries(bundle, entries: list[dict]) -> list[dict]:
|
|
676
|
+
"""The compose-mode map's entries: each record with confidence and
|
|
677
|
+
signature_source set to its constituent's tier, the confidence of the
|
|
678
|
+
per_library_extractions[] entry whose library is its source_library, and
|
|
679
|
+
extraction_method set to COMPOSE_METHOD.
|
|
680
|
+
|
|
681
|
+
Raises InputError on a record naming no library of the bundle, or a
|
|
682
|
+
constituent whose confidence is not a tier.
|
|
683
|
+
"""
|
|
684
|
+
tiers: dict[str, tuple[object, str]] = {}
|
|
685
|
+
for i, entry in enumerate(bundle.get("per_library_extractions") or []):
|
|
686
|
+
if isinstance(entry, dict) and isinstance(entry.get("library"), str):
|
|
687
|
+
tiers[entry["library"]] = (entry.get("confidence"),
|
|
688
|
+
f"the bundle's per_library_extractions[{i}].confidence")
|
|
689
|
+
out = []
|
|
690
|
+
for i, record in enumerate(entries):
|
|
691
|
+
library = record.get("source_library")
|
|
692
|
+
if not isinstance(library, str) or library not in tiers:
|
|
693
|
+
raise InputError(f"entries[{i}].source_library {library!r} is not a library of the bundle's "
|
|
694
|
+
f"per_library_extractions")
|
|
695
|
+
value, where = tiers[library]
|
|
696
|
+
tier = _token(value, TIERS, where)
|
|
697
|
+
out.append(dict(record, confidence=tier, extraction_method=COMPOSE_METHOD, signature_source=tier))
|
|
698
|
+
return out
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def provenance_map(base, entries: list[dict], integrations: list[dict] | None = None,
|
|
702
|
+
constituents: list[dict] | None = None) -> dict:
|
|
703
|
+
"""The provenance map: `base` with `entries` set to the records and, when
|
|
704
|
+
given, `integrations` set to the bundle's and `constituents` to the
|
|
705
|
+
compose-mode constituents.
|
|
706
|
+
|
|
707
|
+
The entries take the place of any `entries` in `base`, or go before its
|
|
708
|
+
`integrations` (the schema's order), or last; the given integrations and
|
|
709
|
+
constituents take the place of any in `base`, or go last, in that order.
|
|
710
|
+
Raises InputError when `base` is not an object.
|
|
711
|
+
"""
|
|
712
|
+
if not isinstance(base, dict):
|
|
713
|
+
raise InputError(f"the provenance map input must be a JSON object; got {type(base).__name__}")
|
|
714
|
+
fields = dict(base)
|
|
715
|
+
if integrations is not None:
|
|
716
|
+
fields["integrations"] = integrations
|
|
717
|
+
if constituents is not None:
|
|
718
|
+
fields["constituents"] = constituents
|
|
719
|
+
out: dict = {}
|
|
720
|
+
for key, value in fields.items():
|
|
721
|
+
if key == "entries" or (key == "integrations" and "entries" not in fields):
|
|
722
|
+
out["entries"] = entries
|
|
723
|
+
if key == "entries":
|
|
724
|
+
continue
|
|
725
|
+
out[key] = value
|
|
726
|
+
out.setdefault("entries", entries)
|
|
727
|
+
return out
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def pair_tiers(mode: str, libraries: list[dict], pairs: list[tuple[str, str]]) -> list[dict]:
|
|
731
|
+
"""Each pair with the tier combine_pair_tier gives it."""
|
|
732
|
+
tier = {library["name"]: library["confidence"] for library in libraries}
|
|
733
|
+
return [{"a": a, "b": b, "tier": combine_pair_tier(tier[a], tier[b], mode)} for a, b in pairs]
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def stack_metadata(mode: str, libraries: list[dict], pairs: list[tuple[str, str]]) -> dict:
|
|
737
|
+
"""The computed fields of a stack's metadata.json."""
|
|
738
|
+
distribution = library_distribution([library["confidence"] for library in libraries])
|
|
739
|
+
return {
|
|
740
|
+
"mode": mode,
|
|
741
|
+
"library_count": len(libraries),
|
|
742
|
+
"integration_count": len(pairs),
|
|
743
|
+
"libraries": [library["name"] for library in libraries],
|
|
744
|
+
"integration_pairs": [[a, b] for a, b in pairs],
|
|
745
|
+
"confidence_distribution": distribution,
|
|
746
|
+
"confidence_tier": dominant_tier(distribution),
|
|
747
|
+
"source_authority": lowest_authority([library["source_authority"] for library in libraries]),
|
|
748
|
+
"integrations": pair_tiers(mode, libraries, pairs),
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
# --------------------------------------------------------------------------
|
|
753
|
+
# CLI
|
|
754
|
+
# --------------------------------------------------------------------------
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _read_input(source: str):
|
|
758
|
+
"""The parsed JSON of a file path, or of stdin for '-'. Raises InputError."""
|
|
759
|
+
try:
|
|
760
|
+
text = sys.stdin.read() if source == "-" else Path(source).read_text(encoding="utf-8")
|
|
761
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
762
|
+
raise InputError(f"cannot read the input {source}: {exc}") from exc
|
|
763
|
+
try:
|
|
764
|
+
return json.loads(text)
|
|
765
|
+
except json.JSONDecodeError as exc:
|
|
766
|
+
raise InputError(f"the input is not valid JSON: {exc}") from exc
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _write_atomic(path: Path, data: dict) -> None:
|
|
770
|
+
"""Write `data` as JSON to `path` atomically: a temp file of its own beside
|
|
771
|
+
it, then a rename. Raises WriteError, leaving `path` as it was."""
|
|
772
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}-{secrets.token_hex(4)}.skf-tmp")
|
|
773
|
+
payload = (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
|
774
|
+
# O_EXCL: never write into another writer's temp file. O_BINARY (Windows
|
|
775
|
+
# only; 0 elsewhere) keeps the \n line ends.
|
|
776
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
|
|
777
|
+
try:
|
|
778
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
779
|
+
fd = os.open(tmp, flags, 0o644)
|
|
780
|
+
except OSError as exc:
|
|
781
|
+
raise WriteError(f"cannot write {path}: {exc.strerror or exc}") from exc
|
|
782
|
+
try:
|
|
783
|
+
with open(fd, "wb") as fh:
|
|
784
|
+
fh.write(payload)
|
|
785
|
+
fh.flush()
|
|
786
|
+
os.fsync(fh.fileno())
|
|
787
|
+
deadline = time.monotonic() + _REPLACE_WAIT_SECONDS
|
|
788
|
+
while True:
|
|
789
|
+
try:
|
|
790
|
+
os.replace(tmp, path)
|
|
791
|
+
break
|
|
792
|
+
except PermissionError:
|
|
793
|
+
if not _IS_WINDOWS or time.monotonic() >= deadline:
|
|
794
|
+
raise
|
|
795
|
+
time.sleep(_REPLACE_POLL_SECONDS)
|
|
796
|
+
except BaseException as exc:
|
|
797
|
+
with contextlib.suppress(OSError):
|
|
798
|
+
tmp.unlink()
|
|
799
|
+
if isinstance(exc, OSError):
|
|
800
|
+
raise WriteError(f"cannot write {path}: {exc.strerror or exc}") from exc
|
|
801
|
+
raise
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _records_file(value: str, command: str, flag: str = "--records") -> str:
|
|
805
|
+
"""The path a command reads from a file, never stdin (--records, or --bundle)."""
|
|
806
|
+
if value == "-":
|
|
807
|
+
raise InputError(f"{command} reads {flag} from a file, not stdin")
|
|
808
|
+
return value
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _cmd_relabel(args: argparse.Namespace) -> tuple[dict, int]:
|
|
812
|
+
stats = _load_stats_rules()
|
|
813
|
+
data = _read_input(_records_file(args.records, "relabel"))
|
|
814
|
+
entries = parse_records(data)
|
|
815
|
+
relabeled, distribution, coherence = relabel(stats, entries)
|
|
816
|
+
if relabeled:
|
|
817
|
+
_write_atomic(Path(args.records), data)
|
|
818
|
+
result = {"relabeled": relabeled, "confidence_distribution": distribution, "coherence": coherence}
|
|
819
|
+
return result, 0 if coherence["ok"] else 1
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _cmd_library_tiers(args: argparse.Namespace) -> dict:
|
|
823
|
+
names = parse_library_names(args.libraries)
|
|
824
|
+
return {"libraries": library_tiers(_read_input(args.records), names)}
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _cmd_pair_tiers(args: argparse.Namespace) -> dict:
|
|
828
|
+
mode, libraries, pairs = parse_stack(_read_input(args.input))
|
|
829
|
+
return {"mode": mode, "integrations": pair_tiers(mode, libraries, pairs)}
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _cmd_metadata(args: argparse.Namespace) -> dict:
|
|
833
|
+
return stack_metadata(*parse_stack(_read_input(args.input)))
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _cmd_provenance(args: argparse.Namespace) -> dict:
|
|
837
|
+
entries = parse_records(_read_input(_records_file(args.records, "provenance")))
|
|
838
|
+
bundle = _read_input(_records_file(args.bundle, "provenance", "--bundle"))
|
|
839
|
+
mode = bundle_mode(bundle)
|
|
840
|
+
compose_flags = {"--inventory": args.inventory, "--skills-root": args.skills_root,
|
|
841
|
+
"--project-root": args.project_root}
|
|
842
|
+
if mode == "code":
|
|
843
|
+
given = [flag for flag, value in compose_flags.items() if value is not None]
|
|
844
|
+
if given:
|
|
845
|
+
raise InputError(f"{', '.join(given)} is for a compose-mode bundle; this bundle's mode is code")
|
|
846
|
+
if args.pairs is None:
|
|
847
|
+
raise InputError("a code-mode bundle needs --pairs, the skf-pair-intersect.py result")
|
|
848
|
+
pairs = pair_files(_read_input(_records_file(args.pairs, "provenance", "--pairs")))
|
|
849
|
+
elif args.pairs is not None:
|
|
850
|
+
raise InputError("--pairs is for a code-mode bundle; this bundle's mode is compose")
|
|
851
|
+
else:
|
|
852
|
+
missing = [flag for flag, value in compose_flags.items() if value is None]
|
|
853
|
+
if missing:
|
|
854
|
+
raise InputError(f"a compose-mode bundle needs {', '.join(missing)}")
|
|
855
|
+
pairs = None
|
|
856
|
+
base = _read_input(args.input)
|
|
857
|
+
if not isinstance(base, dict):
|
|
858
|
+
raise InputError(f"the provenance map input must be a JSON object; got {type(base).__name__}")
|
|
859
|
+
integrations = bundle_integrations(bundle, pairs)
|
|
860
|
+
constituents = None
|
|
861
|
+
if mode == "compose":
|
|
862
|
+
hashes = inventory_hashes(_read_input(_records_file(args.inventory, "provenance", "--inventory")))
|
|
863
|
+
constituents = compose_constituents(bundle, hashes, args.skills_root, args.project_root,
|
|
864
|
+
base.get("generated_at"))
|
|
865
|
+
entries = compose_entries(bundle, entries)
|
|
866
|
+
target = Path(args.target)
|
|
867
|
+
_write_atomic(target, provenance_map(base, entries, integrations, constituents))
|
|
868
|
+
result = {"target": str(target), "entry_count": len(entries), "integration_count": len(integrations)}
|
|
869
|
+
if constituents is not None:
|
|
870
|
+
result["constituent_count"] = len(constituents)
|
|
871
|
+
return result
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
875
|
+
parser = argparse.ArgumentParser(
|
|
876
|
+
prog="skf-render-stack-metadata",
|
|
877
|
+
description=(
|
|
878
|
+
"Relabel a code-mode stack's export records against the label check of "
|
|
879
|
+
"skf-render-metadata-stats.py, compute its library tiers (T1 only when an "
|
|
880
|
+
"ast-grep rule matched every export), its pair tiers (the weaker of the "
|
|
881
|
+
"two libraries' tiers, in both modes) and the counted and ranked fields "
|
|
882
|
+
"of its metadata.json: counts, libraries, integration_pairs, "
|
|
883
|
+
"confidence_distribution (each library once), confidence_tier (the "
|
|
884
|
+
"dominant tier) and source_authority (the lowest, community for a "
|
|
885
|
+
"library that records none), and write its provenance map from the "
|
|
886
|
+
"records, the extraction bundle and, in code mode, the pair-intersect "
|
|
887
|
+
"result or, in compose mode, the stack inventory."
|
|
888
|
+
),
|
|
889
|
+
)
|
|
890
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
891
|
+
text = ("relabel the export records against the label check, rewrite the file when a "
|
|
892
|
+
"label changed, and print the relabeled exports and the label counts as JSON")
|
|
893
|
+
command = sub.add_parser("relabel", help=text, description=text)
|
|
894
|
+
command.add_argument(
|
|
895
|
+
"--records", required=True, help="path to the export records JSON ({entries: [...]}), rewritten in place")
|
|
896
|
+
command.set_defaults(func=_cmd_relabel)
|
|
897
|
+
text = "print each library's tier from its checked export records as JSON"
|
|
898
|
+
command = sub.add_parser("library-tiers", help=text, description=text)
|
|
899
|
+
command.add_argument(
|
|
900
|
+
"--records",
|
|
901
|
+
required=True,
|
|
902
|
+
help="path to the export records JSON ({entries: [...]}), or '-' for stdin",
|
|
903
|
+
)
|
|
904
|
+
command.add_argument(
|
|
905
|
+
"--libraries",
|
|
906
|
+
required=True,
|
|
907
|
+
help="the stack's libraries, comma-separated; a library with no record is T1-low",
|
|
908
|
+
)
|
|
909
|
+
command.set_defaults(func=_cmd_library_tiers)
|
|
910
|
+
for name, func, text in (
|
|
911
|
+
("pair-tiers", _cmd_pair_tiers, "print the tier of each integration pair as JSON"),
|
|
912
|
+
("metadata", _cmd_metadata, "print the computed metadata.json fields of the stack as JSON"),
|
|
913
|
+
):
|
|
914
|
+
command = sub.add_parser(name, help=text, description=text)
|
|
915
|
+
command.add_argument(
|
|
916
|
+
"--input",
|
|
917
|
+
required=True,
|
|
918
|
+
help="path to the stack JSON ({mode, libraries, integrations}), or '-' for stdin",
|
|
919
|
+
)
|
|
920
|
+
command.set_defaults(func=func)
|
|
921
|
+
text = ("write provenance-map.json from the run's files: its entries and integrations, "
|
|
922
|
+
"and its constituents in compose mode")
|
|
923
|
+
command = sub.add_parser("provenance", help=text, description=text)
|
|
924
|
+
command.add_argument("--records", required=True, help="path to the export records JSON ({entries: [...]})")
|
|
925
|
+
command.add_argument(
|
|
926
|
+
"--bundle", required=True,
|
|
927
|
+
help="path to the extraction bundle JSON, whose mode and integrations[] the map takes")
|
|
928
|
+
command.add_argument(
|
|
929
|
+
"--pairs", help="code mode: path to the skf-pair-intersect.py result, which holds each pair's co-import files")
|
|
930
|
+
command.add_argument(
|
|
931
|
+
"--inventory",
|
|
932
|
+
help="compose mode: path to step 4's skf-enumerate-stack-skills.py result, which holds each metadata_hash")
|
|
933
|
+
command.add_argument("--skills-root", help="compose mode: the skills output folder that holds each constituent")
|
|
934
|
+
command.add_argument("--project-root", help="compose mode: the project root each skill_path is relative to")
|
|
935
|
+
command.add_argument(
|
|
936
|
+
"--input",
|
|
937
|
+
required=True,
|
|
938
|
+
help="path to the map's other fields as JSON (an object), or '-' for stdin",
|
|
939
|
+
)
|
|
940
|
+
command.add_argument("--target", required=True, help="the provenance-map.json to write")
|
|
941
|
+
command.set_defaults(func=_cmd_provenance)
|
|
942
|
+
return parser
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def main(argv: list[str] | None = None) -> int:
|
|
946
|
+
args = _build_parser().parse_args(argv)
|
|
947
|
+
try:
|
|
948
|
+
result = args.func(args)
|
|
949
|
+
except (InputError, ImportError, WriteError) as exc:
|
|
950
|
+
print(f"error: {' '.join(str(exc).split())}", file=sys.stderr)
|
|
951
|
+
return 2
|
|
952
|
+
result, code = result if isinstance(result, tuple) else (result, 0)
|
|
953
|
+
# ASCII escapes: a Windows console's code page cannot encode every name.
|
|
954
|
+
sys.stdout.write(json.dumps(result, indent=2) + "\n")
|
|
955
|
+
return code
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _force_utf8(*streams) -> None:
|
|
959
|
+
"""Reconfigure the standard streams to UTF-8, keeping each one's error handler.
|
|
960
|
+
|
|
961
|
+
A Windows console pipes them as cp1252, which cannot carry every character
|
|
962
|
+
a library name or an error message may hold. stdin is reconfigured before
|
|
963
|
+
its first read, so `--input -` reads the UTF-8 JSON a step pipes.
|
|
964
|
+
"""
|
|
965
|
+
for stream in streams:
|
|
966
|
+
if hasattr(stream, "reconfigure"):
|
|
967
|
+
errors = getattr(stream, "errors", None)
|
|
968
|
+
if errors is None:
|
|
969
|
+
stream.reconfigure(encoding="utf-8")
|
|
970
|
+
else:
|
|
971
|
+
stream.reconfigure(encoding="utf-8", errors=errors)
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
if __name__ == "__main__":
|
|
975
|
+
_force_utf8(sys.stdin, sys.stdout, sys.stderr)
|
|
976
|
+
sys.exit(main())
|