bmad-module-skill-forge 2.1.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 +51 -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 +14 -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 +117 -9
- package/src/shared/scripts/skf-detect-docs.py +892 -4
- 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 +215 -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 +17 -6
- 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 +55 -18
- package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
- package/src/skf-create-skill/references/sub/fetch-docs.md +106 -57
- 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 -89
- package/src/skf-setup/references/detect-and-tier.md +58 -66
- 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 -645
- 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,56 @@ 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
|
+
File scope (--files FILE):
|
|
67
|
+
For a caller that re-extracted only the files that changed (update-skill's
|
|
68
|
+
Category B), --files limits the diff to the exports of the files FILE
|
|
69
|
+
names: a JSON array of paths, or the output of skf-classify-changed-files.py
|
|
70
|
+
classify, which names category_a's modified, added and deleted files and
|
|
71
|
+
both paths of each moved_files item. A path compares in the form an
|
|
72
|
+
export's file does (see Matching). An export of any other file is taken as
|
|
73
|
+
unchanged on both sides: it enters no list and no count, and its name
|
|
74
|
+
still counts when a move needs a name unique on both sides, so the scoped
|
|
75
|
+
diff pairs the moves a diff of the whole inventories would. The current
|
|
76
|
+
inventory must hold the exports of every named file the source still has,
|
|
77
|
+
or they read as removed.
|
|
78
|
+
|
|
79
|
+
Extra current entries (--current-extra FILE):
|
|
80
|
+
For a caller that adds to a recipe runner's output what the recipes do
|
|
81
|
+
not record (update-skill's Category B: each export's params and
|
|
82
|
+
return_type, read at its line, and the exports read by eye), FILE is an
|
|
83
|
+
inventory in any input format above, merged into the current one before
|
|
84
|
+
the diff: an entry with the name and file of a current entry fills each
|
|
85
|
+
field that entry lacks (null or absent), and any other entry is added as
|
|
86
|
+
an export of its own. Names and files compare as written, the file in the
|
|
87
|
+
form Matching describes.
|
|
37
88
|
|
|
38
89
|
Canonicalization (applied symmetrically to BOTH sides before matching):
|
|
39
90
|
The baseline extractor (skf-create-skill) and the re-extractor (audit step 2)
|
|
@@ -55,53 +106,171 @@ Canonicalization (applied symmetrically to BOTH sides before matching):
|
|
|
55
106
|
|
|
56
107
|
The reexport map is taken from --reexport-map when provided; otherwise it is
|
|
57
108
|
derived from the baseline provenance map itself (top-level `reexport_map`
|
|
58
|
-
plus any per-entry `reexported_as`)
|
|
109
|
+
plus any per-entry `reexported_as`), the same projection produced by
|
|
59
110
|
`skf-load-provenance.py normalize`.
|
|
60
111
|
|
|
112
|
+
params (each parameter) and return_type are canonicalized the same way as
|
|
113
|
+
a signature. A parameter the recipe runner records as {name, type,
|
|
114
|
+
default, optional} is first written in the provenance map's form
|
|
115
|
+
(skf-extraction-inventory.py typed_param: `name: type`, `name?: type`,
|
|
116
|
+
` = default`), so a runner's export compares with the map's entry.
|
|
117
|
+
|
|
61
118
|
Change detection:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
119
|
+
The diffed fields are type, signature, params, return_type and line. A
|
|
120
|
+
field is only compared when present (non-null) on BOTH sides: a field
|
|
121
|
+
absent on one side means "insufficient data", never an asserted change.
|
|
122
|
+
This avoids false positives when the two inventories carry different
|
|
123
|
+
metadata. The line of a moved export is not compared: a move changes it.
|
|
124
|
+
|
|
125
|
+
A signature has two parts, the parameters and the return type. A side
|
|
126
|
+
carries the parameters as params or in its signature, and the return
|
|
127
|
+
type as return_type or in its signature. When both sides carry a part
|
|
128
|
+
but no field holds it on both (a provenance map's params and return_type
|
|
129
|
+
against a snapshot's signature text), that part is never compared, and
|
|
130
|
+
the export is listed in signature_unverified[]: a change there cannot be
|
|
131
|
+
seen, so a diff that finds nothing does not pass for a verified one.
|
|
132
|
+
|
|
133
|
+
Provenance labels:
|
|
134
|
+
confidence and extraction_method describe the tool that extracted an export,
|
|
135
|
+
not the source, so a difference in them is not drift. For an export matched
|
|
136
|
+
on both sides, a label that differs (compared case-insensitively after trimming,
|
|
137
|
+
and only when both sides have a value) is reported once per export in
|
|
138
|
+
label_changes[]. A blank label counts as no value and is emitted as null.
|
|
139
|
+
The stack spellings of extraction_method compare equal to the library ones
|
|
140
|
+
(ast_bridge = ast-grep, source_reading = source-read); emitted values keep
|
|
141
|
+
the spelling each side wrote. Label changes never enter changed[], never
|
|
142
|
+
make an export count as changed, and never affect the exit code.
|
|
65
143
|
|
|
66
144
|
Output:
|
|
67
145
|
JSON object:
|
|
68
|
-
summary: { added, removed, changed, moved, unchanged
|
|
146
|
+
summary: { added, removed, changed, moved, unchanged,
|
|
147
|
+
label_changes, ambiguous_names,
|
|
148
|
+
signature_unverified }
|
|
69
149
|
added: list of entries present in current but not baseline
|
|
70
150
|
removed: list of entries present in baseline but not current
|
|
71
|
-
changed: list of { name, field, baseline_value, current_value
|
|
72
|
-
|
|
151
|
+
changed: list of { name, field, baseline_value, current_value,
|
|
152
|
+
file, line, confidence } where field is type,
|
|
153
|
+
signature, params, return_type or line, and file,
|
|
154
|
+
line and confidence are the export's current ones
|
|
155
|
+
moved: list of { name, previous_file, current_file,
|
|
156
|
+
previous_line, line, confidence }
|
|
157
|
+
ambiguous_names: list of { name, removed: [{file, line}], added:
|
|
158
|
+
[{file, line}] }: a name left on both sides that
|
|
159
|
+
occurs more than once on a side, so its entries stay
|
|
160
|
+
in removed[] and added[] unpaired
|
|
161
|
+
label_changes: list of { name, file, baseline: {confidence,
|
|
162
|
+
extraction_method}, current: {confidence,
|
|
163
|
+
extraction_method} }; informational, not drift
|
|
164
|
+
signature_unverified: list of { name, file, baseline, current }: a
|
|
165
|
+
matched export with a signature part no field
|
|
166
|
+
holds on both sides (see Change detection);
|
|
167
|
+
baseline and current list which of signature,
|
|
168
|
+
params and return_type each side carries.
|
|
169
|
+
Informational: never a change, never in the exit
|
|
170
|
+
code
|
|
73
171
|
unchanged_count: number of entries that matched with no field change
|
|
74
|
-
|
|
172
|
+
(a label change or an unverified signature alone
|
|
173
|
+
leaves an entry unchanged)
|
|
174
|
+
applied_transforms: list of { transform, count }: which canonicalization
|
|
75
175
|
transforms actually fired and how many values each
|
|
76
176
|
touched (empty when none fired). Surfaced by audit
|
|
77
177
|
step 6's Provenance section so a reviewer can tell
|
|
78
178
|
which differences the diff collapsed.
|
|
79
179
|
|
|
180
|
+
added[] and removed[] hold normalized records: name, type, signature,
|
|
181
|
+
params, return_type, file, line, confidence and extraction_method.
|
|
182
|
+
|
|
183
|
+
With --group-by source_library, each side is split by its entries'
|
|
184
|
+
source_library (a stack's libraries; an entry without one falls in a
|
|
185
|
+
null group, diffed against the other side's) and each group is diffed on
|
|
186
|
+
its own, so an export never matches one of another library. The lists
|
|
187
|
+
above then hold every group's items, each tagged with its source_library,
|
|
188
|
+
the summary sums the groups, and two keys are added:
|
|
189
|
+
group_by: "source_library"
|
|
190
|
+
groups: list of { source_library, summary }, one per group
|
|
191
|
+
An inventory whose entries all lack a source_library is an error when
|
|
192
|
+
the other inventory's entries have one: its every export would land in
|
|
193
|
+
the null group and read as added or removed.
|
|
194
|
+
|
|
195
|
+
With --files, one key is added:
|
|
196
|
+
file_scope: { files, baseline_left_out, current_left_out }: the
|
|
197
|
+
paths FILE names, and the exports of each inventory
|
|
198
|
+
left out as outside them
|
|
199
|
+
|
|
200
|
+
With -o, the JSON is written to the file and stdout gets one line:
|
|
201
|
+
{ status: "ok", output: <file>, summary: {...} }.
|
|
202
|
+
|
|
80
203
|
Exit codes:
|
|
81
|
-
0
|
|
82
|
-
1
|
|
204
|
+
0 no export added, removed, changed or moved
|
|
205
|
+
1 differences found
|
|
206
|
+
2 error: an input that cannot be read or parsed, a --files file that is
|
|
207
|
+
neither a path list nor a classify output, --group-by over an
|
|
208
|
+
inventory whose entries all lack the field while the other's have it,
|
|
209
|
+
an output that cannot be written, or a usage error ({status: "error",
|
|
210
|
+
error} on stdout, or argparse's usage on stderr)
|
|
211
|
+
Label differences (label_changes) and unverified signatures
|
|
212
|
+
(signature_unverified) do not count toward the exit code.
|
|
83
213
|
"""
|
|
84
214
|
|
|
85
215
|
from __future__ import annotations
|
|
86
216
|
|
|
87
217
|
import argparse
|
|
88
218
|
import collections
|
|
219
|
+
import importlib.util
|
|
89
220
|
import json
|
|
90
221
|
import re
|
|
91
222
|
import sys
|
|
92
223
|
from pathlib import Path
|
|
93
224
|
|
|
225
|
+
_INVENTORY = None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _typed_params(params: object, language: object) -> object:
|
|
229
|
+
"""A recipe runner's {name, type, default, optional} parameters in the provenance map's typed form, through
|
|
230
|
+
skf-extraction-inventory.py's typed_param (loaded once from this folder); any other value as it is."""
|
|
231
|
+
global _INVENTORY
|
|
232
|
+
if not isinstance(params, list) or not any(isinstance(p, dict) for p in params):
|
|
233
|
+
return params
|
|
234
|
+
if _INVENTORY is None:
|
|
235
|
+
path = Path(__file__).resolve().parent / "skf-extraction-inventory.py"
|
|
236
|
+
spec = importlib.util.spec_from_file_location("skf_extraction_inventory", path)
|
|
237
|
+
if spec is None or spec.loader is None:
|
|
238
|
+
raise ImportError(f"cannot load {path}")
|
|
239
|
+
_INVENTORY = importlib.util.module_from_spec(spec)
|
|
240
|
+
spec.loader.exec_module(_INVENTORY)
|
|
241
|
+
lang = language if isinstance(language, str) else None
|
|
242
|
+
return [s for s in (_INVENTORY.typed_param(p, lang) for p in params) if s is not None]
|
|
243
|
+
|
|
94
244
|
|
|
95
245
|
# Fields compared for change detection (in order).
|
|
96
|
-
# "name"
|
|
97
|
-
#
|
|
98
|
-
|
|
246
|
+
# "name" and "file" identify the export and are not diffed as fields; a file
|
|
247
|
+
# change is tracked separately in the "moved" list.
|
|
248
|
+
# Provenance labels are excluded too: they say which tool extracted the export,
|
|
249
|
+
# so a relabel is reported in label_changes[] and is never drift.
|
|
250
|
+
# params and return_type are the provenance map's form of a signature.
|
|
251
|
+
DIFF_FIELDS = ["type", "signature", "params", "return_type", "line"]
|
|
252
|
+
|
|
253
|
+
# The fields that hold a signature, and for each of its two parts (the
|
|
254
|
+
# parameters, the return type) the fields that hold that part: a signature
|
|
255
|
+
# string holds both.
|
|
256
|
+
SIGNATURE_FIELDS = ("signature", "params", "return_type")
|
|
257
|
+
SIGNATURE_PARTS = (("params", "signature"), ("return_type", "signature"))
|
|
258
|
+
|
|
259
|
+
# The one --group-by field: a stack's per-library split.
|
|
260
|
+
GROUP_FIELDS = ["source_library"]
|
|
261
|
+
|
|
262
|
+
# Provenance labels compared for the informational label_changes[] list.
|
|
263
|
+
LABEL_FIELDS = ["confidence", "extraction_method"]
|
|
264
|
+
|
|
265
|
+
# Stack provenance maps spell extraction_method differently from library maps
|
|
266
|
+
# and the audit re-index; compare them as the same tool.
|
|
267
|
+
_METHOD_ALIASES = {"ast_bridge": "ast-grep", "source_reading": "source-read"}
|
|
99
268
|
|
|
100
269
|
# Well-known stdlib module prefixes whose unqualified form is importable at
|
|
101
270
|
# the call site. Longer prefixes must precede their own containing prefix
|
|
102
271
|
# (collections.abc before collections) so the alternation strips the longest.
|
|
103
272
|
# The negative lookbehind `(?<![\w.])` ensures we only strip a top-level module
|
|
104
|
-
# reference
|
|
273
|
+
# reference, never a user-defined namespace such as `pkg.typing.Foo`.
|
|
105
274
|
_STDLIB_PREFIX_RE = re.compile(
|
|
106
275
|
r"(?<![\w.])(?:typing|dataclasses|collections\.abc|collections|enum)\."
|
|
107
276
|
)
|
|
@@ -165,6 +334,83 @@ def load_inventory(path: Path) -> tuple[list[dict], str | None]:
|
|
|
165
334
|
return entries_from_data(data, str(path))
|
|
166
335
|
|
|
167
336
|
|
|
337
|
+
# The keys one field may be written under (see Input): an entry lacks the
|
|
338
|
+
# field only when it has none of them.
|
|
339
|
+
_ALIAS_GROUPS = {
|
|
340
|
+
key: group
|
|
341
|
+
for group in (("name", "export_name"), ("type", "export_type"), ("file", "source_file"),
|
|
342
|
+
("line", "source_line"))
|
|
343
|
+
for key in group
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def merge_extra(entries: list[dict], extra: list[dict]) -> list[dict]:
|
|
348
|
+
"""The current entries with --current-extra's merged in by name and file:
|
|
349
|
+
an extra entry fills each field the entry of its name and file lacks,
|
|
350
|
+
and one with no such entry is added.
|
|
351
|
+
|
|
352
|
+
>>> merge_extra([{"export_name": "f", "source_file": "a.py", "source_line": 3}],
|
|
353
|
+
... [{"export_name": "f", "source_file": "./a.py", "params": ["x: int"]},
|
|
354
|
+
... {"name": "g", "file": "a.py", "line": 9}])
|
|
355
|
+
[{'export_name': 'f', 'source_file': 'a.py', 'source_line': 3, 'params': ['x: int']}, {'name': 'g', 'file': 'a.py', 'line': 9}]
|
|
356
|
+
"""
|
|
357
|
+
merged = [dict(entry) if isinstance(entry, dict) else entry for entry in entries]
|
|
358
|
+
by_key: dict[tuple, dict] = {}
|
|
359
|
+
for entry in merged:
|
|
360
|
+
if isinstance(entry, dict):
|
|
361
|
+
name = _first(entry, "name", "export_name")
|
|
362
|
+
if isinstance(name, str):
|
|
363
|
+
by_key.setdefault((name.strip(), _file_key(_first(entry, "file", "source_file"))), entry)
|
|
364
|
+
for item in extra:
|
|
365
|
+
if not isinstance(item, dict):
|
|
366
|
+
continue
|
|
367
|
+
name = _first(item, "name", "export_name")
|
|
368
|
+
target = by_key.get((name.strip(), _file_key(_first(item, "file", "source_file")))) \
|
|
369
|
+
if isinstance(name, str) else None
|
|
370
|
+
if target is None:
|
|
371
|
+
merged.append(dict(item))
|
|
372
|
+
continue
|
|
373
|
+
for field, value in item.items():
|
|
374
|
+
if value is not None and all(target.get(key) is None for key in _ALIAS_GROUPS.get(field, (field,))):
|
|
375
|
+
target[field] = value
|
|
376
|
+
return merged
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def files_from_data(data: object, source: str = "") -> tuple[list[str], str | None]:
|
|
380
|
+
"""The paths of a --files file, already parsed.
|
|
381
|
+
|
|
382
|
+
Accepts:
|
|
383
|
+
- [...] (a JSON array of paths)
|
|
384
|
+
- {"category_a": {...}} (skf-classify-changed-files.py classify output:
|
|
385
|
+
category_a's modified, added and deleted
|
|
386
|
+
paths, and both paths of each moved_files item)
|
|
387
|
+
|
|
388
|
+
Returns (paths, error_message).
|
|
389
|
+
"""
|
|
390
|
+
where = f" in '{source}'" if source else ""
|
|
391
|
+
if isinstance(data, list):
|
|
392
|
+
if all(isinstance(path, str) for path in data):
|
|
393
|
+
return data, None
|
|
394
|
+
return [], f"--files list{where} must hold only path strings"
|
|
395
|
+
if not isinstance(data, dict) or not isinstance(data.get("category_a"), dict):
|
|
396
|
+
return [], (
|
|
397
|
+
f"Unrecognised --files format{where}: expected an array of paths or "
|
|
398
|
+
f"the output of skf-classify-changed-files.py classify"
|
|
399
|
+
)
|
|
400
|
+
paths: list[str] = []
|
|
401
|
+
for key in ("modified", "added", "deleted"):
|
|
402
|
+
listed = data["category_a"].get(key, [])
|
|
403
|
+
if not isinstance(listed, list) or not all(isinstance(path, str) for path in listed):
|
|
404
|
+
return [], f"--files category_a.{key}{where} must be a list of paths"
|
|
405
|
+
paths.extend(listed)
|
|
406
|
+
moves = data.get("moved_files", [])
|
|
407
|
+
if not isinstance(moves, list) or not all(isinstance(move, dict) for move in moves):
|
|
408
|
+
return [], f"--files moved_files{where} must be a list of {{old_path, new_path}} objects"
|
|
409
|
+
for move in moves:
|
|
410
|
+
paths.extend(move[key] for key in ("old_path", "new_path") if isinstance(move.get(key), str))
|
|
411
|
+
return paths, None
|
|
412
|
+
|
|
413
|
+
|
|
168
414
|
# --------------------------------------------------------------------------
|
|
169
415
|
# Re-export map
|
|
170
416
|
# --------------------------------------------------------------------------
|
|
@@ -248,19 +494,47 @@ def canon_signature(sig: object) -> tuple[object, set[str]]:
|
|
|
248
494
|
return out, applied
|
|
249
495
|
|
|
250
496
|
|
|
497
|
+
def _canon_value(value: object, transform_counts: collections.Counter) -> object:
|
|
498
|
+
"""Canonicalize a signature, a return type or each item of a parameter list."""
|
|
499
|
+
if isinstance(value, list):
|
|
500
|
+
return [_canon_value(item, transform_counts) for item in value]
|
|
501
|
+
canon, fired = canon_signature(value)
|
|
502
|
+
for t in fired:
|
|
503
|
+
transform_counts[t] += 1
|
|
504
|
+
return canon
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _is_line(value: object) -> bool:
|
|
508
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _file_key(file: object) -> str | None:
|
|
512
|
+
"""The file half of an export's key: stripped, forward slashes, no
|
|
513
|
+
leading `./` (normalize_rel_path of skf-resolve-authoritative-files.py),
|
|
514
|
+
or None when the entry names no file."""
|
|
515
|
+
if not isinstance(file, str):
|
|
516
|
+
return None
|
|
517
|
+
path = file.strip().replace("\\", "/")
|
|
518
|
+
while path.startswith("./"):
|
|
519
|
+
path = path[2:]
|
|
520
|
+
return path or None
|
|
521
|
+
|
|
522
|
+
|
|
251
523
|
def _normalize_entries(
|
|
252
524
|
entries: list[dict],
|
|
253
525
|
reexport_map: dict[str, str],
|
|
254
526
|
transform_counts: collections.Counter,
|
|
255
|
-
) -> dict[
|
|
256
|
-
"""Build a
|
|
527
|
+
) -> dict[tuple, dict]:
|
|
528
|
+
"""Build a dict of canonicalized records keyed by (name, file).
|
|
257
529
|
|
|
258
530
|
Applies field-name aliasing, signature canonicalization, and re-export
|
|
259
531
|
name resolution. Accumulates fired-transform counts into transform_counts.
|
|
260
|
-
Nameless entries are skipped.
|
|
261
|
-
|
|
532
|
+
Nameless entries are skipped. The key holds the file in _file_key's form;
|
|
533
|
+
the record keeps it as written. When one (name, file) repeats, the entry
|
|
534
|
+
on the first line wins, whatever order the inventory lists them in (an
|
|
535
|
+
entry with no line only wins when no other has one).
|
|
262
536
|
"""
|
|
263
|
-
result: dict[
|
|
537
|
+
result: dict[tuple, dict] = {}
|
|
264
538
|
for entry in entries:
|
|
265
539
|
if not isinstance(entry, dict):
|
|
266
540
|
continue
|
|
@@ -273,78 +547,188 @@ def _normalize_entries(
|
|
|
273
547
|
transform_counts["reexport-resolution"] += 1
|
|
274
548
|
name = resolved
|
|
275
549
|
|
|
276
|
-
|
|
277
|
-
for t in sig_transforms:
|
|
278
|
-
transform_counts[t] += 1
|
|
279
|
-
|
|
280
|
-
# Normalized record — also the public entry shape emitted in
|
|
550
|
+
# Normalized record: also the public entry shape emitted in
|
|
281
551
|
# added[]/removed[], so both sides render into one consistent table
|
|
282
552
|
# regardless of the input shape they came from.
|
|
283
|
-
|
|
553
|
+
record = {
|
|
284
554
|
"name": name,
|
|
285
555
|
"type": _first(entry, "type", "export_type"),
|
|
286
|
-
"signature":
|
|
556
|
+
"signature": _canon_value(entry.get("signature"), transform_counts),
|
|
557
|
+
"params": _canon_value(_typed_params(entry.get("params"), entry.get("language")), transform_counts),
|
|
558
|
+
"return_type": _canon_value(entry.get("return_type"), transform_counts),
|
|
287
559
|
"file": _first(entry, "file", "source_file"),
|
|
288
560
|
"line": _first(entry, "line", "source_line"),
|
|
289
561
|
"confidence": entry.get("confidence"),
|
|
562
|
+
"extraction_method": entry.get("extraction_method"),
|
|
290
563
|
}
|
|
564
|
+
key = (name, _file_key(record["file"]))
|
|
565
|
+
kept = result.get(key)
|
|
566
|
+
if kept is not None and not (
|
|
567
|
+
_is_line(record["line"]) and (not _is_line(kept["line"]) or record["line"] < kept["line"])
|
|
568
|
+
):
|
|
569
|
+
continue
|
|
570
|
+
result[key] = record
|
|
291
571
|
return result
|
|
292
572
|
|
|
293
573
|
|
|
574
|
+
def _label_key(field: str, value: object) -> object:
|
|
575
|
+
"""Comparison key for a provenance label, or None when the side has no value.
|
|
576
|
+
|
|
577
|
+
Strings compare case-insensitively after trimming; a blank string counts
|
|
578
|
+
as no value. Stack extraction_method spellings map to the library ones.
|
|
579
|
+
"""
|
|
580
|
+
if value is None:
|
|
581
|
+
return None
|
|
582
|
+
if isinstance(value, str):
|
|
583
|
+
value = value.strip().casefold()
|
|
584
|
+
if not value:
|
|
585
|
+
return None
|
|
586
|
+
if field == "extraction_method":
|
|
587
|
+
value = _METHOD_ALIASES.get(value, value)
|
|
588
|
+
return value
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _label_side(rec: dict) -> dict:
|
|
592
|
+
"""One side's labels as emitted: the value as written, or null when blank."""
|
|
593
|
+
return {
|
|
594
|
+
k: (rec.get(k) if _label_key(k, rec.get(k)) is not None else None)
|
|
595
|
+
for k in LABEL_FIELDS
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _label_change(base_rec: dict, curr_rec: dict) -> dict | None:
|
|
600
|
+
"""The label_changes[] item for one matched export, or None when labels agree.
|
|
601
|
+
|
|
602
|
+
A label is compared only when both sides have a value for it.
|
|
603
|
+
"""
|
|
604
|
+
for field in LABEL_FIELDS:
|
|
605
|
+
base_key = _label_key(field, base_rec.get(field))
|
|
606
|
+
curr_key = _label_key(field, curr_rec.get(field))
|
|
607
|
+
if base_key is None or curr_key is None:
|
|
608
|
+
continue
|
|
609
|
+
if base_key != curr_key:
|
|
610
|
+
return {
|
|
611
|
+
"name": curr_rec["name"],
|
|
612
|
+
"file": curr_rec["file"],
|
|
613
|
+
"baseline": _label_side(base_rec),
|
|
614
|
+
"current": _label_side(curr_rec),
|
|
615
|
+
}
|
|
616
|
+
return None
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _signature_fields(rec: dict) -> list[str]:
|
|
620
|
+
return [field for field in SIGNATURE_FIELDS if rec.get(field) is not None]
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _unverified_signature(base_rec: dict, curr_rec: dict) -> dict | None:
|
|
624
|
+
"""The signature_unverified[] item for one matched export, or None when
|
|
625
|
+
every signature part both sides carry sits in a field both sides have."""
|
|
626
|
+
for fields in SIGNATURE_PARTS:
|
|
627
|
+
base_has = {field for field in fields if base_rec.get(field) is not None}
|
|
628
|
+
curr_has = {field for field in fields if curr_rec.get(field) is not None}
|
|
629
|
+
if base_has and curr_has and not (base_has & curr_has):
|
|
630
|
+
return {
|
|
631
|
+
"name": curr_rec["name"],
|
|
632
|
+
"file": curr_rec["file"],
|
|
633
|
+
"baseline": _signature_fields(base_rec),
|
|
634
|
+
"current": _signature_fields(curr_rec),
|
|
635
|
+
}
|
|
636
|
+
return None
|
|
637
|
+
|
|
638
|
+
|
|
294
639
|
# --------------------------------------------------------------------------
|
|
295
640
|
# Diff
|
|
296
641
|
# --------------------------------------------------------------------------
|
|
297
642
|
|
|
298
643
|
|
|
299
|
-
|
|
300
|
-
baseline_entries: list[dict],
|
|
301
|
-
current_entries: list[dict],
|
|
302
|
-
reexport_map: dict[str, str] | None = None,
|
|
303
|
-
) -> dict:
|
|
304
|
-
"""Compute the structural diff between two export inventories.
|
|
644
|
+
LISTS = ["added", "removed", "changed", "moved", "ambiguous_names", "label_changes", "signature_unverified"]
|
|
305
645
|
|
|
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
646
|
|
|
312
|
-
|
|
313
|
-
|
|
647
|
+
def _key_order(key: tuple) -> tuple:
|
|
648
|
+
return (key[0], key[1] or "")
|
|
649
|
+
|
|
314
650
|
|
|
315
|
-
|
|
316
|
-
|
|
651
|
+
def _file_line(rec: dict) -> dict:
|
|
652
|
+
return {"file": rec["file"], "line": rec["line"]}
|
|
317
653
|
|
|
318
|
-
added_names = current_names - baseline_names
|
|
319
|
-
removed_names = baseline_names - current_names
|
|
320
|
-
common_names = baseline_names & current_names
|
|
321
654
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
655
|
+
def _diff_records(
|
|
656
|
+
baseline: dict[tuple, dict],
|
|
657
|
+
current: dict[tuple, dict],
|
|
658
|
+
unchanged_names: collections.Counter | None = None,
|
|
659
|
+
) -> dict:
|
|
660
|
+
"""Diff two (name, file)-keyed record sets: the LISTS, plus the counts
|
|
661
|
+
unchanged_count and changed_exports (exports with a changed field).
|
|
662
|
+
|
|
663
|
+
unchanged_names counts the names of exports left out of both sets as
|
|
664
|
+
unchanged (--files): each counts on both sides when a move needs a name
|
|
665
|
+
unique on both sides.
|
|
666
|
+
"""
|
|
667
|
+
removed_keys = sorted((k for k in baseline if k not in current), key=_key_order)
|
|
668
|
+
added_keys = sorted((k for k in current if k not in baseline), key=_key_order)
|
|
669
|
+
|
|
670
|
+
# A leftover is paired as a move only when its name occurs once on each
|
|
671
|
+
# side; any other name left on both sides is ambiguous.
|
|
672
|
+
base_names = collections.Counter(name for name, _ in baseline)
|
|
673
|
+
curr_names = collections.Counter(name for name, _ in current)
|
|
674
|
+
if unchanged_names:
|
|
675
|
+
base_names.update(unchanged_names)
|
|
676
|
+
curr_names.update(unchanged_names)
|
|
677
|
+
removed_by_name: dict[str, list[tuple]] = collections.defaultdict(list)
|
|
678
|
+
added_by_name: dict[str, list[tuple]] = collections.defaultdict(list)
|
|
679
|
+
for key in removed_keys:
|
|
680
|
+
removed_by_name[key[0]].append(key)
|
|
681
|
+
for key in added_keys:
|
|
682
|
+
added_by_name[key[0]].append(key)
|
|
683
|
+
|
|
684
|
+
pairs: list[tuple[tuple, tuple]] = [(key, key) for key in baseline if key in current]
|
|
685
|
+
ambiguous_names: list[dict] = []
|
|
686
|
+
for name in sorted(set(removed_by_name) & set(added_by_name)):
|
|
687
|
+
if base_names[name] == 1 and curr_names[name] == 1:
|
|
688
|
+
pairs.append((removed_by_name[name][0], added_by_name[name][0]))
|
|
689
|
+
continue
|
|
690
|
+
ambiguous_names.append({
|
|
691
|
+
"name": name,
|
|
692
|
+
"removed": [_file_line(baseline[k]) for k in removed_by_name[name]],
|
|
693
|
+
"added": [_file_line(current[k]) for k in added_by_name[name]],
|
|
694
|
+
})
|
|
695
|
+
paired_base = {base_key for base_key, _ in pairs}
|
|
696
|
+
paired_curr = {curr_key for _, curr_key in pairs}
|
|
697
|
+
|
|
698
|
+
# Emit normalized entries (uniform shape) so added[] and removed[] render
|
|
699
|
+
# into the same report table even though they originate from the
|
|
700
|
+
# snapshot and provenance-map shapes.
|
|
701
|
+
added = [current[k] for k in added_keys if k not in paired_curr]
|
|
702
|
+
removed = [baseline[k] for k in removed_keys if k not in paired_base]
|
|
327
703
|
|
|
328
704
|
changed: list[dict] = []
|
|
329
705
|
moved: list[dict] = []
|
|
706
|
+
label_changes: list[dict] = []
|
|
707
|
+
signature_unverified: list[dict] = []
|
|
330
708
|
unchanged_count = 0
|
|
709
|
+
changed_exports = 0
|
|
331
710
|
|
|
332
|
-
for
|
|
333
|
-
base_rec = baseline[
|
|
334
|
-
curr_rec = current[
|
|
711
|
+
for base_key, curr_key in sorted(pairs, key=lambda pair: _key_order(pair[1])):
|
|
712
|
+
base_rec = baseline[base_key]
|
|
713
|
+
curr_rec = current[curr_key]
|
|
335
714
|
|
|
336
|
-
# File moves are tracked separately from field changes
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
if
|
|
715
|
+
# File moves are tracked separately from field changes, and a moved
|
|
716
|
+
# export's line is not compared: the move changes it.
|
|
717
|
+
is_move = base_key[1] is not None and curr_key[1] is not None and base_key[1] != curr_key[1]
|
|
718
|
+
if is_move:
|
|
340
719
|
moved.append({
|
|
341
|
-
"name": name,
|
|
342
|
-
"previous_file":
|
|
343
|
-
"current_file":
|
|
720
|
+
"name": curr_rec["name"],
|
|
721
|
+
"previous_file": base_rec["file"],
|
|
722
|
+
"current_file": curr_rec["file"],
|
|
723
|
+
"previous_line": base_rec["line"],
|
|
724
|
+
"line": curr_rec["line"],
|
|
725
|
+
"confidence": curr_rec["confidence"],
|
|
344
726
|
})
|
|
345
727
|
|
|
346
728
|
entry_changed = False
|
|
347
729
|
for field in DIFF_FIELDS:
|
|
730
|
+
if is_move and field == "line":
|
|
731
|
+
continue
|
|
348
732
|
base_val = base_rec.get(field)
|
|
349
733
|
curr_val = curr_rec.get(field)
|
|
350
734
|
# Compare only when data is present on both sides; a missing value
|
|
@@ -353,68 +737,193 @@ def diff_inventories(
|
|
|
353
737
|
continue
|
|
354
738
|
if base_val != curr_val:
|
|
355
739
|
changed.append({
|
|
356
|
-
"name": name,
|
|
740
|
+
"name": curr_rec["name"],
|
|
357
741
|
"field": field,
|
|
358
742
|
"baseline_value": base_val,
|
|
359
743
|
"current_value": curr_val,
|
|
744
|
+
"file": curr_rec["file"],
|
|
745
|
+
"line": curr_rec["line"],
|
|
746
|
+
"confidence": curr_rec["confidence"],
|
|
360
747
|
})
|
|
361
748
|
entry_changed = True
|
|
362
749
|
|
|
363
|
-
if
|
|
750
|
+
if entry_changed:
|
|
751
|
+
changed_exports += 1
|
|
752
|
+
else:
|
|
364
753
|
unchanged_count += 1
|
|
365
754
|
|
|
366
|
-
|
|
755
|
+
# Provenance labels are informational: reported, never counted as drift.
|
|
756
|
+
label_change = _label_change(base_rec, curr_rec)
|
|
757
|
+
if label_change is not None:
|
|
758
|
+
label_changes.append(label_change)
|
|
367
759
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if
|
|
372
|
-
|
|
760
|
+
# A signature part the two sides hold in different fields was never
|
|
761
|
+
# compared: reported, so an empty diff does not read as verified.
|
|
762
|
+
unverified = _unverified_signature(base_rec, curr_rec)
|
|
763
|
+
if unverified is not None:
|
|
764
|
+
signature_unverified.append(unverified)
|
|
373
765
|
|
|
374
766
|
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
767
|
"added": added,
|
|
383
768
|
"removed": removed,
|
|
384
769
|
"changed": changed,
|
|
385
770
|
"moved": moved,
|
|
771
|
+
"ambiguous_names": ambiguous_names,
|
|
772
|
+
"label_changes": label_changes,
|
|
773
|
+
"signature_unverified": signature_unverified,
|
|
386
774
|
"unchanged_count": unchanged_count,
|
|
387
|
-
"
|
|
775
|
+
"changed_exports": changed_exports,
|
|
388
776
|
}
|
|
389
777
|
|
|
390
778
|
|
|
779
|
+
def _summary(part: dict) -> dict:
|
|
780
|
+
return {
|
|
781
|
+
"added": len(part["added"]),
|
|
782
|
+
"removed": len(part["removed"]),
|
|
783
|
+
"changed": part["changed_exports"],
|
|
784
|
+
"moved": len(part["moved"]),
|
|
785
|
+
"unchanged": part["unchanged_count"],
|
|
786
|
+
"label_changes": len(part["label_changes"]),
|
|
787
|
+
"ambiguous_names": len(part["ambiguous_names"]),
|
|
788
|
+
"signature_unverified": len(part["signature_unverified"]),
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _group_of(entry: object, group_by: str) -> str | None:
|
|
793
|
+
value = entry.get(group_by) if isinstance(entry, dict) else None
|
|
794
|
+
return value.strip() if isinstance(value, str) and value.strip() else None
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _grouped(entries: list, group_by: str) -> bool:
|
|
798
|
+
return any(_group_of(entry, group_by) is not None for entry in entries)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _in_scope(entry: object, scope: set[str]) -> bool:
|
|
802
|
+
return isinstance(entry, dict) and _file_key(_first(entry, "file", "source_file")) in scope
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def diff_inventories(
|
|
806
|
+
baseline_entries: list[dict],
|
|
807
|
+
current_entries: list[dict],
|
|
808
|
+
reexport_map: dict[str, str] | None = None,
|
|
809
|
+
group_by: str | None = None,
|
|
810
|
+
sources: tuple[str, str] = ("the baseline inventory", "the current inventory"),
|
|
811
|
+
files: list[str] | None = None,
|
|
812
|
+
) -> dict:
|
|
813
|
+
"""Compute the structural diff between two export inventories.
|
|
814
|
+
|
|
815
|
+
Returns a dict with keys: summary, added, removed, changed, moved,
|
|
816
|
+
ambiguous_names, label_changes, signature_unverified, unchanged_count,
|
|
817
|
+
applied_transforms; with group_by, group_by and groups; and with files,
|
|
818
|
+
file_scope (see the module docstring). files limits the diff to the
|
|
819
|
+
exports of those files, and every other export is taken as unchanged.
|
|
820
|
+
|
|
821
|
+
Raises ValueError when group_by is set and one side has entries but
|
|
822
|
+
none with a group_by value while the other side has some; the message
|
|
823
|
+
names that side as sources does (the CLI passes the two file paths).
|
|
824
|
+
"""
|
|
825
|
+
if group_by:
|
|
826
|
+
sides = (
|
|
827
|
+
(baseline_entries, current_entries, sources[0]),
|
|
828
|
+
(current_entries, baseline_entries, sources[1]),
|
|
829
|
+
)
|
|
830
|
+
for entries, other, source in sides:
|
|
831
|
+
if entries and not _grouped(entries, group_by) and _grouped(other, group_by):
|
|
832
|
+
raise ValueError(f"--group-by {group_by}: {source} has no entry with {group_by}")
|
|
833
|
+
|
|
834
|
+
reexport_map = reexport_map or {}
|
|
835
|
+
transform_counts: collections.Counter = collections.Counter()
|
|
836
|
+
scope = None if files is None else {key for key in map(_file_key, files) if key}
|
|
837
|
+
|
|
838
|
+
base_groups: dict[str | None, list] = collections.defaultdict(list)
|
|
839
|
+
curr_groups: dict[str | None, list] = collections.defaultdict(list)
|
|
840
|
+
for entry in baseline_entries:
|
|
841
|
+
base_groups[_group_of(entry, group_by) if group_by else None].append(entry)
|
|
842
|
+
for entry in current_entries:
|
|
843
|
+
curr_groups[_group_of(entry, group_by) if group_by else None].append(entry)
|
|
844
|
+
values = sorted(set(base_groups) | set(curr_groups), key=lambda v: (v is None, v or ""))
|
|
845
|
+
|
|
846
|
+
total: dict = {name: [] for name in LISTS}
|
|
847
|
+
total.update(unchanged_count=0, changed_exports=0)
|
|
848
|
+
groups: list[dict] = []
|
|
849
|
+
left_out = {"baseline": 0, "current": 0}
|
|
850
|
+
for value in values or [None]:
|
|
851
|
+
base_part = base_groups.get(value, [])
|
|
852
|
+
curr_part = curr_groups.get(value, [])
|
|
853
|
+
unchanged_names: collections.Counter = collections.Counter()
|
|
854
|
+
if scope is not None:
|
|
855
|
+
# Exports outside the scope are normalized apart, so a transform
|
|
856
|
+
# they need is not reported: they are in no list and no count.
|
|
857
|
+
outside = {
|
|
858
|
+
side: _normalize_entries(
|
|
859
|
+
[e for e in entries if not _in_scope(e, scope)], reexport_map, collections.Counter()
|
|
860
|
+
)
|
|
861
|
+
for side, entries in (("baseline", base_part), ("current", curr_part))
|
|
862
|
+
}
|
|
863
|
+
unchanged_names.update(name for name, _ in outside["baseline"])
|
|
864
|
+
for side, records in outside.items():
|
|
865
|
+
left_out[side] += len(records)
|
|
866
|
+
base_part = [e for e in base_part if _in_scope(e, scope)]
|
|
867
|
+
curr_part = [e for e in curr_part if _in_scope(e, scope)]
|
|
868
|
+
part = _diff_records(
|
|
869
|
+
_normalize_entries(base_part, reexport_map, transform_counts),
|
|
870
|
+
_normalize_entries(curr_part, reexport_map, transform_counts),
|
|
871
|
+
unchanged_names,
|
|
872
|
+
)
|
|
873
|
+
if group_by:
|
|
874
|
+
for name in LISTS:
|
|
875
|
+
part[name] = [{group_by: value, **item} for item in part[name]]
|
|
876
|
+
groups.append({group_by: value, "summary": _summary(part)})
|
|
877
|
+
for name in LISTS:
|
|
878
|
+
total[name].extend(part[name])
|
|
879
|
+
total["unchanged_count"] += part["unchanged_count"]
|
|
880
|
+
total["changed_exports"] += part["changed_exports"]
|
|
881
|
+
|
|
882
|
+
applied_transforms = [
|
|
883
|
+
{"transform": name, "count": transform_counts[name]}
|
|
884
|
+
for name in sorted(transform_counts)
|
|
885
|
+
if transform_counts[name] > 0
|
|
886
|
+
]
|
|
887
|
+
|
|
888
|
+
result = {"summary": _summary(total)}
|
|
889
|
+
result.update((name, total[name]) for name in LISTS)
|
|
890
|
+
result["unchanged_count"] = total["unchanged_count"]
|
|
891
|
+
result["applied_transforms"] = applied_transforms
|
|
892
|
+
if group_by:
|
|
893
|
+
result["group_by"] = group_by
|
|
894
|
+
result["groups"] = groups
|
|
895
|
+
if scope is not None:
|
|
896
|
+
result["file_scope"] = {
|
|
897
|
+
"files": len(scope),
|
|
898
|
+
"baseline_left_out": left_out["baseline"],
|
|
899
|
+
"current_left_out": left_out["current"],
|
|
900
|
+
}
|
|
901
|
+
return result
|
|
902
|
+
|
|
903
|
+
|
|
391
904
|
# --------------------------------------------------------------------------
|
|
392
905
|
# CLI
|
|
393
906
|
# --------------------------------------------------------------------------
|
|
394
907
|
|
|
395
908
|
|
|
396
|
-
|
|
909
|
+
# Exit codes: no difference, differences found, error.
|
|
910
|
+
EXIT_SAME = 0
|
|
911
|
+
EXIT_DIFFERENT = 1
|
|
912
|
+
EXIT_ERROR = 2
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _fail(message: str) -> int:
|
|
916
|
+
print(json.dumps({"status": "error", "error": message}, indent=2))
|
|
917
|
+
return EXIT_ERROR
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def main(argv: list[str] | None = None) -> int:
|
|
921
|
+
# --help prints the module docstring: the input, matching and output
|
|
922
|
+
# contract a calling step cites.
|
|
397
923
|
parser = argparse.ArgumentParser(
|
|
398
924
|
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
|
-
),
|
|
925
|
+
description=__doc__,
|
|
411
926
|
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
927
|
)
|
|
419
928
|
parser.add_argument(
|
|
420
929
|
"baseline",
|
|
@@ -435,45 +944,89 @@ def main() -> int:
|
|
|
435
944
|
"derived from the baseline provenance map itself."
|
|
436
945
|
),
|
|
437
946
|
)
|
|
947
|
+
parser.add_argument(
|
|
948
|
+
"--group-by",
|
|
949
|
+
choices=GROUP_FIELDS,
|
|
950
|
+
help=(
|
|
951
|
+
"diff each group of entries on its own (source_library: a stack's "
|
|
952
|
+
"libraries) and tag every listed item with its group; an "
|
|
953
|
+
"inventory whose entries all lack the field is an error when "
|
|
954
|
+
"the other's have it"
|
|
955
|
+
),
|
|
956
|
+
)
|
|
957
|
+
parser.add_argument(
|
|
958
|
+
"--files",
|
|
959
|
+
metavar="FILE",
|
|
960
|
+
help=(
|
|
961
|
+
"diff only the exports of the files FILE names (a JSON array of "
|
|
962
|
+
"paths, or a skf-classify-changed-files.py classify output) and "
|
|
963
|
+
"take every other export as unchanged"
|
|
964
|
+
),
|
|
965
|
+
)
|
|
966
|
+
parser.add_argument(
|
|
967
|
+
"--current-extra",
|
|
968
|
+
metavar="FILE",
|
|
969
|
+
help=(
|
|
970
|
+
"an inventory merged into the current one by name and file: it fills "
|
|
971
|
+
"the fields a current entry lacks, and adds the entries no current "
|
|
972
|
+
"one has"
|
|
973
|
+
),
|
|
974
|
+
)
|
|
438
975
|
parser.add_argument(
|
|
439
976
|
"-o",
|
|
440
977
|
"--output",
|
|
441
978
|
metavar="FILE",
|
|
442
|
-
help="write JSON
|
|
979
|
+
help="write the JSON diff to FILE and print only a summary line on stdout",
|
|
443
980
|
)
|
|
444
981
|
|
|
445
|
-
args = parser.parse_args()
|
|
982
|
+
args = parser.parse_args(argv)
|
|
446
983
|
|
|
447
984
|
baseline_path = Path(args.baseline)
|
|
448
985
|
current_path = Path(args.current)
|
|
449
986
|
|
|
450
987
|
baseline_data, err = load_json(baseline_path)
|
|
451
988
|
if err:
|
|
452
|
-
|
|
453
|
-
return 1
|
|
989
|
+
return _fail(err)
|
|
454
990
|
current_data, err = load_json(current_path)
|
|
455
991
|
if err:
|
|
456
|
-
|
|
457
|
-
return 1
|
|
992
|
+
return _fail(err)
|
|
458
993
|
|
|
459
994
|
baseline_entries, err = entries_from_data(baseline_data, str(baseline_path))
|
|
460
995
|
if err:
|
|
461
|
-
|
|
462
|
-
return 1
|
|
996
|
+
return _fail(err)
|
|
463
997
|
current_entries, err = entries_from_data(current_data, str(current_path))
|
|
464
998
|
if err:
|
|
465
|
-
|
|
466
|
-
|
|
999
|
+
return _fail(err)
|
|
1000
|
+
if args.current_extra:
|
|
1001
|
+
extra_entries, err = load_inventory(Path(args.current_extra))
|
|
1002
|
+
if err:
|
|
1003
|
+
return _fail(err)
|
|
1004
|
+
current_entries = merge_extra(current_entries, extra_entries)
|
|
467
1005
|
|
|
468
1006
|
if args.reexport_map:
|
|
469
1007
|
reexport_map, err = load_reexport_map(Path(args.reexport_map))
|
|
470
1008
|
if err:
|
|
471
|
-
|
|
472
|
-
return 1
|
|
1009
|
+
return _fail(err)
|
|
473
1010
|
else:
|
|
474
1011
|
reexport_map = extract_reexport_map(baseline_data)
|
|
475
1012
|
|
|
476
|
-
|
|
1013
|
+
files = None
|
|
1014
|
+
if args.files:
|
|
1015
|
+
files_data, err = load_json(Path(args.files))
|
|
1016
|
+
if err:
|
|
1017
|
+
return _fail(err)
|
|
1018
|
+
files, err = files_from_data(files_data, args.files)
|
|
1019
|
+
if err:
|
|
1020
|
+
return _fail(err)
|
|
1021
|
+
|
|
1022
|
+
try:
|
|
1023
|
+
result = diff_inventories(
|
|
1024
|
+
baseline_entries, current_entries, reexport_map, args.group_by,
|
|
1025
|
+
sources=(str(baseline_path), str(current_path)),
|
|
1026
|
+
files=files,
|
|
1027
|
+
)
|
|
1028
|
+
except ValueError as exc:
|
|
1029
|
+
return _fail(str(exc))
|
|
477
1030
|
output_text = json.dumps(result, indent=2)
|
|
478
1031
|
|
|
479
1032
|
if args.output:
|
|
@@ -481,16 +1034,14 @@ def main() -> int:
|
|
|
481
1034
|
try:
|
|
482
1035
|
out_path.write_text(output_text + "\n", encoding="utf-8")
|
|
483
1036
|
except OSError as exc:
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
)
|
|
487
|
-
return 1
|
|
1037
|
+
return _fail(f"Cannot write output: {exc}")
|
|
1038
|
+
print(json.dumps({"status": "ok", "output": str(out_path), "summary": result["summary"]}))
|
|
488
1039
|
else:
|
|
489
1040
|
print(output_text)
|
|
490
1041
|
|
|
491
1042
|
summary = result["summary"]
|
|
492
1043
|
has_diff = summary["added"] or summary["removed"] or summary["changed"] or summary["moved"]
|
|
493
|
-
return
|
|
1044
|
+
return EXIT_DIFFERENT if has_diff else EXIT_SAME
|
|
494
1045
|
|
|
495
1046
|
|
|
496
1047
|
if __name__ == "__main__":
|