bmad-module-skill-forge 2.2.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +49 -49
- package/docs/404.md +10 -8
- package/docs/_data/pinned.yaml +22 -13
- package/docs/_internal/STABILITY.md +58 -35
- package/docs/agents.md +39 -53
- package/docs/architecture.md +65 -51
- package/docs/bmad-synergy.md +53 -51
- package/docs/campaign.md +127 -75
- package/docs/concepts.md +34 -28
- package/docs/examples.md +70 -68
- package/docs/forge-auto.md +34 -31
- package/docs/getting-started.md +118 -57
- package/docs/how-it-works.md +25 -21
- package/docs/index.md +15 -15
- package/docs/skill-model.md +70 -59
- package/docs/troubleshooting.md +135 -18
- package/docs/verifying-a-skill.md +83 -57
- package/docs/why-skf.md +24 -22
- package/docs/workflows.md +274 -110
- package/package.json +18 -10
- package/src/forger/preferences.yaml +8 -1
- package/src/knowledge/agentskills-spec.md +2 -0
- package/src/knowledge/ccc-bridge.md +40 -24
- package/src/knowledge/confidence-tiers.md +9 -5
- package/src/knowledge/overview.md +3 -3
- package/src/knowledge/progressive-capability.md +8 -8
- package/src/knowledge/provenance-tracking.md +9 -5
- package/src/knowledge/qmd-registry.md +2 -2
- package/src/knowledge/skf-knowledge-index.csv +2 -2
- package/src/knowledge/skill-lifecycle.md +18 -11
- package/src/knowledge/split-body-strategy.md +0 -3
- package/src/knowledge/tool-resolution.md +4 -2
- package/src/knowledge/version-paths.md +101 -15
- package/src/module-help.csv +3 -3
- package/src/module.yaml +10 -0
- package/src/shared/data/ast-grep-recipes.yaml +608 -0
- package/src/shared/data/ide-context-files.json +30 -0
- package/src/shared/health-check.md +32 -12
- package/src/shared/references/description-guard-protocol.md +12 -9
- package/src/shared/references/feasibility-report-schema.md +19 -13
- package/src/shared/references/headless-gate-convention.md +55 -3
- package/src/shared/references/output-contract-schema.md +19 -8
- package/src/shared/references/pipeline-contracts.md +94 -29
- package/src/shared/references/tessl-review.md +77 -0
- package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
- package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
- package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
- package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
- package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
- package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
- package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
- package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
- package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
- package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
- package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
- package/src/shared/scripts/skf-atomic-write.py +30 -14
- package/src/shared/scripts/skf-build-change-manifest.py +2203 -24
- package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
- package/src/shared/scripts/skf-check-unit-records.py +184 -0
- package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
- package/src/shared/scripts/skf-classify-changed-files.py +694 -0
- package/src/shared/scripts/skf-comention-pairs.py +1333 -123
- package/src/shared/scripts/skf-compare-file-hashes.py +1 -1
- package/src/shared/scripts/skf-count-imports.py +1867 -0
- package/src/shared/scripts/skf-count-tokens.py +1 -1
- package/src/shared/scripts/skf-derive-assembly-shape.py +3 -2
- package/src/shared/scripts/skf-description-guard.py +99 -11
- package/src/shared/scripts/skf-detect-docs.py +694 -2
- package/src/shared/scripts/skf-detect-language.py +486 -107
- package/src/shared/scripts/skf-detect-registry.py +887 -0
- package/src/shared/scripts/skf-detect-scripts-assets.py +1 -1
- package/src/shared/scripts/skf-detect-tools.py +209 -27
- package/src/shared/scripts/skf-detect-workspaces.py +506 -30
- package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
- package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
- package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
- package/src/shared/scripts/skf-extract-public-api.py +4084 -85
- package/src/shared/scripts/skf-extraction-inventory.py +746 -0
- package/src/shared/scripts/skf-extraction-snapshot.py +539 -0
- package/src/shared/scripts/skf-fetch-temporal.py +674 -0
- package/src/shared/scripts/skf-find-cycles.py +97 -29
- package/src/shared/scripts/skf-find-test-report.py +476 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +444 -203
- package/src/shared/scripts/skf-github-fetch.py +365 -0
- package/src/shared/scripts/skf-github-probe.py +661 -0
- package/src/shared/scripts/skf-hash-content.py +166 -12
- package/src/shared/scripts/skf-language-corpora.py +1 -1
- package/src/shared/scripts/skf-load-provenance.py +159 -27
- package/src/shared/scripts/skf-manifest-ops.py +1 -1
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
- package/src/shared/scripts/skf-merge-doc-urls.py +1 -1
- package/src/shared/scripts/skf-names-present.py +221 -0
- package/src/shared/scripts/skf-pair-intersect.py +119 -28
- package/src/shared/scripts/skf-parse-gaps.py +1384 -0
- package/src/shared/scripts/skf-preapply.py +1 -1
- package/src/shared/scripts/skf-preflight.py +161 -27
- package/src/shared/scripts/skf-promote-staged.py +430 -0
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +39 -7
- package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
- package/src/shared/scripts/skf-quick-batch.py +774 -0
- package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
- package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
- package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
- package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
- package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
- package/src/shared/scripts/skf-resolve-authoritative-files.py +128 -13
- package/src/shared/scripts/skf-resolve-package.py +523 -106
- package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
- package/src/shared/scripts/skf-run-lock.py +577 -0
- package/src/shared/scripts/skf-scan-manifests.py +998 -57
- package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
- package/src/shared/scripts/skf-severity-classify.py +712 -108
- package/src/shared/scripts/skf-shape-detect.py +298 -204
- package/src/shared/scripts/skf-shard-body.py +9 -8
- package/src/shared/scripts/skf-skill-inventory.py +2412 -89
- package/src/shared/scripts/skf-skills-module.py +472 -0
- package/src/shared/scripts/skf-source-tree.py +1796 -0
- package/src/shared/scripts/skf-structural-diff.py +670 -119
- package/src/shared/scripts/skf-tessl-review.py +706 -0
- package/src/shared/scripts/skf-update-active-symlink.py +5 -5
- package/src/shared/scripts/skf-update-run-state.py +624 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
- package/src/shared/scripts/skf-validate-brief-schema.py +4 -1
- package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
- package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
- package/src/shared/scripts/skf-validate-output.py +712 -64
- package/src/shared/scripts/skf-validate-pins.py +1 -1
- package/src/shared/scripts/skf-verify-no-trace.py +116 -10
- package/src/shared/scripts/skf-verify-provenance-completeness.py +2919 -54
- package/src/shared/scripts/skf-write-skill-brief.py +367 -19
- package/src/shared/tool-requirements.yaml +201 -0
- package/src/skf-analyze-source/SKILL.md +41 -36
- package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
- package/src/skf-analyze-source/customize.toml +36 -24
- package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
- package/src/skf-analyze-source/references/continue.md +2 -13
- package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
- package/src/skf-analyze-source/references/generate-briefs.md +121 -55
- package/src/skf-analyze-source/references/headless-contract.md +42 -15
- package/src/skf-analyze-source/references/identify-units.md +96 -110
- package/src/skf-analyze-source/references/init.md +73 -71
- package/src/skf-analyze-source/references/map-and-detect.md +123 -105
- package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
- package/src/skf-analyze-source/references/recommend.md +51 -70
- package/src/skf-analyze-source/references/scan-project.md +60 -84
- package/src/skf-analyze-source/references/scan-root.md +39 -0
- package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
- package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
- package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
- package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
- package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
- package/src/skf-audit-skill/SKILL.md +43 -51
- package/src/skf-audit-skill/assets/drift-report-template.md +27 -3
- package/src/skf-audit-skill/customize.toml +41 -20
- package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
- package/src/skf-audit-skill/references/doc-drift.md +141 -0
- package/src/skf-audit-skill/references/headless-contract.md +29 -0
- package/src/skf-audit-skill/references/init.md +148 -210
- package/src/skf-audit-skill/references/re-index.md +96 -108
- package/src/skf-audit-skill/references/report.md +96 -33
- package/src/skf-audit-skill/references/semantic-diff.md +27 -18
- package/src/skf-audit-skill/references/severity-classify.md +79 -65
- package/src/skf-audit-skill/references/severity-rules.md +15 -6
- package/src/skf-audit-skill/references/structural-diff.md +75 -90
- package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
- package/src/skf-audit-skill/scripts/render-drift-tables.py +685 -0
- package/src/skf-brief-skill/SKILL.md +36 -25
- package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +17 -24
- package/src/skf-brief-skill/assets/skill-brief-schema.md +29 -15
- package/src/skf-brief-skill/customize.toml +48 -20
- package/src/skf-brief-skill/references/analyze-target.md +142 -125
- package/src/skf-brief-skill/references/confirm-brief.md +10 -8
- package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
- package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
- package/src/skf-brief-skill/references/gather-intent.md +87 -182
- package/src/skf-brief-skill/references/headless-args.md +84 -16
- package/src/skf-brief-skill/references/health-check.md +1 -1
- package/src/skf-brief-skill/references/invocation-contract.md +18 -13
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
- package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
- package/src/skf-brief-skill/references/scope-definition.md +123 -47
- package/src/skf-brief-skill/references/step-auto-brief.md +24 -80
- package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
- package/src/skf-brief-skill/references/version-resolution.md +3 -2
- package/src/skf-brief-skill/references/write-brief.md +66 -57
- package/src/skf-campaign/SKILL.md +31 -34
- package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
- package/src/skf-campaign/customize.toml +47 -16
- package/src/skf-campaign/references/campaign-contracts.md +39 -29
- package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
- package/src/skf-campaign/references/health-check.md +7 -16
- package/src/skf-campaign/references/step-01-setup.md +46 -84
- package/src/skf-campaign/references/step-02-strategy.md +31 -30
- package/src/skf-campaign/references/step-03-pins.md +26 -18
- package/src/skf-campaign/references/step-04-provenance.md +24 -23
- package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
- package/src/skf-campaign/references/step-06-batch.md +41 -34
- package/src/skf-campaign/references/step-07-capstone.md +40 -25
- package/src/skf-campaign/references/step-08-verify.md +28 -29
- package/src/skf-campaign/references/step-09-refine.md +29 -30
- package/src/skf-campaign/references/step-10-export.md +48 -36
- package/src/skf-campaign/references/step-11-maintenance.md +31 -34
- package/src/skf-campaign/references/step-resume.md +37 -68
- package/src/skf-campaign/scripts/campaign-deps.py +57 -18
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
- package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
- package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
- package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
- package/src/skf-campaign/scripts/campaign-report.py +204 -37
- package/src/skf-campaign/scripts/campaign-state.py +1128 -0
- package/src/skf-campaign/scripts/campaign-status.py +10 -74
- package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
- package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
- package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
- package/src/skf-campaign/templates/campaign-report-template.md +5 -1
- package/src/skf-campaign/templates/kickoff-template.md +0 -14
- package/src/skf-create-skill/SKILL.md +27 -20
- package/src/skf-create-skill/assets/compile-assembly-rules.md +6 -5
- package/src/skf-create-skill/assets/skill-sections.md +15 -5
- package/src/skf-create-skill/customize.toml +20 -8
- package/src/skf-create-skill/references/authoritative-files-protocol.md +41 -42
- package/src/skf-create-skill/references/batch-mode.md +81 -0
- package/src/skf-create-skill/references/ccc-index-check.md +27 -0
- package/src/skf-create-skill/references/compile.md +107 -29
- package/src/skf-create-skill/references/component-extraction.md +113 -91
- package/src/skf-create-skill/references/enrich.md +41 -23
- package/src/skf-create-skill/references/entry-points-by-hand.md +20 -0
- package/src/skf-create-skill/references/extract.md +200 -161
- package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +2 -2
- package/src/skf-create-skill/references/extraction-patterns.md +584 -196
- package/src/skf-create-skill/references/generate-artifacts.md +145 -93
- package/src/skf-create-skill/references/health-check.md +1 -1
- package/src/skf-create-skill/references/load-brief.md +53 -36
- package/src/skf-create-skill/references/relabel-rule.md +12 -0
- package/src/skf-create-skill/references/report.md +28 -59
- package/src/skf-create-skill/references/source-resolution-protocols.md +49 -155
- package/src/skf-create-skill/references/step-auto-shard.md +20 -93
- package/src/skf-create-skill/references/step-doc-rot.md +44 -60
- package/src/skf-create-skill/references/step-doc-sources.md +28 -19
- package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
- package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
- package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
- package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
- package/src/skf-create-skill/references/validate.md +173 -99
- package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
- package/src/skf-create-stack-skill/SKILL.md +29 -56
- package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
- package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
- package/src/skf-create-stack-skill/customize.toml +23 -12
- package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
- package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
- package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
- package/src/skf-create-stack-skill/references/generate-output.md +138 -128
- package/src/skf-create-stack-skill/references/init.md +90 -32
- package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
- package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
- package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
- package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
- package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
- package/src/skf-create-stack-skill/references/report.md +39 -33
- package/src/skf-create-stack-skill/references/validate.md +20 -39
- package/src/skf-drop-skill/SKILL.md +28 -51
- package/src/skf-drop-skill/customize.toml +44 -30
- package/src/skf-drop-skill/references/execute.md +79 -146
- package/src/skf-drop-skill/references/invocation-contract.md +64 -0
- package/src/skf-drop-skill/references/report.md +46 -30
- package/src/skf-drop-skill/references/select.md +118 -124
- package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
- package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
- package/src/skf-export-skill/SKILL.md +40 -62
- package/src/skf-export-skill/assets/managed-section-format.md +14 -50
- package/src/skf-export-skill/assets/snippet-format.md +3 -1
- package/src/skf-export-skill/customize.toml +42 -13
- package/src/skf-export-skill/references/generate-snippet.md +54 -46
- package/src/skf-export-skill/references/health-check.md +2 -0
- package/src/skf-export-skill/references/invocation-contract.md +15 -0
- package/src/skf-export-skill/references/load-skill.md +88 -62
- package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
- package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
- package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
- package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
- package/src/skf-export-skill/references/package.md +15 -73
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
- package/src/skf-export-skill/references/result-envelope.md +31 -16
- package/src/skf-export-skill/references/summary.md +41 -17
- package/src/skf-export-skill/references/token-report.md +4 -12
- package/src/skf-export-skill/references/update-context.md +118 -237
- package/src/skf-forger/SKILL.md +61 -37
- package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
- package/src/skf-forger/customize.toml +43 -0
- package/src/skf-forger/references/pipeline-mode.md +73 -18
- package/src/skf-forger/scripts/forge-status.py +299 -0
- package/src/skf-forger/scripts/parse-pipeline.py +322 -54
- package/src/skf-forger/scripts/pipeline-gate.py +428 -0
- package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
- package/src/skf-quick-skill/SKILL.md +54 -53
- package/src/skf-quick-skill/assets/skill-template.md +4 -2
- package/src/skf-quick-skill/customize.toml +51 -20
- package/src/skf-quick-skill/references/batch-mode.md +74 -21
- package/src/skf-quick-skill/references/compile.md +30 -31
- package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
- package/src/skf-quick-skill/references/finalize.md +69 -18
- package/src/skf-quick-skill/references/halt-contract.md +41 -29
- package/src/skf-quick-skill/references/health-check.md +5 -3
- package/src/skf-quick-skill/references/quick-extract.md +114 -45
- package/src/skf-quick-skill/references/registry-resolution.md +9 -64
- package/src/skf-quick-skill/references/resolve-target.md +116 -66
- package/src/skf-quick-skill/references/write-and-validate.md +79 -40
- package/src/skf-refine-architecture/SKILL.md +43 -53
- package/src/skf-refine-architecture/customize.toml +49 -20
- package/src/skf-refine-architecture/references/compile.md +97 -54
- package/src/skf-refine-architecture/references/exit-codes.md +30 -0
- package/src/skf-refine-architecture/references/finding-storage.md +21 -0
- package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
- package/src/skf-refine-architecture/references/improvements.md +14 -20
- package/src/skf-refine-architecture/references/init.md +98 -57
- package/src/skf-refine-architecture/references/issue-detection.md +32 -39
- package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
- package/src/skf-refine-architecture/references/report.md +65 -54
- package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
- package/src/skf-rename-skill/SKILL.md +19 -54
- package/src/skf-rename-skill/customize.toml +32 -26
- package/src/skf-rename-skill/references/execute.md +230 -184
- package/src/skf-rename-skill/references/exit-codes.md +7 -7
- package/src/skf-rename-skill/references/health-check.md +1 -7
- package/src/skf-rename-skill/references/invocation-contract.md +25 -0
- package/src/skf-rename-skill/references/report.md +63 -52
- package/src/skf-rename-skill/references/select.md +197 -115
- package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
- package/src/skf-setup/SKILL.md +26 -26
- package/src/skf-setup/customize.toml +14 -10
- package/src/skf-setup/references/auto-index.md +50 -63
- package/src/skf-setup/references/ccc-index.md +29 -90
- package/src/skf-setup/references/detect-and-tier.md +58 -67
- package/src/skf-setup/references/health-check.md +2 -0
- package/src/skf-setup/references/invocation-contract.md +14 -0
- package/src/skf-setup/references/report.md +43 -166
- package/src/skf-setup/references/tier-rules.md +5 -5
- package/src/skf-setup/references/write-config.md +34 -57
- package/src/skf-test-skill/SKILL.md +17 -57
- package/src/skf-test-skill/assets/output-section-formats.md +33 -34
- package/src/skf-test-skill/customize.toml +27 -13
- package/src/skf-test-skill/references/coherence-check.md +134 -62
- package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
- package/src/skf-test-skill/references/coverage-check.md +217 -254
- package/src/skf-test-skill/references/detect-mode.md +5 -5
- package/src/skf-test-skill/references/external-validators.md +94 -82
- package/src/skf-test-skill/references/health-check.md +6 -7
- package/src/skf-test-skill/references/init.md +163 -88
- package/src/skf-test-skill/references/invocation-contract.md +66 -0
- package/src/skf-test-skill/references/migration-section-rules.md +5 -2
- package/src/skf-test-skill/references/report.md +162 -142
- package/src/skf-test-skill/references/score.md +80 -107
- package/src/skf-test-skill/references/scoring-rules.md +41 -155
- package/src/skf-test-skill/references/source-access-protocol.md +19 -92
- package/src/skf-test-skill/references/step-hard-gate.md +51 -33
- package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
- package/src/skf-test-skill/scripts/build-result-context.py +287 -0
- package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
- package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
- package/src/skf-test-skill/scripts/compute-score.py +223 -45
- package/src/skf-test-skill/scripts/gap-ledger.py +1381 -0
- package/src/skf-test-skill/scripts/hard-gate.py +203 -0
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +760 -0
- package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
- package/src/skf-test-skill/scripts/reconcile-coverage.py +273 -79
- package/src/skf-test-skill/scripts/score-signatures.py +459 -0
- package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
- package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +127 -52
- package/src/skf-test-skill/templates/test-report-template.md +21 -15
- package/src/skf-update-skill/SKILL.md +38 -25
- package/src/skf-update-skill/customize.toml +27 -12
- package/src/skf-update-skill/references/detect-changes.md +233 -222
- package/src/skf-update-skill/references/gap-driven.md +336 -0
- package/src/skf-update-skill/references/health-check.md +31 -5
- package/src/skf-update-skill/references/init.md +250 -84
- package/src/skf-update-skill/references/invocation-contract.md +12 -0
- package/src/skf-update-skill/references/manual-section-rules.md +6 -23
- package/src/skf-update-skill/references/merge.md +180 -47
- package/src/skf-update-skill/references/re-extract.md +53 -262
- package/src/skf-update-skill/references/report.md +100 -35
- package/src/skf-update-skill/references/write.md +331 -149
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +29 -7
- package/src/skf-verify-stack/SKILL.md +23 -42
- package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
- package/src/skf-verify-stack/customize.toml +55 -22
- package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
- package/src/skf-verify-stack/references/coverage.md +67 -64
- package/src/skf-verify-stack/references/exit-codes.md +21 -8
- package/src/skf-verify-stack/references/init.md +107 -62
- package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
- package/src/skf-verify-stack/references/integrations.md +106 -65
- package/src/skf-verify-stack/references/report.md +59 -42
- package/src/skf-verify-stack/references/requirements.md +32 -9
- package/src/skf-verify-stack/references/synthesize.md +53 -39
- package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
- package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
- package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
- package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
- package/tools/cli/commands/install.js +11 -4
- package/tools/cli/commands/status.js +15 -3
- package/tools/cli/commands/update.js +11 -4
- package/tools/cli/lib/tool-check.js +320 -0
- package/tools/cli/lib/version-check.js +22 -9
- package/tools/cli/skf-cli.js +4 -1
- package/.gitattributes +0 -16
- package/.nvmrc +0 -1
- package/docs/_internal/RELEASING.md +0 -679
- package/release-audits/v1.0.0-launch-audit.md +0 -728
- package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
- package/src/skf-campaign/manifest.yaml +0 -15
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
- package/src/skf-create-skill/references/ecosystem-check.md +0 -74
- package/src/skf-drop-skill/references/headless-contract.md +0 -18
- package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
- package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
- package/src/skf-update-skill/references/validate.md +0 -124
- package/tools/validate-docs-drift.js +0 -233
- package/tools/validate-docs-links.js +0 -665
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
|
+
# dependencies = ["pyyaml"]
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Detect Registry: a component library's files, its demo files and its
|
|
6
|
+
component registry, listed, counted and scored for create-skill step 3d.
|
|
7
|
+
|
|
8
|
+
create-skill's component-library extraction (references/component-extraction.md)
|
|
9
|
+
leaves demo and example files out before it extracts, and reads the
|
|
10
|
+
component registry, an array of entries such as `{id, name, category}`, as
|
|
11
|
+
the library's primary API surface. Listing the files in the brief's scope,
|
|
12
|
+
counting the demo files per pattern, scoring each registry candidate by its
|
|
13
|
+
entries and fields and listing the registry's entries is fixed work, and the
|
|
14
|
+
score alone decides whether a headless run uses a candidate, so this helper
|
|
15
|
+
does all of it. The step keeps the user's confirmation and the judgment on
|
|
16
|
+
whether a borderline candidate really is the registry.
|
|
17
|
+
|
|
18
|
+
CLI:
|
|
19
|
+
uv run skf-detect-registry.py demo (--source-root <dir> | --files-from <list>) \\
|
|
20
|
+
[--brief <skill-brief.yaml>] [--pattern GLOB]... [--files-to <file>] \\
|
|
21
|
+
[--kept-to <file>]
|
|
22
|
+
uv run skf-detect-registry.py registry --files-from <list> --source-root <dir>
|
|
23
|
+
uv run skf-detect-registry.py registry --source-root <dir> --path <file> [--entries]
|
|
24
|
+
uv run skf-detect-registry.py registry --files-from <list> --candidates-only
|
|
25
|
+
|
|
26
|
+
--files-from reads a file list: one path per line, relative to the source
|
|
27
|
+
root, or a JSON list of paths; `-` reads it from stdin. A list that opens
|
|
28
|
+
with `[` but is not a JSON list of strings is read line by line (a Next.js
|
|
29
|
+
route such as `[slug]/page.tsx` can come first). It also reads the listing
|
|
30
|
+
skf-github-probe.py tree --out writes (the sibling in this folder), the
|
|
31
|
+
tree.json skf-brief-skill stages in its run folder: a JSON object whose
|
|
32
|
+
`tree` lists the paths; one whose `status` is not `ok` is a usage error.
|
|
33
|
+
|
|
34
|
+
demo
|
|
35
|
+
----
|
|
36
|
+
Which files. --source-root lists the files under it: those git lists there
|
|
37
|
+
(tracked, and untracked but not ignored), or every file when it is not in a
|
|
38
|
+
git work tree, leaving out hidden entries and symbolic links, as
|
|
39
|
+
skf-extract-public-api.py --mode full lists a tree. --files-from takes the
|
|
40
|
+
paths a list names instead (a remote repository's tree listing). --brief
|
|
41
|
+
keeps those its scope.include globs match (every file when it has none) and
|
|
42
|
+
no scope.exclude glob does: the step's filtered file list. --files-to writes
|
|
43
|
+
that list, and --kept-to the files of it no demo pattern matches, each as a
|
|
44
|
+
JSON list, which --files-from here and skf-extract-public-api.py's
|
|
45
|
+
--files-from both read whatever the paths hold.
|
|
46
|
+
|
|
47
|
+
Which patterns. Without --pattern, the auto-detected patterns (DEMO_FOLDERS
|
|
48
|
+
as `**/<folder>/**`, DEMO_FILE_PATTERNS as `**/<pattern>`) are tried and
|
|
49
|
+
only those that match a file are reported. With --pattern (the brief's
|
|
50
|
+
`scope.demo_patterns`, or the patterns the user gave), each is reported,
|
|
51
|
+
matched or not, and `also_matched` reports the auto-detected patterns that
|
|
52
|
+
match a file the given ones leave in. A given pattern is read as a glob,
|
|
53
|
+
with two shorthands: a name ending in `/` with no other `/` is a folder at
|
|
54
|
+
any depth (`examples/` is `**/examples/**`), and a pattern with no `/` is a
|
|
55
|
+
file name at any depth (`*.stories.*` is `**/*.stories.*`). Globs follow
|
|
56
|
+
skf-resolve-authoritative-files.py's rules (the sibling in this folder):
|
|
57
|
+
`**` spans any number of path segments, none included, and `*` and `?` stay
|
|
58
|
+
inside one.
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
"mode": "auto" | "given",
|
|
62
|
+
"files_listed": N, # the files --source-root or --files-from gave
|
|
63
|
+
"files_scanned": N, # those in --brief's scope
|
|
64
|
+
"patterns": [{"pattern": "<glob>", "files": N,
|
|
65
|
+
"sample": ["<path>", ...]}, ...], # up to 3 paths each
|
|
66
|
+
"excluded": N, # files at least one pattern matches
|
|
67
|
+
"directories": N, # distinct folders holding them
|
|
68
|
+
"kept": N,
|
|
69
|
+
"also_matched": [{"pattern", "files", "sample"}, ...], # with --pattern
|
|
70
|
+
"files_file": "<path>" | null,
|
|
71
|
+
"kept_file": "<path>" | null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
registry
|
|
75
|
+
--------
|
|
76
|
+
The candidates are the listed files named, in priority order (`rule`):
|
|
77
|
+
1 registry.ts, registry.tsx or registry.js, in any folder
|
|
78
|
+
2 components.ts or components.tsx in a registry/, catalog/ or components/
|
|
79
|
+
folder
|
|
80
|
+
3 index.ts or index.tsx in a registry/ or catalog/ folder
|
|
81
|
+
With --path, the one file it names is the only candidate (a brief's
|
|
82
|
+
`scope.registry_path`, or a path the user gave), listed or not.
|
|
83
|
+
|
|
84
|
+
Each candidate is read from --source-root, and its largest array of object
|
|
85
|
+
literals is taken as the registry: its entries are the object literals it
|
|
86
|
+
holds, and `fields` counts, for each key, the entries that have it (a
|
|
87
|
+
spread such as `...base` and a computed key such as `[key]: value` add no
|
|
88
|
+
key). The score, out of 9:
|
|
89
|
+
+3 every entry has `id`
|
|
90
|
+
+2 every entry has `name` or every entry has `component`
|
|
91
|
+
+2 every entry has `category` or every entry has `tags`
|
|
92
|
+
+1 the file's folder is registry/ or catalog/
|
|
93
|
+
+1 20 or more entries
|
|
94
|
+
A candidate qualifies with 10 or more entries and a score of 5 or more.
|
|
95
|
+
`selected` is the qualifying candidate with the highest score (then the
|
|
96
|
+
lower rule, then the path), and `headless_accept` is true when its score is
|
|
97
|
+
7 or more: the score a headless run accepts a candidate at without asking.
|
|
98
|
+
With --path, the file is selected whatever its score, and headless_accept
|
|
99
|
+
is true, unless it cannot be read or lies outside --source-root: then its
|
|
100
|
+
`error` says why, selected is null and headless_accept false.
|
|
101
|
+
|
|
102
|
+
The parse is lexical, for JavaScript and TypeScript (JSX included): strings,
|
|
103
|
+
template literals, comments and regular expression literals are skipped,
|
|
104
|
+
and brackets are matched. A file it cannot read is listed with its `error`
|
|
105
|
+
and no entries.
|
|
106
|
+
|
|
107
|
+
{
|
|
108
|
+
"explicit": bool, # --path was given
|
|
109
|
+
"candidates": [{"path", "rule": 0-3, # 0: --path
|
|
110
|
+
"error": null | "<why it was not read>",
|
|
111
|
+
"array": {"name": "<binding or key>" | null,
|
|
112
|
+
"line": N, "annotation": "<type>" | null} | null,
|
|
113
|
+
"entry_count": N, "other_elements": N,
|
|
114
|
+
"fields": {"<key>": N, ...},
|
|
115
|
+
"score": N, "score_parts": {"id", "name_or_component",
|
|
116
|
+
"category_or_tags", "registry_folder",
|
|
117
|
+
"entries_20_plus"},
|
|
118
|
+
"qualifies": bool,
|
|
119
|
+
"sample": [{"line": N, "id"?, "name"?, "component"?,
|
|
120
|
+
"category"?, "tags"?}, ...],
|
|
121
|
+
"entries": [{"line": N, "keys": ["<key>", ...],
|
|
122
|
+
"id"?, ..., "description"?}, ...]},
|
|
123
|
+
...], # entries: --entries, selected one only
|
|
124
|
+
"selected": "<path>" | null,
|
|
125
|
+
"headless_accept": bool
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
`sample` holds the first 5 entries, each with the line of its `{` and the
|
|
129
|
+
SAMPLE_FIELDS it has: a string's text, a list of strings, or the value's
|
|
130
|
+
source (whitespace collapsed), cut at 80 characters. --entries adds
|
|
131
|
+
`entries` to the selected candidate: every entry, with the line of its
|
|
132
|
+
`{`, `keys` (every key it has, in order) and the ENTRY_FIELDS it has, in
|
|
133
|
+
full. The candidates are listed qualifying first, in the order `selected`
|
|
134
|
+
is picked from. --candidates-only prints the candidate paths the list
|
|
135
|
+
holds, one per line (LF, on Windows too), and reads no file: a caller
|
|
136
|
+
without a local tree fetches exactly those into a folder and passes it as
|
|
137
|
+
--source-root.
|
|
138
|
+
|
|
139
|
+
Exit codes:
|
|
140
|
+
0 JSON (or, with --candidates-only, the paths) printed, a
|
|
141
|
+
candidate-free list included
|
|
142
|
+
2 usage error, or a file list, a brief or a folder that cannot be read
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
from __future__ import annotations
|
|
146
|
+
|
|
147
|
+
import argparse
|
|
148
|
+
import importlib.util
|
|
149
|
+
import json
|
|
150
|
+
import os
|
|
151
|
+
import posixpath
|
|
152
|
+
import re
|
|
153
|
+
import sys
|
|
154
|
+
from pathlib import Path
|
|
155
|
+
from typing import NamedTuple
|
|
156
|
+
|
|
157
|
+
DEMO_FOLDERS = ("demo", "demos", "stories", "__stories__", "storybook", "examples", "example")
|
|
158
|
+
DEMO_FILE_PATTERNS = ("*.stories.*", "*.story.*", "*.example.*", "*.demo.*")
|
|
159
|
+
DEMO_SAMPLE = 3
|
|
160
|
+
|
|
161
|
+
# (rule, file names, folders the file must sit in; None for any folder)
|
|
162
|
+
REGISTRY_RULES = (
|
|
163
|
+
(1, ("registry.ts", "registry.tsx", "registry.js"), None),
|
|
164
|
+
(2, ("components.ts", "components.tsx"), ("registry", "catalog", "components")),
|
|
165
|
+
(3, ("index.ts", "index.tsx"), ("registry", "catalog")),
|
|
166
|
+
)
|
|
167
|
+
REGISTRY_FOLDERS = ("registry", "catalog")
|
|
168
|
+
MIN_ENTRIES = 10
|
|
169
|
+
MIN_SCORE = 5
|
|
170
|
+
AUTO_ACCEPT_SCORE = 7
|
|
171
|
+
MANY_ENTRIES = 20
|
|
172
|
+
SAMPLE_SIZE = 5
|
|
173
|
+
SAMPLE_FIELDS = ("id", "name", "component", "category", "tags")
|
|
174
|
+
ENTRY_FIELDS = (*SAMPLE_FIELDS, "description")
|
|
175
|
+
VALUE_TEXT_LIMIT = 80
|
|
176
|
+
|
|
177
|
+
_SIBLINGS: dict[str, object] = {}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class UsageError(Exception):
|
|
181
|
+
"""An input the helper cannot use (exit 2)."""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# --------------------------------------------------------------------------
|
|
185
|
+
# File list
|
|
186
|
+
# --------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _norm(path: object) -> str:
|
|
190
|
+
"""A path as the helper compares it: forward slashes, no leading `./`."""
|
|
191
|
+
text = str(path or "").strip().replace("\\", "/")
|
|
192
|
+
while text.startswith("./"):
|
|
193
|
+
text = text[2:]
|
|
194
|
+
return text
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _sibling(filename: str):
|
|
198
|
+
"""A helper beside this script, loaded once: skf-resolve-authoritative-files.py
|
|
199
|
+
(the brief, its scope and the glob rules) and skf-source-tree.py (git
|
|
200
|
+
without the caller's hooks, never one planted in the current folder)."""
|
|
201
|
+
module = _SIBLINGS.get(filename)
|
|
202
|
+
if module is None:
|
|
203
|
+
path = Path(__file__).resolve().parent / filename
|
|
204
|
+
name = "skf_" + filename.removeprefix("skf-").removesuffix(".py").replace("-", "_")
|
|
205
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
206
|
+
if spec is None or spec.loader is None:
|
|
207
|
+
raise UsageError(f"cannot load {filename} beside {Path(__file__).name}")
|
|
208
|
+
module = importlib.util.module_from_spec(spec)
|
|
209
|
+
spec.loader.exec_module(module)
|
|
210
|
+
_SIBLINGS[filename] = module
|
|
211
|
+
return module
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _hidden(rel: str) -> bool:
|
|
215
|
+
return any(part.startswith(".") for part in rel.split("/"))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def list_tree(root: Path) -> list[str]:
|
|
219
|
+
"""The files under `root` (see the demo section of the module docstring),
|
|
220
|
+
as sorted POSIX paths relative to it: those git lists in a git work
|
|
221
|
+
tree, else every file, as when git lists none."""
|
|
222
|
+
listed: list[str] = []
|
|
223
|
+
res = _sibling("skf-source-tree.py")._git(root, "ls-files", "-z", "--cached", "--others", "--exclude-standard")
|
|
224
|
+
if res is not None and res[0] == 0:
|
|
225
|
+
for raw in res[1].split(b"\0"):
|
|
226
|
+
rel = os.fsdecode(raw).replace("\\", "/") if raw else ""
|
|
227
|
+
if rel and (root / rel).is_file() and not (root / rel).is_symlink():
|
|
228
|
+
listed.append(rel)
|
|
229
|
+
if not listed:
|
|
230
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
231
|
+
rel_dir = os.path.relpath(dirpath, root).replace(os.sep, "/")
|
|
232
|
+
rel_dir = "" if rel_dir == "." else rel_dir
|
|
233
|
+
dirnames[:] = sorted(d for d in dirnames
|
|
234
|
+
if not d.startswith(".") and not os.path.islink(os.path.join(dirpath, d)))
|
|
235
|
+
for name in filenames:
|
|
236
|
+
if not name.startswith(".") and not os.path.islink(os.path.join(dirpath, name)):
|
|
237
|
+
listed.append(f"{rel_dir}/{name}" if rel_dir else name)
|
|
238
|
+
return sorted(rel for rel in listed if not _hidden(rel))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def brief_scope(brief: str) -> tuple[list[str], list[str]]:
|
|
242
|
+
"""(scope.include, scope.exclude) of a skill brief, as globs."""
|
|
243
|
+
resolver = _sibling("skf-resolve-authoritative-files.py")
|
|
244
|
+
try:
|
|
245
|
+
includes, excludes, _amendments = resolver.extract_scope(resolver.load_brief(Path(brief)))
|
|
246
|
+
except ValueError as exc:
|
|
247
|
+
raise UsageError(str(exc)) from exc
|
|
248
|
+
return [g for g in map(_norm, includes) if g], [g for g in map(_norm, excludes) if g]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def in_scope(files: list[str], includes: list[str], excludes: list[str]) -> list[str]:
|
|
252
|
+
"""The files an include glob matches (every file when there is none) and no exclude glob does."""
|
|
253
|
+
glob_match = _sibling("skf-resolve-authoritative-files.py").glob_match
|
|
254
|
+
return [rel for rel in files
|
|
255
|
+
if (not includes or any(glob_match(rel, g) for g in includes))
|
|
256
|
+
and not any(glob_match(rel, g) for g in excludes)]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def read_file_list(source: str) -> list[str]:
|
|
260
|
+
"""The paths of a --files-from list (a file, or `-` for stdin), in order, each once."""
|
|
261
|
+
if source == "-":
|
|
262
|
+
text = sys.stdin.read()
|
|
263
|
+
else:
|
|
264
|
+
try:
|
|
265
|
+
text = Path(source).read_text(encoding="utf-8")
|
|
266
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
267
|
+
raise UsageError(f"cannot read file list {source}: {exc}") from exc
|
|
268
|
+
items: object = None
|
|
269
|
+
if text.lstrip().startswith(("[", "{")):
|
|
270
|
+
try:
|
|
271
|
+
items = json.loads(text)
|
|
272
|
+
except ValueError:
|
|
273
|
+
items = None
|
|
274
|
+
if isinstance(items, dict):
|
|
275
|
+
# skf-github-probe.py tree --out: the paths are its `tree`
|
|
276
|
+
if items.get("status", "ok") != "ok":
|
|
277
|
+
raise UsageError(f"file list {source} reports a failure ({items.get('status')}): "
|
|
278
|
+
f"{items.get('message') or 'no message'}")
|
|
279
|
+
items = items.get("tree")
|
|
280
|
+
if not isinstance(items, list) or not all(isinstance(item, str) for item in items):
|
|
281
|
+
raise UsageError(f"file list {source} holds no `tree` list of paths")
|
|
282
|
+
if not isinstance(items, list) or not all(isinstance(item, str) for item in items):
|
|
283
|
+
items = text.splitlines()
|
|
284
|
+
return list(dict.fromkeys(rel for rel in (_norm(item) for item in items) if rel))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# --------------------------------------------------------------------------
|
|
288
|
+
# demo
|
|
289
|
+
# --------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def auto_patterns() -> list[str]:
|
|
293
|
+
"""The auto-detected demo patterns, folders first, as globs."""
|
|
294
|
+
return [f"**/{name}/**" for name in DEMO_FOLDERS] + [f"**/{pattern}" for pattern in DEMO_FILE_PATTERNS]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def normalize_pattern(pattern: str) -> str | None:
|
|
298
|
+
"""A given demo pattern as a glob (see the demo section of the module docstring)."""
|
|
299
|
+
text = _norm(pattern)
|
|
300
|
+
if not text:
|
|
301
|
+
return None
|
|
302
|
+
if text.endswith("/"):
|
|
303
|
+
folder = text.rstrip("/")
|
|
304
|
+
if not folder:
|
|
305
|
+
return None
|
|
306
|
+
return f"**/{folder}/**" if "/" not in folder else f"{folder}/**"
|
|
307
|
+
return text if "/" in text else f"**/{text}"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _matches(files: list[str], patterns: list[str]) -> tuple[dict[str, list[str]], list[str], list[str]]:
|
|
311
|
+
"""(the files each pattern matches, the files any matches, the others)."""
|
|
312
|
+
glob_match = _sibling("skf-resolve-authoritative-files.py").glob_match
|
|
313
|
+
matches: dict[str, list[str]] = {p: [] for p in patterns}
|
|
314
|
+
hit: list[str] = []
|
|
315
|
+
missed: list[str] = []
|
|
316
|
+
for rel in files:
|
|
317
|
+
found = [p for p in patterns if glob_match(rel, p)]
|
|
318
|
+
for p in found:
|
|
319
|
+
matches[p].append(rel)
|
|
320
|
+
(hit if found else missed).append(rel)
|
|
321
|
+
return matches, hit, missed
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _report(matches: dict[str, list[str]], every: bool) -> list[dict]:
|
|
325
|
+
return [{"pattern": p, "files": len(found), "sample": sorted(found)[:DEMO_SAMPLE]}
|
|
326
|
+
for p, found in matches.items() if found or every]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def detect_demo(files: list[str], given: list[str] | None = None) -> tuple[dict, list[str]]:
|
|
330
|
+
"""(the demo result, the kept files) for a file list."""
|
|
331
|
+
if given is None:
|
|
332
|
+
patterns, mode = auto_patterns(), "auto"
|
|
333
|
+
else:
|
|
334
|
+
patterns = list(dict.fromkeys(p for p in (normalize_pattern(g) for g in given) if p))
|
|
335
|
+
mode = "given"
|
|
336
|
+
matches, excluded, kept = _matches(files, patterns)
|
|
337
|
+
also = _report(_matches(kept, auto_patterns())[0], False) if mode == "given" else []
|
|
338
|
+
result = {
|
|
339
|
+
"mode": mode,
|
|
340
|
+
"files_listed": len(files),
|
|
341
|
+
"files_scanned": len(files),
|
|
342
|
+
"patterns": _report(matches, mode == "given"),
|
|
343
|
+
"excluded": len(excluded),
|
|
344
|
+
"directories": len({posixpath.dirname(rel) for rel in excluded}),
|
|
345
|
+
"kept": len(kept),
|
|
346
|
+
"also_matched": also,
|
|
347
|
+
"files_file": None,
|
|
348
|
+
"kept_file": None,
|
|
349
|
+
}
|
|
350
|
+
return result, kept
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# --------------------------------------------------------------------------
|
|
354
|
+
# registry: lexical scan
|
|
355
|
+
# --------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class Token(NamedTuple):
|
|
359
|
+
kind: str # "punct", "str", "ident", "num", "other"
|
|
360
|
+
text: str # a string's text, else the token as written
|
|
361
|
+
start: int
|
|
362
|
+
end: int
|
|
363
|
+
line: int
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
_IDENT_RE = re.compile(r"[A-Za-z_$À-][\w$À-]*")
|
|
367
|
+
_NUMBER_RE = re.compile(r"\d[\w.]*")
|
|
368
|
+
_OPENERS = {"(": ")", "[": "]", "{": "}"}
|
|
369
|
+
_CLOSERS = {")", "]", "}"}
|
|
370
|
+
# After one of these a `/` opens a regular expression literal, not a division.
|
|
371
|
+
_REGEX_KEYWORDS = frozenset({"return", "typeof", "case", "in", "of", "new", "delete", "void", "throw",
|
|
372
|
+
"instanceof", "yield", "await", "else", "do"})
|
|
373
|
+
_ESCAPES = {"n": "\n", "t": "\t", "r": "\r", "b": "\b", "f": "\f", "v": "\v", "0": "\0"}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _quoted(src: str, i: int) -> tuple[str, int] | None:
|
|
377
|
+
"""(text, index after the closing quote) of the string opening at src[i], or None
|
|
378
|
+
when the line ends first (an apostrophe in JSX text is no string)."""
|
|
379
|
+
quote = src[i]
|
|
380
|
+
out: list[str] = []
|
|
381
|
+
j = i + 1
|
|
382
|
+
while j < len(src):
|
|
383
|
+
ch = src[j]
|
|
384
|
+
if ch == "\\" and j + 1 < len(src):
|
|
385
|
+
nxt = src[j + 1]
|
|
386
|
+
if nxt == "\n":
|
|
387
|
+
j += 2
|
|
388
|
+
continue
|
|
389
|
+
out.append(_ESCAPES.get(nxt, nxt))
|
|
390
|
+
j += 2
|
|
391
|
+
continue
|
|
392
|
+
if ch == quote:
|
|
393
|
+
return "".join(out), j + 1
|
|
394
|
+
if ch == "\n":
|
|
395
|
+
return None
|
|
396
|
+
out.append(ch)
|
|
397
|
+
j += 1
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _skip_template(src: str, i: int) -> int:
|
|
402
|
+
"""The index after the template literal opening at src[i] (a backtick),
|
|
403
|
+
its `${ ... }` expressions included, however nested."""
|
|
404
|
+
j = i + 1
|
|
405
|
+
while j < len(src):
|
|
406
|
+
ch = src[j]
|
|
407
|
+
if ch == "\\":
|
|
408
|
+
j += 2
|
|
409
|
+
continue
|
|
410
|
+
if ch == "`":
|
|
411
|
+
return j + 1
|
|
412
|
+
if ch == "$" and src.startswith("${", j):
|
|
413
|
+
j = _skip_expression(src, j + 2)
|
|
414
|
+
continue
|
|
415
|
+
j += 1
|
|
416
|
+
return len(src)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _skip_expression(src: str, j: int) -> int:
|
|
420
|
+
"""The index after the `}` that closes a template expression starting at src[j]."""
|
|
421
|
+
depth = 0
|
|
422
|
+
while j < len(src):
|
|
423
|
+
ch = src[j]
|
|
424
|
+
if ch in "'\"":
|
|
425
|
+
quoted = _quoted(src, j)
|
|
426
|
+
j = quoted[1] if quoted else j + 1
|
|
427
|
+
continue
|
|
428
|
+
if ch == "`":
|
|
429
|
+
j = _skip_template(src, j)
|
|
430
|
+
continue
|
|
431
|
+
if ch == "{":
|
|
432
|
+
depth += 1
|
|
433
|
+
elif ch == "}":
|
|
434
|
+
if depth == 0:
|
|
435
|
+
return j + 1
|
|
436
|
+
depth -= 1
|
|
437
|
+
j += 1
|
|
438
|
+
return len(src)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _regex_end(src: str, i: int) -> int | None:
|
|
442
|
+
"""The index after the regular expression literal opening at src[i], or None
|
|
443
|
+
when the line ends first."""
|
|
444
|
+
j = i + 1
|
|
445
|
+
in_class = False
|
|
446
|
+
while j < len(src):
|
|
447
|
+
ch = src[j]
|
|
448
|
+
if ch == "\n":
|
|
449
|
+
return None
|
|
450
|
+
if ch == "\\":
|
|
451
|
+
j += 2
|
|
452
|
+
continue
|
|
453
|
+
if ch == "[":
|
|
454
|
+
in_class = True
|
|
455
|
+
elif ch == "]":
|
|
456
|
+
in_class = False
|
|
457
|
+
elif ch == "/" and not in_class:
|
|
458
|
+
j += 1
|
|
459
|
+
while j < len(src) and (src[j].isalnum() or src[j] == "_"):
|
|
460
|
+
j += 1
|
|
461
|
+
return j
|
|
462
|
+
j += 1
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _regex_allowed(prev: Token | None) -> bool:
|
|
467
|
+
"""True when a `/` after `prev` opens a regular expression literal."""
|
|
468
|
+
if prev is None:
|
|
469
|
+
return True
|
|
470
|
+
if prev.kind == "ident":
|
|
471
|
+
return prev.text in _REGEX_KEYWORDS
|
|
472
|
+
if prev.kind in ("str", "num"):
|
|
473
|
+
return False
|
|
474
|
+
return prev.text not in (")", "]", "}", "<")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def tokenize(src: str) -> list[Token]:
|
|
478
|
+
"""The tokens of a JavaScript or TypeScript source, comments left out."""
|
|
479
|
+
tokens: list[Token] = []
|
|
480
|
+
line_starts = [0] + [m.end() for m in re.finditer("\n", src)]
|
|
481
|
+
|
|
482
|
+
def line_of(pos: int) -> int:
|
|
483
|
+
lo, hi = 0, len(line_starts) - 1
|
|
484
|
+
while lo < hi:
|
|
485
|
+
mid = (lo + hi + 1) // 2
|
|
486
|
+
if line_starts[mid] <= pos:
|
|
487
|
+
lo = mid
|
|
488
|
+
else:
|
|
489
|
+
hi = mid - 1
|
|
490
|
+
return lo + 1
|
|
491
|
+
|
|
492
|
+
i = 0
|
|
493
|
+
n = len(src)
|
|
494
|
+
while i < n:
|
|
495
|
+
ch = src[i]
|
|
496
|
+
if ch.isspace():
|
|
497
|
+
i += 1
|
|
498
|
+
continue
|
|
499
|
+
if src.startswith("//", i):
|
|
500
|
+
end = src.find("\n", i)
|
|
501
|
+
i = n if end == -1 else end
|
|
502
|
+
continue
|
|
503
|
+
if src.startswith("/*", i):
|
|
504
|
+
end = src.find("*/", i + 2)
|
|
505
|
+
i = n if end == -1 else end + 2
|
|
506
|
+
continue
|
|
507
|
+
start = i
|
|
508
|
+
if ch in "'\"":
|
|
509
|
+
quoted = _quoted(src, i)
|
|
510
|
+
if quoted is None:
|
|
511
|
+
tokens.append(Token("other", ch, start, i + 1, line_of(start)))
|
|
512
|
+
i += 1
|
|
513
|
+
else:
|
|
514
|
+
tokens.append(Token("str", quoted[0], start, quoted[1], line_of(start)))
|
|
515
|
+
i = quoted[1]
|
|
516
|
+
continue
|
|
517
|
+
if ch == "`":
|
|
518
|
+
end = _skip_template(src, i)
|
|
519
|
+
tokens.append(Token("str", src[i + 1:end - 1], start, end, line_of(start)))
|
|
520
|
+
i = end
|
|
521
|
+
continue
|
|
522
|
+
if ch == "/" and _regex_allowed(tokens[-1] if tokens else None):
|
|
523
|
+
end = _regex_end(src, i)
|
|
524
|
+
if end is not None:
|
|
525
|
+
tokens.append(Token("other", src[i:end], start, end, line_of(start)))
|
|
526
|
+
i = end
|
|
527
|
+
continue
|
|
528
|
+
m = _IDENT_RE.match(src, i)
|
|
529
|
+
if m:
|
|
530
|
+
tokens.append(Token("ident", m.group(0), start, m.end(), line_of(start)))
|
|
531
|
+
i = m.end()
|
|
532
|
+
continue
|
|
533
|
+
m = _NUMBER_RE.match(src, i)
|
|
534
|
+
if m:
|
|
535
|
+
tokens.append(Token("num", m.group(0), start, m.end(), line_of(start)))
|
|
536
|
+
i = m.end()
|
|
537
|
+
continue
|
|
538
|
+
if src.startswith("...", i):
|
|
539
|
+
tokens.append(Token("punct", "...", start, i + 3, line_of(start)))
|
|
540
|
+
i += 3
|
|
541
|
+
continue
|
|
542
|
+
if src.startswith("=>", i):
|
|
543
|
+
tokens.append(Token("punct", "=>", start, i + 2, line_of(start)))
|
|
544
|
+
i += 2
|
|
545
|
+
continue
|
|
546
|
+
tokens.append(Token("punct", ch, start, i + 1, line_of(start)))
|
|
547
|
+
i += 1
|
|
548
|
+
return tokens
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def match_brackets(tokens: list[Token]) -> dict[int, int]:
|
|
552
|
+
"""Index of each opening bracket -> index of its closing one (unmatched ones left out)."""
|
|
553
|
+
pairs: dict[int, int] = {}
|
|
554
|
+
stack: list[int] = []
|
|
555
|
+
for index, token in enumerate(tokens):
|
|
556
|
+
if token.kind != "punct":
|
|
557
|
+
continue
|
|
558
|
+
if token.text in _OPENERS:
|
|
559
|
+
stack.append(index)
|
|
560
|
+
elif token.text in _CLOSERS:
|
|
561
|
+
while stack:
|
|
562
|
+
opener = stack.pop()
|
|
563
|
+
if _OPENERS[tokens[opener].text] == token.text:
|
|
564
|
+
pairs[opener] = index
|
|
565
|
+
break
|
|
566
|
+
return pairs
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _split(tokens: list[Token], pairs: dict[int, int], start: int, end: int) -> list[tuple[int, int]]:
|
|
570
|
+
"""The comma-separated parts of tokens[start:end] as (start, end) ranges, empty ones left out."""
|
|
571
|
+
parts: list[tuple[int, int]] = []
|
|
572
|
+
i = head = start
|
|
573
|
+
while i < end:
|
|
574
|
+
token = tokens[i]
|
|
575
|
+
if token.kind == "punct" and token.text in _OPENERS and i in pairs:
|
|
576
|
+
i = pairs[i] + 1
|
|
577
|
+
continue
|
|
578
|
+
if token.kind == "punct" and token.text == ",":
|
|
579
|
+
if i > head:
|
|
580
|
+
parts.append((head, i))
|
|
581
|
+
head = i + 1
|
|
582
|
+
i += 1
|
|
583
|
+
if end > head:
|
|
584
|
+
parts.append((head, end))
|
|
585
|
+
return parts
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _source(src: str, tokens: list[Token], start: int, end: int) -> str:
|
|
589
|
+
return " ".join(src[tokens[start].start:tokens[end - 1].end].split())
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _value(src: str, tokens: list[Token], start: int, end: int, pairs: dict[int, int]):
|
|
593
|
+
"""An entry value: a string's text, a list of strings, or the value's source."""
|
|
594
|
+
if end - start == 1 and tokens[start].kind in ("str", "ident", "num"):
|
|
595
|
+
return tokens[start].text
|
|
596
|
+
if tokens[start].text == "[" and pairs.get(start) == end - 1:
|
|
597
|
+
items = _split(tokens, pairs, start + 1, end - 1)
|
|
598
|
+
if all(b - a == 1 and tokens[a].kind == "str" for a, b in items):
|
|
599
|
+
return [tokens[a].text for a, _ in items]
|
|
600
|
+
return _source(src, tokens, start, end)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _cut(value):
|
|
604
|
+
"""A sample value: a text longer than VALUE_TEXT_LIMIT cut short."""
|
|
605
|
+
if isinstance(value, str) and len(value) > VALUE_TEXT_LIMIT:
|
|
606
|
+
return value[:VALUE_TEXT_LIMIT - 3] + "..."
|
|
607
|
+
return value
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _object_entry(src: str, tokens: list[Token], pairs: dict[int, int], open_index: int) -> dict:
|
|
611
|
+
"""{keys, line, values} of the object literal whose `{` is tokens[open_index]."""
|
|
612
|
+
keys: list[str] = []
|
|
613
|
+
values: dict = {}
|
|
614
|
+
for a, b in _split(tokens, pairs, open_index + 1, pairs[open_index]):
|
|
615
|
+
first = tokens[a]
|
|
616
|
+
if first.text == "...":
|
|
617
|
+
continue
|
|
618
|
+
key = None
|
|
619
|
+
value_start = None
|
|
620
|
+
if first.kind in ("ident", "str", "num"):
|
|
621
|
+
nxt = tokens[a + 1] if a + 1 < b else None
|
|
622
|
+
if nxt is None:
|
|
623
|
+
key, value_start = first.text, a # shorthand: `{ id, name }`
|
|
624
|
+
elif nxt.text == ":":
|
|
625
|
+
key, value_start = first.text, a + 2
|
|
626
|
+
elif nxt.text in ("(", "<"):
|
|
627
|
+
key = first.text # a method
|
|
628
|
+
elif first.kind == "ident" and first.text in ("get", "set", "async") and nxt.kind == "ident":
|
|
629
|
+
key = nxt.text
|
|
630
|
+
if key is None or key in keys:
|
|
631
|
+
continue
|
|
632
|
+
keys.append(key)
|
|
633
|
+
if key in ENTRY_FIELDS and value_start is not None and value_start < b:
|
|
634
|
+
values[key] = _value(src, tokens, value_start, b, pairs)
|
|
635
|
+
return {"keys": keys, "line": tokens[open_index].line, "values": values}
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _binding(src: str, tokens: list[Token], open_index: int) -> tuple[str | None, str | None]:
|
|
639
|
+
"""(name, type annotation) of what the array at tokens[open_index] is bound to."""
|
|
640
|
+
if open_index == 0:
|
|
641
|
+
return None, None
|
|
642
|
+
prev = tokens[open_index - 1]
|
|
643
|
+
if prev.text == ":" and open_index >= 2 and tokens[open_index - 2].kind in ("ident", "str"):
|
|
644
|
+
return tokens[open_index - 2].text, None # `items: [` in an object
|
|
645
|
+
if prev.text == "default" and open_index >= 2 and tokens[open_index - 2].text == "export":
|
|
646
|
+
return "default", None
|
|
647
|
+
if prev.text != "=":
|
|
648
|
+
return None, None
|
|
649
|
+
# Walk back over `name: Type =` to a declared name.
|
|
650
|
+
i = open_index - 2
|
|
651
|
+
colon = None
|
|
652
|
+
depth = 0
|
|
653
|
+
while i >= 0:
|
|
654
|
+
token = tokens[i]
|
|
655
|
+
if token.kind == "punct" and token.text in _CLOSERS:
|
|
656
|
+
depth += 1
|
|
657
|
+
elif token.kind == "punct" and token.text in _OPENERS:
|
|
658
|
+
if depth == 0:
|
|
659
|
+
break
|
|
660
|
+
depth -= 1
|
|
661
|
+
elif depth == 0 and token.text in (";", "=", ","):
|
|
662
|
+
break
|
|
663
|
+
elif depth == 0 and token.text == ":" and colon is None:
|
|
664
|
+
colon = i
|
|
665
|
+
elif depth == 0 and token.kind == "ident" and i > 0 and tokens[i - 1].text in ("const", "let", "var"):
|
|
666
|
+
annotation = _source(src, tokens, colon + 1, open_index - 1) if colon is not None else None
|
|
667
|
+
return token.text, annotation
|
|
668
|
+
i -= 1
|
|
669
|
+
if open_index >= 2 and tokens[open_index - 2].kind == "ident": # `module.exports = [`
|
|
670
|
+
j = open_index - 2
|
|
671
|
+
while j >= 2 and tokens[j - 1].text == "." and tokens[j - 2].kind == "ident":
|
|
672
|
+
j -= 2
|
|
673
|
+
return _source(src, tokens, j, open_index - 1), None
|
|
674
|
+
return None, None
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def registry_arrays(src: str) -> list[dict]:
|
|
678
|
+
"""Every array literal of the source that holds object literals."""
|
|
679
|
+
tokens = tokenize(src)
|
|
680
|
+
pairs = match_brackets(tokens)
|
|
681
|
+
arrays = []
|
|
682
|
+
for open_index, close_index in sorted(pairs.items()):
|
|
683
|
+
if tokens[open_index].text != "[":
|
|
684
|
+
continue
|
|
685
|
+
entries = []
|
|
686
|
+
others = 0
|
|
687
|
+
for a, b in _split(tokens, pairs, open_index + 1, close_index):
|
|
688
|
+
if tokens[a].text == "{" and pairs.get(a) == b - 1:
|
|
689
|
+
entries.append(_object_entry(src, tokens, pairs, a))
|
|
690
|
+
else:
|
|
691
|
+
others += 1
|
|
692
|
+
if entries:
|
|
693
|
+
name, annotation = _binding(src, tokens, open_index)
|
|
694
|
+
arrays.append({"name": name, "line": tokens[open_index].line, "annotation": annotation,
|
|
695
|
+
"entries": entries, "other_elements": others})
|
|
696
|
+
return arrays
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
# --------------------------------------------------------------------------
|
|
700
|
+
# registry: candidates and score
|
|
701
|
+
# --------------------------------------------------------------------------
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def candidate_rule(rel: str) -> int | None:
|
|
705
|
+
"""The rule (1-3) a listed path is a registry candidate by, else None."""
|
|
706
|
+
folder, name = posixpath.split(rel)
|
|
707
|
+
parent = posixpath.basename(folder)
|
|
708
|
+
for rule, names, folders in REGISTRY_RULES:
|
|
709
|
+
if name in names and (folders is None or parent in folders):
|
|
710
|
+
return rule
|
|
711
|
+
return None
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def score_candidate(rel: str, entry_count: int, fields: dict[str, int]) -> dict[str, int]:
|
|
715
|
+
"""The score parts of a candidate (see the registry section of the module docstring)."""
|
|
716
|
+
everywhere = {key for key, count in fields.items() if entry_count and count == entry_count}
|
|
717
|
+
return {
|
|
718
|
+
"id": 3 if "id" in everywhere else 0,
|
|
719
|
+
"name_or_component": 2 if everywhere & {"name", "component"} else 0,
|
|
720
|
+
"category_or_tags": 2 if everywhere & {"category", "tags"} else 0,
|
|
721
|
+
"registry_folder": 1 if posixpath.basename(posixpath.dirname(rel)) in REGISTRY_FOLDERS else 0,
|
|
722
|
+
"entries_20_plus": 1 if entry_count >= MANY_ENTRIES else 0,
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def evaluate(root: Path, rel: str, rule: int, entries: bool = False) -> dict:
|
|
727
|
+
"""One candidate, read from root and scored; with `entries`, every entry listed."""
|
|
728
|
+
result = {"path": rel, "rule": rule, "error": None, "array": None, "entry_count": 0,
|
|
729
|
+
"other_elements": 0, "fields": {}, "score": 0, "score_parts": {}, "qualifies": False,
|
|
730
|
+
"sample": []}
|
|
731
|
+
target = root / rel
|
|
732
|
+
try:
|
|
733
|
+
if not target.resolve().is_relative_to(root.resolve()):
|
|
734
|
+
raise OSError("the path leads outside --source-root")
|
|
735
|
+
src = target.read_bytes().decode("utf-8-sig")
|
|
736
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
737
|
+
result["error"] = f"cannot read {rel}: {exc}"
|
|
738
|
+
result["score_parts"] = score_candidate(rel, 0, {})
|
|
739
|
+
result["score"] = sum(result["score_parts"].values())
|
|
740
|
+
return result
|
|
741
|
+
arrays = registry_arrays(src.replace("\r\n", "\n"))
|
|
742
|
+
best = max(arrays, key=lambda a: len(a["entries"]), default=None) # max keeps the first of a tie
|
|
743
|
+
fields: dict[str, int] = {}
|
|
744
|
+
if best is not None:
|
|
745
|
+
for entry in best["entries"]:
|
|
746
|
+
for key in entry["keys"]:
|
|
747
|
+
fields[key] = fields.get(key, 0) + 1
|
|
748
|
+
result["array"] = {"name": best["name"], "line": best["line"], "annotation": best["annotation"]}
|
|
749
|
+
result["entry_count"] = len(best["entries"])
|
|
750
|
+
result["other_elements"] = best["other_elements"]
|
|
751
|
+
result["sample"] = [{"line": e["line"], **{k: _cut(v) for k, v in e["values"].items() if k in SAMPLE_FIELDS}}
|
|
752
|
+
for e in best["entries"][:SAMPLE_SIZE]]
|
|
753
|
+
if entries:
|
|
754
|
+
result["entries"] = [{"line": e["line"], "keys": e["keys"], **e["values"]} for e in best["entries"]]
|
|
755
|
+
result["fields"] = dict(sorted(fields.items()))
|
|
756
|
+
result["score_parts"] = score_candidate(rel, result["entry_count"], fields)
|
|
757
|
+
result["score"] = sum(result["score_parts"].values())
|
|
758
|
+
result["qualifies"] = result["entry_count"] >= MIN_ENTRIES and result["score"] >= MIN_SCORE
|
|
759
|
+
return result
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def detect_registry(root: Path, files: list[str], path: str | None = None, entries: bool = False) -> dict:
|
|
763
|
+
"""The registry result for a file list, or for the one file --path names;
|
|
764
|
+
with `entries`, the selected candidate lists every entry."""
|
|
765
|
+
if path is not None:
|
|
766
|
+
candidate = evaluate(root, _norm(path), 0, entries)
|
|
767
|
+
readable = candidate["error"] is None
|
|
768
|
+
return {"explicit": True, "candidates": [candidate],
|
|
769
|
+
"selected": candidate["path"] if readable else None, "headless_accept": readable}
|
|
770
|
+
candidates = [evaluate(root, rel, rule) for rel in files if (rule := candidate_rule(rel)) is not None]
|
|
771
|
+
candidates.sort(key=lambda c: (not c["qualifies"], -c["score"], c["rule"], c["path"]))
|
|
772
|
+
selected = candidates[0] if candidates and candidates[0]["qualifies"] else None
|
|
773
|
+
if selected is not None and entries:
|
|
774
|
+
candidates[0] = selected = evaluate(root, selected["path"], selected["rule"], True)
|
|
775
|
+
return {
|
|
776
|
+
"explicit": False,
|
|
777
|
+
"candidates": candidates,
|
|
778
|
+
"selected": selected["path"] if selected else None,
|
|
779
|
+
"headless_accept": bool(selected and selected["score"] >= AUTO_ACCEPT_SCORE),
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
# --------------------------------------------------------------------------
|
|
784
|
+
# CLI
|
|
785
|
+
# --------------------------------------------------------------------------
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
789
|
+
parser = argparse.ArgumentParser(
|
|
790
|
+
prog="skf-detect-registry.py",
|
|
791
|
+
description=__doc__,
|
|
792
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
793
|
+
)
|
|
794
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
795
|
+
demo = sub.add_parser("demo", help="list the files in scope and count the files each demo pattern matches")
|
|
796
|
+
files = demo.add_mutually_exclusive_group(required=True)
|
|
797
|
+
files.add_argument("--source-root", metavar="DIR", help="list the files under this folder")
|
|
798
|
+
files.add_argument("--files-from", metavar="FILE",
|
|
799
|
+
help="a file list: one path per line, a JSON list or a tree listing; - for stdin")
|
|
800
|
+
demo.add_argument("--brief", metavar="FILE", help="keep the files in this skill brief's scope")
|
|
801
|
+
demo.add_argument("--pattern", action="append", metavar="GLOB",
|
|
802
|
+
help="a demo pattern (the brief's scope.demo_patterns or the user's); repeatable")
|
|
803
|
+
demo.add_argument("--files-to", metavar="FILE", help="write the files in scope here, as a JSON list")
|
|
804
|
+
demo.add_argument("--kept-to", metavar="FILE", help="write the files no pattern matches here, as a JSON list")
|
|
805
|
+
registry = sub.add_parser("registry", help="find and score the component registry candidates")
|
|
806
|
+
registry.add_argument("--files-from", metavar="FILE",
|
|
807
|
+
help="the filtered file list: one path per line, a JSON list or a tree listing; "
|
|
808
|
+
"- for stdin")
|
|
809
|
+
registry.add_argument("--source-root", metavar="DIR", help="the folder the listed paths are relative to")
|
|
810
|
+
registry.add_argument("--path", metavar="FILE", help="score this file only (relative to --source-root)")
|
|
811
|
+
registry.add_argument("--candidates-only", action="store_true",
|
|
812
|
+
help="print the candidate paths, one per line, and read no file")
|
|
813
|
+
registry.add_argument("--entries", action="store_true", help="list every entry of the selected candidate")
|
|
814
|
+
return parser
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _write_list(target: str, paths: list[str]) -> None:
|
|
818
|
+
try:
|
|
819
|
+
Path(target).parent.mkdir(parents=True, exist_ok=True)
|
|
820
|
+
Path(target).write_bytes((json.dumps(paths, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
821
|
+
except OSError as exc:
|
|
822
|
+
raise UsageError(f"cannot write {target}: {exc}") from exc
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _run(args: argparse.Namespace) -> int:
|
|
826
|
+
if args.command == "demo":
|
|
827
|
+
if args.source_root is not None:
|
|
828
|
+
if not Path(args.source_root).is_dir():
|
|
829
|
+
raise UsageError(f"--source-root must name a folder, got {args.source_root!r}")
|
|
830
|
+
listed = list_tree(Path(args.source_root))
|
|
831
|
+
else:
|
|
832
|
+
listed = read_file_list(args.files_from)
|
|
833
|
+
files = in_scope(listed, *brief_scope(args.brief)) if args.brief else listed
|
|
834
|
+
result, kept = detect_demo(files, args.pattern)
|
|
835
|
+
result["files_listed"] = len(listed)
|
|
836
|
+
for option, key, paths in ((args.files_to, "files_file", files), (args.kept_to, "kept_file", kept)):
|
|
837
|
+
if option:
|
|
838
|
+
_write_list(option, paths)
|
|
839
|
+
result[key] = option
|
|
840
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
841
|
+
return 0
|
|
842
|
+
if args.candidates_only:
|
|
843
|
+
if not args.files_from:
|
|
844
|
+
raise UsageError("--candidates-only needs --files-from")
|
|
845
|
+
files = read_file_list(args.files_from)
|
|
846
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
847
|
+
sys.stdout.reconfigure(newline="\n") # a shell loop reads the lines: no CR on Windows
|
|
848
|
+
sys.stdout.write("".join(rel + "\n" for rel in files if candidate_rule(rel) is not None))
|
|
849
|
+
return 0
|
|
850
|
+
if not args.source_root or not Path(args.source_root).is_dir():
|
|
851
|
+
raise UsageError(f"--source-root must name a folder, got {args.source_root!r}")
|
|
852
|
+
if args.path is None and not args.files_from:
|
|
853
|
+
raise UsageError("registry needs --files-from, or --path for one file")
|
|
854
|
+
files = read_file_list(args.files_from) if args.files_from and args.path is None else []
|
|
855
|
+
print(json.dumps(detect_registry(Path(args.source_root), files, args.path, args.entries), ensure_ascii=False))
|
|
856
|
+
return 0
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def main(argv: list[str] | None = None) -> int:
|
|
860
|
+
args = _build_parser().parse_args(argv)
|
|
861
|
+
try:
|
|
862
|
+
return _run(args)
|
|
863
|
+
except UsageError as exc:
|
|
864
|
+
sys.stderr.write(f"skf-detect-registry: {exc}\n")
|
|
865
|
+
return 2
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _force_utf8(*streams) -> None:
|
|
869
|
+
"""Reconfigure stdin, stdout and stderr to UTF-8, keeping each stream's error handler.
|
|
870
|
+
|
|
871
|
+
A Windows console pipes them as cp1252, which cannot print every
|
|
872
|
+
character of a path or a registry value the JSON carries, nor read a
|
|
873
|
+
UTF-8 file list piped to stdin (reconfigured here, before the first
|
|
874
|
+
read).
|
|
875
|
+
"""
|
|
876
|
+
for stream in streams:
|
|
877
|
+
if hasattr(stream, "reconfigure"):
|
|
878
|
+
errors = getattr(stream, "errors", None)
|
|
879
|
+
if errors is None:
|
|
880
|
+
stream.reconfigure(encoding="utf-8")
|
|
881
|
+
else:
|
|
882
|
+
stream.reconfigure(encoding="utf-8", errors=errors)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
if __name__ == "__main__":
|
|
886
|
+
_force_utf8(sys.stdin, sys.stdout, sys.stderr)
|
|
887
|
+
sys.exit(main())
|