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
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# /// script
|
|
3
|
-
# requires-python = ">=3.
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
4
|
# dependencies = []
|
|
5
5
|
# ///
|
|
6
|
-
"""Structural Diff
|
|
6
|
+
"""Structural Diff: deterministic comparison of skill export inventories.
|
|
7
7
|
|
|
8
8
|
Compares a baseline export inventory against a current export inventory and
|
|
9
9
|
produces a structured JSON diff showing added, removed, changed, and moved
|
|
10
|
-
entries. Used by audit-skill (structural-diff step
|
|
11
|
-
replace LLM-based, token-spending inventory comparison
|
|
12
|
-
drop or mis-match entries when diffing dozens/hundreds of exports
|
|
10
|
+
entries. Used by audit-skill (structural-diff step, references/structural-diff.md
|
|
11
|
+
section 1) to replace LLM-based, token-spending inventory comparison: the LLM
|
|
12
|
+
can silently drop or mis-match entries when diffing dozens/hundreds of exports
|
|
13
|
+
by hand.
|
|
13
14
|
|
|
14
15
|
CLI:
|
|
15
16
|
python3 skf-structural-diff.py baseline.json current.json
|
|
16
17
|
python3 skf-structural-diff.py baseline.json current.json -o diff-result.json
|
|
17
18
|
python3 skf-structural-diff.py provenance-map.json snapshot.json --reexport-map map.json
|
|
19
|
+
python3 skf-structural-diff.py provenance-map.json snapshot.json --group-by source_library
|
|
20
|
+
python3 skf-structural-diff.py provenance-map.json snapshot.json --files category-a.json
|
|
21
|
+
python3 skf-structural-diff.py provenance-map.json extraction.json --current-extra details.json
|
|
18
22
|
|
|
19
23
|
Input:
|
|
20
24
|
Two JSON files. Each file may be either:
|
|
@@ -31,9 +35,70 @@ Input:
|
|
|
31
35
|
- file <- file | source_file
|
|
32
36
|
- line <- line | source_line
|
|
33
37
|
- signature <- signature
|
|
38
|
+
- params <- params (a provenance map's parameter list)
|
|
39
|
+
- return_type <- return_type
|
|
34
40
|
- confidence <- confidence
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
- extraction_method <- extraction_method
|
|
42
|
+
|
|
43
|
+
Nameless entries are skipped. ast_node_type is ignored on purpose: it
|
|
44
|
+
describes the tool's match, not the export, and a relabel changes it along
|
|
45
|
+
with confidence.
|
|
46
|
+
|
|
47
|
+
Matching:
|
|
48
|
+
An export is identified by its name and its file together, so two exports
|
|
49
|
+
that share a name (a `GET` handler in each of two route files, two `parse`
|
|
50
|
+
functions) are never taken for one another, whatever order the inventories
|
|
51
|
+
list them in. The file is compared stripped, with backslashes read as
|
|
52
|
+
forward slashes and no leading `./`, so a path written on Windows or with
|
|
53
|
+
a `./` prefix names the same file as the plain one; every list emits the
|
|
54
|
+
file as the inventory wrote it. One side listing the same name twice in
|
|
55
|
+
one file (overload signatures, a definition in both branches of an `if`)
|
|
56
|
+
keeps the entry on the first line, as the AST Extraction Protocol
|
|
57
|
+
deduplicates.
|
|
58
|
+
|
|
59
|
+
An entry left on one side only is paired with one left on the other side
|
|
60
|
+
as a move when its name occurs exactly once in each inventory. When the
|
|
61
|
+
name occurs more than once on either side, the leftover entries cannot be
|
|
62
|
+
paired by name alone: they stay in removed[] and added[], and the name is
|
|
63
|
+
listed in ambiguous_names[] with the files on each side, for a reviewer
|
|
64
|
+
to judge.
|
|
65
|
+
|
|
66
|
+
Public surface:
|
|
67
|
+
A current entry may carry `public`, true or false: the mark
|
|
68
|
+
skf-extraction-snapshot.py build --scope-type public-api gives each
|
|
69
|
+
export by the public-surface rule a public-api skill's provenance map is
|
|
70
|
+
kept to. After the pairing above, a current entry marked false that
|
|
71
|
+
pairs with none (no baseline entry of its name and file, and no move)
|
|
72
|
+
goes to not_public[], not added[]: create-skill and update-skill never
|
|
73
|
+
document an export off the surface, so it is no drift. One whose name is
|
|
74
|
+
in ambiguous_names[] stays in added[], where a reviewer pairs it. A
|
|
75
|
+
matched or moved entry is compared whatever its mark, so a baseline
|
|
76
|
+
entry off the surface is never reported removed. An entry with no mark
|
|
77
|
+
(any other scope type, a family with no barrel, an older snapshot) is
|
|
78
|
+
added as before. not_public[] never counts toward the exit code.
|
|
79
|
+
|
|
80
|
+
File scope (--files FILE):
|
|
81
|
+
For a caller that re-extracted only the files that changed (update-skill's
|
|
82
|
+
Category B), --files limits the diff to the exports of the files FILE
|
|
83
|
+
names: a JSON array of paths, or the output of skf-classify-changed-files.py
|
|
84
|
+
classify, which names category_a's modified, added and deleted files and
|
|
85
|
+
both paths of each moved_files item. A path compares in the form an
|
|
86
|
+
export's file does (see Matching). An export of any other file is taken as
|
|
87
|
+
unchanged on both sides: it enters no list and no count, and its name
|
|
88
|
+
still counts when a move needs a name unique on both sides, so the scoped
|
|
89
|
+
diff pairs the moves a diff of the whole inventories would. The current
|
|
90
|
+
inventory must hold the exports of every named file the source still has,
|
|
91
|
+
or they read as removed.
|
|
92
|
+
|
|
93
|
+
Extra current entries (--current-extra FILE):
|
|
94
|
+
For a caller that adds to a recipe runner's output what the recipes do
|
|
95
|
+
not record (update-skill's Category B: each export's params and
|
|
96
|
+
return_type, read at its line, and the exports read by eye), FILE is an
|
|
97
|
+
inventory in any input format above, merged into the current one before
|
|
98
|
+
the diff: an entry with the name and file of a current entry fills each
|
|
99
|
+
field that entry lacks (null or absent), and any other entry is added as
|
|
100
|
+
an export of its own. Names and files compare as written, the file in the
|
|
101
|
+
form Matching describes.
|
|
37
102
|
|
|
38
103
|
Canonicalization (applied symmetrically to BOTH sides before matching):
|
|
39
104
|
The baseline extractor (skf-create-skill) and the re-extractor (audit step 2)
|
|
@@ -55,57 +120,231 @@ Canonicalization (applied symmetrically to BOTH sides before matching):
|
|
|
55
120
|
|
|
56
121
|
The reexport map is taken from --reexport-map when provided; otherwise it is
|
|
57
122
|
derived from the baseline provenance map itself (top-level `reexport_map`
|
|
58
|
-
plus any per-entry `reexported_as`)
|
|
123
|
+
plus any per-entry `reexported_as`), the same projection produced by
|
|
59
124
|
`skf-load-provenance.py normalize`.
|
|
60
125
|
|
|
126
|
+
params (each parameter) and return_type are canonicalized the same way as
|
|
127
|
+
a signature. A parameter the recipe runner records as {name, type,
|
|
128
|
+
default, optional} is first written in the provenance map's form
|
|
129
|
+
(skf-extraction-inventory.py typed_param: `name: type`, `name?: type`,
|
|
130
|
+
` = default`), so a runner's export compares with the map's entry.
|
|
131
|
+
|
|
61
132
|
Change detection:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
133
|
+
The diffed fields are type (compared through export-type, below),
|
|
134
|
+
signature, params, return_type and line. A field is only compared when
|
|
135
|
+
present (non-null) on BOTH sides: a field absent on one side means
|
|
136
|
+
"insufficient data", never an asserted change.
|
|
137
|
+
This avoids false positives when the two inventories carry different
|
|
138
|
+
metadata. The line of a moved export is not compared: a move changes it.
|
|
139
|
+
|
|
140
|
+
export-type: no record is rewritten, but when a matched export's two
|
|
141
|
+
types differ, a semantic kind a by-eye read records is the base kind
|
|
142
|
+
the recipe runner records for the same declaration when the base-kind
|
|
143
|
+
side's canonical signature shows that form: `async_function` is
|
|
144
|
+
`function` for an `async def`, `async function`, `async fn` or
|
|
145
|
+
`async unsafe fn`, or a `const`, `let` or `var` bound to an `async`
|
|
146
|
+
arrow or function (after `export`, `default`, `declare` or `pub`);
|
|
147
|
+
`decorator` is `function` for a Python `def` or `async def`; `enum` is
|
|
148
|
+
`class` for a Python class with a stdlib `Enum`, `IntEnum`, `StrEnum`,
|
|
149
|
+
`ReprEnum`, `Flag` or `IntFlag` base. Either side may hold the by-eye
|
|
150
|
+
kind; with no signature on the base-kind side the types differ. Each
|
|
151
|
+
export whose types it takes as one counts once as `export-type` in
|
|
152
|
+
applied_transforms.
|
|
153
|
+
|
|
154
|
+
A signature has two parts, the parameters and the return type. A side
|
|
155
|
+
carries the parameters as params or in its signature, and the return
|
|
156
|
+
type as return_type or in its signature. When both sides carry a part
|
|
157
|
+
but no field holds it on both (a provenance map's params and return_type
|
|
158
|
+
against a snapshot's signature text), that part is never compared, and
|
|
159
|
+
the export is listed in signature_unverified[]: a change there cannot be
|
|
160
|
+
seen, so a diff that finds nothing does not pass for a verified one.
|
|
161
|
+
|
|
162
|
+
Provenance labels:
|
|
163
|
+
confidence and extraction_method describe the tool that extracted an export,
|
|
164
|
+
not the source, so a difference in them is not drift. For an export matched
|
|
165
|
+
on both sides, a label that differs (compared case-insensitively after trimming,
|
|
166
|
+
and only when both sides have a value) is reported once per export in
|
|
167
|
+
label_changes[]. A blank label counts as no value and is emitted as null.
|
|
168
|
+
The stack spellings of extraction_method compare equal to the library ones
|
|
169
|
+
(ast_bridge = ast-grep, source_reading = source-read); emitted values keep
|
|
170
|
+
the spelling each side wrote. Label changes never enter changed[], never
|
|
171
|
+
make an export count as changed, and never affect the exit code.
|
|
65
172
|
|
|
66
173
|
Output:
|
|
67
174
|
JSON object:
|
|
68
|
-
summary: { added, removed, changed, moved, unchanged
|
|
175
|
+
summary: { added, removed, changed, moved, unchanged,
|
|
176
|
+
label_changes, ambiguous_names,
|
|
177
|
+
signature_unverified, not_public }
|
|
69
178
|
added: list of entries present in current but not baseline
|
|
70
179
|
removed: list of entries present in baseline but not current
|
|
71
|
-
changed: list of { name, field, baseline_value, current_value
|
|
72
|
-
|
|
180
|
+
changed: list of { name, field, baseline_value, current_value,
|
|
181
|
+
file, line, confidence } where field is type,
|
|
182
|
+
signature, params, return_type or line, and file,
|
|
183
|
+
line and confidence are the export's current ones
|
|
184
|
+
moved: list of { name, previous_file, current_file,
|
|
185
|
+
previous_line, line, confidence }
|
|
186
|
+
ambiguous_names: list of { name, removed: [{file, line}], added:
|
|
187
|
+
[{file, line}] }: a name left on both sides that
|
|
188
|
+
occurs more than once on a side, so its entries stay
|
|
189
|
+
in removed[] and added[] unpaired
|
|
190
|
+
label_changes: list of { name, file, baseline: {confidence,
|
|
191
|
+
extraction_method}, current: {confidence,
|
|
192
|
+
extraction_method} }; informational, not drift
|
|
193
|
+
signature_unverified: list of { name, file, baseline, current }: a
|
|
194
|
+
matched export with a signature part no field
|
|
195
|
+
holds on both sides (see Change detection);
|
|
196
|
+
baseline and current list which of signature,
|
|
197
|
+
params and return_type each side carries.
|
|
198
|
+
Informational: never a change, never in the exit
|
|
199
|
+
code
|
|
200
|
+
not_public: list of current entries marked `public: false`
|
|
201
|
+
that pair with no baseline entry and whose name is
|
|
202
|
+
not ambiguous (see Public surface): exports off a
|
|
203
|
+
public-api skill's public surface, left out of
|
|
204
|
+
added[]. Not drift, never in the exit code
|
|
73
205
|
unchanged_count: number of entries that matched with no field change
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
206
|
+
(a label change or an unverified signature alone
|
|
207
|
+
leaves an entry unchanged)
|
|
208
|
+
applied_transforms: list of { transform, count }: which canonicalization
|
|
209
|
+
transforms, and the export-type comparison,
|
|
210
|
+
actually fired and how many values each touched
|
|
211
|
+
(export-type: how many exports; empty when none
|
|
212
|
+
fired). Surfaced by audit
|
|
77
213
|
step 6's Provenance section so a reviewer can tell
|
|
78
214
|
which differences the diff collapsed.
|
|
79
215
|
|
|
216
|
+
added[], removed[] and not_public[] hold normalized records: name, type,
|
|
217
|
+
signature, params, return_type, file, line, confidence and
|
|
218
|
+
extraction_method, and `public` when the entry carries a true or false
|
|
219
|
+
mark (see Public surface).
|
|
220
|
+
|
|
221
|
+
With --group-by source_library, each side is split by its entries'
|
|
222
|
+
source_library (a stack's libraries; an entry without one falls in a
|
|
223
|
+
null group, diffed against the other side's) and each group is diffed on
|
|
224
|
+
its own, so an export never matches one of another library. The lists
|
|
225
|
+
above then hold every group's items, each tagged with its source_library,
|
|
226
|
+
the summary sums the groups, and two keys are added:
|
|
227
|
+
group_by: "source_library"
|
|
228
|
+
groups: list of { source_library, summary }, one per group
|
|
229
|
+
An inventory whose entries all lack a source_library is an error when
|
|
230
|
+
the other inventory's entries have one: its every export would land in
|
|
231
|
+
the null group and read as added or removed.
|
|
232
|
+
|
|
233
|
+
With --files, one key is added:
|
|
234
|
+
file_scope: { files, baseline_left_out, current_left_out }: the
|
|
235
|
+
paths FILE names, and the exports of each inventory
|
|
236
|
+
left out as outside them
|
|
237
|
+
|
|
238
|
+
With -o, the JSON is written to the file and stdout gets one line:
|
|
239
|
+
{ status: "ok", output: <file>, summary: {...} }.
|
|
240
|
+
|
|
80
241
|
Exit codes:
|
|
81
|
-
0
|
|
82
|
-
1
|
|
242
|
+
0 no export added, removed, changed or moved
|
|
243
|
+
1 differences found
|
|
244
|
+
2 error: an input that cannot be read or parsed, a --files file that is
|
|
245
|
+
neither a path list nor a classify output, --group-by over an
|
|
246
|
+
inventory whose entries all lack the field while the other's have it,
|
|
247
|
+
an output that cannot be written, or a usage error ({status: "error",
|
|
248
|
+
error} on stdout, or argparse's usage on stderr)
|
|
249
|
+
Label differences (label_changes), unverified signatures
|
|
250
|
+
(signature_unverified) and exports off the public surface (not_public) do
|
|
251
|
+
not count toward the exit code.
|
|
83
252
|
"""
|
|
84
253
|
|
|
85
254
|
from __future__ import annotations
|
|
86
255
|
|
|
87
256
|
import argparse
|
|
88
257
|
import collections
|
|
258
|
+
import importlib.util
|
|
89
259
|
import json
|
|
90
260
|
import re
|
|
91
261
|
import sys
|
|
92
262
|
from pathlib import Path
|
|
93
263
|
|
|
264
|
+
_INVENTORY = None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _typed_params(params: object, language: object) -> object:
|
|
268
|
+
"""A recipe runner's {name, type, default, optional} parameters in the provenance map's typed form, through
|
|
269
|
+
skf-extraction-inventory.py's typed_param (loaded once from this folder); any other value as it is."""
|
|
270
|
+
global _INVENTORY
|
|
271
|
+
if not isinstance(params, list) or not any(isinstance(p, dict) for p in params):
|
|
272
|
+
return params
|
|
273
|
+
if _INVENTORY is None:
|
|
274
|
+
path = Path(__file__).resolve().parent / "skf-extraction-inventory.py"
|
|
275
|
+
spec = importlib.util.spec_from_file_location("skf_extraction_inventory", path)
|
|
276
|
+
if spec is None or spec.loader is None:
|
|
277
|
+
raise ImportError(f"cannot load {path}")
|
|
278
|
+
_INVENTORY = importlib.util.module_from_spec(spec)
|
|
279
|
+
spec.loader.exec_module(_INVENTORY)
|
|
280
|
+
lang = language if isinstance(language, str) else None
|
|
281
|
+
return [s for s in (_INVENTORY.typed_param(p, lang) for p in params) if s is not None]
|
|
282
|
+
|
|
94
283
|
|
|
95
284
|
# Fields compared for change detection (in order).
|
|
96
|
-
# "name"
|
|
97
|
-
#
|
|
98
|
-
|
|
285
|
+
# "name" and "file" identify the export and are not diffed as fields; a file
|
|
286
|
+
# change is tracked separately in the "moved" list.
|
|
287
|
+
# Provenance labels are excluded too: they say which tool extracted the export,
|
|
288
|
+
# so a relabel is reported in label_changes[] and is never drift.
|
|
289
|
+
# params and return_type are the provenance map's form of a signature.
|
|
290
|
+
DIFF_FIELDS = ["type", "signature", "params", "return_type", "line"]
|
|
291
|
+
|
|
292
|
+
# The fields that hold a signature, and for each of its two parts (the
|
|
293
|
+
# parameters, the return type) the fields that hold that part: a signature
|
|
294
|
+
# string holds both.
|
|
295
|
+
SIGNATURE_FIELDS = ("signature", "params", "return_type")
|
|
296
|
+
SIGNATURE_PARTS = (("params", "signature"), ("return_type", "signature"))
|
|
297
|
+
|
|
298
|
+
# The one --group-by field: a stack's per-library split.
|
|
299
|
+
GROUP_FIELDS = ["source_library"]
|
|
300
|
+
|
|
301
|
+
# Provenance labels compared for the informational label_changes[] list.
|
|
302
|
+
LABEL_FIELDS = ["confidence", "extraction_method"]
|
|
303
|
+
|
|
304
|
+
# Stack provenance maps spell extraction_method differently from library maps
|
|
305
|
+
# and the audit re-index; compare them as the same tool.
|
|
306
|
+
_METHOD_ALIASES = {"ast_bridge": "ast-grep", "source_reading": "source-read"}
|
|
99
307
|
|
|
100
308
|
# Well-known stdlib module prefixes whose unqualified form is importable at
|
|
101
309
|
# the call site. Longer prefixes must precede their own containing prefix
|
|
102
310
|
# (collections.abc before collections) so the alternation strips the longest.
|
|
103
311
|
# The negative lookbehind `(?<![\w.])` ensures we only strip a top-level module
|
|
104
|
-
# reference
|
|
312
|
+
# reference, never a user-defined namespace such as `pkg.typing.Foo`.
|
|
105
313
|
_STDLIB_PREFIX_RE = re.compile(
|
|
106
314
|
r"(?<![\w.])(?:typing|dataclasses|collections\.abc|collections|enum)\."
|
|
107
315
|
)
|
|
108
316
|
|
|
317
|
+
# export-type: a semantic kind a by-eye read records, against the base kind
|
|
318
|
+
# the recipe runner records for the same declaration, names one kind when
|
|
319
|
+
# the base-kind side's canonical signature shows that form. Any other pair
|
|
320
|
+
# of types, or a base-kind side with no signature, is compared as written.
|
|
321
|
+
# An async form: `async def`, `async function`, `async fn` or `async unsafe
|
|
322
|
+
# fn`, or a `const` / `let` / `var` bound to an `async` arrow or function
|
|
323
|
+
# (`export const add = async (x) => x;`), after any `export`, `default`,
|
|
324
|
+
# `declare` or `pub` modifier.
|
|
325
|
+
_ASYNC_FORM_RE = re.compile(
|
|
326
|
+
r"^\s*(?:(?:export|default|declare|pub(?:\s*\([^()]*\))?)\s+)*"
|
|
327
|
+
r"(?:async\s+(?:unsafe\s+)?(?:def|function|fn)\b"
|
|
328
|
+
r"|(?:const|let|var)\s+[\w$]+\s*(?::(?:[^=]|=>)*)?=\s*async\b)"
|
|
329
|
+
)
|
|
330
|
+
_DEF_FORM_RE = re.compile(r"^\s*(?:async\s+)?def\s")
|
|
331
|
+
_CLASS_BASES_RE = re.compile(r"^\s*class\s+\w+\s*\(([^()]*)\)")
|
|
332
|
+
_ENUM_BASES = frozenset({"Enum", "IntEnum", "StrEnum", "ReprEnum", "Flag", "IntFlag"})
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _enum_class_form(signature: str) -> bool:
|
|
336
|
+
"""True when a Python class declaration lists a stdlib enum base."""
|
|
337
|
+
m = _CLASS_BASES_RE.match(signature)
|
|
338
|
+
return m is not None and any(base.strip() in _ENUM_BASES for base in m.group(1).split(","))
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# (by-eye kind, base kind) -> the test of the base-kind side's signature.
|
|
342
|
+
EXPORT_TYPE_FORMS = {
|
|
343
|
+
("async_function", "function"): lambda signature: _ASYNC_FORM_RE.match(signature) is not None,
|
|
344
|
+
("decorator", "function"): lambda signature: _DEF_FORM_RE.match(signature) is not None,
|
|
345
|
+
("enum", "class"): _enum_class_form,
|
|
346
|
+
}
|
|
347
|
+
|
|
109
348
|
|
|
110
349
|
# --------------------------------------------------------------------------
|
|
111
350
|
# Loading
|
|
@@ -165,6 +404,83 @@ def load_inventory(path: Path) -> tuple[list[dict], str | None]:
|
|
|
165
404
|
return entries_from_data(data, str(path))
|
|
166
405
|
|
|
167
406
|
|
|
407
|
+
# The keys one field may be written under (see Input): an entry lacks the
|
|
408
|
+
# field only when it has none of them.
|
|
409
|
+
_ALIAS_GROUPS = {
|
|
410
|
+
key: group
|
|
411
|
+
for group in (("name", "export_name"), ("type", "export_type"), ("file", "source_file"),
|
|
412
|
+
("line", "source_line"))
|
|
413
|
+
for key in group
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def merge_extra(entries: list[dict], extra: list[dict]) -> list[dict]:
|
|
418
|
+
"""The current entries with --current-extra's merged in by name and file:
|
|
419
|
+
an extra entry fills each field the entry of its name and file lacks,
|
|
420
|
+
and one with no such entry is added.
|
|
421
|
+
|
|
422
|
+
>>> merge_extra([{"export_name": "f", "source_file": "a.py", "source_line": 3}],
|
|
423
|
+
... [{"export_name": "f", "source_file": "./a.py", "params": ["x: int"]},
|
|
424
|
+
... {"name": "g", "file": "a.py", "line": 9}])
|
|
425
|
+
[{'export_name': 'f', 'source_file': 'a.py', 'source_line': 3, 'params': ['x: int']}, {'name': 'g', 'file': 'a.py', 'line': 9}]
|
|
426
|
+
"""
|
|
427
|
+
merged = [dict(entry) if isinstance(entry, dict) else entry for entry in entries]
|
|
428
|
+
by_key: dict[tuple, dict] = {}
|
|
429
|
+
for entry in merged:
|
|
430
|
+
if isinstance(entry, dict):
|
|
431
|
+
name = _first(entry, "name", "export_name")
|
|
432
|
+
if isinstance(name, str):
|
|
433
|
+
by_key.setdefault((name.strip(), _file_key(_first(entry, "file", "source_file"))), entry)
|
|
434
|
+
for item in extra:
|
|
435
|
+
if not isinstance(item, dict):
|
|
436
|
+
continue
|
|
437
|
+
name = _first(item, "name", "export_name")
|
|
438
|
+
target = by_key.get((name.strip(), _file_key(_first(item, "file", "source_file")))) \
|
|
439
|
+
if isinstance(name, str) else None
|
|
440
|
+
if target is None:
|
|
441
|
+
merged.append(dict(item))
|
|
442
|
+
continue
|
|
443
|
+
for field, value in item.items():
|
|
444
|
+
if value is not None and all(target.get(key) is None for key in _ALIAS_GROUPS.get(field, (field,))):
|
|
445
|
+
target[field] = value
|
|
446
|
+
return merged
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def files_from_data(data: object, source: str = "") -> tuple[list[str], str | None]:
|
|
450
|
+
"""The paths of a --files file, already parsed.
|
|
451
|
+
|
|
452
|
+
Accepts:
|
|
453
|
+
- [...] (a JSON array of paths)
|
|
454
|
+
- {"category_a": {...}} (skf-classify-changed-files.py classify output:
|
|
455
|
+
category_a's modified, added and deleted
|
|
456
|
+
paths, and both paths of each moved_files item)
|
|
457
|
+
|
|
458
|
+
Returns (paths, error_message).
|
|
459
|
+
"""
|
|
460
|
+
where = f" in '{source}'" if source else ""
|
|
461
|
+
if isinstance(data, list):
|
|
462
|
+
if all(isinstance(path, str) for path in data):
|
|
463
|
+
return data, None
|
|
464
|
+
return [], f"--files list{where} must hold only path strings"
|
|
465
|
+
if not isinstance(data, dict) or not isinstance(data.get("category_a"), dict):
|
|
466
|
+
return [], (
|
|
467
|
+
f"Unrecognised --files format{where}: expected an array of paths or "
|
|
468
|
+
f"the output of skf-classify-changed-files.py classify"
|
|
469
|
+
)
|
|
470
|
+
paths: list[str] = []
|
|
471
|
+
for key in ("modified", "added", "deleted"):
|
|
472
|
+
listed = data["category_a"].get(key, [])
|
|
473
|
+
if not isinstance(listed, list) or not all(isinstance(path, str) for path in listed):
|
|
474
|
+
return [], f"--files category_a.{key}{where} must be a list of paths"
|
|
475
|
+
paths.extend(listed)
|
|
476
|
+
moves = data.get("moved_files", [])
|
|
477
|
+
if not isinstance(moves, list) or not all(isinstance(move, dict) for move in moves):
|
|
478
|
+
return [], f"--files moved_files{where} must be a list of {{old_path, new_path}} objects"
|
|
479
|
+
for move in moves:
|
|
480
|
+
paths.extend(move[key] for key in ("old_path", "new_path") if isinstance(move.get(key), str))
|
|
481
|
+
return paths, None
|
|
482
|
+
|
|
483
|
+
|
|
168
484
|
# --------------------------------------------------------------------------
|
|
169
485
|
# Re-export map
|
|
170
486
|
# --------------------------------------------------------------------------
|
|
@@ -248,19 +564,47 @@ def canon_signature(sig: object) -> tuple[object, set[str]]:
|
|
|
248
564
|
return out, applied
|
|
249
565
|
|
|
250
566
|
|
|
567
|
+
def _canon_value(value: object, transform_counts: collections.Counter) -> object:
|
|
568
|
+
"""Canonicalize a signature, a return type or each item of a parameter list."""
|
|
569
|
+
if isinstance(value, list):
|
|
570
|
+
return [_canon_value(item, transform_counts) for item in value]
|
|
571
|
+
canon, fired = canon_signature(value)
|
|
572
|
+
for t in fired:
|
|
573
|
+
transform_counts[t] += 1
|
|
574
|
+
return canon
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _is_line(value: object) -> bool:
|
|
578
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _file_key(file: object) -> str | None:
|
|
582
|
+
"""The file half of an export's key: stripped, forward slashes, no
|
|
583
|
+
leading `./` (normalize_rel_path of skf-resolve-authoritative-files.py),
|
|
584
|
+
or None when the entry names no file."""
|
|
585
|
+
if not isinstance(file, str):
|
|
586
|
+
return None
|
|
587
|
+
path = file.strip().replace("\\", "/")
|
|
588
|
+
while path.startswith("./"):
|
|
589
|
+
path = path[2:]
|
|
590
|
+
return path or None
|
|
591
|
+
|
|
592
|
+
|
|
251
593
|
def _normalize_entries(
|
|
252
594
|
entries: list[dict],
|
|
253
595
|
reexport_map: dict[str, str],
|
|
254
596
|
transform_counts: collections.Counter,
|
|
255
|
-
) -> dict[
|
|
256
|
-
"""Build a
|
|
597
|
+
) -> dict[tuple, dict]:
|
|
598
|
+
"""Build a dict of canonicalized records keyed by (name, file).
|
|
257
599
|
|
|
258
600
|
Applies field-name aliasing, signature canonicalization, and re-export
|
|
259
601
|
name resolution. Accumulates fired-transform counts into transform_counts.
|
|
260
|
-
Nameless entries are skipped.
|
|
261
|
-
|
|
602
|
+
Nameless entries are skipped. The key holds the file in _file_key's form;
|
|
603
|
+
the record keeps it as written. When one (name, file) repeats, the entry
|
|
604
|
+
on the first line wins, whatever order the inventory lists them in (an
|
|
605
|
+
entry with no line only wins when no other has one).
|
|
262
606
|
"""
|
|
263
|
-
result: dict[
|
|
607
|
+
result: dict[tuple, dict] = {}
|
|
264
608
|
for entry in entries:
|
|
265
609
|
if not isinstance(entry, dict):
|
|
266
610
|
continue
|
|
@@ -273,78 +617,224 @@ def _normalize_entries(
|
|
|
273
617
|
transform_counts["reexport-resolution"] += 1
|
|
274
618
|
name = resolved
|
|
275
619
|
|
|
276
|
-
|
|
277
|
-
for t in sig_transforms:
|
|
278
|
-
transform_counts[t] += 1
|
|
279
|
-
|
|
280
|
-
# Normalized record — also the public entry shape emitted in
|
|
620
|
+
# Normalized record: also the public entry shape emitted in
|
|
281
621
|
# added[]/removed[], so both sides render into one consistent table
|
|
282
622
|
# regardless of the input shape they came from.
|
|
283
|
-
|
|
623
|
+
record = {
|
|
284
624
|
"name": name,
|
|
285
625
|
"type": _first(entry, "type", "export_type"),
|
|
286
|
-
"signature":
|
|
626
|
+
"signature": _canon_value(entry.get("signature"), transform_counts),
|
|
627
|
+
"params": _canon_value(_typed_params(entry.get("params"), entry.get("language")), transform_counts),
|
|
628
|
+
"return_type": _canon_value(entry.get("return_type"), transform_counts),
|
|
287
629
|
"file": _first(entry, "file", "source_file"),
|
|
288
630
|
"line": _first(entry, "line", "source_line"),
|
|
289
631
|
"confidence": entry.get("confidence"),
|
|
632
|
+
"extraction_method": entry.get("extraction_method"),
|
|
290
633
|
}
|
|
634
|
+
# The snapshot's public-surface mark, kept only when it is one, so an
|
|
635
|
+
# unmarked inventory's records are as before.
|
|
636
|
+
if isinstance(entry.get("public"), bool):
|
|
637
|
+
record["public"] = entry["public"]
|
|
638
|
+
key = (name, _file_key(record["file"]))
|
|
639
|
+
kept = result.get(key)
|
|
640
|
+
if kept is not None and not (
|
|
641
|
+
_is_line(record["line"]) and (not _is_line(kept["line"]) or record["line"] < kept["line"])
|
|
642
|
+
):
|
|
643
|
+
continue
|
|
644
|
+
result[key] = record
|
|
291
645
|
return result
|
|
292
646
|
|
|
293
647
|
|
|
648
|
+
def _label_key(field: str, value: object) -> object:
|
|
649
|
+
"""Comparison key for a provenance label, or None when the side has no value.
|
|
650
|
+
|
|
651
|
+
Strings compare case-insensitively after trimming; a blank string counts
|
|
652
|
+
as no value. Stack extraction_method spellings map to the library ones.
|
|
653
|
+
"""
|
|
654
|
+
if value is None:
|
|
655
|
+
return None
|
|
656
|
+
if isinstance(value, str):
|
|
657
|
+
value = value.strip().casefold()
|
|
658
|
+
if not value:
|
|
659
|
+
return None
|
|
660
|
+
if field == "extraction_method":
|
|
661
|
+
value = _METHOD_ALIASES.get(value, value)
|
|
662
|
+
return value
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _label_side(rec: dict) -> dict:
|
|
666
|
+
"""One side's labels as emitted: the value as written, or null when blank."""
|
|
667
|
+
return {
|
|
668
|
+
k: (rec.get(k) if _label_key(k, rec.get(k)) is not None else None)
|
|
669
|
+
for k in LABEL_FIELDS
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _label_change(base_rec: dict, curr_rec: dict) -> dict | None:
|
|
674
|
+
"""The label_changes[] item for one matched export, or None when labels agree.
|
|
675
|
+
|
|
676
|
+
A label is compared only when both sides have a value for it.
|
|
677
|
+
"""
|
|
678
|
+
for field in LABEL_FIELDS:
|
|
679
|
+
base_key = _label_key(field, base_rec.get(field))
|
|
680
|
+
curr_key = _label_key(field, curr_rec.get(field))
|
|
681
|
+
if base_key is None or curr_key is None:
|
|
682
|
+
continue
|
|
683
|
+
if base_key != curr_key:
|
|
684
|
+
return {
|
|
685
|
+
"name": curr_rec["name"],
|
|
686
|
+
"file": curr_rec["file"],
|
|
687
|
+
"baseline": _label_side(base_rec),
|
|
688
|
+
"current": _label_side(curr_rec),
|
|
689
|
+
}
|
|
690
|
+
return None
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _export_type_collapses(base_rec: dict, curr_rec: dict) -> bool:
|
|
694
|
+
"""True when two matched records' types name one kind (EXPORT_TYPE_FORMS):
|
|
695
|
+
one side's by-eye kind against the other's base kind, whose canonical
|
|
696
|
+
signature shows that form. Either side may hold the by-eye kind."""
|
|
697
|
+
for by_eye, base in ((base_rec, curr_rec), (curr_rec, base_rec)):
|
|
698
|
+
kinds = (by_eye.get("type"), base.get("type"))
|
|
699
|
+
signature = base.get("signature")
|
|
700
|
+
if not all(isinstance(kind, str) for kind in kinds) or not isinstance(signature, str):
|
|
701
|
+
continue
|
|
702
|
+
form = EXPORT_TYPE_FORMS.get(kinds)
|
|
703
|
+
if form is not None and form(signature):
|
|
704
|
+
return True
|
|
705
|
+
return False
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _signature_fields(rec: dict) -> list[str]:
|
|
709
|
+
return [field for field in SIGNATURE_FIELDS if rec.get(field) is not None]
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _unverified_signature(base_rec: dict, curr_rec: dict) -> dict | None:
|
|
713
|
+
"""The signature_unverified[] item for one matched export, or None when
|
|
714
|
+
every signature part both sides carry sits in a field both sides have."""
|
|
715
|
+
for fields in SIGNATURE_PARTS:
|
|
716
|
+
base_has = {field for field in fields if base_rec.get(field) is not None}
|
|
717
|
+
curr_has = {field for field in fields if curr_rec.get(field) is not None}
|
|
718
|
+
if base_has and curr_has and not (base_has & curr_has):
|
|
719
|
+
return {
|
|
720
|
+
"name": curr_rec["name"],
|
|
721
|
+
"file": curr_rec["file"],
|
|
722
|
+
"baseline": _signature_fields(base_rec),
|
|
723
|
+
"current": _signature_fields(curr_rec),
|
|
724
|
+
}
|
|
725
|
+
return None
|
|
726
|
+
|
|
727
|
+
|
|
294
728
|
# --------------------------------------------------------------------------
|
|
295
729
|
# Diff
|
|
296
730
|
# --------------------------------------------------------------------------
|
|
297
731
|
|
|
298
732
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
current_entries: list[dict],
|
|
302
|
-
reexport_map: dict[str, str] | None = None,
|
|
303
|
-
) -> dict:
|
|
304
|
-
"""Compute the structural diff between two export inventories.
|
|
733
|
+
LISTS = ["added", "removed", "changed", "moved", "ambiguous_names", "label_changes", "signature_unverified",
|
|
734
|
+
"not_public"]
|
|
305
735
|
|
|
306
|
-
Returns a dict with keys: summary, added, removed, changed, moved,
|
|
307
|
-
unchanged_count, applied_transforms.
|
|
308
|
-
"""
|
|
309
|
-
reexport_map = reexport_map or {}
|
|
310
|
-
transform_counts: collections.Counter = collections.Counter()
|
|
311
736
|
|
|
312
|
-
|
|
313
|
-
|
|
737
|
+
def _key_order(key: tuple) -> tuple:
|
|
738
|
+
return (key[0], key[1] or "")
|
|
739
|
+
|
|
314
740
|
|
|
315
|
-
|
|
316
|
-
|
|
741
|
+
def _file_line(rec: dict) -> dict:
|
|
742
|
+
return {"file": rec["file"], "line": rec["line"]}
|
|
317
743
|
|
|
318
|
-
added_names = current_names - baseline_names
|
|
319
|
-
removed_names = baseline_names - current_names
|
|
320
|
-
common_names = baseline_names & current_names
|
|
321
744
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
745
|
+
def _diff_records(
|
|
746
|
+
baseline: dict[tuple, dict],
|
|
747
|
+
current: dict[tuple, dict],
|
|
748
|
+
unchanged_names: collections.Counter | None = None,
|
|
749
|
+
transform_counts: collections.Counter | None = None,
|
|
750
|
+
) -> dict:
|
|
751
|
+
"""Diff two (name, file)-keyed record sets: the LISTS, plus the counts
|
|
752
|
+
unchanged_count and changed_exports (exports with a changed field).
|
|
753
|
+
|
|
754
|
+
unchanged_names counts the names of exports left out of both sets as
|
|
755
|
+
unchanged (--files): each counts on both sides when a move needs a name
|
|
756
|
+
unique on both sides. transform_counts gains one export-type for each
|
|
757
|
+
matched export whose types name one kind (_export_type_collapses).
|
|
758
|
+
"""
|
|
759
|
+
if transform_counts is None:
|
|
760
|
+
transform_counts = collections.Counter()
|
|
761
|
+
removed_keys = sorted((k for k in baseline if k not in current), key=_key_order)
|
|
762
|
+
added_keys = sorted((k for k in current if k not in baseline), key=_key_order)
|
|
763
|
+
|
|
764
|
+
# A leftover is paired as a move only when its name occurs once on each
|
|
765
|
+
# side; any other name left on both sides is ambiguous.
|
|
766
|
+
base_names = collections.Counter(name for name, _ in baseline)
|
|
767
|
+
curr_names = collections.Counter(name for name, _ in current)
|
|
768
|
+
if unchanged_names:
|
|
769
|
+
base_names.update(unchanged_names)
|
|
770
|
+
curr_names.update(unchanged_names)
|
|
771
|
+
removed_by_name: dict[str, list[tuple]] = collections.defaultdict(list)
|
|
772
|
+
added_by_name: dict[str, list[tuple]] = collections.defaultdict(list)
|
|
773
|
+
for key in removed_keys:
|
|
774
|
+
removed_by_name[key[0]].append(key)
|
|
775
|
+
for key in added_keys:
|
|
776
|
+
added_by_name[key[0]].append(key)
|
|
777
|
+
|
|
778
|
+
pairs: list[tuple[tuple, tuple]] = [(key, key) for key in baseline if key in current]
|
|
779
|
+
ambiguous_names: list[dict] = []
|
|
780
|
+
for name in sorted(set(removed_by_name) & set(added_by_name)):
|
|
781
|
+
if base_names[name] == 1 and curr_names[name] == 1:
|
|
782
|
+
pairs.append((removed_by_name[name][0], added_by_name[name][0]))
|
|
783
|
+
continue
|
|
784
|
+
ambiguous_names.append({
|
|
785
|
+
"name": name,
|
|
786
|
+
"removed": [_file_line(baseline[k]) for k in removed_by_name[name]],
|
|
787
|
+
"added": [_file_line(current[k]) for k in added_by_name[name]],
|
|
788
|
+
})
|
|
789
|
+
paired_base = {base_key for base_key, _ in pairs}
|
|
790
|
+
paired_curr = {curr_key for _, curr_key in pairs}
|
|
791
|
+
|
|
792
|
+
# Emit normalized entries (uniform shape) so added[] and removed[] render
|
|
793
|
+
# into the same report table even though they originate from the
|
|
794
|
+
# snapshot and provenance-map shapes. After the pairing, a leftover
|
|
795
|
+
# current entry marked off the public surface is no addition, unless its
|
|
796
|
+
# name is ambiguous: a reviewer may pair it as a move.
|
|
797
|
+
ambiguous = {item["name"] for item in ambiguous_names}
|
|
798
|
+
added: list[dict] = []
|
|
799
|
+
not_public: list[dict] = []
|
|
800
|
+
for key in added_keys:
|
|
801
|
+
if key in paired_curr:
|
|
802
|
+
continue
|
|
803
|
+
rec = current[key]
|
|
804
|
+
if rec.get("public") is False and rec["name"] not in ambiguous:
|
|
805
|
+
not_public.append(rec)
|
|
806
|
+
else:
|
|
807
|
+
added.append(rec)
|
|
808
|
+
removed = [baseline[k] for k in removed_keys if k not in paired_base]
|
|
327
809
|
|
|
328
810
|
changed: list[dict] = []
|
|
329
811
|
moved: list[dict] = []
|
|
812
|
+
label_changes: list[dict] = []
|
|
813
|
+
signature_unverified: list[dict] = []
|
|
330
814
|
unchanged_count = 0
|
|
815
|
+
changed_exports = 0
|
|
331
816
|
|
|
332
|
-
for
|
|
333
|
-
base_rec = baseline[
|
|
334
|
-
curr_rec = current[
|
|
817
|
+
for base_key, curr_key in sorted(pairs, key=lambda pair: _key_order(pair[1])):
|
|
818
|
+
base_rec = baseline[base_key]
|
|
819
|
+
curr_rec = current[curr_key]
|
|
335
820
|
|
|
336
|
-
# File moves are tracked separately from field changes
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
if
|
|
821
|
+
# File moves are tracked separately from field changes, and a moved
|
|
822
|
+
# export's line is not compared: the move changes it.
|
|
823
|
+
is_move = base_key[1] is not None and curr_key[1] is not None and base_key[1] != curr_key[1]
|
|
824
|
+
if is_move:
|
|
340
825
|
moved.append({
|
|
341
|
-
"name": name,
|
|
342
|
-
"previous_file":
|
|
343
|
-
"current_file":
|
|
826
|
+
"name": curr_rec["name"],
|
|
827
|
+
"previous_file": base_rec["file"],
|
|
828
|
+
"current_file": curr_rec["file"],
|
|
829
|
+
"previous_line": base_rec["line"],
|
|
830
|
+
"line": curr_rec["line"],
|
|
831
|
+
"confidence": curr_rec["confidence"],
|
|
344
832
|
})
|
|
345
833
|
|
|
346
834
|
entry_changed = False
|
|
347
835
|
for field in DIFF_FIELDS:
|
|
836
|
+
if is_move and field == "line":
|
|
837
|
+
continue
|
|
348
838
|
base_val = base_rec.get(field)
|
|
349
839
|
curr_val = curr_rec.get(field)
|
|
350
840
|
# Compare only when data is present on both sides; a missing value
|
|
@@ -352,69 +842,203 @@ def diff_inventories(
|
|
|
352
842
|
if base_val is None or curr_val is None:
|
|
353
843
|
continue
|
|
354
844
|
if base_val != curr_val:
|
|
845
|
+
# A by-eye kind and the runner's base kind of one declaration
|
|
846
|
+
# are one type: compared pairwise, never rewritten.
|
|
847
|
+
if field == "type" and _export_type_collapses(base_rec, curr_rec):
|
|
848
|
+
transform_counts["export-type"] += 1
|
|
849
|
+
continue
|
|
355
850
|
changed.append({
|
|
356
|
-
"name": name,
|
|
851
|
+
"name": curr_rec["name"],
|
|
357
852
|
"field": field,
|
|
358
853
|
"baseline_value": base_val,
|
|
359
854
|
"current_value": curr_val,
|
|
855
|
+
"file": curr_rec["file"],
|
|
856
|
+
"line": curr_rec["line"],
|
|
857
|
+
"confidence": curr_rec["confidence"],
|
|
360
858
|
})
|
|
361
859
|
entry_changed = True
|
|
362
860
|
|
|
363
|
-
if
|
|
861
|
+
if entry_changed:
|
|
862
|
+
changed_exports += 1
|
|
863
|
+
else:
|
|
364
864
|
unchanged_count += 1
|
|
365
865
|
|
|
366
|
-
|
|
866
|
+
# Provenance labels are informational: reported, never counted as drift.
|
|
867
|
+
label_change = _label_change(base_rec, curr_rec)
|
|
868
|
+
if label_change is not None:
|
|
869
|
+
label_changes.append(label_change)
|
|
367
870
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if
|
|
372
|
-
|
|
871
|
+
# A signature part the two sides hold in different fields was never
|
|
872
|
+
# compared: reported, so an empty diff does not read as verified.
|
|
873
|
+
unverified = _unverified_signature(base_rec, curr_rec)
|
|
874
|
+
if unverified is not None:
|
|
875
|
+
signature_unverified.append(unverified)
|
|
373
876
|
|
|
374
877
|
return {
|
|
375
|
-
"summary": {
|
|
376
|
-
"added": len(added),
|
|
377
|
-
"removed": len(removed),
|
|
378
|
-
"changed": changed_names,
|
|
379
|
-
"moved": len(moved),
|
|
380
|
-
"unchanged": unchanged_count,
|
|
381
|
-
},
|
|
382
878
|
"added": added,
|
|
383
879
|
"removed": removed,
|
|
384
880
|
"changed": changed,
|
|
385
881
|
"moved": moved,
|
|
882
|
+
"ambiguous_names": ambiguous_names,
|
|
883
|
+
"label_changes": label_changes,
|
|
884
|
+
"signature_unverified": signature_unverified,
|
|
885
|
+
"not_public": not_public,
|
|
386
886
|
"unchanged_count": unchanged_count,
|
|
387
|
-
"
|
|
887
|
+
"changed_exports": changed_exports,
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _summary(part: dict) -> dict:
|
|
892
|
+
return {
|
|
893
|
+
"added": len(part["added"]),
|
|
894
|
+
"removed": len(part["removed"]),
|
|
895
|
+
"changed": part["changed_exports"],
|
|
896
|
+
"moved": len(part["moved"]),
|
|
897
|
+
"unchanged": part["unchanged_count"],
|
|
898
|
+
"label_changes": len(part["label_changes"]),
|
|
899
|
+
"ambiguous_names": len(part["ambiguous_names"]),
|
|
900
|
+
"signature_unverified": len(part["signature_unverified"]),
|
|
901
|
+
"not_public": len(part["not_public"]),
|
|
388
902
|
}
|
|
389
903
|
|
|
390
904
|
|
|
905
|
+
def _group_of(entry: object, group_by: str) -> str | None:
|
|
906
|
+
value = entry.get(group_by) if isinstance(entry, dict) else None
|
|
907
|
+
return value.strip() if isinstance(value, str) and value.strip() else None
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _grouped(entries: list, group_by: str) -> bool:
|
|
911
|
+
return any(_group_of(entry, group_by) is not None for entry in entries)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _in_scope(entry: object, scope: set[str]) -> bool:
|
|
915
|
+
return isinstance(entry, dict) and _file_key(_first(entry, "file", "source_file")) in scope
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def diff_inventories(
|
|
919
|
+
baseline_entries: list[dict],
|
|
920
|
+
current_entries: list[dict],
|
|
921
|
+
reexport_map: dict[str, str] | None = None,
|
|
922
|
+
group_by: str | None = None,
|
|
923
|
+
sources: tuple[str, str] = ("the baseline inventory", "the current inventory"),
|
|
924
|
+
files: list[str] | None = None,
|
|
925
|
+
) -> dict:
|
|
926
|
+
"""Compute the structural diff between two export inventories.
|
|
927
|
+
|
|
928
|
+
Returns a dict with keys: summary, added, removed, changed, moved,
|
|
929
|
+
ambiguous_names, label_changes, signature_unverified, not_public,
|
|
930
|
+
unchanged_count, applied_transforms; with group_by, group_by and
|
|
931
|
+
groups; and with files, file_scope (see the module docstring). files
|
|
932
|
+
limits the diff to the exports of those files, and every other export
|
|
933
|
+
is taken as unchanged.
|
|
934
|
+
|
|
935
|
+
Raises ValueError when group_by is set and one side has entries but
|
|
936
|
+
none with a group_by value while the other side has some; the message
|
|
937
|
+
names that side as sources does (the CLI passes the two file paths).
|
|
938
|
+
"""
|
|
939
|
+
if group_by:
|
|
940
|
+
sides = (
|
|
941
|
+
(baseline_entries, current_entries, sources[0]),
|
|
942
|
+
(current_entries, baseline_entries, sources[1]),
|
|
943
|
+
)
|
|
944
|
+
for entries, other, source in sides:
|
|
945
|
+
if entries and not _grouped(entries, group_by) and _grouped(other, group_by):
|
|
946
|
+
raise ValueError(f"--group-by {group_by}: {source} has no entry with {group_by}")
|
|
947
|
+
|
|
948
|
+
reexport_map = reexport_map or {}
|
|
949
|
+
transform_counts: collections.Counter = collections.Counter()
|
|
950
|
+
scope = None if files is None else {key for key in map(_file_key, files) if key}
|
|
951
|
+
|
|
952
|
+
base_groups: dict[str | None, list] = collections.defaultdict(list)
|
|
953
|
+
curr_groups: dict[str | None, list] = collections.defaultdict(list)
|
|
954
|
+
for entry in baseline_entries:
|
|
955
|
+
base_groups[_group_of(entry, group_by) if group_by else None].append(entry)
|
|
956
|
+
for entry in current_entries:
|
|
957
|
+
curr_groups[_group_of(entry, group_by) if group_by else None].append(entry)
|
|
958
|
+
values = sorted(set(base_groups) | set(curr_groups), key=lambda v: (v is None, v or ""))
|
|
959
|
+
|
|
960
|
+
total: dict = {name: [] for name in LISTS}
|
|
961
|
+
total.update(unchanged_count=0, changed_exports=0)
|
|
962
|
+
groups: list[dict] = []
|
|
963
|
+
left_out = {"baseline": 0, "current": 0}
|
|
964
|
+
for value in values or [None]:
|
|
965
|
+
base_part = base_groups.get(value, [])
|
|
966
|
+
curr_part = curr_groups.get(value, [])
|
|
967
|
+
unchanged_names: collections.Counter = collections.Counter()
|
|
968
|
+
if scope is not None:
|
|
969
|
+
# Exports outside the scope are normalized apart, so a transform
|
|
970
|
+
# they need is not reported: they are in no list and no count.
|
|
971
|
+
outside = {
|
|
972
|
+
side: _normalize_entries(
|
|
973
|
+
[e for e in entries if not _in_scope(e, scope)], reexport_map, collections.Counter()
|
|
974
|
+
)
|
|
975
|
+
for side, entries in (("baseline", base_part), ("current", curr_part))
|
|
976
|
+
}
|
|
977
|
+
unchanged_names.update(name for name, _ in outside["baseline"])
|
|
978
|
+
for side, records in outside.items():
|
|
979
|
+
left_out[side] += len(records)
|
|
980
|
+
base_part = [e for e in base_part if _in_scope(e, scope)]
|
|
981
|
+
curr_part = [e for e in curr_part if _in_scope(e, scope)]
|
|
982
|
+
part = _diff_records(
|
|
983
|
+
_normalize_entries(base_part, reexport_map, transform_counts),
|
|
984
|
+
_normalize_entries(curr_part, reexport_map, transform_counts),
|
|
985
|
+
unchanged_names,
|
|
986
|
+
transform_counts,
|
|
987
|
+
)
|
|
988
|
+
if group_by:
|
|
989
|
+
for name in LISTS:
|
|
990
|
+
part[name] = [{group_by: value, **item} for item in part[name]]
|
|
991
|
+
groups.append({group_by: value, "summary": _summary(part)})
|
|
992
|
+
for name in LISTS:
|
|
993
|
+
total[name].extend(part[name])
|
|
994
|
+
total["unchanged_count"] += part["unchanged_count"]
|
|
995
|
+
total["changed_exports"] += part["changed_exports"]
|
|
996
|
+
|
|
997
|
+
applied_transforms = [
|
|
998
|
+
{"transform": name, "count": transform_counts[name]}
|
|
999
|
+
for name in sorted(transform_counts)
|
|
1000
|
+
if transform_counts[name] > 0
|
|
1001
|
+
]
|
|
1002
|
+
|
|
1003
|
+
result = {"summary": _summary(total)}
|
|
1004
|
+
result.update((name, total[name]) for name in LISTS)
|
|
1005
|
+
result["unchanged_count"] = total["unchanged_count"]
|
|
1006
|
+
result["applied_transforms"] = applied_transforms
|
|
1007
|
+
if group_by:
|
|
1008
|
+
result["group_by"] = group_by
|
|
1009
|
+
result["groups"] = groups
|
|
1010
|
+
if scope is not None:
|
|
1011
|
+
result["file_scope"] = {
|
|
1012
|
+
"files": len(scope),
|
|
1013
|
+
"baseline_left_out": left_out["baseline"],
|
|
1014
|
+
"current_left_out": left_out["current"],
|
|
1015
|
+
}
|
|
1016
|
+
return result
|
|
1017
|
+
|
|
1018
|
+
|
|
391
1019
|
# --------------------------------------------------------------------------
|
|
392
1020
|
# CLI
|
|
393
1021
|
# --------------------------------------------------------------------------
|
|
394
1022
|
|
|
395
1023
|
|
|
396
|
-
|
|
1024
|
+
# Exit codes: no difference, differences found, error.
|
|
1025
|
+
EXIT_SAME = 0
|
|
1026
|
+
EXIT_DIFFERENT = 1
|
|
1027
|
+
EXIT_ERROR = 2
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _fail(message: str) -> int:
|
|
1031
|
+
print(json.dumps({"status": "error", "error": message}, indent=2))
|
|
1032
|
+
return EXIT_ERROR
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1036
|
+
# --help prints the module docstring: the input, matching and output
|
|
1037
|
+
# contract a calling step cites.
|
|
397
1038
|
parser = argparse.ArgumentParser(
|
|
398
1039
|
prog="skf-structural-diff.py",
|
|
399
|
-
description=
|
|
400
|
-
"Compare two JSON export inventories and produce a structured diff.\n"
|
|
401
|
-
"\n"
|
|
402
|
-
"Each inventory may be a JSON array of export entries, an object with\n"
|
|
403
|
-
"an 'exports' key (extraction-snapshot), or an object with an 'entries'\n"
|
|
404
|
-
"key (provenance-map.json). Field names are aliased across the two\n"
|
|
405
|
-
"shapes, and signatures are canonicalized (quote style, stdlib module\n"
|
|
406
|
-
"prefixes, public re-export names) symmetrically before matching.\n"
|
|
407
|
-
"\n"
|
|
408
|
-
"Exit code 0 means no differences were found.\n"
|
|
409
|
-
"Exit code 1 means differences were found (or an error occurred)."
|
|
410
|
-
),
|
|
1040
|
+
description=__doc__,
|
|
411
1041
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
412
|
-
epilog=(
|
|
413
|
-
"examples:\n"
|
|
414
|
-
" python3 skf-structural-diff.py provenance-map.json extraction-snapshot.json\n"
|
|
415
|
-
" python3 skf-structural-diff.py baseline.json current.json -o diff.json\n"
|
|
416
|
-
" python3 skf-structural-diff.py provenance-map.json snapshot.json --reexport-map map.json\n"
|
|
417
|
-
),
|
|
418
1042
|
)
|
|
419
1043
|
parser.add_argument(
|
|
420
1044
|
"baseline",
|
|
@@ -435,45 +1059,89 @@ def main() -> int:
|
|
|
435
1059
|
"derived from the baseline provenance map itself."
|
|
436
1060
|
),
|
|
437
1061
|
)
|
|
1062
|
+
parser.add_argument(
|
|
1063
|
+
"--group-by",
|
|
1064
|
+
choices=GROUP_FIELDS,
|
|
1065
|
+
help=(
|
|
1066
|
+
"diff each group of entries on its own (source_library: a stack's "
|
|
1067
|
+
"libraries) and tag every listed item with its group; an "
|
|
1068
|
+
"inventory whose entries all lack the field is an error when "
|
|
1069
|
+
"the other's have it"
|
|
1070
|
+
),
|
|
1071
|
+
)
|
|
1072
|
+
parser.add_argument(
|
|
1073
|
+
"--files",
|
|
1074
|
+
metavar="FILE",
|
|
1075
|
+
help=(
|
|
1076
|
+
"diff only the exports of the files FILE names (a JSON array of "
|
|
1077
|
+
"paths, or a skf-classify-changed-files.py classify output) and "
|
|
1078
|
+
"take every other export as unchanged"
|
|
1079
|
+
),
|
|
1080
|
+
)
|
|
1081
|
+
parser.add_argument(
|
|
1082
|
+
"--current-extra",
|
|
1083
|
+
metavar="FILE",
|
|
1084
|
+
help=(
|
|
1085
|
+
"an inventory merged into the current one by name and file: it fills "
|
|
1086
|
+
"the fields a current entry lacks, and adds the entries no current "
|
|
1087
|
+
"one has"
|
|
1088
|
+
),
|
|
1089
|
+
)
|
|
438
1090
|
parser.add_argument(
|
|
439
1091
|
"-o",
|
|
440
1092
|
"--output",
|
|
441
1093
|
metavar="FILE",
|
|
442
|
-
help="write JSON
|
|
1094
|
+
help="write the JSON diff to FILE and print only a summary line on stdout",
|
|
443
1095
|
)
|
|
444
1096
|
|
|
445
|
-
args = parser.parse_args()
|
|
1097
|
+
args = parser.parse_args(argv)
|
|
446
1098
|
|
|
447
1099
|
baseline_path = Path(args.baseline)
|
|
448
1100
|
current_path = Path(args.current)
|
|
449
1101
|
|
|
450
1102
|
baseline_data, err = load_json(baseline_path)
|
|
451
1103
|
if err:
|
|
452
|
-
|
|
453
|
-
return 1
|
|
1104
|
+
return _fail(err)
|
|
454
1105
|
current_data, err = load_json(current_path)
|
|
455
1106
|
if err:
|
|
456
|
-
|
|
457
|
-
return 1
|
|
1107
|
+
return _fail(err)
|
|
458
1108
|
|
|
459
1109
|
baseline_entries, err = entries_from_data(baseline_data, str(baseline_path))
|
|
460
1110
|
if err:
|
|
461
|
-
|
|
462
|
-
return 1
|
|
1111
|
+
return _fail(err)
|
|
463
1112
|
current_entries, err = entries_from_data(current_data, str(current_path))
|
|
464
1113
|
if err:
|
|
465
|
-
|
|
466
|
-
|
|
1114
|
+
return _fail(err)
|
|
1115
|
+
if args.current_extra:
|
|
1116
|
+
extra_entries, err = load_inventory(Path(args.current_extra))
|
|
1117
|
+
if err:
|
|
1118
|
+
return _fail(err)
|
|
1119
|
+
current_entries = merge_extra(current_entries, extra_entries)
|
|
467
1120
|
|
|
468
1121
|
if args.reexport_map:
|
|
469
1122
|
reexport_map, err = load_reexport_map(Path(args.reexport_map))
|
|
470
1123
|
if err:
|
|
471
|
-
|
|
472
|
-
return 1
|
|
1124
|
+
return _fail(err)
|
|
473
1125
|
else:
|
|
474
1126
|
reexport_map = extract_reexport_map(baseline_data)
|
|
475
1127
|
|
|
476
|
-
|
|
1128
|
+
files = None
|
|
1129
|
+
if args.files:
|
|
1130
|
+
files_data, err = load_json(Path(args.files))
|
|
1131
|
+
if err:
|
|
1132
|
+
return _fail(err)
|
|
1133
|
+
files, err = files_from_data(files_data, args.files)
|
|
1134
|
+
if err:
|
|
1135
|
+
return _fail(err)
|
|
1136
|
+
|
|
1137
|
+
try:
|
|
1138
|
+
result = diff_inventories(
|
|
1139
|
+
baseline_entries, current_entries, reexport_map, args.group_by,
|
|
1140
|
+
sources=(str(baseline_path), str(current_path)),
|
|
1141
|
+
files=files,
|
|
1142
|
+
)
|
|
1143
|
+
except ValueError as exc:
|
|
1144
|
+
return _fail(str(exc))
|
|
477
1145
|
output_text = json.dumps(result, indent=2)
|
|
478
1146
|
|
|
479
1147
|
if args.output:
|
|
@@ -481,16 +1149,14 @@ def main() -> int:
|
|
|
481
1149
|
try:
|
|
482
1150
|
out_path.write_text(output_text + "\n", encoding="utf-8")
|
|
483
1151
|
except OSError as exc:
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
)
|
|
487
|
-
return 1
|
|
1152
|
+
return _fail(f"Cannot write output: {exc}")
|
|
1153
|
+
print(json.dumps({"status": "ok", "output": str(out_path), "summary": result["summary"]}))
|
|
488
1154
|
else:
|
|
489
1155
|
print(output_text)
|
|
490
1156
|
|
|
491
1157
|
summary = result["summary"]
|
|
492
1158
|
has_diff = summary["added"] or summary["removed"] or summary["changed"] or summary["moved"]
|
|
493
|
-
return
|
|
1159
|
+
return EXIT_DIFFERENT if has_diff else EXIT_SAME
|
|
494
1160
|
|
|
495
1161
|
|
|
496
1162
|
if __name__ == "__main__":
|