bmad-module-skill-forge 2.2.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +49 -49
- package/docs/404.md +10 -8
- package/docs/_data/pinned.yaml +22 -13
- package/docs/_internal/STABILITY.md +58 -35
- package/docs/agents.md +39 -53
- package/docs/architecture.md +65 -51
- package/docs/bmad-synergy.md +53 -51
- package/docs/campaign.md +127 -75
- package/docs/concepts.md +34 -28
- package/docs/examples.md +70 -68
- package/docs/forge-auto.md +34 -31
- package/docs/getting-started.md +118 -57
- package/docs/how-it-works.md +25 -21
- package/docs/index.md +15 -15
- package/docs/skill-model.md +70 -59
- package/docs/troubleshooting.md +143 -18
- package/docs/verifying-a-skill.md +87 -57
- package/docs/why-skf.md +24 -22
- package/docs/workflows.md +274 -110
- package/package.json +18 -10
- package/src/forger/preferences.yaml +8 -1
- package/src/knowledge/agentskills-spec.md +2 -0
- package/src/knowledge/ccc-bridge.md +40 -24
- package/src/knowledge/confidence-tiers.md +9 -5
- package/src/knowledge/overview.md +3 -3
- package/src/knowledge/progressive-capability.md +8 -8
- package/src/knowledge/provenance-tracking.md +9 -5
- package/src/knowledge/qmd-registry.md +3 -3
- package/src/knowledge/skf-knowledge-index.csv +2 -2
- package/src/knowledge/skill-lifecycle.md +18 -11
- package/src/knowledge/split-body-strategy.md +0 -3
- package/src/knowledge/tool-resolution.md +4 -2
- package/src/knowledge/version-paths.md +101 -15
- package/src/module-help.csv +3 -3
- package/src/module.yaml +10 -0
- package/src/shared/data/ast-grep-recipes.yaml +608 -0
- package/src/shared/data/ide-context-files.json +30 -0
- package/src/shared/health-check.md +31 -59
- package/src/shared/references/description-guard-protocol.md +12 -9
- package/src/shared/references/feasibility-report-schema.md +19 -13
- package/src/shared/references/headless-gate-convention.md +55 -3
- package/src/shared/references/output-contract-schema.md +19 -8
- package/src/shared/references/pipeline-contracts.md +94 -29
- package/src/shared/references/tessl-review.md +77 -0
- package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
- package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
- package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
- package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
- package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
- package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
- package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
- package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
- package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
- package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
- package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
- package/src/shared/scripts/skf-atomic-write.py +233 -64
- package/src/shared/scripts/skf-build-change-manifest.py +2753 -28
- package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
- package/src/shared/scripts/skf-check-unit-records.py +184 -0
- package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
- package/src/shared/scripts/skf-classify-changed-files.py +772 -0
- package/src/shared/scripts/skf-comention-pairs.py +1333 -123
- package/src/shared/scripts/skf-compare-file-hashes.py +267 -105
- package/src/shared/scripts/skf-count-imports.py +1867 -0
- package/src/shared/scripts/skf-count-tokens.py +1 -1
- package/src/shared/scripts/skf-derive-assembly-shape.py +21 -3
- package/src/shared/scripts/skf-description-guard.py +99 -11
- package/src/shared/scripts/skf-detect-docs.py +694 -2
- package/src/shared/scripts/skf-detect-language.py +486 -107
- package/src/shared/scripts/skf-detect-registry.py +887 -0
- package/src/shared/scripts/skf-detect-scripts-assets.py +197 -55
- package/src/shared/scripts/skf-detect-tools.py +209 -27
- package/src/shared/scripts/skf-detect-workspaces.py +506 -30
- package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
- package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
- package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
- package/src/shared/scripts/skf-extract-public-api.py +5247 -96
- package/src/shared/scripts/skf-extraction-inventory.py +1280 -0
- package/src/shared/scripts/skf-extraction-snapshot.py +664 -0
- package/src/shared/scripts/skf-fetch-temporal.py +674 -0
- package/src/shared/scripts/skf-find-cycles.py +97 -29
- package/src/shared/scripts/skf-find-test-report.py +476 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +469 -205
- package/src/shared/scripts/skf-github-fetch.py +365 -0
- package/src/shared/scripts/skf-github-probe.py +700 -0
- package/src/shared/scripts/skf-hash-content.py +180 -20
- package/src/shared/scripts/skf-language-corpora.py +1 -1
- package/src/shared/scripts/skf-load-provenance.py +161 -27
- package/src/shared/scripts/skf-manifest-ops.py +1 -1
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
- package/src/shared/scripts/skf-merge-doc-urls.py +143 -20
- package/src/shared/scripts/skf-names-present.py +221 -0
- package/src/shared/scripts/skf-pair-intersect.py +119 -28
- package/src/shared/scripts/skf-parse-gaps.py +1384 -0
- package/src/shared/scripts/skf-preapply.py +1 -1
- package/src/shared/scripts/skf-preflight.py +176 -28
- package/src/shared/scripts/skf-promote-staged.py +430 -0
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +64 -8
- package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
- package/src/shared/scripts/skf-quick-batch.py +774 -0
- package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
- package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
- package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
- package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
- package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
- package/src/shared/scripts/skf-resolve-authoritative-files.py +155 -14
- package/src/shared/scripts/skf-resolve-package.py +523 -106
- package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
- package/src/shared/scripts/skf-run-lock.py +577 -0
- package/src/shared/scripts/skf-scan-manifests.py +998 -57
- package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
- package/src/shared/scripts/skf-severity-classify.py +712 -108
- package/src/shared/scripts/skf-shape-detect.py +298 -204
- package/src/shared/scripts/skf-shard-body.py +9 -8
- package/src/shared/scripts/skf-skill-inventory.py +2412 -89
- package/src/shared/scripts/skf-skills-module.py +472 -0
- package/src/shared/scripts/skf-source-tree.py +1796 -0
- package/src/shared/scripts/skf-structural-diff.py +787 -121
- package/src/shared/scripts/skf-tessl-review.py +706 -0
- package/src/shared/scripts/skf-update-active-symlink.py +120 -67
- package/src/shared/scripts/skf-update-run-state.py +624 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
- package/src/shared/scripts/skf-validate-brief-schema.py +23 -2
- package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
- package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
- package/src/shared/scripts/skf-validate-output.py +712 -64
- package/src/shared/scripts/skf-validate-pins.py +1 -1
- package/src/shared/scripts/skf-verify-no-trace.py +116 -10
- package/src/shared/scripts/skf-verify-provenance-completeness.py +3945 -152
- package/src/shared/scripts/skf-write-skill-brief.py +406 -25
- package/src/shared/tool-requirements.yaml +201 -0
- package/src/skf-analyze-source/SKILL.md +41 -36
- package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
- package/src/skf-analyze-source/customize.toml +36 -24
- package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
- package/src/skf-analyze-source/references/continue.md +2 -13
- package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
- package/src/skf-analyze-source/references/generate-briefs.md +121 -55
- package/src/skf-analyze-source/references/headless-contract.md +42 -15
- package/src/skf-analyze-source/references/identify-units.md +96 -110
- package/src/skf-analyze-source/references/init.md +73 -71
- package/src/skf-analyze-source/references/map-and-detect.md +123 -105
- package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
- package/src/skf-analyze-source/references/recommend.md +51 -70
- package/src/skf-analyze-source/references/scan-project.md +60 -84
- package/src/skf-analyze-source/references/scan-root.md +39 -0
- package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
- package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
- package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
- package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
- package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
- package/src/skf-audit-skill/SKILL.md +43 -51
- package/src/skf-audit-skill/assets/drift-report-template.md +28 -3
- package/src/skf-audit-skill/customize.toml +41 -20
- package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
- package/src/skf-audit-skill/references/doc-drift.md +141 -0
- package/src/skf-audit-skill/references/headless-contract.md +29 -0
- package/src/skf-audit-skill/references/health-check.md +2 -0
- package/src/skf-audit-skill/references/init.md +149 -210
- package/src/skf-audit-skill/references/re-index.md +103 -110
- package/src/skf-audit-skill/references/report.md +96 -33
- package/src/skf-audit-skill/references/semantic-diff.md +34 -25
- package/src/skf-audit-skill/references/severity-classify.md +79 -65
- package/src/skf-audit-skill/references/severity-rules.md +15 -6
- package/src/skf-audit-skill/references/structural-diff.md +80 -90
- package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
- package/src/skf-audit-skill/scripts/render-drift-tables.py +712 -0
- package/src/skf-brief-skill/SKILL.md +37 -26
- package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +18 -25
- package/src/skf-brief-skill/assets/skill-brief-schema.md +30 -23
- package/src/skf-brief-skill/customize.toml +48 -20
- package/src/skf-brief-skill/references/analyze-target.md +144 -127
- package/src/skf-brief-skill/references/confirm-brief.md +11 -9
- package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
- package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
- package/src/skf-brief-skill/references/gather-intent.md +87 -182
- package/src/skf-brief-skill/references/headless-args.md +84 -16
- package/src/skf-brief-skill/references/health-check.md +1 -1
- package/src/skf-brief-skill/references/invocation-contract.md +18 -13
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
- package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
- package/src/skf-brief-skill/references/scope-definition.md +123 -47
- package/src/skf-brief-skill/references/step-auto-brief.md +26 -100
- package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
- package/src/skf-brief-skill/references/version-resolution.md +7 -10
- package/src/skf-brief-skill/references/write-brief.md +66 -57
- package/src/skf-campaign/SKILL.md +31 -34
- package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
- package/src/skf-campaign/customize.toml +47 -16
- package/src/skf-campaign/references/campaign-contracts.md +39 -29
- package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
- package/src/skf-campaign/references/health-check.md +7 -16
- package/src/skf-campaign/references/step-01-setup.md +46 -84
- package/src/skf-campaign/references/step-02-strategy.md +31 -30
- package/src/skf-campaign/references/step-03-pins.md +26 -18
- package/src/skf-campaign/references/step-04-provenance.md +24 -23
- package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
- package/src/skf-campaign/references/step-06-batch.md +41 -34
- package/src/skf-campaign/references/step-07-capstone.md +40 -25
- package/src/skf-campaign/references/step-08-verify.md +28 -29
- package/src/skf-campaign/references/step-09-refine.md +29 -30
- package/src/skf-campaign/references/step-10-export.md +48 -36
- package/src/skf-campaign/references/step-11-maintenance.md +31 -34
- package/src/skf-campaign/references/step-resume.md +37 -68
- package/src/skf-campaign/scripts/campaign-deps.py +57 -18
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
- package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
- package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
- package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
- package/src/skf-campaign/scripts/campaign-report.py +204 -37
- package/src/skf-campaign/scripts/campaign-state.py +1128 -0
- package/src/skf-campaign/scripts/campaign-status.py +10 -74
- package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
- package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
- package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
- package/src/skf-campaign/templates/campaign-report-template.md +5 -1
- package/src/skf-campaign/templates/kickoff-template.md +0 -14
- package/src/skf-create-skill/SKILL.md +27 -20
- package/src/skf-create-skill/assets/compile-assembly-rules.md +11 -8
- package/src/skf-create-skill/assets/skill-sections.md +21 -11
- package/src/skf-create-skill/customize.toml +20 -8
- package/src/skf-create-skill/references/authoritative-files-protocol.md +36 -58
- package/src/skf-create-skill/references/batch-mode.md +81 -0
- package/src/skf-create-skill/references/ccc-index-check.md +27 -0
- package/src/skf-create-skill/references/compile.md +119 -70
- package/src/skf-create-skill/references/component-extraction.md +113 -91
- package/src/skf-create-skill/references/enrich.md +41 -23
- package/src/skf-create-skill/references/entry-points-by-hand.md +37 -0
- package/src/skf-create-skill/references/extract.md +204 -162
- package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +11 -11
- package/src/skf-create-skill/references/extraction-patterns.md +588 -201
- package/src/skf-create-skill/references/generate-artifacts.md +145 -93
- package/src/skf-create-skill/references/health-check.md +1 -1
- package/src/skf-create-skill/references/load-brief.md +53 -36
- package/src/skf-create-skill/references/relabel-rule.md +12 -0
- package/src/skf-create-skill/references/report.md +37 -57
- package/src/skf-create-skill/references/source-resolution-protocols.md +58 -169
- package/src/skf-create-skill/references/step-auto-shard.md +20 -93
- package/src/skf-create-skill/references/step-doc-rot.md +44 -60
- package/src/skf-create-skill/references/step-doc-sources.md +28 -19
- package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
- package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
- package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
- package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
- package/src/skf-create-skill/references/validate.md +173 -99
- package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
- package/src/skf-create-stack-skill/SKILL.md +29 -56
- package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
- package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
- package/src/skf-create-stack-skill/customize.toml +23 -12
- package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
- package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
- package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
- package/src/skf-create-stack-skill/references/generate-output.md +138 -128
- package/src/skf-create-stack-skill/references/init.md +90 -32
- package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
- package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
- package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
- package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
- package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
- package/src/skf-create-stack-skill/references/report.md +39 -33
- package/src/skf-create-stack-skill/references/validate.md +20 -39
- package/src/skf-drop-skill/SKILL.md +28 -51
- package/src/skf-drop-skill/customize.toml +44 -30
- package/src/skf-drop-skill/references/execute.md +83 -146
- package/src/skf-drop-skill/references/health-check.md +2 -0
- package/src/skf-drop-skill/references/invocation-contract.md +64 -0
- package/src/skf-drop-skill/references/report.md +46 -30
- package/src/skf-drop-skill/references/select.md +122 -124
- package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
- package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
- package/src/skf-export-skill/SKILL.md +40 -62
- package/src/skf-export-skill/assets/managed-section-format.md +14 -50
- package/src/skf-export-skill/assets/snippet-format.md +3 -1
- package/src/skf-export-skill/customize.toml +42 -13
- package/src/skf-export-skill/references/generate-snippet.md +54 -46
- package/src/skf-export-skill/references/health-check.md +2 -0
- package/src/skf-export-skill/references/invocation-contract.md +15 -0
- package/src/skf-export-skill/references/load-skill.md +88 -62
- package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
- package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
- package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
- package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
- package/src/skf-export-skill/references/package.md +15 -73
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
- package/src/skf-export-skill/references/result-envelope.md +31 -16
- package/src/skf-export-skill/references/summary.md +41 -17
- package/src/skf-export-skill/references/token-report.md +4 -12
- package/src/skf-export-skill/references/update-context.md +118 -237
- package/src/skf-forger/SKILL.md +61 -37
- package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
- package/src/skf-forger/customize.toml +43 -0
- package/src/skf-forger/references/pipeline-mode.md +73 -18
- package/src/skf-forger/scripts/forge-status.py +299 -0
- package/src/skf-forger/scripts/parse-pipeline.py +322 -54
- package/src/skf-forger/scripts/pipeline-gate.py +428 -0
- package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
- package/src/skf-quick-skill/SKILL.md +54 -53
- package/src/skf-quick-skill/assets/skill-template.md +4 -2
- package/src/skf-quick-skill/customize.toml +51 -20
- package/src/skf-quick-skill/references/batch-mode.md +74 -21
- package/src/skf-quick-skill/references/compile.md +30 -31
- package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
- package/src/skf-quick-skill/references/finalize.md +69 -18
- package/src/skf-quick-skill/references/halt-contract.md +41 -29
- package/src/skf-quick-skill/references/health-check.md +5 -3
- package/src/skf-quick-skill/references/quick-extract.md +114 -45
- package/src/skf-quick-skill/references/registry-resolution.md +9 -64
- package/src/skf-quick-skill/references/resolve-target.md +116 -66
- package/src/skf-quick-skill/references/write-and-validate.md +79 -40
- package/src/skf-refine-architecture/SKILL.md +43 -53
- package/src/skf-refine-architecture/customize.toml +49 -20
- package/src/skf-refine-architecture/references/compile.md +97 -54
- package/src/skf-refine-architecture/references/exit-codes.md +30 -0
- package/src/skf-refine-architecture/references/finding-storage.md +21 -0
- package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
- package/src/skf-refine-architecture/references/improvements.md +14 -20
- package/src/skf-refine-architecture/references/init.md +98 -57
- package/src/skf-refine-architecture/references/issue-detection.md +32 -39
- package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
- package/src/skf-refine-architecture/references/report.md +65 -54
- package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
- package/src/skf-rename-skill/SKILL.md +19 -54
- package/src/skf-rename-skill/customize.toml +32 -26
- package/src/skf-rename-skill/references/execute.md +230 -184
- package/src/skf-rename-skill/references/exit-codes.md +7 -7
- package/src/skf-rename-skill/references/health-check.md +1 -7
- package/src/skf-rename-skill/references/invocation-contract.md +25 -0
- package/src/skf-rename-skill/references/report.md +63 -52
- package/src/skf-rename-skill/references/select.md +197 -115
- package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
- package/src/skf-setup/SKILL.md +26 -26
- package/src/skf-setup/customize.toml +14 -10
- package/src/skf-setup/references/auto-index.md +50 -63
- package/src/skf-setup/references/ccc-index.md +29 -90
- package/src/skf-setup/references/detect-and-tier.md +58 -67
- package/src/skf-setup/references/health-check.md +2 -0
- package/src/skf-setup/references/invocation-contract.md +14 -0
- package/src/skf-setup/references/report.md +43 -166
- package/src/skf-setup/references/tier-rules.md +5 -5
- package/src/skf-setup/references/write-config.md +34 -57
- package/src/skf-test-skill/SKILL.md +17 -57
- package/src/skf-test-skill/assets/output-section-formats.md +33 -34
- package/src/skf-test-skill/customize.toml +27 -13
- package/src/skf-test-skill/references/coherence-check.md +134 -62
- package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
- package/src/skf-test-skill/references/coverage-check.md +209 -264
- package/src/skf-test-skill/references/detect-mode.md +5 -5
- package/src/skf-test-skill/references/external-validators.md +94 -82
- package/src/skf-test-skill/references/health-check.md +6 -7
- package/src/skf-test-skill/references/init.md +164 -91
- package/src/skf-test-skill/references/invocation-contract.md +66 -0
- package/src/skf-test-skill/references/migration-section-rules.md +5 -2
- package/src/skf-test-skill/references/report.md +164 -142
- package/src/skf-test-skill/references/score.md +80 -107
- package/src/skf-test-skill/references/scoring-rules.md +42 -155
- package/src/skf-test-skill/references/source-access-protocol.md +18 -94
- package/src/skf-test-skill/references/step-hard-gate.md +51 -33
- package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
- package/src/skf-test-skill/scripts/build-result-context.py +380 -0
- package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
- package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
- package/src/skf-test-skill/scripts/compute-score.py +223 -45
- package/src/skf-test-skill/scripts/gap-ledger.py +1666 -0
- package/src/skf-test-skill/scripts/hard-gate.py +203 -0
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +1198 -0
- package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
- package/src/skf-test-skill/scripts/reconcile-coverage.py +547 -79
- package/src/skf-test-skill/scripts/score-signatures.py +479 -0
- package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
- package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +133 -56
- package/src/skf-test-skill/templates/test-report-template.md +21 -15
- package/src/skf-update-skill/SKILL.md +38 -25
- package/src/skf-update-skill/customize.toml +27 -12
- package/src/skf-update-skill/references/detect-changes.md +253 -223
- package/src/skf-update-skill/references/gap-driven.md +336 -0
- package/src/skf-update-skill/references/health-check.md +31 -5
- package/src/skf-update-skill/references/init.md +254 -84
- package/src/skf-update-skill/references/invocation-contract.md +12 -0
- package/src/skf-update-skill/references/manual-section-rules.md +6 -23
- package/src/skf-update-skill/references/merge.md +182 -49
- package/src/skf-update-skill/references/re-extract.md +53 -262
- package/src/skf-update-skill/references/report.md +102 -35
- package/src/skf-update-skill/references/write.md +357 -155
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +183 -46
- package/src/skf-verify-stack/SKILL.md +23 -42
- package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
- package/src/skf-verify-stack/customize.toml +55 -22
- package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
- package/src/skf-verify-stack/references/coverage.md +67 -64
- package/src/skf-verify-stack/references/exit-codes.md +21 -8
- package/src/skf-verify-stack/references/init.md +107 -62
- package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
- package/src/skf-verify-stack/references/integrations.md +106 -65
- package/src/skf-verify-stack/references/report.md +59 -42
- package/src/skf-verify-stack/references/requirements.md +32 -9
- package/src/skf-verify-stack/references/synthesize.md +53 -39
- package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
- package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
- package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
- package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
- package/tools/cli/commands/install.js +11 -4
- package/tools/cli/commands/status.js +15 -3
- package/tools/cli/commands/update.js +11 -4
- package/tools/cli/lib/tool-check.js +320 -0
- package/tools/cli/lib/version-check.js +22 -9
- package/tools/cli/skf-cli.js +4 -1
- package/.gitattributes +0 -16
- package/.nvmrc +0 -1
- package/docs/_internal/RELEASING.md +0 -679
- package/release-audits/v1.0.0-launch-audit.md +0 -728
- package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
- package/src/skf-campaign/manifest.yaml +0 -15
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
- package/src/skf-create-skill/references/ecosystem-check.md +0 -74
- package/src/skf-drop-skill/references/headless-contract.md +0 -18
- package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
- package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
- package/src/skf-update-skill/references/validate.md +0 -124
- package/tools/validate-docs-drift.js +0 -233
- package/tools/validate-docs-links.js +0 -665
|
@@ -0,0 +1,1198 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
|
+
# dependencies = ["pyyaml"]
|
|
5
|
+
# ///
|
|
6
|
+
"""SKF test-skill coverage inputs: the counts, name sets and candidates coverage-check reads.
|
|
7
|
+
|
|
8
|
+
coverage-check.md decides whether a bookkeeping variant is a real export,
|
|
9
|
+
and makes the judgments the denominator clause needs where no file holds
|
|
10
|
+
them (--pattern-reference-intent, and --curated-subset and --barrel where
|
|
11
|
+
the extraction does not say); the clause, everything it counts, folds or
|
|
12
|
+
unions on the way runs here, so no count is made by hand. Three subcommands, each
|
|
13
|
+
reading files and writing one JSON object:
|
|
14
|
+
|
|
15
|
+
census docs-only detection. Counts the citations SKILL.md and each .md
|
|
16
|
+
file directly in references/ write, by form: `[EXT:` against
|
|
17
|
+
the local forms `[AST:`, `[SRC:` and a stack's `[from skill:`
|
|
18
|
+
(`[QMD:` and `[DOC:` are counted and decide nothing). A skill is
|
|
19
|
+
docs-only when it cites `[EXT:` and no local form.
|
|
20
|
+
|
|
21
|
+
metadata the metadata.json and provenance-map counts. Cluster A
|
|
22
|
+
(`stats.exports_public_api`, the `exports[]` length) and Cluster
|
|
23
|
+
B (`stats.exports_documented`, the provenance entry names, the
|
|
24
|
+
`confidence_distribution`) under the keys
|
|
25
|
+
check-metadata-coherence.py reads; the provenance named exports
|
|
26
|
+
(`::` impl-block methods left out); the inflation signature
|
|
27
|
+
(`stats.exports_documented` equals `stats.effective_denominator`,
|
|
28
|
+
or, for a map with a named entry and no `::` entry, its distinct
|
|
29
|
+
entry names do: the stored count counts each name once, the map
|
|
30
|
+
holds a name passed on from two files twice)
|
|
31
|
+
with the declared names verify-declared-numerator.py looks up
|
|
32
|
+
(the `exports[]` names, else the provenance named exports); and,
|
|
33
|
+
for a stack, its composition surface: the distinct provenance
|
|
34
|
+
named exports when the map has entries, else each library and
|
|
35
|
+
each integration pair (`{a} + {b}`, as the stack's Library Pair
|
|
36
|
+
Integrations heading writes it), as reconcile-coverage.py's
|
|
37
|
+
stack branch takes them.
|
|
38
|
+
|
|
39
|
+
surface the enumerated source surface, from every input given (their
|
|
40
|
+
union):
|
|
41
|
+
--extraction skf-extract-public-api.py --mode full output:
|
|
42
|
+
the names in entry_point_diff.public, a
|
|
43
|
+
namespace with a `members` list given as its
|
|
44
|
+
members (the names its module passes on, each
|
|
45
|
+
at its file, once with a public name of the
|
|
46
|
+
same name and file), less the outside_scope
|
|
47
|
+
names (defined in a file the brief scopes
|
|
48
|
+
out); exports[] only gives each name's
|
|
49
|
+
kind, line and signature line, looked up by the
|
|
50
|
+
name its defining file gives it (an aliased
|
|
51
|
+
re-export's `local`) and that file.
|
|
52
|
+
extraction_gaps are listed to read by eye, and
|
|
53
|
+
the runner's warnings join `warnings`. A Python
|
|
54
|
+
top __init__.py inside the folder of another top
|
|
55
|
+
that exports a public name (a sub-package
|
|
56
|
+
behind a folder with no __init__.py) is no
|
|
57
|
+
barrel, unless scope.include or tier_a_include
|
|
58
|
+
lists its file: its names leave `all`, `root`
|
|
59
|
+
and the umbrella ratio and are listed in
|
|
60
|
+
`excluded.nestedEntries`, but keep their records
|
|
61
|
+
(so the scope.include and tier_a_include sets)
|
|
62
|
+
and their outside_scope and extraction_gaps
|
|
63
|
+
rows. When the runner lists an
|
|
64
|
+
include glob that matches no file
|
|
65
|
+
(scope.unmatched_include: a brief older than the
|
|
66
|
+
source), each outside_scope name a root entry
|
|
67
|
+
point exports is restored, with the file and
|
|
68
|
+
line of its outside_scope row, when no include,
|
|
69
|
+
exclude or declined scope-expansion amendment
|
|
70
|
+
(latest action skipped or demoted-include, from
|
|
71
|
+
--brief) matches its file. A tier_a_include glob
|
|
72
|
+
the runner lists as matching no file
|
|
73
|
+
(scope.unmatched_tier_a_include, [] when the key
|
|
74
|
+
is absent) counts no name. An extraction the
|
|
75
|
+
per-file scan replaces (extraction.fallback.needed)
|
|
76
|
+
gives no name and no set: only its scope, its
|
|
77
|
+
stale globs and its `extraction` object, so the
|
|
78
|
+
fallback scan passes it beside its results.
|
|
79
|
+
--quick skf-extract-public-api.py --mode quick output,
|
|
80
|
+
each name at its defined_in (the file that
|
|
81
|
+
defines it), else at its source_file
|
|
82
|
+
--per-file a subagent's per-file result (exports_found and
|
|
83
|
+
types_found), schema-checked as
|
|
84
|
+
score-signatures.py checks it
|
|
85
|
+
--fallback-scan
|
|
86
|
+
the --per-file results are the fallback
|
|
87
|
+
per-file scan's, each at the file that defines
|
|
88
|
+
its names (a Quick-tier or State 4 result is at
|
|
89
|
+
its entry file): as with an extraction, no
|
|
90
|
+
tier_a_include set is no tier A list
|
|
91
|
+
--name a name read by eye whose file is not known
|
|
92
|
+
--provenance the provenance entries' named exports (State 2)
|
|
93
|
+
--metadata the metadata.json exports[] names (States 2, 3)
|
|
94
|
+
A surface read from the source (--extraction, --quick or
|
|
95
|
+
--per-file) takes no name from --provenance and --metadata:
|
|
96
|
+
they are the baselines of the candidates and guards. Without
|
|
97
|
+
one, with --provenance and --metadata both, `state2` gives the
|
|
98
|
+
provenance count, the metadata count and their union count,
|
|
99
|
+
which compute-score.py turns into the State 2 divergence. --fold
|
|
100
|
+
folds the bookkeeping variants among the provenance and metadata
|
|
101
|
+
names to their base name (`X_def`, `X_exact`, `a11y_X`, and
|
|
102
|
+
`<prefix>X` for each --fold-prefix when `X` is itself a name)
|
|
103
|
+
unless --keep names the variant as a real export, and
|
|
104
|
+
`canonical` gives the provenance map's fold summary; a map
|
|
105
|
+
whose distinct named exports number
|
|
106
|
+
`stats.effective_denominator` (--metadata) is already canonical
|
|
107
|
+
and is not folded (`canonical.skipped`), and `residual` is the
|
|
108
|
+
canonical count less that stored count. --brief gives the
|
|
109
|
+
skill brief's scope, scope type, language and scope-expansion
|
|
110
|
+
amendments; an extraction's recorded scope wins over its scope.
|
|
111
|
+
|
|
112
|
+
Name sets (surface `sets`, each sorted): `all` (the union above, less a
|
|
113
|
+
nested Python top's names);
|
|
114
|
+
`scope.include` and `tier_a_include` when the scope has those globs and a
|
|
115
|
+
record names a file: the names whose file (an extraction's definition file,
|
|
116
|
+
the quick output's defined_in, else its source_file, a per-file result's
|
|
117
|
+
file, a provenance entry's source_file) matches a glob of the list and no
|
|
118
|
+
scope.exclude glob,
|
|
119
|
+
and for `tier_a_include` a scope.include glob too when the scope has one,
|
|
120
|
+
so a name with no file is in `all` only and a restored name is in no
|
|
121
|
+
tier_a_include set (a tier_a_include glob an extraction lists as matching
|
|
122
|
+
no file is left out of the list, and so is one whose folder or file
|
|
123
|
+
differs from every scope.include glob's before any `*` or `?`, such as
|
|
124
|
+
`gen/**` beside `src/**`, and so is one a scope.exclude glob covers
|
|
125
|
+
segment by segment, such as `src/gen/**` or `src/gen/extra.ts` beside an
|
|
126
|
+
exclude of `src/gen/**`; a list that counts no name, each glob left out
|
|
127
|
+
or matching only excluded files, files scope.include leaves out or files
|
|
128
|
+
no record names, gives no tier_a_include set); and, from an
|
|
129
|
+
extraction only, `subpaths` (the names a non-root `exports` map subpath
|
|
130
|
+
without `*` reaches) and `root` (the names the root entry points reach).
|
|
131
|
+
reconcile-coverage.py and score-signatures.py --surface-set pick one.
|
|
132
|
+
|
|
133
|
+
Candidates and guards (with an extraction, --brief or --metadata): each
|
|
134
|
+
set's count, the metadata `stats.effective_denominator` (--metadata), and
|
|
135
|
+
the extractor's own effective_denominator and its basis. Both guards run
|
|
136
|
+
only on a surface read from the source. The deflation guard compares the
|
|
137
|
+
re-derived scope.include set (the `all` set without one) with
|
|
138
|
+
`stats.effective_denominator` and fires above DEFLATION_PCT when there is
|
|
139
|
+
no tier A list (with an extraction or --fallback-scan, no tier_a_include
|
|
140
|
+
set; otherwise no tier_a_include glob left in the list, since a name read
|
|
141
|
+
by eye at Quick tier or State 4, and a quick name with no defined_in,
|
|
142
|
+
carries its entry file, not the file that defines it); a stored 0 is
|
|
143
|
+
deflation, not a
|
|
144
|
+
denominator (`pct` null), and fires under that condition when any name is
|
|
145
|
+
re-derived. The inflation guard compares the scope.include set with the
|
|
146
|
+
provenance entry count (--provenance) and fires above INFLATION_PCT under
|
|
147
|
+
the same condition; `guards.tierAInclude` lists the scope's
|
|
148
|
+
tier_a_include globs ([] when it has none) for gap-ledger.py's wording;
|
|
149
|
+
the umbrella ratio (an
|
|
150
|
+
extraction only) is the share of the root entry points' names that are
|
|
151
|
+
re-exports (a namespace with `members` counting as its members there
|
|
152
|
+
too, each a re-export), and above UMBRELLA_RATIO the barrel is an
|
|
153
|
+
umbrella; the
|
|
154
|
+
stale-scope guard (an extraction with scope.include or tier_a_include only)
|
|
155
|
+
fires when the runner lists an include or tier_a_include glob that matches
|
|
156
|
+
no file, and lists those globs (`unmatchedInclude`,
|
|
157
|
+
`unmatchedTierAInclude`) and the names restored for the include globs, each
|
|
158
|
+
with its file and line (`restored`). Globs follow
|
|
159
|
+
skf-extract-public-api.py: `**` spans any number of path segments, none
|
|
160
|
+
included, and `*` and `?` stay inside one.
|
|
161
|
+
|
|
162
|
+
Denominator (surface `denominator`): the Source API Surface Definition's
|
|
163
|
+
clause (source-access-protocol.md), the branch reconcile-coverage.py takes
|
|
164
|
+
and the set it and score-signatures.py --surface-set read: {clause, branch,
|
|
165
|
+
surfaceSet, ladderStep, absentSet, rebuild, barrel}. The inputs: the scope
|
|
166
|
+
type (the brief's, else the extraction's, else metadata.json's
|
|
167
|
+
`scope_type`), metadata.json's `skill_type` and `stats.effective_denominator`,
|
|
168
|
+
the extraction's `arms`, the deflation guard, and:
|
|
169
|
+
monorepo --monorepo (the workspace layout skf-detect-workspaces.py
|
|
170
|
+
found), or the extraction's `arms.monorepo`.
|
|
171
|
+
curated the extraction's `counts.curated_subset` (the rule Create Skill
|
|
172
|
+
stores the count by), else --curated-subset (the workflow's
|
|
173
|
+
judgment that a monorepo's scope.include lists a curated subset).
|
|
174
|
+
barrel (`barrel`: "barrel", "empty-barrel", "no-entry-point" or null)
|
|
175
|
+
a complete extraction's `entry_points.status`; for one the
|
|
176
|
+
per-file scan replaces, "no-entry-point" when it read a file in
|
|
177
|
+
scope and found no entry point (`entry_points.files` empty), as
|
|
178
|
+
a complete run says; a quick output's (an export is a barrel,
|
|
179
|
+
none and no warning an empty one); else --barrel (`barrel`,
|
|
180
|
+
`empty` or `none`, what the workflow read); else null.
|
|
181
|
+
--pattern-reference-intent the workflow's judgment that scope.notes
|
|
182
|
+
flags pattern-reference intent.
|
|
183
|
+
The first clause that applies:
|
|
184
|
+
stack skill_type stack: branch `stack`, no set.
|
|
185
|
+
pattern-reference not a monorepo, no non-empty barrel, a scope type other
|
|
186
|
+
than specific-modules, component-library, docs-only and
|
|
187
|
+
reference-app, not public-api with a multi-subpath
|
|
188
|
+
`exports` map, and either the intent flag or no entry
|
|
189
|
+
point at all with no such map: the tier_a_include set,
|
|
190
|
+
else `all` with `rebuild` `provenance-fold` on a surface
|
|
191
|
+
read from the source (build it again from the provenance
|
|
192
|
+
map alone, folded), `all` on one that is not.
|
|
193
|
+
empty-barrel an empty barrel: the scope.include set.
|
|
194
|
+
stratified-scope --monorepo and --curated-subset, not full-library.
|
|
195
|
+
specific-modules scope type specific-modules, not --monorepo.
|
|
196
|
+
multi-entry arms.multi_subpath_exports, not --monorepo, a
|
|
197
|
+
full-library or public-api scope.
|
|
198
|
+
standard-barrel any other: the `all` set.
|
|
199
|
+
The last three walk a ladder, `ladderStep` naming the rung taken: rung 1,
|
|
200
|
+
`stats.effective_denominator` when metadata.json has one above 0 (a stored
|
|
201
|
+
0 is no denominator), is the `scalar` branch, whose `surfaceSet` (the set score-signatures.py scores) is
|
|
202
|
+
tier_a_include, else scope.include, else all, unless the deflation guard
|
|
203
|
+
fires: then the barrel branch over scope.include. The next rungs are sets,
|
|
204
|
+
each taken when the surface has it, the last taken in any case:
|
|
205
|
+
tier_a_include then scope.include (stratified-scope), scope.include
|
|
206
|
+
(specific-modules), tier_a_include then subpaths (multi-entry). A set the
|
|
207
|
+
surface lacks is counted as `all`, and `absentSet` names it.
|
|
208
|
+
|
|
209
|
+
Fallback verdict: `extraction.fallback.needed` is true when the extractor
|
|
210
|
+
found no ast-grep (`no-ast-grep`), read no file in scope, read files only
|
|
211
|
+
in languages it has no recipe for (`files_without_recipes` and no export),
|
|
212
|
+
or found files no recipe reads while the skill's language (the --brief
|
|
213
|
+
`language`, else the metadata.json `language`) is not one RECIPE_LANGUAGES
|
|
214
|
+
holds, so the per-file scan has to run instead. An `incomplete` run keeps
|
|
215
|
+
its exports; `extraction.readByEye` lists the files its errors and file
|
|
216
|
+
issues name.
|
|
217
|
+
|
|
218
|
+
Usage:
|
|
219
|
+
uv run load-coverage-inputs.py census --skill-dir <dir> [--output <file>]
|
|
220
|
+
uv run load-coverage-inputs.py metadata --metadata <metadata.json> \\
|
|
221
|
+
[--provenance <provenance-map.json>] [--output <file>]
|
|
222
|
+
uv run load-coverage-inputs.py surface [--extraction <file>] [--quick <file>]... \\
|
|
223
|
+
[--per-file <file>]... [--fallback-scan] [--name <name>]... [--provenance <file>] \\
|
|
224
|
+
[--metadata <file>] [--brief <skill-brief.yaml>] [--fold] [--fold-prefix <prefix>]... \\
|
|
225
|
+
[--keep <name>]... [--monorepo] [--pattern-reference-intent] [--curated-subset] \\
|
|
226
|
+
[--barrel barrel|empty|none] [--output <file>]
|
|
227
|
+
|
|
228
|
+
Exit codes:
|
|
229
|
+
0 the result was printed (and written to --output)
|
|
230
|
+
1 an input file is missing, unreadable or not the JSON (or the YAML
|
|
231
|
+
brief) it should be (one line on stderr)
|
|
232
|
+
2 a per-file result breaks the schema: {"valid": false, "violations":
|
|
233
|
+
[...]} names each breach and nothing is written to --output; or a
|
|
234
|
+
usage error (argparse)
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
from __future__ import annotations
|
|
238
|
+
|
|
239
|
+
import argparse
|
|
240
|
+
import importlib.util
|
|
241
|
+
import json
|
|
242
|
+
import math
|
|
243
|
+
import posixpath
|
|
244
|
+
import re
|
|
245
|
+
import sys
|
|
246
|
+
from pathlib import Path
|
|
247
|
+
|
|
248
|
+
DEFLATION_PCT = 25
|
|
249
|
+
INFLATION_PCT = 25
|
|
250
|
+
UMBRELLA_RATIO = 0.5
|
|
251
|
+
METHOD_SEPARATOR = "::"
|
|
252
|
+
CITATION_FORMS = {
|
|
253
|
+
"EXT": "[EXT:",
|
|
254
|
+
"AST": "[AST:",
|
|
255
|
+
"SRC": "[SRC:",
|
|
256
|
+
"from skill": "[from skill:",
|
|
257
|
+
"QMD": "[QMD:",
|
|
258
|
+
"DOC": "[DOC:",
|
|
259
|
+
}
|
|
260
|
+
LOCAL_FORMS = ("AST", "SRC", "from skill")
|
|
261
|
+
# Bookkeeping variants folded to their base name, in rule order.
|
|
262
|
+
FOLD_SUFFIXES = ("_def", "_exact")
|
|
263
|
+
FOLD_PREFIX = "a11y_"
|
|
264
|
+
ROOT_SUBPATHS = (None, ".")
|
|
265
|
+
# The latest action of a scope-expansion amendment that keeps its path out of scope.
|
|
266
|
+
DECLINED_ACTIONS = ("skipped", "demoted-include")
|
|
267
|
+
# The rungs each laddered clause tries in order, `scalar` being stats.effective_denominator.
|
|
268
|
+
CLAUSE_LADDERS = {
|
|
269
|
+
"stratified-scope": ("scalar", "tier_a_include", "scope.include"),
|
|
270
|
+
"specific-modules": ("scalar", "scope.include"),
|
|
271
|
+
"multi-entry": ("scalar", "tier_a_include", "subpaths"),
|
|
272
|
+
}
|
|
273
|
+
# --barrel: what the workflow read of the entry points, where surface cannot tell -> the extractor's status.
|
|
274
|
+
BARREL_FLAG = {"barrel": "barrel", "empty": "empty-barrel", "none": "no-entry-point"}
|
|
275
|
+
# The scope types the pattern-reference clause never applies to (a reference app's brief carries its own
|
|
276
|
+
# pattern-surface denominator).
|
|
277
|
+
NOT_PATTERN_REFERENCE = frozenset({"specific-modules", "component-library", "docs-only", "reference-app"})
|
|
278
|
+
# The languages skf-extract-public-api.py's recipes read (its LANGUAGE_FAMILIES).
|
|
279
|
+
RECIPE_LANGUAGES = frozenset({"python", "rust", "go", "golang", "typescript", "ts", "tsx", "javascript", "js",
|
|
280
|
+
"jsx", "vue"})
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class InputError(Exception):
|
|
284
|
+
"""An input file that cannot be used."""
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class SchemaError(Exception):
|
|
288
|
+
"""Per-file results that break the schema: `violations` names each breach."""
|
|
289
|
+
|
|
290
|
+
def __init__(self, violations: list[str]):
|
|
291
|
+
super().__init__("; ".join(violations))
|
|
292
|
+
self.violations = violations
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
_SIBLINGS: dict[str, object] = {}
|
|
296
|
+
SHARED_SCRIPTS = Path(__file__).resolve().parent.parent.parent / "shared" / "scripts"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _sibling(filename: str, folder: Path | None = None):
|
|
300
|
+
"""A script beside this one, loaded once: validate-inventory.py (the doc
|
|
301
|
+
text) and score-signatures.py (the per-file result schema); or, with
|
|
302
|
+
`folder`, one in that folder: SHARED_SCRIPTS'
|
|
303
|
+
skf-resolve-authoritative-files.py (a skill brief's YAML)."""
|
|
304
|
+
module = _SIBLINGS.get(filename)
|
|
305
|
+
if module is None:
|
|
306
|
+
path = (folder or Path(__file__).resolve().parent) / filename
|
|
307
|
+
spec = importlib.util.spec_from_file_location("skf_" + filename[:-3].replace("-", "_"), path)
|
|
308
|
+
if spec is None or spec.loader is None or not path.is_file():
|
|
309
|
+
where = f"in {folder}" if folder else f"beside {Path(__file__).name}"
|
|
310
|
+
raise InputError(f"{filename} not found {where}")
|
|
311
|
+
module = importlib.util.module_from_spec(spec)
|
|
312
|
+
spec.loader.exec_module(module)
|
|
313
|
+
_SIBLINGS[filename] = module
|
|
314
|
+
return module
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def round2(value: float) -> float:
|
|
318
|
+
"""Round to 2 decimals with JS-compatible half-up rounding (matches compute-score.py)."""
|
|
319
|
+
return math.floor(value * 100 + 0.5) / 100
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _read_json(path: str, flag: str):
|
|
323
|
+
try:
|
|
324
|
+
return json.loads(Path(path).read_bytes().decode("utf-8-sig"))
|
|
325
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
326
|
+
raise InputError(f"cannot read {flag} {path}: {exc}") from exc
|
|
327
|
+
except json.JSONDecodeError as exc:
|
|
328
|
+
raise InputError(f"{flag} {path} is not JSON: {exc.msg}") from exc
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _object(path: str, flag: str) -> dict:
|
|
332
|
+
data = _read_json(path, flag)
|
|
333
|
+
if not isinstance(data, dict):
|
|
334
|
+
raise InputError(f"{flag} {path} is not a JSON object")
|
|
335
|
+
return data
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _count(value) -> int | None:
|
|
339
|
+
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _export_name(item) -> str | None:
|
|
343
|
+
"""The name of a metadata exports[] item: a string, or an object's name."""
|
|
344
|
+
if isinstance(item, str):
|
|
345
|
+
return item or None
|
|
346
|
+
if isinstance(item, dict):
|
|
347
|
+
for key in ("name", "export_name"):
|
|
348
|
+
if isinstance(item.get(key), str) and item[key]:
|
|
349
|
+
return item[key]
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _entries(provenance: dict | None) -> list[dict]:
|
|
354
|
+
entries = provenance.get("entries") if isinstance(provenance, dict) else None
|
|
355
|
+
return [e for e in entries if isinstance(e, dict)] if isinstance(entries, list) else []
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _entry_names(provenance: dict | None) -> list[str]:
|
|
359
|
+
return [e["export_name"] for e in _entries(provenance)
|
|
360
|
+
if isinstance(e.get("export_name"), str) and e["export_name"]]
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# --------------------------------------------------------------------------
|
|
364
|
+
# census
|
|
365
|
+
# --------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def census(skill_dir: str) -> dict:
|
|
369
|
+
root = Path(skill_dir)
|
|
370
|
+
if not (root / "SKILL.md").is_file():
|
|
371
|
+
raise InputError(f"--skill-dir {skill_dir} holds no SKILL.md")
|
|
372
|
+
try:
|
|
373
|
+
text = _sibling("validate-inventory.py").load_doc_text(skill_dir)
|
|
374
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
375
|
+
raise InputError(f"cannot read the skill text under {skill_dir}: {exc}") from exc
|
|
376
|
+
counts = {form: text.count(marker) for form, marker in CITATION_FORMS.items()}
|
|
377
|
+
local = sum(counts[form] for form in LOCAL_FORMS)
|
|
378
|
+
refs = root / "references"
|
|
379
|
+
files = ["SKILL.md"] + ([f"references/{p.name}" for p in sorted(refs.glob("*.md"))] if refs.is_dir() else [])
|
|
380
|
+
return {
|
|
381
|
+
"citations": counts,
|
|
382
|
+
"localCitations": local,
|
|
383
|
+
"docsOnly": counts["EXT"] > 0 and local == 0,
|
|
384
|
+
"files": files,
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# --------------------------------------------------------------------------
|
|
389
|
+
# metadata
|
|
390
|
+
# --------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _pair_name(pair) -> str | None:
|
|
394
|
+
if isinstance(pair, str):
|
|
395
|
+
return pair or None
|
|
396
|
+
if isinstance(pair, (list, tuple)) and len(pair) == 2 and all(isinstance(p, str) and p for p in pair):
|
|
397
|
+
return f"{pair[0]} + {pair[1]}"
|
|
398
|
+
if isinstance(pair, dict) and all(isinstance(pair.get(k), str) and pair[k] for k in ("a", "b")):
|
|
399
|
+
return f"{pair['a']} + {pair['b']}"
|
|
400
|
+
return None
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _library_name(lib) -> str | None:
|
|
404
|
+
if isinstance(lib, str):
|
|
405
|
+
return lib or None
|
|
406
|
+
if isinstance(lib, dict) and isinstance(lib.get("name"), str) and lib["name"]:
|
|
407
|
+
return lib["name"]
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def metadata_inputs(meta: dict, provenance: dict | None) -> dict:
|
|
412
|
+
stats = meta.get("stats") if isinstance(meta.get("stats"), dict) else {}
|
|
413
|
+
exports = meta.get("exports")
|
|
414
|
+
names = [n for n in (_export_name(e) for e in exports) if n] if isinstance(exports, list) else []
|
|
415
|
+
entry_names = _entry_names(provenance) if provenance is not None else None
|
|
416
|
+
named = sorted({n for n in entry_names or [] if METHOD_SEPARATOR not in n})
|
|
417
|
+
dist = stats.get("confidence_distribution")
|
|
418
|
+
if not isinstance(dist, dict):
|
|
419
|
+
dist = meta.get("confidence_distribution") if isinstance(meta.get("confidence_distribution"), dict) else None
|
|
420
|
+
if dist is not None:
|
|
421
|
+
dist = {tier: dist[tier] for tier in ("t1", "t1_low", "t2", "t3") if _count(dist.get(tier)) is not None}
|
|
422
|
+
documented = _count(stats.get("exports_documented"))
|
|
423
|
+
effective = _count(stats.get("effective_denominator"))
|
|
424
|
+
skill_type = meta.get("skill_type") if isinstance(meta.get("skill_type"), str) else None
|
|
425
|
+
|
|
426
|
+
stack = None
|
|
427
|
+
if skill_type == "stack":
|
|
428
|
+
if named:
|
|
429
|
+
stack = {"basis": "provenance", "denominator": len(named), "compositionNames": named}
|
|
430
|
+
else:
|
|
431
|
+
libraries = [n for n in (_library_name(lib) for lib in meta.get("libraries") or []) if n]
|
|
432
|
+
pairs = [n for n in (_pair_name(p) for p in meta.get("integration_pairs") or []) if n]
|
|
433
|
+
stack = {"basis": "composition", "denominator": len(libraries) + len(pairs),
|
|
434
|
+
"compositionNames": libraries + pairs}
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
"skillType": skill_type,
|
|
438
|
+
"scopeType": meta.get("scope_type") if isinstance(meta.get("scope_type"), str) else None,
|
|
439
|
+
"clusterA": {
|
|
440
|
+
"exports_public_api": _count(stats.get("exports_public_api")),
|
|
441
|
+
"exports_length": len(exports) if isinstance(exports, list) else None,
|
|
442
|
+
},
|
|
443
|
+
"clusterB": {"exports_documented": documented},
|
|
444
|
+
"provenanceExportNames": entry_names,
|
|
445
|
+
"confidenceDistribution": dist or None,
|
|
446
|
+
"namedExports": {"count": len(named), "names": named} if entry_names is not None else None,
|
|
447
|
+
"effectiveDenominator": effective,
|
|
448
|
+
# The stored count counts each name once, while the map (so stats.exports_documented) holds an entry for
|
|
449
|
+
# each name and file: a name the entry points pass on from two files is two entries, one name. Only a map
|
|
450
|
+
# with no `::` entry takes that arm: the stored count holds no method, so a map with methods could match it
|
|
451
|
+
# only by chance, and its methods would stand in for an undocumented name.
|
|
452
|
+
"inflationSignature": effective is not None and (
|
|
453
|
+
documented == effective or (bool(entry_names) and not any(METHOD_SEPARATOR in n for n in entry_names)
|
|
454
|
+
and len(set(entry_names)) == effective)),
|
|
455
|
+
"declaredNames": sorted(set(names)) if names else named,
|
|
456
|
+
"stack": stack,
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# --------------------------------------------------------------------------
|
|
461
|
+
# surface
|
|
462
|
+
# --------------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
_GLOB_CACHE: dict[str, re.Pattern[str]] = {}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def glob_match(rel_path: str, pattern: str) -> bool:
|
|
469
|
+
"""skf-resolve-authoritative-files.py's glob rule, which the extractor applies."""
|
|
470
|
+
compiled = _GLOB_CACHE.get(pattern)
|
|
471
|
+
if compiled is None:
|
|
472
|
+
parts = pattern.split("/")
|
|
473
|
+
out: list[str] = []
|
|
474
|
+
for i, part in enumerate(parts):
|
|
475
|
+
if part == "**":
|
|
476
|
+
out.append("(?:.*/)?" if i + 1 < len(parts) else ".*")
|
|
477
|
+
continue
|
|
478
|
+
out.append("".join("[^/]*" if ch == "*" else "[^/]" if ch == "?" else re.escape(ch) for ch in part))
|
|
479
|
+
if i + 1 < len(parts):
|
|
480
|
+
out.append("/")
|
|
481
|
+
compiled = re.compile("^" + "".join(out) + "$")
|
|
482
|
+
_GLOB_CACHE[pattern] = compiled
|
|
483
|
+
return bool(compiled.match(rel_path))
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _norm(path) -> str:
|
|
487
|
+
text = str(path or "").strip().replace("\\", "/")
|
|
488
|
+
while text.startswith("./"):
|
|
489
|
+
text = text[2:]
|
|
490
|
+
return text.lstrip("/")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _record(name, kind, file, line, signature_line, origin) -> dict:
|
|
494
|
+
return {"name": name, "kind": kind, "file": file, "line": line, "signatureLine": signature_line,
|
|
495
|
+
"origin": origin}
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _globs(value) -> list[str]:
|
|
499
|
+
return [g for g in (_norm(v) for v in value if isinstance(v, str)) if g] if isinstance(value, list) else []
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _scope(include, exclude, tier_a_include) -> dict:
|
|
503
|
+
return {"include": _globs(include), "exclude": _globs(exclude), "tier_a_include": _globs(tier_a_include)}
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def read_brief(path: str) -> dict:
|
|
507
|
+
"""A skill brief's scope globs, scope type and language, the fields skf-extract-public-api.py reads, and the
|
|
508
|
+
latest action of each scope-expansion amendment by its path (`amendments`), a legacy headless skip
|
|
509
|
+
read as a deferral as skf-resolve-authoritative-files.py reads it."""
|
|
510
|
+
try:
|
|
511
|
+
import yaml # --brief only; `uv run` installs it from the header
|
|
512
|
+
except ImportError as exc:
|
|
513
|
+
raise InputError("--brief is read with PyYAML: run the script with `uv run`") from exc
|
|
514
|
+
resolver = _sibling("skf-resolve-authoritative-files.py", SHARED_SCRIPTS)
|
|
515
|
+
try:
|
|
516
|
+
data = resolver.parse_brief_yaml(Path(path).read_bytes().decode("utf-8-sig"))
|
|
517
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
518
|
+
raise InputError(f"cannot read --brief {path}: {exc}") from exc
|
|
519
|
+
except yaml.YAMLError as exc:
|
|
520
|
+
raise InputError(f"--brief {path} is not valid YAML: {' '.join(str(exc).split())}") from exc
|
|
521
|
+
if not isinstance(data, dict):
|
|
522
|
+
raise InputError(f"--brief {path} is not a YAML mapping")
|
|
523
|
+
scope = data.get("scope") if isinstance(data.get("scope"), dict) else {}
|
|
524
|
+
amendments: dict[str, str] = {}
|
|
525
|
+
for amend in scope.get("amendments") if isinstance(scope.get("amendments"), list) else []:
|
|
526
|
+
if isinstance(amend, dict) and amend.get("category") == "scope-expansion":
|
|
527
|
+
amended = _norm(amend["path"]) if isinstance(amend.get("path"), str) else ""
|
|
528
|
+
action = resolver._amendment_action(amend)
|
|
529
|
+
if amended and action is not None:
|
|
530
|
+
amendments[amended] = action
|
|
531
|
+
return {"scope": _scope(scope.get("include"), scope.get("exclude"), scope.get("tier_a_include")),
|
|
532
|
+
"type": scope.get("type") if isinstance(scope.get("type"), str) else None,
|
|
533
|
+
"language": data.get("language") if isinstance(data.get("language"), str) else None,
|
|
534
|
+
"amendments": amendments}
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def matching(records: list[dict], globs: list[str], excludes: list[str]) -> list[str]:
|
|
538
|
+
"""The names whose record names a file one of `globs` matches and no exclude glob does."""
|
|
539
|
+
return sorted({r["name"] for r in records if r.get("file")
|
|
540
|
+
and any(glob_match(_norm(r["file"]), g) for g in globs)
|
|
541
|
+
and not any(glob_match(_norm(r["file"]), g) for g in excludes)})
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _apart(a: str, b: str) -> bool:
|
|
545
|
+
"""True when, before either has a `*` or `?`, the two globs spell a different segment at the same
|
|
546
|
+
place, so no file can match both. Any other pair counts as overlapping, even one no file can match
|
|
547
|
+
both of (`src` beside `src/index.ts`), so this never parts two that can share a file."""
|
|
548
|
+
for x, y in zip(a.split("/"), b.split("/")):
|
|
549
|
+
if any(c in x or c in y for c in "*?"):
|
|
550
|
+
return False
|
|
551
|
+
if x != y:
|
|
552
|
+
return True
|
|
553
|
+
return False
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _inside(a: str, b: str) -> bool:
|
|
557
|
+
"""True when glob `b` matches every path glob `a` can match: segment by segment each of `b`'s is
|
|
558
|
+
`**`, the same as `a`'s, or `*` beside one of `a`'s that is not `**`, and `a` has as many segments
|
|
559
|
+
as `b`, or more when `b` ends in `**`. Any other pair counts as not inside, even one where `b`
|
|
560
|
+
does cover `a` (`gen/**` in `**/gen/**`), so this never drops a glob that can count a name."""
|
|
561
|
+
xs, ys = a.split("/"), b.split("/")
|
|
562
|
+
if len(xs) < len(ys) or (len(xs) > len(ys) and ys[-1] != "**"):
|
|
563
|
+
return False
|
|
564
|
+
return all(y == "**" or x == y or (y == "*" and x != "**") for x, y in zip(xs, ys))
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _nested_tops(entry_files: list[dict], named: set[str], exporting: set[str]) -> dict[str, str]:
|
|
568
|
+
"""{top: the outermost top whose folder holds it} for each Python top
|
|
569
|
+
__init__.py inside the folder of another top that `exporting` lists (one
|
|
570
|
+
that exports at least one public name), a sub-package behind a folder
|
|
571
|
+
with no __init__.py, unless `named` lists its file. A top that exports
|
|
572
|
+
nothing (a stray __init__.py at the source root, say) nests no other."""
|
|
573
|
+
tops = sorted({e["file"] for e in entry_files
|
|
574
|
+
if e.get("language") == "python" and isinstance(e.get("file"), str) and e["file"]})
|
|
575
|
+
nested = {}
|
|
576
|
+
for top in tops:
|
|
577
|
+
folder = posixpath.dirname(top)
|
|
578
|
+
outer = [o for o in tops if o != top and o in exporting
|
|
579
|
+
and (not posixpath.dirname(o) or folder.startswith(posixpath.dirname(o) + "/"))]
|
|
580
|
+
if outer and _norm(top) not in named:
|
|
581
|
+
nested[top] = min(outer, key=lambda o: (o.count("/"), o))
|
|
582
|
+
return nested
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _members_in_place(public: list[dict]) -> list[dict]:
|
|
586
|
+
"""The public names, a namespace item with a `members` list (the names
|
|
587
|
+
its module passes on, `pkg.sub.name`) given as its members, each with
|
|
588
|
+
the namespace's entry and language and `via: namespace`. A member whose
|
|
589
|
+
name and file another public name, or an earlier member, has is left
|
|
590
|
+
out; a namespace whose members the runner could not read (null), or one
|
|
591
|
+
from a runner that lists none, stays itself."""
|
|
592
|
+
expanded = [p for p in public if not (p.get("via") == "namespace" and isinstance(p.get("members"), list))]
|
|
593
|
+
seen = {(p["name"], p.get("file")) for p in expanded}
|
|
594
|
+
for item in public:
|
|
595
|
+
if item.get("via") != "namespace" or not isinstance(item.get("members"), list):
|
|
596
|
+
continue
|
|
597
|
+
for member in item["members"]:
|
|
598
|
+
if not isinstance(member, dict) or not isinstance(member.get("name"), str) or not member["name"]:
|
|
599
|
+
continue
|
|
600
|
+
key = (member["name"], member.get("file"))
|
|
601
|
+
if key in seen:
|
|
602
|
+
continue
|
|
603
|
+
seen.add(key)
|
|
604
|
+
expanded.append({"name": member["name"], "language": item.get("language"), "entry": item.get("entry"),
|
|
605
|
+
"via": "namespace", "local": member.get("local"), "file": member.get("file"),
|
|
606
|
+
"line": member.get("line")})
|
|
607
|
+
return expanded
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _extraction_summary(data: dict, language: str | None) -> dict:
|
|
611
|
+
"""The surface's `extraction` object: the runner's status and counts,
|
|
612
|
+
the files to read by eye and the fallback verdict."""
|
|
613
|
+
counts = data.get("counts") if isinstance(data.get("counts"), dict) else {}
|
|
614
|
+
arms = data.get("arms") if isinstance(data.get("arms"), dict) else {}
|
|
615
|
+
entry_points = data.get("entry_points") if isinstance(data.get("entry_points"), dict) else {}
|
|
616
|
+
without = data.get("files_without_recipes") if isinstance(data.get("files_without_recipes"), dict) else {}
|
|
617
|
+
status = data.get("status")
|
|
618
|
+
reason = None
|
|
619
|
+
if status == "no-ast-grep":
|
|
620
|
+
reason = "no ast-grep the runner can run"
|
|
621
|
+
elif not data.get("files_in_scope"):
|
|
622
|
+
reason = "no file in scope"
|
|
623
|
+
elif without and not data.get("exports"):
|
|
624
|
+
reason = f"no recipe reads the files in scope ({', '.join(sorted(without))})"
|
|
625
|
+
elif without and language and language.strip().lower() not in RECIPE_LANGUAGES:
|
|
626
|
+
reason = (f"no recipe reads the skill's language, {language} "
|
|
627
|
+
f"({', '.join(sorted(without))} in scope)")
|
|
628
|
+
errors = [e for e in data.get("errors") or [] if isinstance(e, dict)]
|
|
629
|
+
read_by_eye = sorted({str(i["file"]) for i in data.get("file_issues") or []
|
|
630
|
+
if isinstance(i, dict) and i.get("file") and i.get("issue") != "no-recipes"}
|
|
631
|
+
| {str(e["first_file"]) for e in errors if e.get("first_file")}
|
|
632
|
+
| {str(f) for e in errors if isinstance(e.get("unread"), list) for f in e["unread"] if f})
|
|
633
|
+
return {
|
|
634
|
+
"status": status,
|
|
635
|
+
"filesInScope": data.get("files_in_scope"),
|
|
636
|
+
"truncated": data.get("truncated"),
|
|
637
|
+
"effectiveDenominator": _count(counts.get("effective_denominator")),
|
|
638
|
+
"effectiveDenominatorBasis": counts.get("effective_denominator_basis"),
|
|
639
|
+
"exportsPublicApi": _count(counts.get("exports_public_api")),
|
|
640
|
+
"arms": arms,
|
|
641
|
+
"curatedSubset": counts.get("curated_subset") if isinstance(counts.get("curated_subset"), bool) else None,
|
|
642
|
+
"entryPointStatus": entry_points.get("status"),
|
|
643
|
+
# A run that read a file in scope and found no entry point says so whether or not ast-grep ran.
|
|
644
|
+
"entryPointsFound": (bool(entry_points["files"]) if isinstance(entry_points.get("files"), list)
|
|
645
|
+
and data.get("files_in_scope") else None),
|
|
646
|
+
"readByEye": read_by_eye,
|
|
647
|
+
"fallback": {"needed": reason is not None, "reason": reason},
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
NO_UMBRELLA = {"entries": [], "names": 0, "reexported": 0, "ratio": None, "umbrella": False}
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def from_extraction(data: dict, language: str | None = None, brief: dict | None = None) -> dict:
|
|
655
|
+
"""The public surface of an extraction, less what lies outside scope,
|
|
656
|
+
plus the root exports a stale scope.include glob left out. A nested
|
|
657
|
+
Python top's records come apart (`nestedRecords`): they count in the
|
|
658
|
+
scope sets, not in `all`. An extraction the per-file scan replaces
|
|
659
|
+
(`extraction.fallback.needed`) gives only its scope, its stale globs and
|
|
660
|
+
its `extraction` object: no name and no set."""
|
|
661
|
+
if data.get("mode") != "full":
|
|
662
|
+
raise InputError("--extraction is not skf-extract-public-api.py --mode full output")
|
|
663
|
+
|
|
664
|
+
# The scope: the one the extractor recorded, else the brief's.
|
|
665
|
+
recorded = data.get("scope") if isinstance(data.get("scope"), dict) else {}
|
|
666
|
+
scope = _scope(recorded.get("include"), recorded.get("exclude"), recorded.get("tier_a_include"))
|
|
667
|
+
if not any(scope.values()) and brief is not None:
|
|
668
|
+
scope = brief["scope"]
|
|
669
|
+
unmatched = _globs(recorded.get("unmatched_include"))
|
|
670
|
+
# A tier A glob that matches no file: the runner's list, never re-matched here.
|
|
671
|
+
unmatched_tier_a = _globs(recorded.get("unmatched_tier_a_include"))
|
|
672
|
+
extraction = _extraction_summary(data, language)
|
|
673
|
+
if extraction["fallback"]["needed"]:
|
|
674
|
+
return {"records": [], "nestedRecords": [], "sets": {}, "outside": [], "nested": [],
|
|
675
|
+
"staleScope": {"unmatchedInclude": unmatched, "unmatchedTierAInclude": unmatched_tier_a,
|
|
676
|
+
"restored": []},
|
|
677
|
+
"gaps": [], "scope": scope, "scopeType": recorded.get("type"), "umbrella": dict(NO_UMBRELLA),
|
|
678
|
+
"extraction": extraction}
|
|
679
|
+
|
|
680
|
+
diff = data.get("entry_point_diff") if isinstance(data.get("entry_point_diff"), dict) else {}
|
|
681
|
+
public = _members_in_place([p for p in diff.get("public") or []
|
|
682
|
+
if isinstance(p, dict) and isinstance(p.get("name"), str)])
|
|
683
|
+
outside = [o for o in diff.get("outside_scope") or [] if isinstance(o, dict)]
|
|
684
|
+
gaps = [g for g in diff.get("extraction_gaps") or [] if isinstance(g, dict)]
|
|
685
|
+
|
|
686
|
+
entry_points = data.get("entry_points") if isinstance(data.get("entry_points"), dict) else {}
|
|
687
|
+
entry_files = [e for e in entry_points.get("files") or [] if isinstance(e, dict)]
|
|
688
|
+
# A Python top nested in another that exports a name is no barrel: its
|
|
689
|
+
# names leave `all`, `root` and the umbrella ratio, and keep their
|
|
690
|
+
# records (so the scope sets a specific-modules or stratified brief
|
|
691
|
+
# counts) and their outside_scope and extraction_gaps rows.
|
|
692
|
+
nested = _nested_tops(entry_files, set(scope["include"]) | set(scope["tier_a_include"]),
|
|
693
|
+
{p.get("entry") for p in public})
|
|
694
|
+
nested_names = {top: sorted({p["name"] for p in public if p.get("entry") == top}) for top in nested}
|
|
695
|
+
|
|
696
|
+
subpath_files = {e.get("file") for e in entry_files
|
|
697
|
+
if e.get("subpath") not in ROOT_SUBPATHS and "*" not in str(e.get("subpath"))}
|
|
698
|
+
root_files = {e.get("file") for e in entry_files if e.get("subpath") in ROOT_SUBPATHS} - set(nested)
|
|
699
|
+
|
|
700
|
+
# A scope.include glob that matches no file: the brief predates the
|
|
701
|
+
# source, so a root export defined in a file no glob covers comes back,
|
|
702
|
+
# unless the brief excludes or declined that file.
|
|
703
|
+
declined = [path for path, action in ((brief or {}).get("amendments") or {}).items()
|
|
704
|
+
if action in DECLINED_ACTIONS]
|
|
705
|
+
restored = [o for o in outside if unmatched and o.get("entry") in root_files
|
|
706
|
+
and isinstance(o.get("name"), str) and isinstance(o.get("file"), str) and o["file"]
|
|
707
|
+
and not any(glob_match(_norm(o["file"]), g)
|
|
708
|
+
for g in scope["include"] + scope["exclude"] + declined)]
|
|
709
|
+
restored_at = {(o["name"], o["file"]): o.get("line") for o in restored}
|
|
710
|
+
outside = [o for o in outside if (o.get("name"), o.get("file")) not in restored_at]
|
|
711
|
+
|
|
712
|
+
outside_keys = {(o.get("name"), o.get("file")) for o in outside}
|
|
713
|
+
index = {(e.get("export_name"), e.get("source_file")): e
|
|
714
|
+
for e in data.get("exports") or [] if isinstance(e, dict)}
|
|
715
|
+
records, nested_records = [], []
|
|
716
|
+
for info in public:
|
|
717
|
+
name, file = info["name"], info.get("file")
|
|
718
|
+
if (name, file) in outside_keys:
|
|
719
|
+
continue
|
|
720
|
+
if (name, file) in restored_at:
|
|
721
|
+
# No recipe reads a file out of scope, so its kind is unknown: the
|
|
722
|
+
# name carries the file and line of its outside_scope row.
|
|
723
|
+
records.append(_record(name, None, file, restored_at[(name, file)], None, "restored"))
|
|
724
|
+
continue
|
|
725
|
+
# An aliased re-export (`export { Options as Config }`) is recorded
|
|
726
|
+
# under the name its defining file gives it, `local`.
|
|
727
|
+
found = index.get((info.get("local") or name, file)) or index.get((name, file))
|
|
728
|
+
kind = found.get("export_type") if found else None
|
|
729
|
+
(nested_records if info.get("entry") in nested else records).append(
|
|
730
|
+
_record(name, None if kind == "re-export" else kind, file,
|
|
731
|
+
found.get("source_line") if found else info.get("line"),
|
|
732
|
+
found.get("signature_line") if found else None, "extraction"))
|
|
733
|
+
|
|
734
|
+
root_names = [p for p in public if p.get("entry") in root_files]
|
|
735
|
+
reexported = [p for p in root_names if p.get("via") not in (None, "declaration")]
|
|
736
|
+
|
|
737
|
+
sets = {}
|
|
738
|
+
if subpath_files:
|
|
739
|
+
sets["subpaths"] = sorted({p["name"] for p in public if p.get("entry") in subpath_files})
|
|
740
|
+
if root_files:
|
|
741
|
+
sets["root"] = sorted({p["name"] for p in root_names})
|
|
742
|
+
|
|
743
|
+
return {
|
|
744
|
+
"records": records,
|
|
745
|
+
"nestedRecords": nested_records,
|
|
746
|
+
"sets": sets,
|
|
747
|
+
"outside": [{"name": o.get("name"), "file": o.get("file")} for o in outside],
|
|
748
|
+
"nested": [{"entry": top, "within": nested[top], "names": nested_names[top]} for top in sorted(nested)],
|
|
749
|
+
"staleScope": {"unmatchedInclude": unmatched, "unmatchedTierAInclude": unmatched_tier_a,
|
|
750
|
+
"restored": [{"name": o["name"], "file": o["file"], "line": o.get("line")}
|
|
751
|
+
for o in sorted(restored, key=lambda o: (o["name"], o["file"]))]},
|
|
752
|
+
"gaps": [{"name": g.get("name"), "file": g.get("file"), "line": g.get("line")} for g in gaps],
|
|
753
|
+
"scope": scope,
|
|
754
|
+
"scopeType": recorded.get("type") if isinstance(recorded.get("type"), str) else None,
|
|
755
|
+
"umbrella": {
|
|
756
|
+
"entries": sorted(f for f in root_files if f),
|
|
757
|
+
"names": len(root_names),
|
|
758
|
+
"reexported": len(reexported),
|
|
759
|
+
"ratio": round2(len(reexported) / len(root_names)) if root_names else None,
|
|
760
|
+
"umbrella": bool(root_names) and len(reexported) / len(root_names) > UMBRELLA_RATIO,
|
|
761
|
+
},
|
|
762
|
+
"extraction": extraction,
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def from_quick(data: dict) -> list[dict]:
|
|
767
|
+
exports = data.get("exports")
|
|
768
|
+
if not isinstance(exports, list):
|
|
769
|
+
raise InputError("--quick is not skf-extract-public-api.py --mode quick output (no exports list)")
|
|
770
|
+
return [_record(e["name"], e.get("type"), e.get("defined_in") or e.get("source_file"), None, None, "quick")
|
|
771
|
+
for e in exports if isinstance(e, dict) and isinstance(e.get("name"), str) and e["name"]]
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def from_per_file(path: str) -> tuple[list[dict], list[str]]:
|
|
775
|
+
"""(the records of one per-file result, its schema breaches)."""
|
|
776
|
+
scorer = _sibling("score-signatures.py")
|
|
777
|
+
try:
|
|
778
|
+
text = Path(path).read_bytes().decode("utf-8-sig")
|
|
779
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
780
|
+
raise InputError(f"cannot read --per-file {path}: {exc}") from exc
|
|
781
|
+
results, errors = scorer.parse_result_text(text, Path(path).name)
|
|
782
|
+
if errors:
|
|
783
|
+
return [], errors
|
|
784
|
+
records = []
|
|
785
|
+
for result in results:
|
|
786
|
+
types = set(result.get("types_found") or [])
|
|
787
|
+
for name in sorted(set(result.get("exports_found") or []) | types):
|
|
788
|
+
if name:
|
|
789
|
+
records.append(_record(name, "type" if name in types else None, result["file"], None, None,
|
|
790
|
+
"per-file"))
|
|
791
|
+
return records, []
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def fold_names(names: list[str], prefixes: list[str], keep: set[str],
|
|
795
|
+
tallied: set[str] | None = None) -> tuple[dict[str, str], dict]:
|
|
796
|
+
"""({variant: base}, the fold tally of `tallied`, else of every name) under the canonicalization rules."""
|
|
797
|
+
present = set(names)
|
|
798
|
+
variants: dict[str, str] = {}
|
|
799
|
+
tally = {"_def": 0, "_exact": 0, FOLD_PREFIX: 0, "other": 0}
|
|
800
|
+
for name in sorted(present):
|
|
801
|
+
if name in keep:
|
|
802
|
+
continue
|
|
803
|
+
rule, base = None, None
|
|
804
|
+
for suffix in FOLD_SUFFIXES:
|
|
805
|
+
if name.endswith(suffix) and len(name) > len(suffix):
|
|
806
|
+
rule, base = suffix, name[: -len(suffix)]
|
|
807
|
+
break
|
|
808
|
+
if rule is None and name.startswith(FOLD_PREFIX) and len(name) > len(FOLD_PREFIX):
|
|
809
|
+
rule, base = FOLD_PREFIX, name[len(FOLD_PREFIX):]
|
|
810
|
+
if rule is None:
|
|
811
|
+
for prefix in prefixes:
|
|
812
|
+
if prefix and name.startswith(prefix) and name[len(prefix):] in present:
|
|
813
|
+
rule, base = "other", name[len(prefix):]
|
|
814
|
+
break
|
|
815
|
+
if rule is not None:
|
|
816
|
+
variants[name] = base
|
|
817
|
+
if tallied is None or name in tallied:
|
|
818
|
+
tally[rule] += 1
|
|
819
|
+
return variants, tally
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _over(clause: str, sets: dict, name: str, step: int | None, rebuild: str | None = None) -> dict:
|
|
823
|
+
"""The barrel branch over set `name`, the `all` set when the surface lacks it."""
|
|
824
|
+
present = name in sets
|
|
825
|
+
return {"clause": clause, "branch": "barrel", "surfaceSet": name if present else "all", "ladderStep": step,
|
|
826
|
+
"absentSet": None if present else name, "rebuild": rebuild}
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def pick_denominator(sets: dict, stats_eff: int | None, deflation_fires: bool, barrel: str | None, arms: dict,
|
|
830
|
+
scope_type: str | None, skill_type: str | None, monorepo: bool, pattern_reference: bool,
|
|
831
|
+
curated: bool, source_read: bool) -> dict:
|
|
832
|
+
"""The Source API Surface Definition's clause for a skill, with the branch
|
|
833
|
+
and the name set reconcile-coverage.py and score-signatures.py read, and
|
|
834
|
+
the entry points' state it was picked by (see "Denominator" above)."""
|
|
835
|
+
return {**_pick(sets, stats_eff, deflation_fires, barrel, arms, scope_type, skill_type, monorepo,
|
|
836
|
+
pattern_reference, curated, source_read), "barrel": barrel}
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _pick(sets: dict, stats_eff: int | None, deflation_fires: bool, barrel: str | None, arms: dict,
|
|
840
|
+
scope_type: str | None, skill_type: str | None, monorepo: bool, pattern_reference: bool,
|
|
841
|
+
curated: bool, source_read: bool) -> dict:
|
|
842
|
+
if skill_type == "stack":
|
|
843
|
+
return {"clause": "stack", "branch": "stack", "surfaceSet": None, "ladderStep": None, "absentSet": None,
|
|
844
|
+
"rebuild": None}
|
|
845
|
+
multi = bool(arms.get("multi_subpath_exports"))
|
|
846
|
+
if (not monorepo and barrel != "barrel" and scope_type not in NOT_PATTERN_REFERENCE
|
|
847
|
+
and not (multi and scope_type == "public-api")
|
|
848
|
+
and (pattern_reference or (barrel == "no-entry-point" and not multi))):
|
|
849
|
+
if "tier_a_include" in sets:
|
|
850
|
+
return _over("pattern-reference", sets, "tier_a_include", None)
|
|
851
|
+
# The canonicalized provenance map: a surface read from the source is built again from the map alone.
|
|
852
|
+
return _over("pattern-reference", sets, "all", None, "provenance-fold" if source_read else None)
|
|
853
|
+
if barrel == "empty-barrel":
|
|
854
|
+
return _over("empty-barrel", sets, "scope.include", None)
|
|
855
|
+
if monorepo and curated and scope_type != "full-library":
|
|
856
|
+
clause = "stratified-scope"
|
|
857
|
+
elif scope_type == "specific-modules" and not monorepo:
|
|
858
|
+
clause = "specific-modules"
|
|
859
|
+
elif multi and not monorepo and scope_type in ("full-library", "public-api"):
|
|
860
|
+
clause = "multi-entry"
|
|
861
|
+
else:
|
|
862
|
+
return _over("standard-barrel", sets, "all", None)
|
|
863
|
+
ladder = CLAUSE_LADDERS[clause]
|
|
864
|
+
for step, rung in enumerate(ladder, 1):
|
|
865
|
+
if rung != "scalar":
|
|
866
|
+
if rung in sets or step == len(ladder):
|
|
867
|
+
return _over(clause, sets, rung, step)
|
|
868
|
+
elif stats_eff:
|
|
869
|
+
# A stored 0 is no denominator: the next rung.
|
|
870
|
+
if deflation_fires:
|
|
871
|
+
return _over(clause, sets, "scope.include", step)
|
|
872
|
+
scored = next((name for name in ("tier_a_include", "scope.include") if name in sets), "all")
|
|
873
|
+
return {"clause": clause, "branch": "scalar", "surfaceSet": scored, "ladderStep": step,
|
|
874
|
+
"absentSet": None, "rebuild": None}
|
|
875
|
+
raise AssertionError(f"the {clause} ladder ends on a set")
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def surface(args: argparse.Namespace) -> dict:
|
|
879
|
+
records: list[dict] = []
|
|
880
|
+
sets: dict[str, list[str]] = {}
|
|
881
|
+
warnings: list[str] = []
|
|
882
|
+
out: dict = {"candidates": None, "guards": None, "denominator": None, "state2": None, "canonical": None,
|
|
883
|
+
"extraction": None, "extractionGaps": [], "excluded": {"outsideScope": [], "nestedEntries": []}}
|
|
884
|
+
|
|
885
|
+
meta = _object(args.metadata, "--metadata") if args.metadata else None
|
|
886
|
+
provenance = _object(args.provenance, "--provenance") if args.provenance else None
|
|
887
|
+
brief = read_brief(args.brief) if args.brief else None
|
|
888
|
+
language = (brief or {}).get("language")
|
|
889
|
+
if not language and meta is not None and isinstance(meta.get("language"), str):
|
|
890
|
+
language = meta["language"]
|
|
891
|
+
stats = meta.get("stats") if isinstance(meta, dict) and isinstance(meta.get("stats"), dict) else {}
|
|
892
|
+
stats_eff = _count(stats.get("effective_denominator"))
|
|
893
|
+
|
|
894
|
+
extracted = None
|
|
895
|
+
if args.extraction:
|
|
896
|
+
data = _object(args.extraction, "--extraction")
|
|
897
|
+
extracted = from_extraction(data, language, brief)
|
|
898
|
+
records += extracted["records"]
|
|
899
|
+
sets.update(extracted["sets"])
|
|
900
|
+
out["extraction"] = extracted["extraction"]
|
|
901
|
+
out["extractionGaps"] = extracted["gaps"]
|
|
902
|
+
out["excluded"] = {"outsideScope": extracted["outside"], "nestedEntries": extracted["nested"]}
|
|
903
|
+
warnings += [f"{Path(args.extraction).name}: {w}" for w in data.get("warnings") or [] if isinstance(w, str)]
|
|
904
|
+
quick_outputs = []
|
|
905
|
+
for path in args.quick or []:
|
|
906
|
+
data = _object(path, "--quick")
|
|
907
|
+
quick_outputs.append(data)
|
|
908
|
+
records += from_quick(data)
|
|
909
|
+
warnings += [f"{Path(path).name}: {w}" for w in data.get("warnings") or [] if isinstance(w, str)]
|
|
910
|
+
violations: list[str] = []
|
|
911
|
+
for path in args.per_file or []:
|
|
912
|
+
found, errors = from_per_file(path)
|
|
913
|
+
records += found
|
|
914
|
+
violations += errors
|
|
915
|
+
if violations:
|
|
916
|
+
raise SchemaError(violations)
|
|
917
|
+
records += [_record(name, None, None, None, None, "by-eye") for name in args.name or [] if name]
|
|
918
|
+
|
|
919
|
+
# A surface read from the source takes no name from the provenance map or
|
|
920
|
+
# metadata.json: they are only the baselines of the candidates and guards.
|
|
921
|
+
source_read = extracted is not None or bool(args.quick) or bool(args.per_file)
|
|
922
|
+
if not source_read and (provenance is not None or meta is not None):
|
|
923
|
+
named = [e for e in _entries(provenance)
|
|
924
|
+
if isinstance(e.get("export_name"), str) and e["export_name"]
|
|
925
|
+
and METHOD_SEPARATOR not in e["export_name"]]
|
|
926
|
+
prov_names = sorted({e["export_name"] for e in named})
|
|
927
|
+
exports = meta.get("exports") if meta is not None else None
|
|
928
|
+
meta_names = sorted({n for n in (_export_name(e) for e in exports) if n}) if isinstance(exports, list) else []
|
|
929
|
+
base_of: dict[str, str] = {}
|
|
930
|
+
# A map whose raw count is the stored effective_denominator is already canonical: no fold.
|
|
931
|
+
skipped = bool(args.fold) and provenance is not None and stats_eff == len(prov_names)
|
|
932
|
+
if args.fold:
|
|
933
|
+
# One fold over both lists, so a variant folded out of the map
|
|
934
|
+
# never comes back through metadata.json.
|
|
935
|
+
base_of, tally = fold_names(sorted(set(prov_names) | set(meta_names)), args.fold_prefix or [],
|
|
936
|
+
set(args.keep or []), tallied=set(prov_names))
|
|
937
|
+
if skipped:
|
|
938
|
+
base_of, tally = {}, dict.fromkeys(tally, 0)
|
|
939
|
+
if provenance is not None:
|
|
940
|
+
canonical = sorted({base_of.get(n, n) for n in prov_names})
|
|
941
|
+
out["canonical"] = {
|
|
942
|
+
"raw": len(prov_names),
|
|
943
|
+
"canonical": len(canonical),
|
|
944
|
+
"folded": tally,
|
|
945
|
+
"variants": {n: base_of[n] for n in prov_names if n in base_of},
|
|
946
|
+
"skipped": skipped,
|
|
947
|
+
"residual": None if stats_eff is None else len(canonical) - stats_eff,
|
|
948
|
+
"summary": (f"Provenance-map canonicalization: {len(prov_names)} raw entries → "
|
|
949
|
+
f"{len(canonical)} canonical bases ({len(prov_names) - len(canonical)} "
|
|
950
|
+
f"bookkeeping variants folded: _def×{tally['_def']}, _exact×{tally['_exact']}, "
|
|
951
|
+
f"a11y_×{tally[FOLD_PREFIX]}, other×{tally['other']})"
|
|
952
|
+
+ (": the raw count equals `stats.effective_denominator`, so the map is already "
|
|
953
|
+
"canonical" if skipped else "")),
|
|
954
|
+
}
|
|
955
|
+
seen = set()
|
|
956
|
+
for e in named:
|
|
957
|
+
name = base_of.get(e["export_name"], e["export_name"])
|
|
958
|
+
if name in seen:
|
|
959
|
+
continue
|
|
960
|
+
seen.add(name)
|
|
961
|
+
records.append(_record(name, e.get("export_type"), e.get("source_file"), e.get("source_line"),
|
|
962
|
+
None, "provenance"))
|
|
963
|
+
meta_bases = sorted({base_of.get(n, n) for n in meta_names})
|
|
964
|
+
records += [_record(n, None, None, None, None, "metadata") for n in meta_bases]
|
|
965
|
+
if seen and meta_bases:
|
|
966
|
+
out["state2"] = {
|
|
967
|
+
"provenanceCount": len(seen),
|
|
968
|
+
"metadataCount": len(meta_bases),
|
|
969
|
+
"unionCount": len(seen | set(meta_bases)),
|
|
970
|
+
"metadataOnly": sorted(set(meta_bases) - seen),
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
sets["all"] = sorted({r["name"] for r in records})
|
|
974
|
+
# A nested Python top's records count in the scope sets, never in `all`.
|
|
975
|
+
records += extracted["nestedRecords"] if extracted is not None else []
|
|
976
|
+
records.sort(key=lambda r: (r["name"], str(r["file"] or ""), r["origin"]))
|
|
977
|
+
|
|
978
|
+
# The scope: the one the extractor recorded, else the brief's.
|
|
979
|
+
scope = extracted["scope"] if extracted is not None and any(extracted["scope"].values()) else None
|
|
980
|
+
if scope is None:
|
|
981
|
+
scope = brief["scope"] if brief is not None else _scope(None, None, None)
|
|
982
|
+
# A tier A glob the runner lists as matching no file counts no name, and
|
|
983
|
+
# so does one whose folder or file differs from every scope.include
|
|
984
|
+
# glob's before any `*` or `?` (`gen/**` beside `src/**`) or one a
|
|
985
|
+
# scope.exclude glob covers segment by segment (`src/gen/**` beside an
|
|
986
|
+
# exclude of `src/gen/**`), whatever the tree holds: the set comes from
|
|
987
|
+
# the other globs, over the names whose file a scope.include glob matches
|
|
988
|
+
# too (any file with none), so a restored name is in no set. A tier A
|
|
989
|
+
# list that counts no name (every glob stale, or matching only excluded
|
|
990
|
+
# files, files scope.include leaves out or files that define no name)
|
|
991
|
+
# narrows nothing: there is no set.
|
|
992
|
+
stale_tier_a = set(extracted["staleScope"]["unmatchedTierAInclude"]) if extracted is not None else set()
|
|
993
|
+
tier_a = [g for g in scope["tier_a_include"] if g not in stale_tier_a
|
|
994
|
+
and not (scope["include"] and all(_apart(g, i) for i in scope["include"]))
|
|
995
|
+
and not any(_inside(g, e) for e in scope["exclude"])]
|
|
996
|
+
filed = [r for r in records if r["file"]]
|
|
997
|
+
if filed and scope["include"]:
|
|
998
|
+
sets["scope.include"] = matching(filed, scope["include"], scope["exclude"])
|
|
999
|
+
in_include = [r for r in filed if not scope["include"]
|
|
1000
|
+
or any(glob_match(_norm(r["file"]), g) for g in scope["include"])]
|
|
1001
|
+
tier_a_names = matching(in_include, tier_a, scope["exclude"]) if tier_a else []
|
|
1002
|
+
if tier_a_names:
|
|
1003
|
+
sets["tier_a_include"] = tier_a_names
|
|
1004
|
+
|
|
1005
|
+
if extracted is not None or brief is not None or meta is not None:
|
|
1006
|
+
include_union = len(sets["scope.include"]) if "scope.include" in sets else len(sets["all"])
|
|
1007
|
+
extraction = extracted["extraction"] if extracted is not None else {}
|
|
1008
|
+
out["candidates"] = {
|
|
1009
|
+
"statsEffectiveDenominator": stats_eff,
|
|
1010
|
+
"tierAIncludeUnion": len(sets["tier_a_include"]) if "tier_a_include" in sets else None,
|
|
1011
|
+
"scopeIncludeUnion": len(sets["scope.include"]) if "scope.include" in sets else None,
|
|
1012
|
+
"subpathUnion": len(sets["subpaths"]) if "subpaths" in sets else None,
|
|
1013
|
+
"rootBarrel": len(sets["root"]) if "root" in sets else None,
|
|
1014
|
+
"extractorEffectiveDenominator": extraction.get("effectiveDenominator"),
|
|
1015
|
+
"extractorBasis": extraction.get("effectiveDenominatorBasis"),
|
|
1016
|
+
}
|
|
1017
|
+
# With an extraction or the fallback scan's results (each at the file
|
|
1018
|
+
# that defines its names), no tier A set is no tier A list; otherwise
|
|
1019
|
+
# the globs left in the list decide (a name read by eye at Quick tier
|
|
1020
|
+
# or State 4, and a quick name with no defined_in, carries its entry
|
|
1021
|
+
# file, not the file that defines it).
|
|
1022
|
+
no_tier_a = ("tier_a_include" not in sets if extracted is not None or args.fallback_scan
|
|
1023
|
+
else not tier_a)
|
|
1024
|
+
deflation = {"applicable": source_read and stats_eff is not None, "effectiveDenominator": stats_eff,
|
|
1025
|
+
"rederived": include_union if source_read else None, "pct": None, "fires": False}
|
|
1026
|
+
if deflation["applicable"] and stats_eff:
|
|
1027
|
+
deflation["pct"] = round2((include_union - stats_eff) / stats_eff * 100)
|
|
1028
|
+
deflation["fires"] = no_tier_a and deflation["pct"] > DEFLATION_PCT
|
|
1029
|
+
elif deflation["applicable"]:
|
|
1030
|
+
# A stored 0 is deflation, not a denominator, once a name is re-derived.
|
|
1031
|
+
deflation["fires"] = no_tier_a and include_union > 0
|
|
1032
|
+
entry_count = len(_entries(provenance)) if provenance is not None else 0
|
|
1033
|
+
inflation = {"applicable": source_read and no_tier_a and bool(scope["include"]) and entry_count > 0,
|
|
1034
|
+
"scopeIncludeUnion": include_union if scope["include"] else None,
|
|
1035
|
+
"provenanceEntries": entry_count if provenance is not None else None,
|
|
1036
|
+
"pct": None, "fires": False}
|
|
1037
|
+
if inflation["applicable"]:
|
|
1038
|
+
inflation["pct"] = round2((include_union - entry_count) / entry_count * 100)
|
|
1039
|
+
inflation["fires"] = inflation["pct"] > INFLATION_PCT
|
|
1040
|
+
stale_scope = {"applicable": extracted is not None and bool(scope["include"] or scope["tier_a_include"]),
|
|
1041
|
+
"fires": False, "unmatchedInclude": [], "unmatchedTierAInclude": [], "restored": []}
|
|
1042
|
+
if stale_scope["applicable"]:
|
|
1043
|
+
stale_scope.update(extracted["staleScope"])
|
|
1044
|
+
stale_scope["fires"] = bool(stale_scope["unmatchedInclude"] or stale_scope["unmatchedTierAInclude"])
|
|
1045
|
+
out["guards"] = {
|
|
1046
|
+
"deflation": deflation,
|
|
1047
|
+
"inflation": inflation,
|
|
1048
|
+
"staleScope": stale_scope,
|
|
1049
|
+
"tierAInclude": list(scope["tier_a_include"]),
|
|
1050
|
+
"umbrella": extracted["umbrella"] if extracted is not None else dict(NO_UMBRELLA),
|
|
1051
|
+
"thresholds": {"deflationPct": DEFLATION_PCT, "inflationPct": INFLATION_PCT,
|
|
1052
|
+
"umbrellaRatio": UMBRELLA_RATIO},
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
# The clause: an extraction's entry-point status (one the per-file scan replaces says only that it found no
|
|
1056
|
+
# entry point), else a quick output's (an export is a barrel; none, and no warning, an empty one), else what
|
|
1057
|
+
# the workflow read (--barrel).
|
|
1058
|
+
barrel = None
|
|
1059
|
+
summary = extracted["extraction"] if extracted is not None else None
|
|
1060
|
+
if summary is not None and not summary["fallback"]["needed"]:
|
|
1061
|
+
barrel = summary["entryPointStatus"]
|
|
1062
|
+
elif summary is not None and summary["entryPointsFound"] is False:
|
|
1063
|
+
barrel = "no-entry-point"
|
|
1064
|
+
elif quick_outputs:
|
|
1065
|
+
barrel = "barrel" if any(q["exports"] for q in quick_outputs) else (
|
|
1066
|
+
None if any(q.get("warnings") for q in quick_outputs) else "empty-barrel")
|
|
1067
|
+
if barrel is None and args.barrel:
|
|
1068
|
+
barrel = BARREL_FLAG[args.barrel]
|
|
1069
|
+
recorded_type = extracted["scopeType"] if extracted is not None else None
|
|
1070
|
+
meta_type = meta.get("scope_type") if isinstance(meta, dict) and isinstance(meta.get("scope_type"), str) else None
|
|
1071
|
+
arms = summary["arms"] if summary is not None else {}
|
|
1072
|
+
# The extraction's verdict, the rule Create Skill stores the count by, else the workflow's judgment.
|
|
1073
|
+
curated = summary["curatedSubset"] if summary is not None and summary["curatedSubset"] is not None \
|
|
1074
|
+
else bool(args.curated_subset)
|
|
1075
|
+
out["denominator"] = pick_denominator(
|
|
1076
|
+
sets, stats_eff, bool((out["guards"] or {}).get("deflation", {}).get("fires")), barrel, arms,
|
|
1077
|
+
(brief or {}).get("type") or recorded_type or meta_type,
|
|
1078
|
+
meta.get("skill_type") if isinstance(meta, dict) else None, bool(args.monorepo or arms.get("monorepo")),
|
|
1079
|
+
bool(args.pattern_reference_intent), curated, source_read)
|
|
1080
|
+
|
|
1081
|
+
return {
|
|
1082
|
+
"inputs": {
|
|
1083
|
+
"extraction": args.extraction, "quick": args.quick or [], "perFile": args.per_file or [],
|
|
1084
|
+
"names": args.name or [], "provenance": args.provenance, "metadata": args.metadata,
|
|
1085
|
+
"brief": args.brief, "fold": bool(args.fold),
|
|
1086
|
+
},
|
|
1087
|
+
"exports": records,
|
|
1088
|
+
"sets": sets,
|
|
1089
|
+
**out,
|
|
1090
|
+
"warnings": warnings,
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
# --------------------------------------------------------------------------
|
|
1095
|
+
# CLI
|
|
1096
|
+
# --------------------------------------------------------------------------
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def _cmd_census(args: argparse.Namespace) -> dict:
|
|
1100
|
+
return census(args.skill_dir)
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def _cmd_metadata(args: argparse.Namespace) -> dict:
|
|
1104
|
+
meta = _object(args.metadata, "--metadata")
|
|
1105
|
+
provenance = _object(args.provenance, "--provenance") if args.provenance else None
|
|
1106
|
+
return metadata_inputs(meta, provenance)
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
1110
|
+
parser = argparse.ArgumentParser(
|
|
1111
|
+
prog="load-coverage-inputs",
|
|
1112
|
+
description="The counts, name sets and denominator candidates coverage-check reads, from files.",
|
|
1113
|
+
)
|
|
1114
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
1115
|
+
|
|
1116
|
+
p = sub.add_parser("census", help="count the citation forms: is the skill docs-only?")
|
|
1117
|
+
p.add_argument("--skill-dir", required=True, help="the skill package: SKILL.md and references/")
|
|
1118
|
+
p.add_argument("--output", help="also write the result to this file")
|
|
1119
|
+
p.set_defaults(func=_cmd_census)
|
|
1120
|
+
|
|
1121
|
+
p = sub.add_parser("metadata", help="the metadata.json and provenance-map counts")
|
|
1122
|
+
p.add_argument("--metadata", required=True, help="the skill's metadata.json")
|
|
1123
|
+
p.add_argument("--provenance", help="the provenance map, when there is one")
|
|
1124
|
+
p.add_argument("--output", help="also write the result to this file")
|
|
1125
|
+
p.set_defaults(func=_cmd_metadata)
|
|
1126
|
+
|
|
1127
|
+
p = sub.add_parser("surface", help="the enumerated source surface, its name sets and candidates")
|
|
1128
|
+
p.add_argument("--extraction", help="skf-extract-public-api.py --mode full output")
|
|
1129
|
+
p.add_argument("--quick", action="append", help="skf-extract-public-api.py --mode quick output (repeatable)")
|
|
1130
|
+
p.add_argument("--per-file", action="append", help="a subagent per-file result (repeatable)")
|
|
1131
|
+
p.add_argument("--fallback-scan", action="store_true",
|
|
1132
|
+
help="the --per-file results are the fallback scan's, each at the file that defines its "
|
|
1133
|
+
"names: as with --extraction, no tier_a_include set is no tier A list, so the deflation "
|
|
1134
|
+
"and inflation guards run; without either, a tier_a_include glob keeps them off unless "
|
|
1135
|
+
"its folder or file differs from every scope.include glob's before any wildcard "
|
|
1136
|
+
"(gen/** beside src/**) or a scope.exclude glob covers it segment by segment "
|
|
1137
|
+
"(src/gen/** beside an exclude of src/gen/**)")
|
|
1138
|
+
p.add_argument("--name", action="append", help="a name read by eye whose file is not known (repeatable)")
|
|
1139
|
+
p.add_argument("--provenance", help="the provenance map: its named exports")
|
|
1140
|
+
p.add_argument("--metadata", help="metadata.json: its exports[] names, stats and language")
|
|
1141
|
+
p.add_argument("--brief", help="the skill brief: its scope globs and language")
|
|
1142
|
+
p.add_argument("--fold", action="store_true", help="fold the provenance and metadata bookkeeping variants")
|
|
1143
|
+
p.add_argument("--fold-prefix", action="append", help="a renderer prefix to fold (repeatable)")
|
|
1144
|
+
p.add_argument("--keep", action="append", help="a variant that is a real export (repeatable)")
|
|
1145
|
+
p.add_argument("--monorepo", action="store_true",
|
|
1146
|
+
help="the source is a monorepo (skf-detect-workspaces.py's is_monorepo)")
|
|
1147
|
+
p.add_argument("--pattern-reference-intent", action="store_true",
|
|
1148
|
+
help="the brief's scope.notes flags pattern-reference intent (your judgment)")
|
|
1149
|
+
p.add_argument("--curated-subset", action="store_true",
|
|
1150
|
+
help="with --monorepo: the brief's scope.include lists a curated subset rather than the full "
|
|
1151
|
+
"workspace (your judgment; an extraction's counts.curated_subset wins)")
|
|
1152
|
+
p.add_argument("--barrel", choices=tuple(BARREL_FLAG),
|
|
1153
|
+
help="the entry points as you read them, where no extraction or quick output tells: none (no "
|
|
1154
|
+
"barrel file at any recognized entry-point path), empty (the primary one exports nothing) "
|
|
1155
|
+
"or barrel")
|
|
1156
|
+
p.add_argument("--output", help="also write the result to this file")
|
|
1157
|
+
p.set_defaults(func=surface)
|
|
1158
|
+
return parser
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1162
|
+
args = _build_parser().parse_args(argv)
|
|
1163
|
+
try:
|
|
1164
|
+
result = args.func(args)
|
|
1165
|
+
except InputError as exc:
|
|
1166
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
1167
|
+
return 1
|
|
1168
|
+
except SchemaError as exc:
|
|
1169
|
+
if args.output:
|
|
1170
|
+
Path(args.output).unlink(missing_ok=True)
|
|
1171
|
+
print(json.dumps({"valid": False, "violations": exc.violations}, indent=2))
|
|
1172
|
+
return 2
|
|
1173
|
+
if args.output:
|
|
1174
|
+
target = Path(args.output)
|
|
1175
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1176
|
+
target.write_bytes((json.dumps(result, indent=2, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
1177
|
+
print(json.dumps(result, indent=2))
|
|
1178
|
+
return 0
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def _force_utf8(*streams) -> None:
|
|
1182
|
+
"""Reconfigure stdout and stderr to UTF-8, keeping each stream's error handler.
|
|
1183
|
+
|
|
1184
|
+
A Windows console pipes them as cp1252, which cannot print every character
|
|
1185
|
+
of the --help text, so --help would stop with UnicodeEncodeError.
|
|
1186
|
+
"""
|
|
1187
|
+
for stream in streams:
|
|
1188
|
+
if hasattr(stream, "reconfigure"):
|
|
1189
|
+
errors = getattr(stream, "errors", None)
|
|
1190
|
+
if errors is None:
|
|
1191
|
+
stream.reconfigure(encoding="utf-8")
|
|
1192
|
+
else:
|
|
1193
|
+
stream.reconfigure(encoding="utf-8", errors=errors)
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
if __name__ == "__main__":
|
|
1197
|
+
_force_utf8(sys.stdout, sys.stderr)
|
|
1198
|
+
sys.exit(main())
|