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
|
@@ -1,32 +1,223 @@
|
|
|
1
1
|
# /// script
|
|
2
|
-
# requires-python = ">=3.
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
3
|
# dependencies = []
|
|
4
4
|
# ///
|
|
5
5
|
"""SKF Skill Inventory — Scan skills directory and produce structured inventory.
|
|
6
6
|
|
|
7
7
|
Scans the skills output folder, reads manifests and metadata, resolves active
|
|
8
|
-
versions via symlinks, and outputs a JSON inventory.
|
|
8
|
+
versions via symlinks, and outputs a JSON inventory. Read by drop-skill and
|
|
9
|
+
rename-skill (roster, the ownership of each skill folder and its forge
|
|
10
|
+
folder, the purge and rename verdicts, and the guarded delete),
|
|
11
|
+
create-skill, quick-skill and create-stack-skill (the write check before a
|
|
12
|
+
version is written), analyze-source (coexistence matches and the name of
|
|
13
|
+
every unit and brief), the flat-layout
|
|
14
|
+
fallback of update-skill, export-skill, audit-skill and test-skill (the
|
|
15
|
+
ownership gate before a flat skill is migrated), and test-skill's report
|
|
16
|
+
(the discovery catalog).
|
|
9
17
|
|
|
10
|
-
CLI:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
18
|
+
CLI: uv run skf-skill-inventory.py <skills-output-folder>
|
|
19
|
+
uv run skf-skill-inventory.py <skills-output-folder> --skill <name>
|
|
20
|
+
uv run skf-skill-inventory.py <skills-output-folder> --manifest-only
|
|
21
|
+
uv run skf-skill-inventory.py <skills-output-folder> --match-target <url-or-name>
|
|
22
|
+
uv run skf-skill-inventory.py <skills-output-folder> --near <name>
|
|
23
|
+
uv run skf-skill-inventory.py derive-name --target <url-or-path> [--manifest-name <name>]
|
|
24
|
+
[--skills-folder <skills-output-folder>] [--probe-git]
|
|
25
|
+
uv run skf-skill-inventory.py derive-name --from <names.json|-> [--skills-folder <folder>]
|
|
26
|
+
uv run skf-skill-inventory.py <skills-output-folder> --forge-data-folder <path>
|
|
27
|
+
uv run skf-skill-inventory.py <skills-output-folder> --skill <name> --write-check
|
|
28
|
+
[--write-version <version>] [--forge-data-folder <path>]
|
|
29
|
+
uv run skf-skill-inventory.py <skills-output-folder> --skill <name> --purge-check
|
|
30
|
+
[--purge-version <version>] --forge-data-folder <path>
|
|
31
|
+
uv run skf-skill-inventory.py <skills-output-folder> --skill <name> --rename-check
|
|
32
|
+
--forge-data-folder <path>
|
|
33
|
+
uv run skf-skill-inventory.py guarded-delete --root <folder> [--root <folder>] [<path>...]
|
|
34
|
+
uv run skf-skill-inventory.py resolve <skills-output-folder> --skill <name>
|
|
35
|
+
--forge-data-folder <path> [--version <version>]
|
|
36
|
+
uv run skf-skill-inventory.py version normalize <version>
|
|
37
|
+
uv run skf-skill-inventory.py version order <a> <b>
|
|
38
|
+
uv run skf-skill-inventory.py version next-patch <version>
|
|
39
|
+
uv run skf-skill-inventory.py version bump --prior <version>
|
|
40
|
+
--prior-libraries <a,b> --libraries <a,c>
|
|
41
|
+
uv run skf-skill-inventory.py version primary <candidates.json|->
|
|
42
|
+
|
|
43
|
+
Exit 0 when `status` is "ok" (a write, purge or rename check exits 0
|
|
44
|
+
whatever its verdict, and a guarded delete whatever its `purge_status`);
|
|
45
|
+
exit 1 on an error (`DIR_NOT_FOUND`, `SKILL_NOT_FOUND` for `--skill` or
|
|
46
|
+
`resolve`, `USAGE` for a flag used wrongly, two check flags in one call,
|
|
47
|
+
`--forge-data-folder` without a value or missing from a purge or rename
|
|
48
|
+
check, for `version`: `NOT_A_VERSION`, `NOT_INCREASING` or `BAD_INPUT`,
|
|
49
|
+
and for `derive-name`: `BAD_INPUT`, or `NO_NAME` for one --target).
|
|
50
|
+
|
|
51
|
+
Ownership. The skills folder can hold skills SKF did not generate (a module's
|
|
52
|
+
own skills, skills installed from elsewhere). Only a `metadata.json` carrying
|
|
53
|
+
an SKF marker (`generated_by` naming an SKF generator, `tool_versions.skf`, or
|
|
54
|
+
`skill_type` with `forge_tier` or `confidence_tier`) proves that SKF generated
|
|
55
|
+
a skill, at the group root (flat layout) or at `{v}/{name}/metadata.json`
|
|
56
|
+
(versioned layout). The versioned layout, an `active` link, a manifest key or
|
|
57
|
+
a result file are not proof on their own. Each `skills[]` entry adds:
|
|
58
|
+
|
|
59
|
+
- `ownership`: "skf" (SKF evidence and nothing else), "mixed" (SKF evidence
|
|
60
|
+
plus entries SKF did not generate) or "foreign" (no SKF evidence). Evidence
|
|
61
|
+
is a marked version, a marked root `metadata.json`, the `_batch` folder, or
|
|
62
|
+
`.skf-` in the folder name (`_has_skf_evidence`, which the ccc helper
|
|
63
|
+
shares, so setup's ccc exclusions cover exactly these groups). A linked
|
|
64
|
+
folder is always "foreign", and a linked version folder, or a linked
|
|
65
|
+
package inside one, is never SKF output.
|
|
66
|
+
- `skf_skill`: a marked version, or a flat skill with a marked root.
|
|
67
|
+
- `flat_skf`: a root `SKILL.md` beside a marked root `metadata.json`; the
|
|
68
|
+
only flat layout a workflow may migrate.
|
|
69
|
+
- `foreign_entries`: the entries SKF did not generate, sorted; directories
|
|
70
|
+
end in `/`, a linked entry is listed by its bare name, and an entry inside
|
|
71
|
+
a marked version folder `v` is listed as `v/<entry>` (SKF puts only the
|
|
72
|
+
`{name}/` package and `.skf-` staging names there). A folder that holds no
|
|
73
|
+
file (only neutral clutter, `.skf-` names or folders like itself) is never
|
|
74
|
+
foreign: an interrupted SKF run leaves one before it writes metadata.json.
|
|
75
|
+
|
|
76
|
+
The existing `errors` list also names a linked folder (never checked for a
|
|
77
|
+
marker), a folder in the skill folder that SKF cannot list or search (listed
|
|
78
|
+
as foreign) and a `metadata.json` that cannot be read. The top-level `not_skf_output` lists the scanned names (never `_batch` or a `.skf-` name) whose `skf_skill` is
|
|
79
|
+
false that still look like a skill (a root `SKILL.md` or a `{v}/{name}/`
|
|
80
|
+
folder). `versions`, `active_version` and `active_path` keep their structural
|
|
81
|
+
meaning for every entry, except that the package folders of a flat SKF skill
|
|
82
|
+
(`references/{name}/`, for example) are never read as versions.
|
|
83
|
+
|
|
84
|
+
Forge folders. With `--forge-data-folder`, the top-level `forge_groups[]`
|
|
85
|
+
classifies `{forge_data_folder}/{name}` for every scanned name (`name`,
|
|
86
|
+
`path`, `ownership`, `foreign_entries`, `errors`; see `classify_forge_group`),
|
|
87
|
+
and `same_folder` says whether both settings name one folder. When they do,
|
|
88
|
+
`forge_groups` is empty and each skill folder is classified once, accepting
|
|
89
|
+
SKF's forge files too. The path given is echoed as `forge_data_folder`.
|
|
90
|
+
|
|
91
|
+
Write check. `--skill <name> --write-check [--write-version <v>]` returns
|
|
92
|
+
`write_check` (see `write_check`); the writers run it before creating any
|
|
93
|
+
directory. Its `version_generated_by` and `version_confidence_tier` name the
|
|
94
|
+
build already at that version, so quick-skill can ask before it replaces
|
|
95
|
+
another generator's package.
|
|
96
|
+
|
|
97
|
+
Purge and rename checks. `--skill <name> --purge-check [--purge-version
|
|
98
|
+
<v>]` returns `purge_check` (see `purge_check`): whether drop-skill may
|
|
99
|
+
purge the whole skill or one version, the rule that refused it, and the
|
|
100
|
+
folders the purge deletes and leaves in place. `--skill <name>
|
|
101
|
+
--rename-check` returns `rename_check` (see `rename_check`): whether
|
|
102
|
+
rename-skill may move the skill, and whether its forge folder moves with
|
|
103
|
+
it. Both need `--forge-data-folder` and apply the same version rule as the
|
|
104
|
+
write check (`0.1.0-rc/` is never version `0.1.0`).
|
|
105
|
+
|
|
106
|
+
Guarded delete. `guarded-delete --root <folder>... [<path>...]` deletes
|
|
107
|
+
each path that is a plain folder inside a root, reached through no link,
|
|
108
|
+
and reports each outcome and a `purge_status` (see `guarded_delete`); with
|
|
109
|
+
no path it deletes nothing and reports "success". drop-skill's purge and
|
|
110
|
+
rename-skill's delete of the old folders go through it, with the skills
|
|
111
|
+
and forge folders as roots.
|
|
112
|
+
|
|
113
|
+
Resolve. `resolve` returns `resolve` (see `resolve_skill`) for one skill:
|
|
114
|
+
the manifest's `active_version`, the `active` link's target, the version a
|
|
115
|
+
reading workflow uses and why (the Manifest-lag guard of
|
|
116
|
+
knowledge/version-paths.md, with each version's provenance `generated_at`),
|
|
117
|
+
`forge_version`, the metadata.json, provenance-map.json and
|
|
118
|
+
evidence-report.md paths (versioned first, flat as the fallback), and every
|
|
119
|
+
version newest first with its manifest status and the counts drop-skill and
|
|
120
|
+
rename-skill read. `--version` names the version instead (an operator's
|
|
121
|
+
choice). It reads, never writes, and does not decide ownership.
|
|
122
|
+
|
|
123
|
+
Version. `version` turns version strings into one answer: `normalize`
|
|
124
|
+
reduces a version or range to one folder name (`^18.2.0` gives `18.2.0`,
|
|
125
|
+
build metadata stripped), `order` compares two (`1.10.0` above `1.9.0`),
|
|
126
|
+
`next-patch` gives the next patch version, `bump` the compose-mode stack
|
|
127
|
+
version after a prior one (major when a library was removed, else minor,
|
|
128
|
+
refused when not above it), and `primary` the code-mode primary library of
|
|
129
|
+
a stack and the version it gives, ties broken by a fixed rule.
|
|
130
|
+
|
|
131
|
+
Skill names. `derive_name` is the one rule for the name analyze-source
|
|
132
|
+
writes a unit or a brief under, and the name `--match-target` compares, so
|
|
133
|
+
a coexistence check never looks for a name the brief does not get. With a
|
|
134
|
+
manifest name (the name the unit's own manifest gives its package, as
|
|
135
|
+
skf-scan-manifests.py reads it) of a package that is not private, the name
|
|
136
|
+
comes from the manifest: an npm scope joins the name (`@trpc/server` gives
|
|
137
|
+
`trpc-server`), a Go module path gives its last segment less a `/vN`
|
|
138
|
+
major-version suffix (`github.com/spf13/cobra` gives `cobra`), a Maven
|
|
139
|
+
`groupId:artifactId` its artifact, and any other name is kebab-cased
|
|
140
|
+
(`PyYAML` gives `pyyaml`, `serde_json` gives `serde-json`). A private
|
|
141
|
+
manifest (a monorepo's workspace root, an internal app) names nothing.
|
|
142
|
+
Without a manifest name, a merged unit (a composite, a merged monorepo)
|
|
143
|
+
takes the name its members' manifest names share (`@aws-sdk/client-s3`
|
|
144
|
+
and `@aws-sdk/core` give `aws-sdk`; Maven members of one group the
|
|
145
|
+
group's last segment). Else a documentation URL (an http(s) URL whose
|
|
146
|
+
host is not github.com, gitlab.com or bitbucket.org and that is not a git
|
|
147
|
+
clone URL, one whose path ends in `.git` or holds `/_git/`) gives its host
|
|
148
|
+
(`https://docs.example.com/guide` gives `docs-example-com`), and any other
|
|
149
|
+
target its last folder or repository name (`https://github.com/vercel/next.js`
|
|
150
|
+
gives `next-js`, `C:\\code\\mono` gives `mono`, and `.` the current
|
|
151
|
+
folder's name).
|
|
152
|
+
|
|
153
|
+
`derive-name --target <t> [--manifest-name <n>]` returns `name` and
|
|
154
|
+
`basis` ("manifest", "docs-host" or "target"), and exits 1 with `NO_NAME`
|
|
155
|
+
when no name can be derived. It also returns the target's `kind` and
|
|
156
|
+
`clone_url` (see `target_kind` and `clone_url`), so analyze-source routes
|
|
157
|
+
and clones a target by one rule on its `[auto]` path and its step-by-step
|
|
158
|
+
chain: `kind` is "local" for a path that starts with `/`, `./`, `../` or
|
|
159
|
+
`~` (or a Windows drive or `.\\`) or names an existing folder, "docs" for a
|
|
160
|
+
documentation URL and "remote" for anything else, and `clone_url` the URL
|
|
161
|
+
git clones a remote target from (null for the other kinds): a URL with a
|
|
162
|
+
scheme or an SSH address as given, `https://<path>` for a host path
|
|
163
|
+
without a scheme (`github.com/acme/mono`) and `https://github.com/<path>`
|
|
164
|
+
for the `owner/repo` shorthand. Both are null without a --target.
|
|
165
|
+
`--probe-git` adds git's verdict on a "docs" target as `git_probe` (see
|
|
166
|
+
`probe_git`): "repository" when git reads a repository at the URL (on most
|
|
167
|
+
hosts a repository's main page, such as a self-hosted GitLab, Gitea or
|
|
168
|
+
Codeberg page), "credentials" when the server asks git for credentials (a
|
|
169
|
+
private repository, or any page behind a login), "unanswered" when git
|
|
170
|
+
gives no answer in time, "no-git" when git is missing, else "docs". It also
|
|
171
|
+
adds `halt_message`, the message analyze-source stops with on
|
|
172
|
+
"repository", "credentials" and "unanswered" (see `git_probe_halt`), else
|
|
173
|
+
null. Both are null for the other kinds, which it does not probe. The name,
|
|
174
|
+
`kind` and `clone_url` never depend on them, so the name stays the one
|
|
175
|
+
coexistence matching compares offline.
|
|
176
|
+
`derive-name --from <file|->` takes a JSON
|
|
177
|
+
array of {"target", "manifest_name", "private", "members"} (all optional)
|
|
178
|
+
and returns `names[]`, each entry's `target` and `manifest_name` with its
|
|
179
|
+
`name` (null when none can be derived) and `basis` ("members" too). Entries
|
|
180
|
+
that derive one name are told apart by the folders above their targets
|
|
181
|
+
(`src/server/api` and `src/client/api` give `server-api` and `client-api`),
|
|
182
|
+
the shared name kept in `clash`; `unnamed` lists the entries with no name
|
|
183
|
+
and `duplicates` the index groups still sharing one. With
|
|
184
|
+
`--skills-folder` each name also carries `existing`: the skill of that
|
|
185
|
+
name in the folder, in the `matches[]` entry shape below (`match_reason`
|
|
186
|
+
"name"), or null.
|
|
187
|
+
|
|
188
|
+
Near names. `--near <name>` adds a top-level `near[]`: every skill SKF
|
|
189
|
+
generated that has a SKILL.md (`skf_skill` and `has_skill_md`), as
|
|
190
|
+
{name, active_version, close}, the closest to the name first. test-skill
|
|
191
|
+
shows it when the name it was given resolves to no version, instead of
|
|
192
|
+
sending the user to create a skill that may already exist. A name is
|
|
193
|
+
`close` when one name holds the other (case-insensitive) or difflib's
|
|
194
|
+
similarity ratio of the lower-cased names reaches 0.6 (the cutoff of
|
|
195
|
+
difflib.get_close_matches); the close names come first, then each group by
|
|
196
|
+
ratio, highest first, then by name.
|
|
14
197
|
|
|
15
198
|
The --match-target mode deterministically computes coexistence matches: it
|
|
16
|
-
normalizes scheme / trailing .git / trailing slash, derives the expected
|
|
17
|
-
skill name, compares case-insensitively, and emits a
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
199
|
+
normalizes scheme / trailing .git / trailing slash, derives the expected
|
|
200
|
+
skill name (`derive_name`), compares case-insensitively, and emits a
|
|
201
|
+
top-level `matches[]` array (each match carries the skill's `skf_skill`)
|
|
202
|
+
and the name it compared, `match_name`. This replaces the equivalent
|
|
203
|
+
normalize/derive/compare that a consuming prompt would otherwise perform
|
|
204
|
+
by hand (identical (target, inventory) always yields the same match set).
|
|
21
205
|
"""
|
|
22
206
|
|
|
23
207
|
from __future__ import annotations
|
|
24
208
|
|
|
209
|
+
import difflib
|
|
25
210
|
import json
|
|
26
211
|
import os
|
|
27
212
|
import re
|
|
213
|
+
import shutil
|
|
214
|
+
import stat
|
|
215
|
+
import subprocess
|
|
28
216
|
import sys
|
|
217
|
+
import tempfile
|
|
218
|
+
from datetime import datetime, timedelta, timezone
|
|
29
219
|
from pathlib import Path
|
|
220
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
30
221
|
|
|
31
222
|
|
|
32
223
|
def read_json_file(path):
|
|
@@ -38,6 +229,486 @@ def read_json_file(path):
|
|
|
38
229
|
return None, f"Not found: {path}"
|
|
39
230
|
except json.JSONDecodeError as e:
|
|
40
231
|
return None, f"JSON parse error in {path}: {e}"
|
|
232
|
+
except (OSError, ValueError) as e:
|
|
233
|
+
# A directory named like the file, a permission error, or bytes that
|
|
234
|
+
# are not UTF-8: report it instead of crashing the whole scan.
|
|
235
|
+
return None, f"Cannot read {path}: {e}"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# Keep identical to RESULT_JSON_RE in skf-merge-ccc-exclusions.py
|
|
239
|
+
# (test/test-skf-skill-inventory.py pins the copies).
|
|
240
|
+
RESULT_JSON_RE = re.compile(r"^([a-z0-9][a-z0-9-]*)-result(-latest|-\d[^/]*)?\.json$")
|
|
241
|
+
# Keep identical to SKF_GENERATORS in skf-merge-ccc-exclusions.py
|
|
242
|
+
# and skf-enumerate-stack-skills.py
|
|
243
|
+
# (test/test-skf-skill-inventory.py pins the copies).
|
|
244
|
+
SKF_GENERATORS = frozenset({"quick-skill", "create-skill", "create-stack-skill"})
|
|
245
|
+
# The flat package a migration moves into {v}/{name}/ (knowledge/version-paths.md).
|
|
246
|
+
FLAT_PACKAGE_ENTRIES = frozenset({"SKILL.md", "metadata.json", "context-snippet.md",
|
|
247
|
+
"references", "scripts", "assets"})
|
|
248
|
+
# OS and VCS clutter that never decides who owns a skill folder.
|
|
249
|
+
NEUTRAL_GROUP_ENTRIES = frozenset({".DS_Store", ".gitkeep", ".gitignore", ".gitattributes",
|
|
250
|
+
"Thumbs.db", "desktop.ini"})
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# Keep identical to _has_skf_metadata in skf-merge-ccc-exclusions.py
|
|
254
|
+
# and skf-enumerate-stack-skills.py
|
|
255
|
+
# (test/test-skf-skill-inventory.py pins the copies).
|
|
256
|
+
def _has_skf_metadata(path: Path) -> bool:
|
|
257
|
+
"""True when a flat skill's metadata.json carries an SKF marker."""
|
|
258
|
+
try:
|
|
259
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
260
|
+
except (OSError, ValueError):
|
|
261
|
+
return False
|
|
262
|
+
if not isinstance(data, dict):
|
|
263
|
+
return False
|
|
264
|
+
generated_by = data.get("generated_by")
|
|
265
|
+
if isinstance(generated_by, str) and generated_by in SKF_GENERATORS:
|
|
266
|
+
return True
|
|
267
|
+
tool_versions = data.get("tool_versions")
|
|
268
|
+
if isinstance(tool_versions, dict) and "skf" in tool_versions:
|
|
269
|
+
return True
|
|
270
|
+
return (data.get("skill_type") in ("single", "individual", "stack")
|
|
271
|
+
and ("forge_tier" in data or "confidence_tier" in data))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# Keep identical to _is_link_or_junction in skf-atomic-write.py, skf-enumerate-stack-skills.py,
|
|
275
|
+
# skf-validate-rename-name.py, skf-source-tree.py and skf-tessl-review.py
|
|
276
|
+
# (test/test-skf-skill-inventory.py pins the copies).
|
|
277
|
+
def _is_link_or_junction(p: Path) -> bool:
|
|
278
|
+
"""True for POSIX symlinks AND Windows junctions/symlinks.
|
|
279
|
+
|
|
280
|
+
`Path.is_symlink()` is False for Windows junctions; os.readlink succeeds
|
|
281
|
+
for both symlinks and junctions (since CPython 3.8 on Windows). A regular
|
|
282
|
+
directory raises OSError on readlink, which is the signal we want to
|
|
283
|
+
refuse replacement. On Windows, any other reparse point (a cloud-sync
|
|
284
|
+
placeholder, a deduplicated file, an app execution alias) raises
|
|
285
|
+
ValueError: it does not redirect to another path, so it is not a link.
|
|
286
|
+
"""
|
|
287
|
+
if p.is_symlink():
|
|
288
|
+
return True
|
|
289
|
+
if not p.exists() and not p.is_symlink():
|
|
290
|
+
return False
|
|
291
|
+
try:
|
|
292
|
+
os.readlink(p)
|
|
293
|
+
return True
|
|
294
|
+
except (OSError, ValueError):
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _is_active_pointer(name):
|
|
299
|
+
"""True for the `active` link name and the flip helper's `active.skf-*` names."""
|
|
300
|
+
return name == "active" or name.startswith("active.skf-")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# Keep identical to _is_marked_version in skf-merge-ccc-exclusions.py
|
|
304
|
+
# and skf-enumerate-stack-skills.py
|
|
305
|
+
# (test/test-skf-skill-inventory.py pins the copies).
|
|
306
|
+
def _is_marked_version(version_dir: Path, name: str) -> bool:
|
|
307
|
+
"""True when `version_dir/name/metadata.json` carries an SKF marker.
|
|
308
|
+
|
|
309
|
+
SKF never links a version folder or the package inside one: a linked
|
|
310
|
+
one is not its output, whatever the metadata behind the link says.
|
|
311
|
+
"""
|
|
312
|
+
package = version_dir / name
|
|
313
|
+
return (not _is_link_or_junction(version_dir) and not _is_link_or_junction(package)
|
|
314
|
+
and _has_skf_metadata(package / "metadata.json"))
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# Keep identical to _has_skf_evidence in skf-merge-ccc-exclusions.py
|
|
318
|
+
# (test/test-skf-skill-inventory.py pins the copies).
|
|
319
|
+
def _has_skf_evidence(group_dir: Path, name: str) -> bool:
|
|
320
|
+
"""True when skill group `group_dir` holds evidence that SKF generated it.
|
|
321
|
+
|
|
322
|
+
Evidence is the `_batch` name, `.skf-` in the name, a marked root
|
|
323
|
+
`metadata.json`, or a marked version folder (`.skf-` staging folders
|
|
324
|
+
aside). The versioned layout, an `active` link, a manifest key or a
|
|
325
|
+
result file are not evidence on their own, and a linked group holds none.
|
|
326
|
+
"""
|
|
327
|
+
if _is_link_or_junction(group_dir):
|
|
328
|
+
return False
|
|
329
|
+
if name == "_batch" or ".skf-" in name:
|
|
330
|
+
return True
|
|
331
|
+
if _has_skf_metadata(group_dir / "metadata.json"):
|
|
332
|
+
return True
|
|
333
|
+
try:
|
|
334
|
+
children = list(group_dir.iterdir())
|
|
335
|
+
except OSError:
|
|
336
|
+
return False
|
|
337
|
+
return any(".skf-" not in child.name and child.is_dir() and _is_marked_version(child, name)
|
|
338
|
+
for child in children)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# Keep identical to FORGE_GROUP_DIRS in skf-merge-ccc-exclusions.py
|
|
342
|
+
# (test/test-skf-skill-inventory.py pins the copies).
|
|
343
|
+
FORGE_GROUP_DIRS = frozenset({"_campaign", "improvement-queue"})
|
|
344
|
+
# Keep identical to FORGE_VERSION_ANCHORS in skf-merge-ccc-exclusions.py
|
|
345
|
+
# (test/test-skf-skill-inventory.py pins the copies).
|
|
346
|
+
FORGE_VERSION_ANCHORS = frozenset({"provenance-map.json", "evidence-report.md", "extraction-rules.yaml"})
|
|
347
|
+
# The other names SKF writes into a forge version folder
|
|
348
|
+
# (knowledge/version-paths.md, forge_data_folder tree), and
|
|
349
|
+
# .manual-inventory.json, which update-skill kept there before it kept
|
|
350
|
+
# that inventory beside its lock. A test-skill run writes its report as
|
|
351
|
+
# .skf-test-report-{name}-{run_id}.md beside .test-skill.lock until the
|
|
352
|
+
# report's checks pass: like every `.skf-` name it is SKF's, never foreign.
|
|
353
|
+
# manual-backup/ holds the earlier SKILL.md that skf-promote-staged.py
|
|
354
|
+
# --carry-manual keeps when a re-run's SKILL.md lacks one of its [MANUAL]
|
|
355
|
+
# blocks; it is no `.skf-` name, which would read as an interrupted run's.
|
|
356
|
+
FORGE_VERSION_FILES = FORGE_VERSION_ANCHORS | frozenset({
|
|
357
|
+
"evidence-report-fallback.md", "extraction-snapshot.json", ".manual-inventory.json",
|
|
358
|
+
".test-skill.lock", "manual-backup"})
|
|
359
|
+
FORGE_REPORT_RE = re.compile(r"^(test-report|drift-report)(-.+)?\.md$")
|
|
360
|
+
# test-skill's gap ledger, one per run (skf-test-skill scripts/gap-ledger.py).
|
|
361
|
+
TEST_FINDINGS_RE = re.compile(r"^test-findings-.+\.json$")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# Keep identical to _has_forge_evidence in skf-merge-ccc-exclusions.py
|
|
365
|
+
# (test/test-skf-skill-inventory.py pins the copies).
|
|
366
|
+
def _has_forge_evidence(group_dir: Path, name: str) -> bool:
|
|
367
|
+
"""True when forge group `group_dir` holds evidence that SKF generated it.
|
|
368
|
+
|
|
369
|
+
Evidence is `.skf-` in the name, SKF's own `_campaign` or
|
|
370
|
+
`improvement-queue` folder, a skill brief (`skill-brief.yaml*` or
|
|
371
|
+
`.brief-draft.json`) or a `*-result*.json` file directly in the group, or
|
|
372
|
+
a provenance map, evidence report, extraction rules or `*-result*.json`
|
|
373
|
+
file directly in a folder of the group (`.skf-` staging folders aside).
|
|
374
|
+
A linked group, folder or file is never evidence.
|
|
375
|
+
"""
|
|
376
|
+
if _is_link_or_junction(group_dir):
|
|
377
|
+
return False
|
|
378
|
+
if ".skf-" in name or name in FORGE_GROUP_DIRS:
|
|
379
|
+
return True
|
|
380
|
+
try:
|
|
381
|
+
children = list(group_dir.iterdir())
|
|
382
|
+
except OSError:
|
|
383
|
+
return False
|
|
384
|
+
for child in children:
|
|
385
|
+
entry = child.name
|
|
386
|
+
if _is_link_or_junction(child):
|
|
387
|
+
continue
|
|
388
|
+
if child.is_file():
|
|
389
|
+
if (entry.startswith("skill-brief.yaml") or entry == ".brief-draft.json"
|
|
390
|
+
or RESULT_JSON_RE.match(entry)):
|
|
391
|
+
return True
|
|
392
|
+
elif child.is_dir() and ".skf-" not in entry:
|
|
393
|
+
try:
|
|
394
|
+
inner = list(child.iterdir())
|
|
395
|
+
except OSError:
|
|
396
|
+
continue
|
|
397
|
+
if any((f.name in FORGE_VERSION_ANCHORS or RESULT_JSON_RE.match(f.name))
|
|
398
|
+
and not _is_link_or_junction(f) and f.is_file() for f in inner):
|
|
399
|
+
return True
|
|
400
|
+
return False
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _is_forge_version_name(entry):
|
|
404
|
+
"""True for a name SKF writes into a forge version folder."""
|
|
405
|
+
return (entry in FORGE_VERSION_FILES or bool(FORGE_REPORT_RE.match(entry))
|
|
406
|
+
or bool(TEST_FINDINGS_RE.match(entry)) or bool(RESULT_JSON_RE.match(entry))
|
|
407
|
+
or entry.endswith("-tmp"))
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _is_forge_root_name(entry, evidence):
|
|
411
|
+
"""True for a file SKF writes directly into a forge group.
|
|
412
|
+
|
|
413
|
+
A brief or a result file always is. The legacy flat artifacts (a
|
|
414
|
+
provenance map, evidence report, extraction rules or test or drift
|
|
415
|
+
report, or a test-skill gap ledger, at the group root) count only beside
|
|
416
|
+
other evidence.
|
|
417
|
+
"""
|
|
418
|
+
if (entry.startswith("skill-brief.yaml") or entry == ".brief-draft.json"
|
|
419
|
+
or RESULT_JSON_RE.match(entry)):
|
|
420
|
+
return True
|
|
421
|
+
return evidence and (entry in FORGE_VERSION_ANCHORS or bool(FORGE_REPORT_RE.match(entry))
|
|
422
|
+
or bool(TEST_FINDINGS_RE.match(entry)))
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _holds_no_file(folder):
|
|
426
|
+
"""True when `folder` holds no file: only neutral clutter, `.skf-` names
|
|
427
|
+
and folders that hold no file themselves (a link is content).
|
|
428
|
+
|
|
429
|
+
That is all an interrupted SKF run leaves before it writes a package's
|
|
430
|
+
metadata.json; deleting or writing into such a folder loses no file. A
|
|
431
|
+
folder SKF cannot list or search holds content too.
|
|
432
|
+
"""
|
|
433
|
+
try:
|
|
434
|
+
children = list(folder.iterdir())
|
|
435
|
+
except OSError:
|
|
436
|
+
return False
|
|
437
|
+
for child in children:
|
|
438
|
+
name = child.name
|
|
439
|
+
if name in NEUTRAL_GROUP_ENTRIES or ".skf-" in name:
|
|
440
|
+
continue
|
|
441
|
+
try:
|
|
442
|
+
if _is_link_or_junction(child) or not child.is_dir() or not _holds_no_file(child):
|
|
443
|
+
return False
|
|
444
|
+
except OSError: # a folder it cannot search: pathlib raises there before Python 3.14
|
|
445
|
+
return False
|
|
446
|
+
return True
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _forge_folder_entries(folder, group_evidence, package=None):
|
|
450
|
+
"""(verdict, entries SKF did not write as `v/<entry>`) for folder `v` of a forge group.
|
|
451
|
+
|
|
452
|
+
A folder is marked when it directly holds a provenance map, evidence
|
|
453
|
+
report, extraction rules or `*-result*.json` file. SKF's per-version
|
|
454
|
+
names count inside a marked folder or in a group with evidence, except
|
|
455
|
+
`package`: in a skills-side version folder (both settings name one
|
|
456
|
+
folder) the skill's own package is SKF output only when marked, even
|
|
457
|
+
when its name looks like a forge name such as `*-tmp`.
|
|
458
|
+
Verdicts: "skf", "mixed" (marked, plus other entries), "empty" (not
|
|
459
|
+
marked, nothing else) or "foreign".
|
|
460
|
+
"""
|
|
461
|
+
v = folder.name
|
|
462
|
+
try:
|
|
463
|
+
children = sorted(folder.iterdir(), key=lambda p: p.name)
|
|
464
|
+
except OSError:
|
|
465
|
+
return "foreign", []
|
|
466
|
+
marked = any((c.name in FORGE_VERSION_ANCHORS or RESULT_JSON_RE.match(c.name))
|
|
467
|
+
and not _is_link_or_junction(c) and c.is_file() for c in children)
|
|
468
|
+
skf_named = marked or group_evidence
|
|
469
|
+
inner = []
|
|
470
|
+
for child in children:
|
|
471
|
+
name = child.name
|
|
472
|
+
if name in NEUTRAL_GROUP_ENTRIES or ".skf-" in name:
|
|
473
|
+
continue
|
|
474
|
+
if _is_link_or_junction(child):
|
|
475
|
+
inner.append(f"{v}/{name}")
|
|
476
|
+
continue
|
|
477
|
+
if skf_named and name != package and _is_forge_version_name(name):
|
|
478
|
+
continue
|
|
479
|
+
if child.is_dir() and _holds_no_file(child):
|
|
480
|
+
continue
|
|
481
|
+
inner.append(f"{v}/{name}/" if child.is_dir() else f"{v}/{name}")
|
|
482
|
+
if marked:
|
|
483
|
+
return ("mixed" if inner else "skf"), inner
|
|
484
|
+
return ("foreign" if inner else "empty"), inner
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def classify_forge_group(group_dir, skill_name):
|
|
488
|
+
"""Decide whether SKF generated forge group `group_dir` ({forge_data_folder}/{name}).
|
|
489
|
+
|
|
490
|
+
Returns {name, path, ownership, foreign_entries, errors}. `ownership`:
|
|
491
|
+
"absent" (nothing at the path), "reserved" (SKF's own `_campaign` or
|
|
492
|
+
`improvement-queue` folder, never a skill's), "skf" (SKF evidence and
|
|
493
|
+
nothing else), "mixed" (evidence plus entries SKF did not write), "empty"
|
|
494
|
+
(no evidence and nothing SKF did not write) or "foreign" (no evidence
|
|
495
|
+
and entries SKF did not write; a link or a path that is not a folder,
|
|
496
|
+
even with a reserved name, with the reason in `errors`). Whether the
|
|
497
|
+
group holds SKF output at all is `_has_forge_evidence`, the rule the ccc
|
|
498
|
+
helper shares. A folder in the group is listed as `v/` when SKF wrote
|
|
499
|
+
nothing in it, and its entries as `v/<entry>` when it is marked; a
|
|
500
|
+
linked entry is listed by its bare name.
|
|
501
|
+
"""
|
|
502
|
+
result = {"name": skill_name, "path": str(group_dir), "ownership": "absent",
|
|
503
|
+
"foreign_entries": [], "errors": []}
|
|
504
|
+
if not os.path.lexists(group_dir):
|
|
505
|
+
return result
|
|
506
|
+
result["ownership"] = "foreign"
|
|
507
|
+
if _is_link_or_junction(group_dir):
|
|
508
|
+
result["errors"].append(
|
|
509
|
+
f"forge folder is a link; SKF never moves or deletes through it: {group_dir}")
|
|
510
|
+
return result
|
|
511
|
+
if not group_dir.is_dir():
|
|
512
|
+
result["errors"].append(f"forge folder is not a folder: {group_dir}")
|
|
513
|
+
return result
|
|
514
|
+
if skill_name in FORGE_GROUP_DIRS:
|
|
515
|
+
result["ownership"] = "reserved"
|
|
516
|
+
return result
|
|
517
|
+
try:
|
|
518
|
+
children = sorted(group_dir.iterdir(), key=lambda p: p.name)
|
|
519
|
+
except OSError as e:
|
|
520
|
+
result["errors"].append(f"Cannot list {group_dir}: {e}")
|
|
521
|
+
return result
|
|
522
|
+
evidence = _has_forge_evidence(group_dir, skill_name)
|
|
523
|
+
foreign = []
|
|
524
|
+
for child in children:
|
|
525
|
+
name = child.name
|
|
526
|
+
if name in NEUTRAL_GROUP_ENTRIES or ".skf-" in name:
|
|
527
|
+
continue
|
|
528
|
+
if _is_link_or_junction(child):
|
|
529
|
+
foreign.append(name)
|
|
530
|
+
elif child.is_dir():
|
|
531
|
+
verdict, inner = _forge_folder_entries(child, evidence)
|
|
532
|
+
if verdict == "mixed":
|
|
533
|
+
foreign.extend(inner)
|
|
534
|
+
elif verdict == "foreign":
|
|
535
|
+
foreign.append(name + "/")
|
|
536
|
+
elif not _is_forge_root_name(name, evidence):
|
|
537
|
+
foreign.append(name)
|
|
538
|
+
result["foreign_entries"] = sorted(foreign)
|
|
539
|
+
if evidence:
|
|
540
|
+
result["ownership"] = "mixed" if foreign else "skf"
|
|
541
|
+
else:
|
|
542
|
+
result["ownership"] = "foreign" if foreign else "empty"
|
|
543
|
+
return result
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _same_folder(skills_dir, forge_dir):
|
|
547
|
+
"""True when the skills and forge settings name one folder."""
|
|
548
|
+
if forge_dir is None:
|
|
549
|
+
return False
|
|
550
|
+
try:
|
|
551
|
+
if skills_dir.exists() and forge_dir.exists():
|
|
552
|
+
return os.path.samefile(skills_dir, forge_dir)
|
|
553
|
+
except OSError:
|
|
554
|
+
pass
|
|
555
|
+
return (os.path.normcase(os.path.abspath(skills_dir))
|
|
556
|
+
== os.path.normcase(os.path.abspath(forge_dir)))
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _version_foreign_entries(version_dir, skill_name, errors, also_forge=False):
|
|
560
|
+
"""The entries of a marked version folder SKF did not put there, as `v/<entry>`.
|
|
561
|
+
|
|
562
|
+
SKF writes only the `{name}/` package and `.skf-` staging names into a
|
|
563
|
+
version folder (and, with `also_forge`, its forge per-version files);
|
|
564
|
+
neutral clutter is ignored. Directories end in `/`, a linked entry is
|
|
565
|
+
listed by its bare name.
|
|
566
|
+
"""
|
|
567
|
+
try:
|
|
568
|
+
children = sorted(version_dir.iterdir(), key=lambda p: p.name)
|
|
569
|
+
except OSError as e:
|
|
570
|
+
errors.append(f"Cannot list {version_dir}: {e}")
|
|
571
|
+
return [version_dir.name + "/"]
|
|
572
|
+
found = []
|
|
573
|
+
for child in children:
|
|
574
|
+
name = child.name
|
|
575
|
+
if name == skill_name or name in NEUTRAL_GROUP_ENTRIES or ".skf-" in name:
|
|
576
|
+
continue
|
|
577
|
+
if also_forge and not _is_link_or_junction(child) and _is_forge_version_name(name):
|
|
578
|
+
continue
|
|
579
|
+
if _is_link_or_junction(child) or not child.is_dir():
|
|
580
|
+
found.append(f"{version_dir.name}/{name}")
|
|
581
|
+
else:
|
|
582
|
+
found.append(f"{version_dir.name}/{name}/")
|
|
583
|
+
return found
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _read_error(folder, name):
|
|
587
|
+
"""The OSError that keeps SKF from listing `folder` or searching it for `name`, else None.
|
|
588
|
+
|
|
589
|
+
os.listdir needs read permission on `folder` and os.lstat of `folder/name`
|
|
590
|
+
search permission. Both raise on every Python version, where pathlib's
|
|
591
|
+
is_dir and is_symlink return False inside a folder they cannot search
|
|
592
|
+
from Python 3.14 on and raise before it.
|
|
593
|
+
"""
|
|
594
|
+
try:
|
|
595
|
+
os.listdir(folder)
|
|
596
|
+
os.lstat(os.path.join(folder, name))
|
|
597
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
598
|
+
return None
|
|
599
|
+
except OSError as e:
|
|
600
|
+
return e
|
|
601
|
+
return None
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def classify_ownership(skill_group_dir, skill_name, also_forge=False):
|
|
605
|
+
"""Decide whether SKF generated the skill group `skill_group_dir`.
|
|
606
|
+
|
|
607
|
+
Returns {ownership, skf_skill, flat_skf, foreign_entries, errors}.
|
|
608
|
+
Whether the group holds SKF output at all is `_has_skf_evidence`, the
|
|
609
|
+
rule the ccc helper shares. Only a marked metadata.json is proof there:
|
|
610
|
+
the versioned layout or an `active` link alone does not count, because a
|
|
611
|
+
module skill can sit in that layout (an earlier SKF migrated such skills
|
|
612
|
+
without asking), and a manifest key does not count because the folder on
|
|
613
|
+
disk may have changed since the export. A top-level entry of the group
|
|
614
|
+
is SKF output when it is a version folder `v` whose `v/{name}/metadata.json`
|
|
615
|
+
is marked (neither `v` nor `v/{name}` a link), an `active` or
|
|
616
|
+
`active.skf-*` link (or a real `active/` folder holding a marked
|
|
617
|
+
package), a name containing `.skf-`, a result file, or, beside a marked
|
|
618
|
+
root metadata.json, one of the flat package entries. Neutral clutter is
|
|
619
|
+
ignored; anything else is foreign. Inside a marked version folder,
|
|
620
|
+
anything but the `{name}/` package, a `.skf-` name or neutral clutter is
|
|
621
|
+
foreign too, listed as `v/<entry>`. Everything inside
|
|
622
|
+
`_batch` or a `.skf-` folder is SKF output. A folder that holds no file
|
|
623
|
+
(only neutral clutter, `.skf-` names or folders like itself) is never
|
|
624
|
+
foreign: an interrupted SKF run leaves one before it writes metadata.json.
|
|
625
|
+
With `also_forge` (both settings name one folder), SKF's forge files
|
|
626
|
+
count too, as ccc's kinds union does: a brief or result file at the
|
|
627
|
+
root, forge per-version files in a version folder, and forge evidence;
|
|
628
|
+
the skill's own `v/{name}/` package still counts only when marked.
|
|
629
|
+
A folder SKF cannot list or search is foreign, and `errors` names it.
|
|
630
|
+
"""
|
|
631
|
+
result = {
|
|
632
|
+
"ownership": "foreign",
|
|
633
|
+
"skf_skill": False,
|
|
634
|
+
"flat_skf": False,
|
|
635
|
+
"foreign_entries": [],
|
|
636
|
+
"errors": [],
|
|
637
|
+
}
|
|
638
|
+
if _is_link_or_junction(skill_group_dir):
|
|
639
|
+
result["errors"].append(
|
|
640
|
+
f"skill folder is a link; SKF never moves or deletes through it: {skill_group_dir}"
|
|
641
|
+
)
|
|
642
|
+
return result
|
|
643
|
+
try:
|
|
644
|
+
children = sorted(skill_group_dir.iterdir(), key=lambda p: p.name)
|
|
645
|
+
except OSError as e:
|
|
646
|
+
result["errors"].append(f"Cannot list {skill_group_dir}: {e}")
|
|
647
|
+
return result
|
|
648
|
+
whole_group = skill_name == "_batch" or ".skf-" in skill_name
|
|
649
|
+
root_marker = _has_skf_metadata(skill_group_dir / "metadata.json")
|
|
650
|
+
try:
|
|
651
|
+
forge_evidence = also_forge and _has_forge_evidence(skill_group_dir, skill_name)
|
|
652
|
+
except OSError: # a folder it cannot search; the loop below names it
|
|
653
|
+
forge_evidence = False
|
|
654
|
+
marked_version = False
|
|
655
|
+
foreign = []
|
|
656
|
+
for child in children:
|
|
657
|
+
name = child.name
|
|
658
|
+
if name in NEUTRAL_GROUP_ENTRIES or whole_group:
|
|
659
|
+
continue
|
|
660
|
+
if _is_link_or_junction(child):
|
|
661
|
+
if not _is_active_pointer(name):
|
|
662
|
+
foreign.append(name)
|
|
663
|
+
continue
|
|
664
|
+
if child.is_dir():
|
|
665
|
+
error = _read_error(child, skill_name)
|
|
666
|
+
if error is not None: # checked first: the checks below read inside it
|
|
667
|
+
result["errors"].append(f"Cannot read {child}: {error}")
|
|
668
|
+
foreign.append(name + "/")
|
|
669
|
+
continue
|
|
670
|
+
if ".skf-" in name or (child.is_file() and RESULT_JSON_RE.match(name)):
|
|
671
|
+
continue
|
|
672
|
+
if root_marker and name in FLAT_PACKAGE_ENTRIES:
|
|
673
|
+
continue # checked first: references/{name}/ is not a version
|
|
674
|
+
is_dir = child.is_dir()
|
|
675
|
+
if also_forge and not is_dir and _is_forge_root_name(name, forge_evidence or root_marker):
|
|
676
|
+
continue
|
|
677
|
+
if is_dir and _is_marked_version(child, skill_name):
|
|
678
|
+
marked_version = True
|
|
679
|
+
foreign.extend(_version_foreign_entries(child, skill_name, result["errors"], also_forge))
|
|
680
|
+
continue
|
|
681
|
+
if is_dir and also_forge:
|
|
682
|
+
verdict, inner = _forge_folder_entries(child, forge_evidence, skill_name)
|
|
683
|
+
if verdict != "foreign":
|
|
684
|
+
foreign.extend(inner)
|
|
685
|
+
continue
|
|
686
|
+
if is_dir and _holds_no_file(child):
|
|
687
|
+
continue # an interrupted run's staging or empty package folders
|
|
688
|
+
foreign.append(name + "/" if is_dir else name)
|
|
689
|
+
result["flat_skf"] = root_marker and (skill_group_dir / "SKILL.md").is_file()
|
|
690
|
+
result["skf_skill"] = marked_version or result["flat_skf"]
|
|
691
|
+
result["foreign_entries"] = sorted(foreign)
|
|
692
|
+
try:
|
|
693
|
+
evidence = _has_skf_evidence(skill_group_dir, skill_name)
|
|
694
|
+
except OSError: # a folder it cannot search, named in `errors` above
|
|
695
|
+
evidence = marked_version or root_marker
|
|
696
|
+
if evidence or forge_evidence:
|
|
697
|
+
result["ownership"] = "mixed" if foreign else "skf"
|
|
698
|
+
return result
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
# Keep identical to _looks_like_skill in skf-enumerate-stack-skills.py
|
|
702
|
+
# (test/test-skf-skill-inventory.py pins the copies).
|
|
703
|
+
def _looks_like_skill(skill_group_dir, skill_name):
|
|
704
|
+
"""True when a group holds a root SKILL.md or a {v}/{name}/ folder."""
|
|
705
|
+
if (skill_group_dir / "SKILL.md").is_file():
|
|
706
|
+
return True
|
|
707
|
+
try:
|
|
708
|
+
return any(child.is_dir() and (child / skill_name).is_dir()
|
|
709
|
+
for child in skill_group_dir.iterdir())
|
|
710
|
+
except OSError:
|
|
711
|
+
return False
|
|
41
712
|
|
|
42
713
|
|
|
43
714
|
def resolve_active_version(skill_group_dir):
|
|
@@ -47,13 +718,16 @@ def resolve_active_version(skill_group_dir):
|
|
|
47
718
|
"""
|
|
48
719
|
active_link = skill_group_dir / "active"
|
|
49
720
|
if active_link.is_symlink() or active_link.is_dir():
|
|
50
|
-
|
|
721
|
+
try:
|
|
722
|
+
target = active_link.resolve()
|
|
723
|
+
except (OSError, RuntimeError): # a link loop
|
|
724
|
+
return None, None
|
|
51
725
|
if target.is_dir():
|
|
52
726
|
return target.name, target
|
|
53
727
|
return None, None
|
|
54
728
|
|
|
55
729
|
|
|
56
|
-
def scan_skill_group(skill_group_dir, skill_name):
|
|
730
|
+
def scan_skill_group(skill_group_dir, skill_name, also_forge=False):
|
|
57
731
|
"""Scan a single skill group directory and return its inventory entry."""
|
|
58
732
|
entry = {
|
|
59
733
|
"name": skill_name,
|
|
@@ -68,14 +742,30 @@ def scan_skill_group(skill_group_dir, skill_name):
|
|
|
68
742
|
"errors": [],
|
|
69
743
|
}
|
|
70
744
|
|
|
71
|
-
|
|
72
|
-
|
|
745
|
+
ownership = classify_ownership(skill_group_dir, skill_name, also_forge)
|
|
746
|
+
entry["errors"].extend(ownership.pop("errors"))
|
|
747
|
+
entry.update(ownership)
|
|
748
|
+
|
|
749
|
+
try:
|
|
750
|
+
children = sorted(skill_group_dir.iterdir())
|
|
751
|
+
except OSError:
|
|
752
|
+
children = [] # classify_ownership already reported it
|
|
753
|
+
|
|
754
|
+
# Check for version directories (contain a skill-name subdirectory). In a
|
|
755
|
+
# flat SKF skill the package's own folders are never versions, even when
|
|
756
|
+
# one holds a same-named subfolder such as references/{name}/. Inside a
|
|
757
|
+
# folder SKF cannot search, the os.path checks return False on every
|
|
758
|
+
# Python version (classify_ownership names that folder in `errors`).
|
|
759
|
+
package_dirs = FLAT_PACKAGE_ENTRIES if entry["flat_skf"] else frozenset()
|
|
760
|
+
for child in children:
|
|
761
|
+
if child.name in package_dirs:
|
|
762
|
+
continue
|
|
73
763
|
if child.is_dir() and child.name != "active" and not child.name.startswith("."):
|
|
74
764
|
# Check if this is a version dir (contains skill-name subdir or SKILL.md)
|
|
75
765
|
skill_subdir = child / skill_name
|
|
76
|
-
if
|
|
766
|
+
if os.path.isdir(skill_subdir):
|
|
77
767
|
entry["versions"].append(child.name)
|
|
78
|
-
elif (child / "SKILL.md")
|
|
768
|
+
elif os.path.exists(child / "SKILL.md"):
|
|
79
769
|
# Flat version dir without skill-name nesting
|
|
80
770
|
entry["versions"].append(child.name)
|
|
81
771
|
|
|
@@ -85,9 +775,9 @@ def scan_skill_group(skill_group_dir, skill_name):
|
|
|
85
775
|
entry["active_version"] = active_ver
|
|
86
776
|
# The active path points to the version dir; skill files are in version/skill-name/
|
|
87
777
|
skill_pkg = active_path / skill_name
|
|
88
|
-
if
|
|
778
|
+
if os.path.isdir(skill_pkg):
|
|
89
779
|
entry["active_path"] = str(skill_pkg)
|
|
90
|
-
elif (active_path / "SKILL.md")
|
|
780
|
+
elif os.path.exists(active_path / "SKILL.md"):
|
|
91
781
|
entry["active_path"] = str(active_path)
|
|
92
782
|
else:
|
|
93
783
|
entry["active_path"] = str(active_path)
|
|
@@ -101,14 +791,20 @@ def scan_skill_group(skill_group_dir, skill_name):
|
|
|
101
791
|
|
|
102
792
|
# Load metadata from active path
|
|
103
793
|
active_dir = Path(entry["active_path"]) if entry["active_path"] else None
|
|
104
|
-
if active_dir and
|
|
105
|
-
entry["has_skill_md"] = (active_dir / "SKILL.md")
|
|
106
|
-
entry["has_provenance_map"] = (active_dir / "provenance-map.json")
|
|
107
|
-
entry["has_context_snippet"] = (active_dir / "context-snippet.md")
|
|
794
|
+
if active_dir and os.path.isdir(active_dir):
|
|
795
|
+
entry["has_skill_md"] = os.path.exists(active_dir / "SKILL.md")
|
|
796
|
+
entry["has_provenance_map"] = os.path.exists(active_dir / "provenance-map.json")
|
|
797
|
+
entry["has_context_snippet"] = os.path.exists(active_dir / "context-snippet.md")
|
|
108
798
|
|
|
109
799
|
metadata_path = active_dir / "metadata.json"
|
|
110
800
|
meta, meta_err = read_json_file(metadata_path)
|
|
111
|
-
if
|
|
801
|
+
if meta_err:
|
|
802
|
+
if "Not found" not in meta_err:
|
|
803
|
+
entry["errors"].append(meta_err)
|
|
804
|
+
elif not isinstance(meta, dict):
|
|
805
|
+
entry["errors"].append(f"metadata.json is not a JSON object: {metadata_path}")
|
|
806
|
+
elif meta:
|
|
807
|
+
stats = meta.get("stats")
|
|
112
808
|
entry["metadata"] = {
|
|
113
809
|
"version": meta.get("version"),
|
|
114
810
|
"language": meta.get("language"),
|
|
@@ -116,14 +812,990 @@ def scan_skill_group(skill_group_dir, skill_name):
|
|
|
116
812
|
"source_repo": meta.get("source_repo"),
|
|
117
813
|
"generated_by": meta.get("generated_by"),
|
|
118
814
|
"confidence_tier": meta.get("confidence_tier"),
|
|
119
|
-
"exports_total":
|
|
815
|
+
"exports_total": stats.get("exports_total") if isinstance(stats, dict) else None,
|
|
120
816
|
}
|
|
121
|
-
elif meta_err and "Not found" not in meta_err:
|
|
122
|
-
entry["errors"].append(meta_err)
|
|
123
817
|
|
|
124
818
|
return entry
|
|
125
819
|
|
|
126
820
|
|
|
821
|
+
def _marked_active_version(skill_group_dir, skill_name):
|
|
822
|
+
"""The version folder `active` names when SKF generated it, else None.
|
|
823
|
+
|
|
824
|
+
`active` counts only as a link to a folder directly in the group (not a
|
|
825
|
+
`.skf-` name, not a link) whose package is marked, or as a real `active/`
|
|
826
|
+
folder holding a marked package, reported as "active".
|
|
827
|
+
"""
|
|
828
|
+
active = skill_group_dir / "active"
|
|
829
|
+
if _is_link_or_junction(active):
|
|
830
|
+
try:
|
|
831
|
+
target = active.resolve(strict=True)
|
|
832
|
+
group = skill_group_dir.resolve(strict=True)
|
|
833
|
+
except (OSError, RuntimeError):
|
|
834
|
+
return None
|
|
835
|
+
version = skill_group_dir / target.name
|
|
836
|
+
if (target.parent != group or ".skf-" in target.name or not target.is_dir()
|
|
837
|
+
or not _is_marked_version(version, skill_name)):
|
|
838
|
+
return None
|
|
839
|
+
return target.name
|
|
840
|
+
if active.is_dir() and _is_marked_version(active, skill_name):
|
|
841
|
+
return "active"
|
|
842
|
+
return None
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def write_check(skills_folder, skill_name, version=None, forge_folder=None):
|
|
846
|
+
"""Decide whether a writer may add `{name}/{version}/{name}/` and flip `active`.
|
|
847
|
+
|
|
848
|
+
Returns {name, version, verdict, reason, folder, detail, foreign_entries,
|
|
849
|
+
marked_active_version, version_generated_by, version_confidence_tier}.
|
|
850
|
+
`verdict` is "ok", "not-skf-output" or "flat-layout" (the halt reason a
|
|
851
|
+
refusal uses); `reason` names the rule that decided (None for "ok").
|
|
852
|
+
Nothing at the skill folder is a new skill. A folder that holds only
|
|
853
|
+
what an interrupted SKF run leaves is written into; a mixed folder is
|
|
854
|
+
written into when the target version is new or SKF's own. `forge_folder`
|
|
855
|
+
only decides whether both settings name one folder, so SKF's forge files
|
|
856
|
+
there count as SKF output. On "ok", `version_generated_by` and
|
|
857
|
+
`version_confidence_tier` are the `generated_by` and `confidence_tier`
|
|
858
|
+
of the `{name}/{version}/{name}/metadata.json` the write would replace
|
|
859
|
+
(each None when that file, or the string, is absent), so a writer names
|
|
860
|
+
the build it would overwrite.
|
|
861
|
+
"""
|
|
862
|
+
skills_dir = Path(skills_folder)
|
|
863
|
+
forge_dir = Path(forge_folder) if forge_folder else None
|
|
864
|
+
group = skills_dir / skill_name
|
|
865
|
+
out = {"name": skill_name, "version": version, "verdict": "ok", "reason": None,
|
|
866
|
+
"folder": str(group), "detail": None, "foreign_entries": [],
|
|
867
|
+
"marked_active_version": None, "version_generated_by": None,
|
|
868
|
+
"version_confidence_tier": None}
|
|
869
|
+
|
|
870
|
+
def refuse(verdict, reason, detail, folder=None):
|
|
871
|
+
out.update(verdict=verdict, reason=reason, detail=detail)
|
|
872
|
+
if folder is not None:
|
|
873
|
+
out["folder"] = str(folder)
|
|
874
|
+
return out
|
|
875
|
+
|
|
876
|
+
if skill_name == "_batch" or ".skf-" in skill_name or skill_name in FORGE_GROUP_DIRS:
|
|
877
|
+
reserved_at = (forge_dir / skill_name) if (forge_dir and skill_name in FORGE_GROUP_DIRS) else group
|
|
878
|
+
return refuse("not-skf-output", "reserved-name",
|
|
879
|
+
"is a name SKF keeps for its own files, never a skill's", reserved_at)
|
|
880
|
+
if not os.path.lexists(group):
|
|
881
|
+
return out
|
|
882
|
+
if _is_link_or_junction(group):
|
|
883
|
+
return refuse("not-skf-output", "link", "is a link; SKF never writes through a link")
|
|
884
|
+
if not group.is_dir():
|
|
885
|
+
return refuse("not-skf-output", "not-a-folder", "is not a folder")
|
|
886
|
+
own = classify_ownership(group, skill_name, _same_folder(skills_dir, forge_dir))
|
|
887
|
+
out["foreign_entries"] = own["foreign_entries"]
|
|
888
|
+
if own["errors"]:
|
|
889
|
+
return refuse("not-skf-output", "unreadable", "; ".join(own["errors"]))
|
|
890
|
+
if own["flat_skf"] and not _marked_version_names(group, skill_name):
|
|
891
|
+
return refuse("flat-layout", "flat-layout", "still uses the flat layout")
|
|
892
|
+
if not own["skf_skill"] and own["foreign_entries"]:
|
|
893
|
+
return refuse("not-skf-output", "not-skf-output",
|
|
894
|
+
"has no SKF marker in any `metadata.json` and holds entries SKF did not "
|
|
895
|
+
"generate: " + ", ".join(own["foreign_entries"]))
|
|
896
|
+
if version is not None:
|
|
897
|
+
listed = _version_entries(own["foreign_entries"], version)
|
|
898
|
+
if listed:
|
|
899
|
+
return refuse("not-skf-output", "version",
|
|
900
|
+
"is not a version SKF generated, or holds entries SKF did not "
|
|
901
|
+
"generate: " + ", ".join(listed), group / version)
|
|
902
|
+
out["marked_active_version"] = _marked_active_version(group, skill_name)
|
|
903
|
+
if version is not None:
|
|
904
|
+
metadata, _ = read_json_file(group / version / skill_name / "metadata.json")
|
|
905
|
+
if isinstance(metadata, dict):
|
|
906
|
+
for key, field in (("version_generated_by", "generated_by"),
|
|
907
|
+
("version_confidence_tier", "confidence_tier")):
|
|
908
|
+
value = metadata.get(field)
|
|
909
|
+
out[key] = value if isinstance(value, str) and value else None
|
|
910
|
+
return out
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _version_entries(entries, version):
|
|
914
|
+
"""The `foreign_entries` that are version folder `version` or inside it.
|
|
915
|
+
|
|
916
|
+
A linked version folder is listed by its bare name (`v`), one SKF did not
|
|
917
|
+
generate as `v/` and an entry SKF did not put in a marked one as
|
|
918
|
+
`v/<entry>`. A prefix of another version is not that version: `0.1.0-rc/`
|
|
919
|
+
is never version `0.1.0`.
|
|
920
|
+
"""
|
|
921
|
+
return [e for e in entries if e == version or e == version + "/" or e.startswith(version + "/")]
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _is_reserved_group(skill_name):
|
|
925
|
+
"""True for SKF's own `_batch` folder and its `.skf-` staging names, never a skill."""
|
|
926
|
+
return skill_name == "_batch" or ".skf-" in skill_name
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def purge_check(skills_folder, skill_name, forge_folder, version=None):
|
|
930
|
+
"""Decide whether drop-skill may purge `skill_name`, whole or one version.
|
|
931
|
+
|
|
932
|
+
Returns {name, version, scope, verdict, reason, folder, detail,
|
|
933
|
+
offending_entries, ownership, foreign_entries, errors, same_folder,
|
|
934
|
+
forge_ownership, forge_foreign_entries, forge_errors,
|
|
935
|
+
affected_directories, forge_left_in_place}. `verdict` is "ok" or
|
|
936
|
+
"not-skf-output". A purge deletes only what SKF generated, so `reason`
|
|
937
|
+
names the rule that refused it:
|
|
938
|
+
|
|
939
|
+
- "reserved-name": `_batch` or a `.skf-` name, SKF's own folders.
|
|
940
|
+
- "skill-foreign": the skill folder is not SKF output (no marker, a link,
|
|
941
|
+
not a folder, or a folder SKF cannot list; `detail` says which).
|
|
942
|
+
- "skill-mixed-whole": a whole-skill purge of a skill folder that also
|
|
943
|
+
holds entries SKF did not generate.
|
|
944
|
+
- "skill-version-not-skf": version folder `version` is a link or not SKF
|
|
945
|
+
output (`v` or `v/` in `foreign_entries`).
|
|
946
|
+
- "skill-version-mixed": version folder `version` holds entries SKF did
|
|
947
|
+
not put there (`v/<entry>`).
|
|
948
|
+
- "forge-mixed-whole" and "forge-version-mixed": the same two rules for
|
|
949
|
+
the skill's forge folder, when the two settings name different folders.
|
|
950
|
+
|
|
951
|
+
A forge folder SKF did not generate (`foreign`, a link or a path that is
|
|
952
|
+
not a folder), SKF's own `reserved` folder, and a forge version folder
|
|
953
|
+
listed as `v` or `v/` do not refuse the purge: the purge leaves them in
|
|
954
|
+
place and `forge_left_in_place` names the path. `affected_directories`
|
|
955
|
+
lists the folders a purge at this scope deletes, whatever the verdict
|
|
956
|
+
(a deprecate keeps them): the skill folder, or its version folder, then
|
|
957
|
+
the forge one, each only when something is there, with no trailing
|
|
958
|
+
separator, and the forge one never when both settings name one folder.
|
|
959
|
+
"""
|
|
960
|
+
skills_dir, forge_dir = Path(skills_folder), Path(forge_folder)
|
|
961
|
+
group, forge_group = skills_dir / skill_name, forge_dir / skill_name
|
|
962
|
+
same = _same_folder(skills_dir, forge_dir)
|
|
963
|
+
scope = "skill" if version is None else "version"
|
|
964
|
+
out = {"name": skill_name, "version": version, "scope": scope, "verdict": "ok", "reason": None,
|
|
965
|
+
"folder": str(group), "detail": None, "offending_entries": [], "ownership": "absent",
|
|
966
|
+
"foreign_entries": [], "errors": [], "same_folder": same, "forge_ownership": None,
|
|
967
|
+
"forge_foreign_entries": [], "forge_errors": [], "affected_directories": [],
|
|
968
|
+
"forge_left_in_place": None}
|
|
969
|
+
|
|
970
|
+
def refuse(reason, detail, folder, entries=()):
|
|
971
|
+
if out["verdict"] == "ok":
|
|
972
|
+
out.update(verdict="not-skf-output", reason=reason, detail=detail, folder=str(folder),
|
|
973
|
+
offending_entries=list(entries))
|
|
974
|
+
|
|
975
|
+
if _is_reserved_group(skill_name):
|
|
976
|
+
refuse("reserved-name", "is a name SKF keeps for its own files, never a skill's", group)
|
|
977
|
+
return out
|
|
978
|
+
target = group if version is None else group / version
|
|
979
|
+
if os.path.lexists(group):
|
|
980
|
+
own = classify_ownership(group, skill_name, same)
|
|
981
|
+
out.update(ownership=own["ownership"], foreign_entries=own["foreign_entries"], errors=own["errors"])
|
|
982
|
+
if own["ownership"] == "foreign":
|
|
983
|
+
refuse("skill-foreign", "; ".join(own["errors"]) or "has no SKF marker in its `metadata.json`",
|
|
984
|
+
group)
|
|
985
|
+
elif own["ownership"] == "mixed" and version is None:
|
|
986
|
+
refuse("skill-mixed-whole", "also holds entries SKF did not generate: "
|
|
987
|
+
+ ", ".join(own["foreign_entries"]), group, own["foreign_entries"])
|
|
988
|
+
elif own["ownership"] == "mixed":
|
|
989
|
+
listed = _version_entries(own["foreign_entries"], version)
|
|
990
|
+
inside = [e for e in listed if e.startswith(version + "/") and e != version + "/"]
|
|
991
|
+
if inside:
|
|
992
|
+
refuse("skill-version-mixed", "holds entries SKF did not generate: " + ", ".join(inside),
|
|
993
|
+
target, inside)
|
|
994
|
+
elif listed:
|
|
995
|
+
refuse("skill-version-not-skf", "is a link or has no SKF marker in its `metadata.json`",
|
|
996
|
+
target, listed)
|
|
997
|
+
if os.path.lexists(target):
|
|
998
|
+
out["affected_directories"].append(str(target))
|
|
999
|
+
if same:
|
|
1000
|
+
return out
|
|
1001
|
+
|
|
1002
|
+
forge = classify_forge_group(forge_group, skill_name)
|
|
1003
|
+
out.update(forge_ownership=forge["ownership"], forge_foreign_entries=forge["foreign_entries"],
|
|
1004
|
+
forge_errors=forge["errors"])
|
|
1005
|
+
forge_target = forge_group if version is None else forge_group / version
|
|
1006
|
+
leave = forge["ownership"] in ("foreign", "reserved")
|
|
1007
|
+
if forge["ownership"] == "mixed" and version is None:
|
|
1008
|
+
refuse("forge-mixed-whole", "also holds entries SKF did not generate: "
|
|
1009
|
+
+ ", ".join(forge["foreign_entries"]), forge_group, forge["foreign_entries"])
|
|
1010
|
+
elif forge["ownership"] == "mixed":
|
|
1011
|
+
listed = _version_entries(forge["foreign_entries"], version)
|
|
1012
|
+
inside = [e for e in listed if e.startswith(version + "/") and e != version + "/"]
|
|
1013
|
+
if inside:
|
|
1014
|
+
refuse("forge-version-mixed", "holds entries SKF did not generate: " + ", ".join(inside),
|
|
1015
|
+
forge_target, inside)
|
|
1016
|
+
leave = bool(listed) and not inside
|
|
1017
|
+
if not os.path.lexists(forge_target):
|
|
1018
|
+
return out
|
|
1019
|
+
if leave:
|
|
1020
|
+
out["forge_left_in_place"] = str(forge_target)
|
|
1021
|
+
else:
|
|
1022
|
+
out["affected_directories"].append(str(forge_target))
|
|
1023
|
+
return out
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def rename_check(skills_folder, skill_name, forge_folder):
|
|
1027
|
+
"""Decide whether rename-skill may move `skill_name` to a new name.
|
|
1028
|
+
|
|
1029
|
+
Rename copies `{skills_output_folder}/{name}/` and its forge folder, then
|
|
1030
|
+
deletes the old ones, so it moves only a skill SKF generated in the
|
|
1031
|
+
versioned layout. Returns {name, verdict, reason, folder, detail,
|
|
1032
|
+
offending_entries, ownership, skf_skill, flat_skf, foreign_entries,
|
|
1033
|
+
errors, same_folder, forge_ownership, forge_foreign_entries,
|
|
1034
|
+
forge_errors, forge_move, forge_left_in_place}. `verdict` is "ok",
|
|
1035
|
+
"not-skf-output" or "flat-layout" (the halt reason a refusal uses);
|
|
1036
|
+
`reason` names the first rule that refused it:
|
|
1037
|
+
|
|
1038
|
+
- "reserved-name": `_batch` or a `.skf-` name.
|
|
1039
|
+
- "absent": nothing at the skill folder.
|
|
1040
|
+
- "foreign": the skill folder is not SKF output (no marker, a link, not
|
|
1041
|
+
a folder, or one SKF cannot list; `detail` says which), or holds SKF's
|
|
1042
|
+
files but no skill SKF generated.
|
|
1043
|
+
- "mixed": it also holds entries SKF did not generate.
|
|
1044
|
+
- "flat-layout": it still uses the flat layout.
|
|
1045
|
+
- "forge-link", "forge-not-a-folder", "forge-unreadable": the forge
|
|
1046
|
+
folder is a link, is not a folder, or cannot be listed.
|
|
1047
|
+
- "forge-mixed": the forge folder also holds entries SKF did not
|
|
1048
|
+
generate.
|
|
1049
|
+
|
|
1050
|
+
When both settings name one folder the skill folder is the forge folder,
|
|
1051
|
+
so no forge rule applies and nothing else moves. Otherwise `forge_move`
|
|
1052
|
+
is true when the forge folder is SKF output or holds no file of its own
|
|
1053
|
+
("skf" or "empty"), and `forge_left_in_place` names a forge folder SKF
|
|
1054
|
+
did not generate, or SKF's own reserved one, which keeps its name.
|
|
1055
|
+
"""
|
|
1056
|
+
skills_dir, forge_dir = Path(skills_folder), Path(forge_folder)
|
|
1057
|
+
group, forge_group = skills_dir / skill_name, forge_dir / skill_name
|
|
1058
|
+
same = _same_folder(skills_dir, forge_dir)
|
|
1059
|
+
out = {"name": skill_name, "verdict": "ok", "reason": None, "folder": str(group), "detail": None,
|
|
1060
|
+
"offending_entries": [], "ownership": "absent", "skf_skill": False, "flat_skf": False,
|
|
1061
|
+
"foreign_entries": [], "errors": [], "same_folder": same, "forge_ownership": None,
|
|
1062
|
+
"forge_foreign_entries": [], "forge_errors": [], "forge_move": False,
|
|
1063
|
+
"forge_left_in_place": None}
|
|
1064
|
+
|
|
1065
|
+
def refuse(verdict, reason, detail, folder=group, entries=()):
|
|
1066
|
+
out.update(verdict=verdict, reason=reason, detail=detail, folder=str(folder),
|
|
1067
|
+
offending_entries=list(entries))
|
|
1068
|
+
return out
|
|
1069
|
+
|
|
1070
|
+
if _is_reserved_group(skill_name):
|
|
1071
|
+
return refuse("not-skf-output", "reserved-name", "is a name SKF keeps for its own files, never a skill's")
|
|
1072
|
+
if not os.path.lexists(group):
|
|
1073
|
+
return refuse("not-skf-output", "absent", "has no folder in the skills folder")
|
|
1074
|
+
own = classify_ownership(group, skill_name, same)
|
|
1075
|
+
out.update(ownership=own["ownership"], skf_skill=own["skf_skill"], flat_skf=own["flat_skf"],
|
|
1076
|
+
foreign_entries=own["foreign_entries"], errors=own["errors"])
|
|
1077
|
+
if own["ownership"] == "foreign":
|
|
1078
|
+
return refuse("not-skf-output", "foreign",
|
|
1079
|
+
"; ".join(own["errors"]) or "has no SKF marker in its `metadata.json`")
|
|
1080
|
+
if own["ownership"] == "mixed":
|
|
1081
|
+
return refuse("not-skf-output", "mixed", "also holds entries SKF did not generate: "
|
|
1082
|
+
+ ", ".join(own["foreign_entries"]), group, own["foreign_entries"])
|
|
1083
|
+
if not own["skf_skill"]: # SKF's files, such as a brief, but no skill it generated
|
|
1084
|
+
return refuse("not-skf-output", "foreign", "has no SKF marker in its `metadata.json`")
|
|
1085
|
+
if own["flat_skf"]:
|
|
1086
|
+
return refuse("flat-layout", "flat-layout", "still uses the flat layout")
|
|
1087
|
+
if same:
|
|
1088
|
+
return out
|
|
1089
|
+
forge = classify_forge_group(forge_group, skill_name)
|
|
1090
|
+
out.update(forge_ownership=forge["ownership"], forge_foreign_entries=forge["foreign_entries"],
|
|
1091
|
+
forge_errors=forge["errors"])
|
|
1092
|
+
if forge["errors"] and forge["ownership"] != "reserved":
|
|
1093
|
+
if _is_link_or_junction(forge_group):
|
|
1094
|
+
reason = "forge-link"
|
|
1095
|
+
elif not forge_group.is_dir():
|
|
1096
|
+
reason = "forge-not-a-folder"
|
|
1097
|
+
else:
|
|
1098
|
+
reason = "forge-unreadable"
|
|
1099
|
+
return refuse("not-skf-output", reason, "; ".join(forge["errors"]), forge_group)
|
|
1100
|
+
if forge["ownership"] == "mixed":
|
|
1101
|
+
return refuse("not-skf-output", "forge-mixed", "also holds entries SKF did not generate: "
|
|
1102
|
+
+ ", ".join(forge["foreign_entries"]), forge_group, forge["foreign_entries"])
|
|
1103
|
+
out["forge_move"] = forge["ownership"] in ("skf", "empty")
|
|
1104
|
+
if forge["ownership"] in ("foreign", "reserved"):
|
|
1105
|
+
out["forge_left_in_place"] = str(forge_group)
|
|
1106
|
+
return out
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
# --------------------------------------------------------------------------
|
|
1110
|
+
# Guarded delete: a purge deletes only plain folders inside SKF's folders
|
|
1111
|
+
# --------------------------------------------------------------------------
|
|
1112
|
+
|
|
1113
|
+
LINK_REFUSAL = "a link; SKF never deletes through a link"
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _strip_trailing_separators(path):
|
|
1117
|
+
"""`path` without the trailing separators that make a delete follow a link."""
|
|
1118
|
+
separators = "/" + os.sep + (os.altsep or "")
|
|
1119
|
+
return path.rstrip(separators) or path
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _parts_below(root, path):
|
|
1123
|
+
"""The folder names from `root` down to `path`, or None when `path` is not below `root`.
|
|
1124
|
+
|
|
1125
|
+
Both are made absolute without resolving links (a link at or above a
|
|
1126
|
+
root is the user's configuration) and compared case-insensitively where
|
|
1127
|
+
the platform is.
|
|
1128
|
+
"""
|
|
1129
|
+
root_abs, path_abs = os.path.abspath(root), os.path.abspath(path)
|
|
1130
|
+
try:
|
|
1131
|
+
inside = os.path.commonpath([os.path.normcase(root_abs), os.path.normcase(path_abs)])
|
|
1132
|
+
except ValueError: # another drive on Windows
|
|
1133
|
+
return None
|
|
1134
|
+
if inside != os.path.normcase(root_abs) or os.path.normcase(path_abs) == inside:
|
|
1135
|
+
return None
|
|
1136
|
+
return Path(os.path.relpath(path_abs, root_abs)).parts
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def _tree_bytes(path):
|
|
1140
|
+
"""The bytes of the files under `path`, a link counted as itself and never followed."""
|
|
1141
|
+
total = 0
|
|
1142
|
+
for folder, _dirs, files in os.walk(path):
|
|
1143
|
+
for name in files:
|
|
1144
|
+
try:
|
|
1145
|
+
total += os.lstat(os.path.join(folder, name)).st_size
|
|
1146
|
+
except OSError:
|
|
1147
|
+
pass
|
|
1148
|
+
return total
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def _remove_tree(path):
|
|
1152
|
+
"""shutil.rmtree, clearing a read-only bit (Windows) and retrying once."""
|
|
1153
|
+
|
|
1154
|
+
def retry(func, target, _error):
|
|
1155
|
+
os.chmod(target, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC)
|
|
1156
|
+
func(target)
|
|
1157
|
+
|
|
1158
|
+
if sys.version_info >= (3, 12):
|
|
1159
|
+
shutil.rmtree(path, onexc=retry)
|
|
1160
|
+
else:
|
|
1161
|
+
shutil.rmtree(path, onerror=retry)
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def _delete_one(roots, path):
|
|
1165
|
+
"""("deleted", bytes), ("absent", None) or ("failed", reason) for one path of guarded_delete."""
|
|
1166
|
+
if not Path(path).parts: # "" or "." would name the working folder
|
|
1167
|
+
return "failed", "names no folder"
|
|
1168
|
+
if ".." in Path(path).parts:
|
|
1169
|
+
return "failed", "has a `..` part; SKF deletes only a plain path"
|
|
1170
|
+
below = [(root, parts) for root in roots if (parts := _parts_below(root, path)) is not None]
|
|
1171
|
+
if not below:
|
|
1172
|
+
return "failed", "is not inside " + " or ".join(roots) + "; SKF deletes only inside its folders"
|
|
1173
|
+
root, parts = max(below, key=lambda b: len(os.path.abspath(b[0]))) # the closest root
|
|
1174
|
+
for i in range(1, len(parts) + 1):
|
|
1175
|
+
step = Path(root, *parts[:i])
|
|
1176
|
+
if _is_link_or_junction(step):
|
|
1177
|
+
return "failed", LINK_REFUSAL if i == len(parts) else f"{step} is {LINK_REFUSAL}"
|
|
1178
|
+
if not os.path.lexists(path):
|
|
1179
|
+
return "absent", None
|
|
1180
|
+
if not os.path.isdir(path):
|
|
1181
|
+
return "failed", "is not a folder; SKF deletes only folders here"
|
|
1182
|
+
size = _tree_bytes(path)
|
|
1183
|
+
try:
|
|
1184
|
+
_remove_tree(path)
|
|
1185
|
+
except OSError as e:
|
|
1186
|
+
return "failed", f"cannot delete it: {e}"
|
|
1187
|
+
if os.path.lexists(path):
|
|
1188
|
+
return "failed", "is still there after the delete"
|
|
1189
|
+
return "deleted", size
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def guarded_delete(roots, paths):
|
|
1193
|
+
"""Delete each folder of `paths` that lies inside one of `roots`, as drop-skill's purge.
|
|
1194
|
+
|
|
1195
|
+
rename-skill deletes its old folders the same way. Each path loses its
|
|
1196
|
+
trailing separators first. It is refused, and listed in
|
|
1197
|
+
`delete_failures` with the reason, when it names no folder or has a
|
|
1198
|
+
`..` part, is not below a root (a root itself included), is a link or
|
|
1199
|
+
junction or lies below one between the closest root and it (SKF never
|
|
1200
|
+
deletes through a link: the delete would reach the files the link points
|
|
1201
|
+
to), or is not a folder. A path with nothing at it goes to
|
|
1202
|
+
`already_absent`. Otherwise its size is measured, the folder is deleted
|
|
1203
|
+
and checked gone: then it goes to `files_deleted`, else to
|
|
1204
|
+
`delete_failures`. Returns {roots, files_deleted, already_absent,
|
|
1205
|
+
delete_failures, attempted, bytes_freed, purge_status}: `attempted`
|
|
1206
|
+
counts the paths deleted or refused, and `purge_status` is "failed" when
|
|
1207
|
+
it is above 0 and nothing was deleted, "partial" when some were, else
|
|
1208
|
+
"success" (no path at all included: a purge whose folders are already
|
|
1209
|
+
gone has nothing to delete).
|
|
1210
|
+
"""
|
|
1211
|
+
deleted, absent, failures, freed = [], [], [], 0
|
|
1212
|
+
for raw in paths:
|
|
1213
|
+
path = _strip_trailing_separators(raw)
|
|
1214
|
+
outcome, detail = _delete_one(roots, path)
|
|
1215
|
+
if outcome == "deleted":
|
|
1216
|
+
deleted.append(path)
|
|
1217
|
+
freed += detail
|
|
1218
|
+
elif outcome == "absent":
|
|
1219
|
+
absent.append(path)
|
|
1220
|
+
else:
|
|
1221
|
+
failures.append({"path": path, "error": detail})
|
|
1222
|
+
attempted = len(deleted) + len(failures)
|
|
1223
|
+
if attempted and not deleted:
|
|
1224
|
+
status = "failed"
|
|
1225
|
+
elif failures:
|
|
1226
|
+
status = "partial"
|
|
1227
|
+
else:
|
|
1228
|
+
status = "success"
|
|
1229
|
+
return {"roots": list(roots), "files_deleted": deleted, "already_absent": absent,
|
|
1230
|
+
"delete_failures": failures, "attempted": attempted, "bytes_freed": freed,
|
|
1231
|
+
"purge_status": status}
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def _marked_version_names(skill_group_dir, skill_name):
|
|
1235
|
+
"""The names of the marked version folders of a group (`.skf-` names aside)."""
|
|
1236
|
+
try:
|
|
1237
|
+
children = list(skill_group_dir.iterdir())
|
|
1238
|
+
except OSError:
|
|
1239
|
+
return []
|
|
1240
|
+
return sorted(c.name for c in children
|
|
1241
|
+
if ".skf-" not in c.name and c.is_dir() and _is_marked_version(c, skill_name))
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
# --------------------------------------------------------------------------
|
|
1245
|
+
# Versions: normalize, order, next patch, compose-mode bump, primary library
|
|
1246
|
+
# --------------------------------------------------------------------------
|
|
1247
|
+
|
|
1248
|
+
# What may follow the dotted numbers of a version: a pre-release tag such as
|
|
1249
|
+
# `-rc.1`, `rc1` or `.dev0`, identifiers split by `.` or `-`.
|
|
1250
|
+
PRE_RELEASE_RE = re.compile(r"[-._]?[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*")
|
|
1251
|
+
BUILD_RE = re.compile(r"[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*")
|
|
1252
|
+
# A comparator of a range: npm, Python, Ruby and Cargo operators, then a version.
|
|
1253
|
+
COMPARATOR_RE = re.compile(r"(===|==|>=|<=|~=|~>|!=|\^|~|>|<|=)?\s*([^\s,]+)")
|
|
1254
|
+
COMPARATOR_SEPARATOR_RE = re.compile(r"\s*,?\s*")
|
|
1255
|
+
UPPER_BOUND_OPERATORS = frozenset({"<", "<=", "!="})
|
|
1256
|
+
# Trailing wildcard components (`1.2.x`, `1.*`), dropped before a bound is read.
|
|
1257
|
+
WILDCARD_TAIL_RE = re.compile(r"(?:\.[xX*])+$")
|
|
1258
|
+
WILDCARDS = frozenset({"x", "X", "*"})
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def _parse_plain_version(text):
|
|
1262
|
+
"""(release, pre, build) for a plain version string, else None.
|
|
1263
|
+
|
|
1264
|
+
A leading `v` is dropped and the build metadata after `+` split off.
|
|
1265
|
+
`release` holds the dotted numbers, padded to three (`2.0` is `2.0.0`);
|
|
1266
|
+
`pre` is whatever follows them (`-rc.1`, `rc1`, `.dev0`) as written, or "".
|
|
1267
|
+
A trailing wildcard component (`1.2.x`) is not a plain version.
|
|
1268
|
+
"""
|
|
1269
|
+
s = str(text).strip()
|
|
1270
|
+
s, plus, build = s.partition("+")
|
|
1271
|
+
if plus and not BUILD_RE.fullmatch(build):
|
|
1272
|
+
return None
|
|
1273
|
+
m = re.match(r"[vV]?(\d+(?:\.\d+)*)", s)
|
|
1274
|
+
if not m:
|
|
1275
|
+
return None
|
|
1276
|
+
pre = s[m.end():]
|
|
1277
|
+
if pre and (not PRE_RELEASE_RE.fullmatch(pre) or re.match(r"\.[xX](?:\.|$)", pre)):
|
|
1278
|
+
return None
|
|
1279
|
+
release = [str(int(part)) for part in m.group(1).split(".")]
|
|
1280
|
+
release += ["0"] * (3 - len(release))
|
|
1281
|
+
return release, pre, (build or None)
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _version_text(parsed):
|
|
1285
|
+
release, pre, _ = parsed
|
|
1286
|
+
return ".".join(release) + pre
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def _order_key(parsed):
|
|
1290
|
+
"""Sort key: numbers compared as numbers (1.10.0 above 1.9.0), a release
|
|
1291
|
+
above its pre-releases, pre-release identifiers compared with their digit
|
|
1292
|
+
runs as numbers (rc10 above rc2) and ranked below letters, as in semver."""
|
|
1293
|
+
release, pre, _ = parsed
|
|
1294
|
+
nums = [int(part) for part in release]
|
|
1295
|
+
while nums and nums[-1] == 0:
|
|
1296
|
+
nums.pop()
|
|
1297
|
+
if not pre:
|
|
1298
|
+
return (tuple(nums), (1,))
|
|
1299
|
+
ids = tuple(
|
|
1300
|
+
tuple((0, int(run)) if run.isdigit() else (1, run) for run in re.findall(r"\d+|\D+", ident))
|
|
1301
|
+
for ident in pre.lstrip("-._").split("."))
|
|
1302
|
+
return (tuple(nums), (0,) + ids)
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def _lower_bound(spec):
|
|
1306
|
+
"""The lower bound of one comparator set (`>=1.2, <2`, `^18.2.0`, `1.0 - 2.0`),
|
|
1307
|
+
as a parsed version, or the reason there is none (a string)."""
|
|
1308
|
+
spec = spec.strip()
|
|
1309
|
+
hyphen = re.fullmatch(r"(\S+)\s+-\s+(\S+)", spec)
|
|
1310
|
+
if hyphen:
|
|
1311
|
+
comparators = [(">=", hyphen.group(1)), ("<=", hyphen.group(2))]
|
|
1312
|
+
else:
|
|
1313
|
+
comparators, pos = [], 0
|
|
1314
|
+
while pos < len(spec):
|
|
1315
|
+
m = COMPARATOR_RE.match(spec, pos)
|
|
1316
|
+
if not m:
|
|
1317
|
+
return f"not a version or range: {spec!r}"
|
|
1318
|
+
comparators.append((m.group(1) or "", m.group(2)))
|
|
1319
|
+
pos = COMPARATOR_SEPARATOR_RE.match(spec, m.end()).end()
|
|
1320
|
+
if not comparators:
|
|
1321
|
+
return "empty version"
|
|
1322
|
+
lows = []
|
|
1323
|
+
for op, token in comparators:
|
|
1324
|
+
if token in WILDCARDS:
|
|
1325
|
+
continue # admits any version: no bound
|
|
1326
|
+
parsed = _parse_plain_version(WILDCARD_TAIL_RE.sub("", token))
|
|
1327
|
+
if parsed is None:
|
|
1328
|
+
return f"not a version: {token!r}"
|
|
1329
|
+
if op not in UPPER_BOUND_OPERATORS:
|
|
1330
|
+
lows.append(parsed)
|
|
1331
|
+
if not lows:
|
|
1332
|
+
return f"names no lower bound: {spec!r}"
|
|
1333
|
+
return max(lows, key=_order_key)
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def _reduce_version(text):
|
|
1337
|
+
"""((release, pre, build), rule) for a version or range, or a reason string.
|
|
1338
|
+
|
|
1339
|
+
`rule` is "version" for a plain version and "range" for a specifier
|
|
1340
|
+
reduced to its lower bound: the version it names after `^`, `~`, `~=`,
|
|
1341
|
+
`~>`, `>=`, `>`, `=` or `==` (a `>` bound kept as written), the highest
|
|
1342
|
+
such bound when a set has several, the lowest over `||` alternatives, and
|
|
1343
|
+
trailing `x` or `*` components read as 0. `workspace:` and `npm:<name>@`
|
|
1344
|
+
prefixes are dropped. A specifier with no lower bound (`<2.0.0`, `*`,
|
|
1345
|
+
`latest`, a URL or a path) has none.
|
|
1346
|
+
"""
|
|
1347
|
+
spec = str(text).strip()
|
|
1348
|
+
if spec.startswith("workspace:"):
|
|
1349
|
+
spec = spec[len("workspace:"):].strip()
|
|
1350
|
+
if spec.startswith("npm:") and "@" in spec[5:]:
|
|
1351
|
+
spec = spec.rsplit("@", 1)[1].strip()
|
|
1352
|
+
if not spec:
|
|
1353
|
+
return "empty version"
|
|
1354
|
+
plain = _parse_plain_version(spec)
|
|
1355
|
+
if plain is not None:
|
|
1356
|
+
return plain, "version"
|
|
1357
|
+
bounds = []
|
|
1358
|
+
for alternative in spec.split("||"):
|
|
1359
|
+
bound = _lower_bound(alternative)
|
|
1360
|
+
if isinstance(bound, str):
|
|
1361
|
+
return bound
|
|
1362
|
+
bounds.append(bound)
|
|
1363
|
+
return min(bounds, key=_order_key), "range"
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def _reduce_or_raise(text):
|
|
1367
|
+
"""The parsed version `text` reduces to; ValueError when it names none."""
|
|
1368
|
+
reduced = _reduce_version(text)
|
|
1369
|
+
if isinstance(reduced, str):
|
|
1370
|
+
raise ValueError(f"{text!r}: {reduced}")
|
|
1371
|
+
return reduced[0]
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def normalize_version(text):
|
|
1375
|
+
"""Reduce a version or a range specifier to one version folder name.
|
|
1376
|
+
|
|
1377
|
+
Returns {input, normalized, rule, build_metadata, reason}: `normalized`
|
|
1378
|
+
is null and `reason` says why when the input names no version. See
|
|
1379
|
+
`_reduce_version` for the rules; build metadata is stripped as the
|
|
1380
|
+
Version Sanitization rules of knowledge/version-paths.md say.
|
|
1381
|
+
"""
|
|
1382
|
+
out = {"input": text, "normalized": None, "rule": None, "build_metadata": None, "reason": None}
|
|
1383
|
+
reduced = _reduce_version(text)
|
|
1384
|
+
if isinstance(reduced, str):
|
|
1385
|
+
out["reason"] = reduced
|
|
1386
|
+
return out
|
|
1387
|
+
parsed, rule = reduced
|
|
1388
|
+
out.update(normalized=_version_text(parsed), rule=rule, build_metadata=parsed[2])
|
|
1389
|
+
return out
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
ORDER_WORDS = {-1: "lower", 0: "equal", 1: "higher"}
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
class NotIncreasingError(ValueError):
|
|
1396
|
+
"""A computed version that is not above the version it follows."""
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
def _sign(a, b):
|
|
1400
|
+
return (a > b) - (a < b)
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def order_versions(a, b):
|
|
1404
|
+
"""How version `a` orders against version `b`.
|
|
1405
|
+
|
|
1406
|
+
Returns {a, b, order, major_minor, higher}: `order` and `major_minor` (the
|
|
1407
|
+
same test on the first two numbers only) are "lower", "equal" or
|
|
1408
|
+
"higher", read as "a is ... than b"; `higher` is the higher normalized
|
|
1409
|
+
version, null when they are equal. Raises ValueError when either names no
|
|
1410
|
+
version.
|
|
1411
|
+
"""
|
|
1412
|
+
pa, pb = _reduce_or_raise(a), _reduce_or_raise(b)
|
|
1413
|
+
cmp = _sign(_order_key(pa), _order_key(pb))
|
|
1414
|
+
mm = _sign(tuple(int(p) for p in pa[0][:2]), tuple(int(p) for p in pb[0][:2]))
|
|
1415
|
+
return {"a": normalize_version(a), "b": normalize_version(b), "order": ORDER_WORDS[cmp],
|
|
1416
|
+
"major_minor": ORDER_WORDS[mm],
|
|
1417
|
+
"higher": None if cmp == 0 else _version_text(pa if cmp > 0 else pb)}
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def next_patch(text):
|
|
1421
|
+
"""The next patch version after `text`: the release of a pre-release
|
|
1422
|
+
(`1.2.3-rc.1` gives `1.2.3`, as semver's patch increment does), else the
|
|
1423
|
+
third number plus one (`1.2.3` gives `1.2.4`; a fourth number is dropped).
|
|
1424
|
+
Raises ValueError when `text` names no version."""
|
|
1425
|
+
parsed = _reduce_or_raise(text)
|
|
1426
|
+
release, pre, _ = parsed
|
|
1427
|
+
if pre:
|
|
1428
|
+
nxt = ".".join(release)
|
|
1429
|
+
else:
|
|
1430
|
+
nxt = f"{release[0]}.{release[1]}.{int(release[2]) + 1}"
|
|
1431
|
+
if _order_key(_parse_plain_version(nxt)) <= _order_key(parsed):
|
|
1432
|
+
raise NotIncreasingError(f"next patch {nxt} is not above {_version_text(parsed)}")
|
|
1433
|
+
return {"input": text, "normalized": _version_text(parsed), "next_patch": nxt}
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def compose_bump(prior, prior_libraries, libraries):
|
|
1437
|
+
"""The compose-mode stack version after `prior` (create-stack-skill
|
|
1438
|
+
generate-output.md §1, Stack version).
|
|
1439
|
+
|
|
1440
|
+
Major when a library of `prior_libraries` is not in `libraries` (removed
|
|
1441
|
+
or replaced), else minor. Returns {prior, prior_normalized, bump, version,
|
|
1442
|
+
removed, added}. Raises ValueError when `prior` names no version or the
|
|
1443
|
+
result is not above it (NotIncreasingError).
|
|
1444
|
+
"""
|
|
1445
|
+
parsed = _reduce_or_raise(prior)
|
|
1446
|
+
release = parsed[0]
|
|
1447
|
+
before, after = set(prior_libraries), set(libraries)
|
|
1448
|
+
removed, added = sorted(before - after), sorted(after - before)
|
|
1449
|
+
if removed:
|
|
1450
|
+
bump, version = "major", f"{int(release[0]) + 1}.0.0"
|
|
1451
|
+
else:
|
|
1452
|
+
bump, version = "minor", f"{release[0]}.{int(release[1]) + 1}.0"
|
|
1453
|
+
if _order_key(_parse_plain_version(version)) <= _order_key(parsed):
|
|
1454
|
+
raise NotIncreasingError(f"{version} is not above the prior version {_version_text(parsed)}")
|
|
1455
|
+
return {"prior": prior, "prior_normalized": _version_text(parsed), "bump": bump,
|
|
1456
|
+
"version": version, "removed": removed, "added": added}
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def primary_library(candidates):
|
|
1460
|
+
"""The code-mode primary library of a stack and the version it gives
|
|
1461
|
+
(create-stack-skill generate-output.md §1, Stack version).
|
|
1462
|
+
|
|
1463
|
+
`candidates` is a list of {name, import_count, version}. The primary is
|
|
1464
|
+
the highest `import_count`. On a tie, a candidate whose version reduces
|
|
1465
|
+
to one (see `normalize_version`) goes first, then the lowest name,
|
|
1466
|
+
compared case-insensitively. `version` falls back to 1.0.0 when the
|
|
1467
|
+
primary has none. Returns {primary, import_count, version_input, version,
|
|
1468
|
+
fallback, tied, reason}; `reason` is "highest-import-count",
|
|
1469
|
+
"tie-usable-version", "tie-name-order" or "no-candidates". Raises
|
|
1470
|
+
ValueError on a malformed candidate.
|
|
1471
|
+
"""
|
|
1472
|
+
rows = []
|
|
1473
|
+
for c in candidates:
|
|
1474
|
+
if not isinstance(c, dict) or not isinstance(c.get("name"), str) or not c["name"]:
|
|
1475
|
+
raise ValueError(f"each candidate needs a name: {c!r}")
|
|
1476
|
+
count = c.get("import_count", 0)
|
|
1477
|
+
if isinstance(count, bool) or not isinstance(count, int) or count < 0:
|
|
1478
|
+
raise ValueError(f"import_count must be a whole number: {c!r}")
|
|
1479
|
+
version = c.get("version")
|
|
1480
|
+
if version is not None and not isinstance(version, str):
|
|
1481
|
+
raise ValueError(f"version must be a string or null: {c!r}")
|
|
1482
|
+
usable = version is not None and not isinstance(_reduce_version(version), str)
|
|
1483
|
+
rows.append((c["name"], count, version, usable))
|
|
1484
|
+
if not rows:
|
|
1485
|
+
return {"primary": None, "import_count": None, "version_input": None,
|
|
1486
|
+
"version": "1.0.0", "fallback": True, "tied": [], "reason": "no-candidates"}
|
|
1487
|
+
top = max(r[1] for r in rows)
|
|
1488
|
+
tied = [r for r in rows if r[1] == top]
|
|
1489
|
+
pool = [r for r in tied if r[3]] or tied
|
|
1490
|
+
name, count, version, usable = min(pool, key=lambda r: (r[0].casefold(), r[0]))
|
|
1491
|
+
if len(tied) == 1:
|
|
1492
|
+
reason = "highest-import-count"
|
|
1493
|
+
elif len(pool) == 1:
|
|
1494
|
+
reason = "tie-usable-version"
|
|
1495
|
+
else:
|
|
1496
|
+
reason = "tie-name-order"
|
|
1497
|
+
return {"primary": name, "import_count": count, "version_input": version,
|
|
1498
|
+
"version": normalize_version(version)["normalized"] if usable else "1.0.0",
|
|
1499
|
+
"fallback": not usable, "tied": sorted((r[0] for r in tied), key=lambda n: (n.casefold(), n)),
|
|
1500
|
+
"reason": reason}
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
def _version_folder_key(name):
|
|
1504
|
+
"""Newest-first sort key for version folder names; a name that is not a
|
|
1505
|
+
version sorts after every version, by name."""
|
|
1506
|
+
parsed = _parse_plain_version(name)
|
|
1507
|
+
if parsed is None:
|
|
1508
|
+
return (0, (), name)
|
|
1509
|
+
return (1, _order_key(parsed), name)
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
# --------------------------------------------------------------------------
|
|
1513
|
+
# Resolve: the version a reading workflow uses, and its paths
|
|
1514
|
+
# --------------------------------------------------------------------------
|
|
1515
|
+
|
|
1516
|
+
ISO_TIME_RE = re.compile(
|
|
1517
|
+
r"(\d{4})-(\d{2})-(\d{2})"
|
|
1518
|
+
r"(?:[Tt ](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d+))?)?)?"
|
|
1519
|
+
r"\s*([Zz]|[+-]\d{2}(?::?\d{2})?)?")
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
# Keep identical to _parse_iso_utc in skf-load-provenance.py
|
|
1523
|
+
# (test/test-skf-skill-inventory.py pins the copies).
|
|
1524
|
+
def _parse_iso_utc(value):
|
|
1525
|
+
"""An ISO-8601 date or date-time as an aware UTC datetime, else None.
|
|
1526
|
+
|
|
1527
|
+
A time without a zone is read as UTC, a date alone as its midnight UTC.
|
|
1528
|
+
"""
|
|
1529
|
+
if not isinstance(value, str):
|
|
1530
|
+
return None
|
|
1531
|
+
m = ISO_TIME_RE.fullmatch(value.strip())
|
|
1532
|
+
if not m:
|
|
1533
|
+
return None
|
|
1534
|
+
year, month, day, hour, minute, second, fraction, zone = m.groups()
|
|
1535
|
+
offset = timedelta(0)
|
|
1536
|
+
if zone and zone not in ("Z", "z"):
|
|
1537
|
+
digits = zone[1:].replace(":", "")
|
|
1538
|
+
offset = timedelta(hours=int(digits[:2]), minutes=int(digits[2:] or 0))
|
|
1539
|
+
if zone[0] == "-":
|
|
1540
|
+
offset = -offset
|
|
1541
|
+
try:
|
|
1542
|
+
moment = datetime(int(year), int(month), int(day), int(hour or 0), int(minute or 0),
|
|
1543
|
+
int(second or 0), int((fraction or "0")[:6].ljust(6, "0")))
|
|
1544
|
+
return (moment - offset).replace(tzinfo=timezone.utc)
|
|
1545
|
+
except (ValueError, OverflowError): # no such date, or out of datetime's range
|
|
1546
|
+
return None
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def _utc_text(moment):
|
|
1550
|
+
return moment.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def _provenance_time(path):
|
|
1554
|
+
"""(generated_at, source) for a provenance map: its `generated_at` field in
|
|
1555
|
+
UTC ("provenance-map"), else the file's modification time ("mtime"), else
|
|
1556
|
+
(None, None) when there is no map."""
|
|
1557
|
+
data, err = read_json_file(path)
|
|
1558
|
+
if err is None and isinstance(data, dict):
|
|
1559
|
+
moment = _parse_iso_utc(data.get("generated_at"))
|
|
1560
|
+
if moment is not None:
|
|
1561
|
+
return _utc_text(moment), "provenance-map"
|
|
1562
|
+
try:
|
|
1563
|
+
if path.is_file():
|
|
1564
|
+
return _utc_text(datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)), "mtime"
|
|
1565
|
+
except OSError:
|
|
1566
|
+
pass
|
|
1567
|
+
return None, None
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
def _active_link_version(skill_group_dir):
|
|
1571
|
+
"""(version, error) for the `active` link of a skill folder.
|
|
1572
|
+
|
|
1573
|
+
The version is the name of the folder the link leads to when that folder
|
|
1574
|
+
sits directly in the skill folder (not a `.skf-` name). A broken or
|
|
1575
|
+
looping link, or one that leads anywhere else, gives (None, reason); no
|
|
1576
|
+
link gives (None, None). A real `active/` folder is not a link.
|
|
1577
|
+
"""
|
|
1578
|
+
active = skill_group_dir / "active"
|
|
1579
|
+
if not _is_link_or_junction(active):
|
|
1580
|
+
if active.is_dir():
|
|
1581
|
+
return None, f"`active` is a folder, not a link: {active}"
|
|
1582
|
+
return None, None
|
|
1583
|
+
try:
|
|
1584
|
+
target = active.resolve(strict=True)
|
|
1585
|
+
group = skill_group_dir.resolve(strict=True)
|
|
1586
|
+
except (OSError, RuntimeError):
|
|
1587
|
+
return None, f"`active` link is broken: {active}"
|
|
1588
|
+
if target.parent != group or ".skf-" in target.name or not target.is_dir():
|
|
1589
|
+
return None, f"`active` link does not lead to a version folder of the skill: {active}"
|
|
1590
|
+
return target.name, None
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
def _manifest_versions(entry, updated_at):
|
|
1594
|
+
"""(active_version, last_exported, {version: status}) from one manifest entry.
|
|
1595
|
+
|
|
1596
|
+
A v2 entry maps each version to its record; a v1 entry lists versions
|
|
1597
|
+
and marks the active one deprecated with `deprecated: true`, as
|
|
1598
|
+
skf-manifest-ops.py migrates it.
|
|
1599
|
+
"""
|
|
1600
|
+
if not isinstance(entry, dict):
|
|
1601
|
+
return None, None, {}
|
|
1602
|
+
active = entry.get("active_version")
|
|
1603
|
+
active = active if isinstance(active, str) and active else None
|
|
1604
|
+
versions = entry.get("versions")
|
|
1605
|
+
statuses, last_exported = {}, None
|
|
1606
|
+
if isinstance(versions, dict):
|
|
1607
|
+
for v, record in versions.items():
|
|
1608
|
+
status = record.get("status") if isinstance(record, dict) else None
|
|
1609
|
+
statuses[v] = status if isinstance(status, str) else None
|
|
1610
|
+
record = versions.get(active) if active else None
|
|
1611
|
+
if isinstance(record, dict) and isinstance(record.get("last_exported"), str):
|
|
1612
|
+
last_exported = record["last_exported"]
|
|
1613
|
+
elif isinstance(versions, list):
|
|
1614
|
+
deprecated = entry.get("deprecated") is True
|
|
1615
|
+
for v in versions:
|
|
1616
|
+
if isinstance(v, str):
|
|
1617
|
+
statuses[v] = ("deprecated" if deprecated else "active") if v == active else "archived"
|
|
1618
|
+
last_exported = updated_at if isinstance(updated_at, str) else None
|
|
1619
|
+
return active, last_exported, statuses
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
def _disk_versions(skill_group_dir, skill_name):
|
|
1623
|
+
"""The version folders of a skill folder: each folder directly in it that
|
|
1624
|
+
holds a `{name}/` folder (not `active`, a `.skf-` or dot name, or a flat
|
|
1625
|
+
package folder beside a root SKILL.md)."""
|
|
1626
|
+
try:
|
|
1627
|
+
children = list(skill_group_dir.iterdir())
|
|
1628
|
+
except OSError:
|
|
1629
|
+
return []
|
|
1630
|
+
flat = (skill_group_dir / "SKILL.md").is_file()
|
|
1631
|
+
found = []
|
|
1632
|
+
for child in children:
|
|
1633
|
+
name = child.name
|
|
1634
|
+
if (name.startswith(".") or _is_active_pointer(name) or ".skf-" in name
|
|
1635
|
+
or (flat and name in FLAT_PACKAGE_ENTRIES)):
|
|
1636
|
+
continue
|
|
1637
|
+
if child.is_dir() and (child / skill_name).is_dir():
|
|
1638
|
+
found.append(name)
|
|
1639
|
+
return found
|
|
1640
|
+
|
|
1641
|
+
|
|
1642
|
+
def _artifact(versioned, flat):
|
|
1643
|
+
"""{path, source, versioned, flat}: the first of the two paths that is a
|
|
1644
|
+
file, versioned first; `path` and `source` are null when neither is."""
|
|
1645
|
+
for source, path in (("versioned", versioned), ("flat", flat)):
|
|
1646
|
+
if path is not None and path.is_file():
|
|
1647
|
+
return {"path": str(path), "source": source,
|
|
1648
|
+
"versioned": str(versioned) if versioned else None, "flat": str(flat)}
|
|
1649
|
+
return {"path": None, "source": None,
|
|
1650
|
+
"versioned": str(versioned) if versioned else None, "flat": str(flat)}
|
|
1651
|
+
|
|
1652
|
+
|
|
1653
|
+
RESOLVE_DETAILS = {
|
|
1654
|
+
"requested": "the caller asked for version {chosen}",
|
|
1655
|
+
"manifest-and-link": "the manifest and the `active` link both name {chosen}",
|
|
1656
|
+
"manifest-lags-link": ("the manifest names {manifest} but the `active` link names {chosen}, "
|
|
1657
|
+
"which a writing workflow flipped after the last export; using the "
|
|
1658
|
+
"link (run export-skill to reconcile the manifest)"),
|
|
1659
|
+
"manifest": "the manifest names {chosen}",
|
|
1660
|
+
"link": "the `active` link names {chosen}; the manifest does not list the skill",
|
|
1661
|
+
"flat-layout": "the skill still uses the flat layout (SKILL.md at the skill folder root)",
|
|
1662
|
+
"newest-on-disk": ("neither the manifest nor an `active` link names a version on disk; "
|
|
1663
|
+
"{chosen} is the newest version folder"),
|
|
1664
|
+
"missing": "no version of the skill is on disk",
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
|
|
1668
|
+
def resolve_skill(skills_folder, skill_name, forge_data_folder, version=None):
|
|
1669
|
+
"""Resolve the version a reading workflow uses for `skill_name`, and its paths.
|
|
1670
|
+
|
|
1671
|
+
Implements Reading Workflows in knowledge/version-paths.md. The chosen
|
|
1672
|
+
version and `reason`, first that applies:
|
|
1673
|
+
|
|
1674
|
+
- "requested": `version` was given.
|
|
1675
|
+
- "manifest-and-link": the manifest's `active_version` and the `active`
|
|
1676
|
+
link name the same version, and its package is on disk.
|
|
1677
|
+
- "manifest-lags-link": they differ and the link's version has its
|
|
1678
|
+
package on disk (the Manifest-lag guard: the link wins).
|
|
1679
|
+
- "manifest": the manifest's version has its package on disk (no link,
|
|
1680
|
+
or the link names a version with no package).
|
|
1681
|
+
- "link": the manifest does not list the skill; the link's version has
|
|
1682
|
+
its package on disk.
|
|
1683
|
+
- "flat-layout": no version resolved and SKILL.md sits at the skill
|
|
1684
|
+
folder root; `chosen_version` and `forge_version` are null.
|
|
1685
|
+
- "newest-on-disk": no version resolved, but a version folder holds the
|
|
1686
|
+
package; the newest one.
|
|
1687
|
+
- "missing": nothing on disk; the manifest's version, else the link's,
|
|
1688
|
+
else null.
|
|
1689
|
+
|
|
1690
|
+
`candidates` describes the manifest's and the link's version (package
|
|
1691
|
+
on disk, provenance map, its `generated_at` in UTC or the file's mtime).
|
|
1692
|
+
`paths` gives metadata.json, provenance-map.json and evidence-report.md,
|
|
1693
|
+
each the versioned path when that file exists, else the flat one
|
|
1694
|
+
(`{skill_group}/metadata.json`, `{forge_group}/<file>`), else null.
|
|
1695
|
+
`versions` lists the manifest's and the on-disk versions newest first,
|
|
1696
|
+
with the manifest status of each; `counts` and `newest_non_deprecated`
|
|
1697
|
+
(the newest manifest version whose status is not "deprecated") read
|
|
1698
|
+
them. Returns None when neither the manifest nor the skills folder
|
|
1699
|
+
knows the skill.
|
|
1700
|
+
"""
|
|
1701
|
+
skills_dir, forge_dir = Path(skills_folder), Path(forge_data_folder)
|
|
1702
|
+
group, forge_group = skills_dir / skill_name, forge_dir / skill_name
|
|
1703
|
+
errors = []
|
|
1704
|
+
manifest, manifest_error = read_json_file(skills_dir / ".export-manifest.json")
|
|
1705
|
+
if manifest_error and manifest_error.startswith("Not found"):
|
|
1706
|
+
manifest_error = None
|
|
1707
|
+
if manifest is not None and not isinstance(manifest, dict):
|
|
1708
|
+
manifest, manifest_error = None, "the export manifest is not a JSON object"
|
|
1709
|
+
exports = manifest.get("exports") if isinstance(manifest, dict) else None
|
|
1710
|
+
entry = exports.get(skill_name) if isinstance(exports, dict) else None
|
|
1711
|
+
manifest_version, last_exported, statuses = _manifest_versions(
|
|
1712
|
+
entry, manifest.get("updated_at") if isinstance(manifest, dict) else None)
|
|
1713
|
+
if manifest_version is not None and not _safe_segment(manifest_version):
|
|
1714
|
+
errors.append(f"the manifest's active_version is not a folder name: {manifest_version!r}")
|
|
1715
|
+
manifest_version = None
|
|
1716
|
+
if not isinstance(entry, dict) and not os.path.lexists(group):
|
|
1717
|
+
return None
|
|
1718
|
+
is_group = group.is_dir()
|
|
1719
|
+
link_version, link_error = _active_link_version(group) if is_group else (None, None)
|
|
1720
|
+
if link_error:
|
|
1721
|
+
errors.append(link_error)
|
|
1722
|
+
on_disk = _disk_versions(group, skill_name) if is_group else []
|
|
1723
|
+
|
|
1724
|
+
def has_package(v):
|
|
1725
|
+
return v is not None and (group / v / skill_name).is_dir()
|
|
1726
|
+
|
|
1727
|
+
listed = sorted(set(statuses) | set(on_disk), key=_version_folder_key, reverse=True)
|
|
1728
|
+
newest_on_disk = next((v for v in listed if v in on_disk), None)
|
|
1729
|
+
if version is not None:
|
|
1730
|
+
chosen, reason = version, "requested"
|
|
1731
|
+
elif has_package(link_version) and link_version == manifest_version:
|
|
1732
|
+
chosen, reason = link_version, "manifest-and-link"
|
|
1733
|
+
elif has_package(link_version) and manifest_version is not None:
|
|
1734
|
+
chosen, reason = link_version, "manifest-lags-link"
|
|
1735
|
+
elif has_package(manifest_version):
|
|
1736
|
+
chosen, reason = manifest_version, "manifest"
|
|
1737
|
+
elif has_package(link_version):
|
|
1738
|
+
chosen, reason = link_version, "link"
|
|
1739
|
+
elif is_group and (group / "SKILL.md").is_file():
|
|
1740
|
+
chosen, reason = None, "flat-layout"
|
|
1741
|
+
elif newest_on_disk is not None:
|
|
1742
|
+
chosen, reason = newest_on_disk, "newest-on-disk"
|
|
1743
|
+
else:
|
|
1744
|
+
chosen, reason = manifest_version or link_version, "missing"
|
|
1745
|
+
|
|
1746
|
+
def candidate(v):
|
|
1747
|
+
if v is None:
|
|
1748
|
+
return None
|
|
1749
|
+
provenance = forge_group / v / "provenance-map.json"
|
|
1750
|
+
generated_at, source = _provenance_time(provenance)
|
|
1751
|
+
return {"version": v, "skill_package_exists": has_package(v),
|
|
1752
|
+
"provenance_map": str(provenance) if provenance.is_file() else None,
|
|
1753
|
+
"generated_at": generated_at, "generated_at_source": source}
|
|
1754
|
+
|
|
1755
|
+
package = group / chosen / skill_name if chosen else None
|
|
1756
|
+
forge_version = forge_group / chosen if chosen else None
|
|
1757
|
+
if reason == "flat-layout":
|
|
1758
|
+
package = group
|
|
1759
|
+
counts = {"total": len(listed), "manifest": len(statuses), "on_disk": len(on_disk),
|
|
1760
|
+
"non_deprecated": sum(1 for s in statuses.values() if s != "deprecated"),
|
|
1761
|
+
"by_status": {}}
|
|
1762
|
+
for status in statuses.values():
|
|
1763
|
+
key = status if status is not None else "unknown"
|
|
1764
|
+
counts["by_status"][key] = counts["by_status"].get(key, 0) + 1
|
|
1765
|
+
return {
|
|
1766
|
+
"name": skill_name,
|
|
1767
|
+
"layout": "flat" if reason == "flat-layout" else ("versioned" if chosen else "none"),
|
|
1768
|
+
"chosen_version": chosen,
|
|
1769
|
+
"reason": reason,
|
|
1770
|
+
"detail": RESOLVE_DETAILS[reason].format(chosen=chosen, manifest=manifest_version),
|
|
1771
|
+
"active_version": manifest_version,
|
|
1772
|
+
"manifest_last_exported": last_exported,
|
|
1773
|
+
"symlink_target": link_version,
|
|
1774
|
+
"candidates": {"manifest": candidate(manifest_version), "symlink": candidate(link_version)},
|
|
1775
|
+
"skill_group": str(group),
|
|
1776
|
+
"skill_package": str(package) if package else None,
|
|
1777
|
+
"skill_package_exists": package is not None and package.is_dir(),
|
|
1778
|
+
"forge_group": str(forge_group),
|
|
1779
|
+
"forge_version": str(forge_version) if forge_version else None,
|
|
1780
|
+
"paths": {
|
|
1781
|
+
"metadata": _artifact(package / "metadata.json" if chosen else None,
|
|
1782
|
+
group / "metadata.json"),
|
|
1783
|
+
"provenance_map": _artifact(forge_version / "provenance-map.json" if chosen else None,
|
|
1784
|
+
forge_group / "provenance-map.json"),
|
|
1785
|
+
"evidence_report": _artifact(forge_version / "evidence-report.md" if chosen else None,
|
|
1786
|
+
forge_group / "evidence-report.md"),
|
|
1787
|
+
},
|
|
1788
|
+
"versions": [{"version": v, "status": statuses.get(v), "in_manifest": v in statuses,
|
|
1789
|
+
"on_disk": v in on_disk} for v in listed],
|
|
1790
|
+
"newest_non_deprecated": next(
|
|
1791
|
+
(v for v in listed if v in statuses and statuses[v] != "deprecated"), None),
|
|
1792
|
+
"newest_on_disk": newest_on_disk,
|
|
1793
|
+
"counts": counts,
|
|
1794
|
+
"manifest_error": manifest_error,
|
|
1795
|
+
"errors": errors,
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
|
|
127
1799
|
def normalize_url(value):
|
|
128
1800
|
"""Normalize a URL / path / source_repo for case-insensitive comparison.
|
|
129
1801
|
|
|
@@ -161,30 +1833,311 @@ def _kebab(segment):
|
|
|
161
1833
|
return s.strip("-")
|
|
162
1834
|
|
|
163
1835
|
|
|
164
|
-
|
|
165
|
-
|
|
1836
|
+
# Hosts step-auto-scope's URL table reads as git hosting; an http(s) URL on
|
|
1837
|
+
# any other host is a documentation URL, named after its host, unless its
|
|
1838
|
+
# path marks a git clone URL (see :func:`_docs_host`). Naming stays offline:
|
|
1839
|
+
# :func:`probe_git` tells a repository's page address among them apart.
|
|
1840
|
+
GIT_HOSTS = ("github.com", "gitlab.com", "bitbucket.org")
|
|
1841
|
+
|
|
1842
|
+
_MAJOR_VERSION_SEGMENT = re.compile(r"v\d+")
|
|
166
1843
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
``
|
|
1844
|
+
|
|
1845
|
+
def _manifest_skill_name(manifest_name):
|
|
1846
|
+
"""The skill name a manifest's package name gives ("" when none).
|
|
1847
|
+
|
|
1848
|
+
``@scope/pkg`` (npm) joins the scope: ``scope-pkg``. ``groupId:artifactId``
|
|
1849
|
+
(Maven) gives the artifact. A path whose first segment is a domain (a Go
|
|
1850
|
+
module) gives its last segment, a ``/vN`` major-version suffix skipped.
|
|
1851
|
+
Anything else (``vendor/package`` from Composer included) is kebab-cased
|
|
1852
|
+
whole.
|
|
172
1853
|
"""
|
|
173
|
-
if not
|
|
1854
|
+
if not manifest_name:
|
|
1855
|
+
return ""
|
|
1856
|
+
s = str(manifest_name).strip()
|
|
1857
|
+
if s.startswith("@"):
|
|
1858
|
+
return _kebab(s)
|
|
1859
|
+
if ":" in s:
|
|
1860
|
+
return _kebab(s.rsplit(":", 1)[1])
|
|
1861
|
+
segments = [seg for seg in s.split("/") if seg]
|
|
1862
|
+
if len(segments) > 1 and "." in segments[0]:
|
|
1863
|
+
if len(segments) > 2 and _MAJOR_VERSION_SEGMENT.fullmatch(segments[-1]):
|
|
1864
|
+
segments = segments[:-1]
|
|
1865
|
+
return _kebab(segments[-1])
|
|
1866
|
+
return _kebab(s)
|
|
1867
|
+
|
|
1868
|
+
|
|
1869
|
+
def _members_skill_name(members):
|
|
1870
|
+
"""The name the members of a merged unit share ("" when none).
|
|
1871
|
+
|
|
1872
|
+
Maven ``group:artifact`` members of one group give the group's last
|
|
1873
|
+
segment (``com.acme:core`` and ``com.acme:api`` give ``acme``); Go module
|
|
1874
|
+
paths their longest common path, domain and one folder at least, less a
|
|
1875
|
+
``/vN`` suffix; any other names the words their skill names start with
|
|
1876
|
+
alike (``@trpc/server`` and ``@trpc/client`` give ``trpc``, ``serde`` and
|
|
1877
|
+
``serde_json`` give ``serde``). Two members at least.
|
|
1878
|
+
"""
|
|
1879
|
+
names = [str(m).strip() for m in members or () if m and str(m).strip()]
|
|
1880
|
+
if len(names) < 2:
|
|
174
1881
|
return ""
|
|
175
|
-
|
|
1882
|
+
if all(":" in n for n in names):
|
|
1883
|
+
groups = {n.rsplit(":", 1)[0] for n in names}
|
|
1884
|
+
return _kebab(groups.pop().split(".")[-1]) if len(groups) == 1 else ""
|
|
1885
|
+
paths = [[seg for seg in n.split("/") if seg] for n in names]
|
|
1886
|
+
if all(len(p) > 1 and "." in p[0] for p in paths):
|
|
1887
|
+
common = os.path.commonprefix(paths)
|
|
1888
|
+
if common and _MAJOR_VERSION_SEGMENT.fullmatch(common[-1]):
|
|
1889
|
+
common = common[:-1]
|
|
1890
|
+
return _kebab(common[-1]) if len(common) > 1 else ""
|
|
1891
|
+
common = os.path.commonprefix([_manifest_skill_name(n).split("-") for n in names])
|
|
1892
|
+
return "-".join(word for word in common if word)
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
def _docs_host(target):
|
|
1896
|
+
"""The host of a documentation URL, or None for any other target.
|
|
1897
|
+
|
|
1898
|
+
A clone URL on any host (its path ends in ``.git``, as
|
|
1899
|
+
``https://git.example.com/team/repo.git`` does, or holds ``/_git/``, as
|
|
1900
|
+
an Azure DevOps ``https://dev.azure.com/org/project/_git/repo`` does)
|
|
1901
|
+
is a repository, not documentation.
|
|
1902
|
+
"""
|
|
1903
|
+
s = str(target).strip()
|
|
1904
|
+
if not s.lower().startswith(("https://", "http://")):
|
|
1905
|
+
return None
|
|
1906
|
+
parts = urlsplit(s)
|
|
1907
|
+
host = (parts.hostname or "").lower()
|
|
1908
|
+
if not host:
|
|
1909
|
+
return None
|
|
1910
|
+
path = parts.path.rstrip("/").lower()
|
|
1911
|
+
if path.endswith(".git") or "/_git/" in path:
|
|
1912
|
+
return None
|
|
1913
|
+
bare = host[4:] if host.startswith("www.") else host
|
|
1914
|
+
return None if bare in GIT_HOSTS else host
|
|
1915
|
+
|
|
1916
|
+
|
|
1917
|
+
def _target_segments(target):
|
|
1918
|
+
"""The path segments of a repository URL or a local path: scheme, a
|
|
1919
|
+
Windows drive, ``.git`` and trailing slashes stripped, ``\\`` read as
|
|
1920
|
+
``/``.
|
|
1921
|
+
"""
|
|
1922
|
+
s = str(target).strip()
|
|
176
1923
|
for scheme in ("https://", "http://"):
|
|
177
|
-
if s.startswith(scheme):
|
|
1924
|
+
if s.lower().startswith(scheme):
|
|
178
1925
|
s = s[len(scheme):]
|
|
179
1926
|
break
|
|
1927
|
+
s = re.sub(r"^[A-Za-z]:(?=[\\/]|$)", "", s.replace("\\", "/"))
|
|
180
1928
|
s = s.rstrip("/")
|
|
181
|
-
if s.endswith(".git"):
|
|
1929
|
+
if s.lower().endswith(".git"):
|
|
182
1930
|
s = s[:-4]
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
1931
|
+
return [seg for seg in s.rstrip("/").split("/") if seg]
|
|
1932
|
+
|
|
1933
|
+
|
|
1934
|
+
def _target_name(target):
|
|
1935
|
+
"""The last folder or repository name of a target ("" when none). A
|
|
1936
|
+
relative or home path that ends in ``.``, ``..`` or ``~`` names the
|
|
1937
|
+
folder it resolves to (``.`` the current one).
|
|
1938
|
+
"""
|
|
1939
|
+
segments = _target_segments(target)
|
|
1940
|
+
last = segments[-1] if segments else ""
|
|
1941
|
+
if last in (".", "..", "~"):
|
|
1942
|
+
local = str(target).strip().replace("\\", "/")
|
|
1943
|
+
return os.path.basename(os.path.abspath(os.path.expanduser(local)))
|
|
1944
|
+
return last
|
|
1945
|
+
|
|
1946
|
+
|
|
1947
|
+
# A URL git reads as given: a scheme (https://, ssh://, git://, file://) or
|
|
1948
|
+
# an SSH address (git@github.com:acme/mono.git).
|
|
1949
|
+
_URL_SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://")
|
|
1950
|
+
_SSH_ADDRESS_RE = re.compile(r"^[\w.-]+@[\w.-]+:")
|
|
1951
|
+
# A Windows path: a drive (C:\code, C:/code, C:), a UNC or rooted path, or .\ and ..\.
|
|
1952
|
+
_WINDOWS_PATH_RE = re.compile(r"^(?:[A-Za-z]:(?:[\\/]|$)|\\|\.{1,2}\\)")
|
|
1953
|
+
|
|
1954
|
+
|
|
1955
|
+
def target_kind(target):
|
|
1956
|
+
"""The kind of an analysis target: "local", "docs" or "remote" (None for none).
|
|
1957
|
+
|
|
1958
|
+
A path that starts with `/`, `./`, `../` or `~` (`.` and `..` too, and a
|
|
1959
|
+
Windows drive, UNC or `.\\` path), or that names an existing folder, is
|
|
1960
|
+
local; a documentation URL (an http(s) URL not on :data:`GIT_HOSTS` and
|
|
1961
|
+
not a clone URL, see :func:`_docs_host`) is docs; anything else is a repository git clones (:func:`clone_url`).
|
|
1962
|
+
"""
|
|
1963
|
+
s = str(target or "").strip()
|
|
1964
|
+
if not s:
|
|
1965
|
+
return None
|
|
1966
|
+
if (s.startswith(("/", "./", "../", "~")) or s in (".", "..") or _WINDOWS_PATH_RE.match(s)
|
|
1967
|
+
or os.path.isdir(os.path.expanduser(s))):
|
|
1968
|
+
return "local"
|
|
1969
|
+
return "docs" if _docs_host(s) else "remote"
|
|
1970
|
+
|
|
1971
|
+
|
|
1972
|
+
def clone_url(target):
|
|
1973
|
+
"""The URL git clones a remote target from, None for a local or docs one.
|
|
1974
|
+
|
|
1975
|
+
A URL with a scheme or an SSH address is used as given. git reads a
|
|
1976
|
+
shorthand or a host path without a scheme as a local folder, so a host
|
|
1977
|
+
path (its first segment holds a `.` or a port, as `github.com/acme/mono`
|
|
1978
|
+
and `gitlab.com/group/repo` do) gets `https://`, and the `owner/repo`
|
|
1979
|
+
shorthand `https://github.com/`.
|
|
1980
|
+
"""
|
|
1981
|
+
if target_kind(target) != "remote":
|
|
1982
|
+
return None
|
|
1983
|
+
s = str(target).strip()
|
|
1984
|
+
if _URL_SCHEME_RE.match(s) or _SSH_ADDRESS_RE.match(s):
|
|
1985
|
+
return s
|
|
1986
|
+
path = s.strip("/")
|
|
1987
|
+
first = path.split("/", 1)[0]
|
|
1988
|
+
return f"https://{path}" if ("." in first or ":" in first) else f"https://github.com/{path}"
|
|
1989
|
+
|
|
1990
|
+
|
|
1991
|
+
# How long `probe_git` waits for git, in seconds.
|
|
1992
|
+
GIT_PROBE_TIMEOUT = 20
|
|
1993
|
+
# What git prints (in the C locale) when the server asks for credentials and
|
|
1994
|
+
# none can be given without a prompt, or refuses the stored ones: a private
|
|
1995
|
+
# repository, or any other page behind a login.
|
|
1996
|
+
_GIT_CREDENTIAL_ERRORS = ("terminal prompts disabled", "Authentication failed")
|
|
1997
|
+
# What a run asks for in place of an address git reads as a repository.
|
|
1998
|
+
_CLONE_REMEDY = "use its `.git` clone URL, its address without https:// or its SSH address"
|
|
1999
|
+
|
|
2000
|
+
|
|
2001
|
+
def probe_git(url):
|
|
2002
|
+
"""git's verdict on ``url``: "repository", "credentials", "unanswered", "no-git" or "docs".
|
|
2003
|
+
|
|
2004
|
+
Runs ``git ls-remote --exit-code --heads <url>``, the URL less its query
|
|
2005
|
+
and fragment (git appends ``/info/refs`` to the address it is given),
|
|
2006
|
+
with every prompt off (no terminal prompt, no askpass program, no
|
|
2007
|
+
credential-manager window), so it never waits on the user; a credential
|
|
2008
|
+
helper's stored credentials still apply. ``--heads`` asks for the branches
|
|
2009
|
+
alone (protocol v2 sends the server the prefix ``refs/heads/``), so a
|
|
2010
|
+
repository with a huge ref list (one ref per Gerrit patch set) answers at
|
|
2011
|
+
once. "repository" when git exits 0, or 2 (a repository that answered
|
|
2012
|
+
with no branch); "credentials" when the server asks for credentials or
|
|
2013
|
+
refuses the stored ones: a private repository answers so, and so does
|
|
2014
|
+
documentation behind a login, so it never says which; "docs" on any
|
|
2015
|
+
other answer (``repository not found`` for a documentation site);
|
|
2016
|
+
"unanswered" when git gives no answer in :data:`GIT_PROBE_TIMEOUT`
|
|
2017
|
+
seconds; "no-git" when git is missing or cannot run. stderr goes to a
|
|
2018
|
+
file, not a pipe, so a timeout returns at once even while git's transport
|
|
2019
|
+
helper still runs.
|
|
2020
|
+
"""
|
|
2021
|
+
address = urlunsplit(urlsplit(str(url).strip())._replace(query="", fragment=""))
|
|
2022
|
+
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "",
|
|
2023
|
+
"GCM_INTERACTIVE": "never", "LC_ALL": "C"}
|
|
2024
|
+
with tempfile.TemporaryFile() as err:
|
|
2025
|
+
try:
|
|
2026
|
+
proc = subprocess.run(["git", "ls-remote", "--exit-code", "--heads", address], env=env,
|
|
2027
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=err,
|
|
2028
|
+
timeout=GIT_PROBE_TIMEOUT)
|
|
2029
|
+
except subprocess.TimeoutExpired:
|
|
2030
|
+
return "unanswered"
|
|
2031
|
+
except OSError:
|
|
2032
|
+
return "no-git"
|
|
2033
|
+
if proc.returncode in (0, 2):
|
|
2034
|
+
return "repository"
|
|
2035
|
+
err.seek(0)
|
|
2036
|
+
message = err.read().decode("utf-8", errors="replace")
|
|
2037
|
+
return "credentials" if any(text in message for text in _GIT_CREDENTIAL_ERRORS) else "docs"
|
|
2038
|
+
|
|
2039
|
+
|
|
2040
|
+
def git_probe_halt(target, verdict):
|
|
2041
|
+
"""The message a run stops with for ``target`` on :func:`probe_git`'s ``verdict``, or None.
|
|
2042
|
+
|
|
2043
|
+
A run stops when git reads the address as a repository, when the server
|
|
2044
|
+
asks for credentials and when git gives no answer; it goes on as
|
|
2045
|
+
documentation on "docs" and on "no-git" (nothing could ask git, and a
|
|
2046
|
+
documentation URL needs no git).
|
|
2047
|
+
"""
|
|
2048
|
+
return {
|
|
2049
|
+
"repository": f"{target} is a git repository: {_CLONE_REMEDY}.",
|
|
2050
|
+
"credentials": f"{target} asks for credentials: SKF reads no documentation behind a login; "
|
|
2051
|
+
f"if it is a private git repository, {_CLONE_REMEDY}.",
|
|
2052
|
+
"unanswered": f"{target} gave git no answer in {GIT_PROBE_TIMEOUT} s: if it is a git repository, "
|
|
2053
|
+
f"{_CLONE_REMEDY}; if it is documentation, give a documentation URL that answers.",
|
|
2054
|
+
}.get(verdict)
|
|
2055
|
+
|
|
2056
|
+
|
|
2057
|
+
def derive_name_with_basis(target, manifest_name=None, private=False, members=None):
|
|
2058
|
+
"""``(name, basis)`` for a unit or brief; see :func:`derive_name`.
|
|
2059
|
+
|
|
2060
|
+
``basis`` is "manifest", "members", "docs-host" or "target", or None
|
|
2061
|
+
when no name can be derived (``name`` is then "").
|
|
2062
|
+
"""
|
|
2063
|
+
name = "" if private else _manifest_skill_name(manifest_name)
|
|
2064
|
+
if name:
|
|
2065
|
+
return name, "manifest"
|
|
2066
|
+
name = _members_skill_name(members)
|
|
2067
|
+
if name:
|
|
2068
|
+
return name, "members"
|
|
2069
|
+
if not target:
|
|
2070
|
+
return "", None
|
|
2071
|
+
host = _docs_host(target)
|
|
2072
|
+
if host:
|
|
2073
|
+
name = _kebab(host)
|
|
2074
|
+
return (name, "docs-host") if name else ("", None)
|
|
2075
|
+
name = _kebab(_target_name(target))
|
|
2076
|
+
return (name, "target") if name else ("", None)
|
|
2077
|
+
|
|
2078
|
+
|
|
2079
|
+
def derive_name(target, manifest_name=None, private=False, members=None):
|
|
2080
|
+
"""The one skill-name rule: the name a unit or brief is written under,
|
|
2081
|
+
and the name coexistence matching compares.
|
|
2082
|
+
|
|
2083
|
+
From ``manifest_name`` when it gives one (see
|
|
2084
|
+
:func:`_manifest_skill_name`) and the package is not ``private`` (a
|
|
2085
|
+
private root, such as a monorepo's workspace manifest, names no
|
|
2086
|
+
published package); else from the ``members`` of a merged unit, the
|
|
2087
|
+
name they share (:func:`_members_skill_name`); else from ``target``: a
|
|
2088
|
+
documentation URL (an http(s) URL not on :data:`GIT_HOSTS` and not a
|
|
2089
|
+
clone URL, see :func:`_docs_host`) gives its host, and any other target its last folder or repository name, scheme,
|
|
2090
|
+
a Windows drive, ``.git`` and trailing slashes stripped (``.``, ``..``
|
|
2091
|
+
and ``~`` name the folder they resolve to). Every case funnels through
|
|
2092
|
+
:func:`_kebab`, so ``github.com/x/bar-baz`` -> ``bar-baz``,
|
|
2093
|
+
``https://docs.example.com/guide/intro`` -> ``docs-example-com``,
|
|
2094
|
+
``C:\\code\\mono`` -> ``mono`` and manifest name ``@trpc/server`` ->
|
|
2095
|
+
``trpc-server``. Returns "" when no name can be derived.
|
|
2096
|
+
"""
|
|
2097
|
+
return derive_name_with_basis(target, manifest_name, private, members)[0]
|
|
2098
|
+
|
|
2099
|
+
|
|
2100
|
+
def _tell_apart(names):
|
|
2101
|
+
"""Rename the batch entries that derive one name: each takes, before
|
|
2102
|
+
it, the fewest folders above its target's last segment that make every
|
|
2103
|
+
name of the group unique (``src/server/api`` and ``src/client/api`` give
|
|
2104
|
+
``server-api`` and ``client-api``), and a renamed entry keeps the name
|
|
2105
|
+
it shared in ``clash``. Returns the index groups that still share a
|
|
2106
|
+
name.
|
|
2107
|
+
"""
|
|
2108
|
+
groups = {}
|
|
2109
|
+
for i, entry in enumerate(names):
|
|
2110
|
+
if entry["name"]:
|
|
2111
|
+
groups.setdefault(entry["name"], []).append(i)
|
|
2112
|
+
for name, members in groups.items():
|
|
2113
|
+
if len(members) < 2:
|
|
2114
|
+
continue
|
|
2115
|
+
parents = {i: _target_segments(names[i]["target"] or "")[:-1] for i in members}
|
|
2116
|
+
for depth in range(1, max(len(p) for p in parents.values()) + 1):
|
|
2117
|
+
renamed = {i: _kebab("-".join(parents[i][-depth:] + [name])) for i in members}
|
|
2118
|
+
if len(set(renamed.values())) == len(members):
|
|
2119
|
+
for i in members:
|
|
2120
|
+
if renamed[i] != name:
|
|
2121
|
+
names[i].update(name=renamed[i], clash=name)
|
|
2122
|
+
break
|
|
2123
|
+
final = {}
|
|
2124
|
+
for i, entry in enumerate(names):
|
|
2125
|
+
if entry["name"]:
|
|
2126
|
+
final.setdefault(entry["name"], []).append(i)
|
|
2127
|
+
return [ids for ids in final.values() if len(ids) > 1]
|
|
2128
|
+
|
|
2129
|
+
|
|
2130
|
+
def _match_entry(entry, reason):
|
|
2131
|
+
"""One ``matches[]`` entry for inventory ``entry``, matched by ``reason``."""
|
|
2132
|
+
meta = entry.get("metadata")
|
|
2133
|
+
return {
|
|
2134
|
+
"name": entry.get("name"),
|
|
2135
|
+
"active_version": entry.get("active_version"),
|
|
2136
|
+
"source_repo": meta.get("source_repo") if meta else None,
|
|
2137
|
+
"active_path": entry.get("active_path"),
|
|
2138
|
+
"match_reason": reason,
|
|
2139
|
+
"skf_skill": bool(entry.get("skf_skill")),
|
|
2140
|
+
}
|
|
188
2141
|
|
|
189
2142
|
|
|
190
2143
|
def compute_matches(skills, target):
|
|
@@ -192,10 +2145,12 @@ def compute_matches(skills, target):
|
|
|
192
2145
|
|
|
193
2146
|
For each inventory skill, a hit fires when either the normalized
|
|
194
2147
|
``metadata.source_repo`` equals the normalized target (URL match) or the
|
|
195
|
-
derived expected name equals the skill's name,
|
|
196
|
-
match). Each match entry is
|
|
197
|
-
``{name, active_version, source_repo, active_path, match_reason}``
|
|
198
|
-
``match_reason`` is ``"url"``, ``"name"``, or ``"both"
|
|
2148
|
+
derived expected name (:func:`derive_name`) equals the skill's name,
|
|
2149
|
+
case-insensitively (name match). Each match entry is
|
|
2150
|
+
``{name, active_version, source_repo, active_path, match_reason, skf_skill}``
|
|
2151
|
+
where ``match_reason`` is ``"url"``, ``"name"``, or ``"both"`` and
|
|
2152
|
+
``skf_skill`` says whether SKF generated the matched skill (only those can
|
|
2153
|
+
be merged into through update-skill).
|
|
199
2154
|
"""
|
|
200
2155
|
norm_target = normalize_url(target)
|
|
201
2156
|
derived = derive_name(target)
|
|
@@ -222,19 +2177,55 @@ def compute_matches(skills, target):
|
|
|
222
2177
|
reason = "url"
|
|
223
2178
|
else:
|
|
224
2179
|
reason = "name"
|
|
225
|
-
matches.append(
|
|
226
|
-
"name": entry.get("name"),
|
|
227
|
-
"active_version": entry.get("active_version"),
|
|
228
|
-
"source_repo": source_repo,
|
|
229
|
-
"active_path": entry.get("active_path"),
|
|
230
|
-
"match_reason": reason,
|
|
231
|
-
})
|
|
2180
|
+
matches.append(_match_entry(entry, reason))
|
|
232
2181
|
return matches
|
|
233
2182
|
|
|
234
2183
|
|
|
235
|
-
def
|
|
2184
|
+
def existing_by_name(skills_folder):
|
|
2185
|
+
"""Lowercased skill name -> its ``matches[]`` entry (``match_reason``
|
|
2186
|
+
"name") for every skill in ``skills_folder``; empty when the folder does
|
|
2187
|
+
not exist.
|
|
2188
|
+
"""
|
|
2189
|
+
inventory = scan_inventory(skills_folder)
|
|
2190
|
+
if inventory.get("status") != "ok":
|
|
2191
|
+
return {}
|
|
2192
|
+
return {str(entry.get("name") or "").strip().lower(): _match_entry(entry, "name")
|
|
2193
|
+
for entry in inventory["skills"]}
|
|
2194
|
+
|
|
2195
|
+
|
|
2196
|
+
# A name at least this similar to the name asked for is a near match (the
|
|
2197
|
+
# default cutoff of difflib.get_close_matches).
|
|
2198
|
+
NEAR_CUTOFF = 0.6
|
|
2199
|
+
|
|
2200
|
+
|
|
2201
|
+
def near_skills(skills, query):
|
|
2202
|
+
"""The skills SKF generated that have a SKILL.md, closest to `query` first.
|
|
2203
|
+
|
|
2204
|
+
>>> skills = [{"name": n, "skf_skill": True, "has_skill_md": True, "active_version": "1.0.0"}
|
|
2205
|
+
... for n in ("zod", "cocoindex-code", "cocoindx")]
|
|
2206
|
+
>>> [(s["name"], s["close"]) for s in near_skills(skills, "cocoindex")]
|
|
2207
|
+
[('cocoindx', True), ('cocoindex-code', True), ('zod', False)]
|
|
2208
|
+
"""
|
|
2209
|
+
wanted = (query or "").strip().lower()
|
|
2210
|
+
ranked = []
|
|
2211
|
+
for entry in skills:
|
|
2212
|
+
if not (entry.get("skf_skill") and entry.get("has_skill_md")):
|
|
2213
|
+
continue
|
|
2214
|
+
name = str(entry.get("name") or "")
|
|
2215
|
+
lowered = name.lower()
|
|
2216
|
+
ratio = difflib.SequenceMatcher(None, wanted, lowered).ratio()
|
|
2217
|
+
close = bool(wanted) and (wanted in lowered or lowered in wanted or ratio >= NEAR_CUTOFF)
|
|
2218
|
+
ranked.append(((not close, -ratio, name),
|
|
2219
|
+
{"name": name, "active_version": entry.get("active_version"), "close": close}))
|
|
2220
|
+
return [item for _, item in sorted(ranked, key=lambda pair: pair[0])]
|
|
2221
|
+
|
|
2222
|
+
|
|
2223
|
+
def scan_inventory(skills_folder, skill_filter=None, manifest_only=False, match_target=None,
|
|
2224
|
+
forge_data_folder=None, near=None):
|
|
236
2225
|
"""Scan the skills output folder and produce an inventory."""
|
|
237
2226
|
skills_dir = Path(skills_folder)
|
|
2227
|
+
forge_dir = Path(forge_data_folder) if forge_data_folder else None
|
|
2228
|
+
same_folder = _same_folder(skills_dir, forge_dir)
|
|
238
2229
|
|
|
239
2230
|
if not skills_dir.is_dir():
|
|
240
2231
|
return {
|
|
@@ -272,8 +2263,9 @@ def scan_inventory(skills_folder, skill_filter=None, manifest_only=False, match_
|
|
|
272
2263
|
skill_names = set()
|
|
273
2264
|
|
|
274
2265
|
# From manifest exports
|
|
275
|
-
|
|
276
|
-
|
|
2266
|
+
exports = manifest.get("exports") if isinstance(manifest, dict) else None
|
|
2267
|
+
if isinstance(exports, dict):
|
|
2268
|
+
skill_names.update(exports.keys())
|
|
277
2269
|
|
|
278
2270
|
# From directory listing
|
|
279
2271
|
for child in skills_dir.iterdir():
|
|
@@ -293,11 +2285,22 @@ def scan_inventory(skills_folder, skill_filter=None, manifest_only=False, match_
|
|
|
293
2285
|
}
|
|
294
2286
|
|
|
295
2287
|
# Scan each skill group
|
|
2288
|
+
not_skf_output = []
|
|
296
2289
|
for name in sorted(skill_names):
|
|
297
2290
|
skill_group_dir = skills_dir / name
|
|
298
2291
|
if skill_group_dir.is_dir():
|
|
299
|
-
entry = scan_skill_group(skill_group_dir, name)
|
|
2292
|
+
entry = scan_skill_group(skill_group_dir, name, same_folder)
|
|
300
2293
|
result["skills"].append(entry)
|
|
2294
|
+
if (not entry["skf_skill"] and name != "_batch" and ".skf-" not in name
|
|
2295
|
+
and _looks_like_skill(skill_group_dir, name)):
|
|
2296
|
+
not_skf_output.append(name)
|
|
2297
|
+
result["not_skf_output"] = not_skf_output
|
|
2298
|
+
# Forge folders (opt-in via --forge-data-folder; additive top-level keys).
|
|
2299
|
+
if forge_dir is not None:
|
|
2300
|
+
result["forge_data_folder"] = str(forge_dir)
|
|
2301
|
+
result["same_folder"] = same_folder
|
|
2302
|
+
result["forge_groups"] = [] if same_folder else [
|
|
2303
|
+
classify_forge_group(forge_dir / name, name) for name in sorted(skill_names)]
|
|
301
2304
|
|
|
302
2305
|
# Compute summary
|
|
303
2306
|
result["summary"]["total_skills"] = len(result["skills"])
|
|
@@ -305,42 +2308,362 @@ def scan_inventory(skills_folder, skill_filter=None, manifest_only=False, match_
|
|
|
305
2308
|
result["summary"]["with_metadata"] = sum(1 for s in result["skills"] if s["metadata"])
|
|
306
2309
|
result["summary"]["with_provenance"] = sum(1 for s in result["skills"] if s["has_provenance_map"])
|
|
307
2310
|
|
|
308
|
-
# Coexistence matching (opt-in via --match-target; additive top-level
|
|
2311
|
+
# Coexistence matching (opt-in via --match-target; additive top-level keys).
|
|
309
2312
|
if match_target is not None:
|
|
2313
|
+
result["match_name"] = derive_name(match_target)
|
|
310
2314
|
result["matches"] = compute_matches(result["skills"], match_target)
|
|
2315
|
+
# Near names (opt-in via --near; additive top-level key).
|
|
2316
|
+
if near is not None:
|
|
2317
|
+
result["near"] = near_skills(result["skills"], near)
|
|
311
2318
|
|
|
312
2319
|
return result
|
|
313
2320
|
|
|
314
2321
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
2322
|
+
USAGE = ("Usage: uv run skf-skill-inventory.py <skills-output-folder> "
|
|
2323
|
+
"[--skill <name>] [--manifest-only] [--match-target <url-or-name>] "
|
|
2324
|
+
"[--near <name>] [--forge-data-folder <path>]\n"
|
|
2325
|
+
" uv run skf-skill-inventory.py derive-name --target <url-or-path> "
|
|
2326
|
+
"[--manifest-name <name>] [--skills-folder <skills-output-folder>] [--probe-git]\n"
|
|
2327
|
+
" uv run skf-skill-inventory.py derive-name --from <names.json|-> "
|
|
2328
|
+
"[--skills-folder <skills-output-folder>]\n"
|
|
2329
|
+
" uv run skf-skill-inventory.py <skills-output-folder> --skill <name> "
|
|
2330
|
+
"--write-check [--write-version <version>] [--forge-data-folder <path>]\n"
|
|
2331
|
+
" uv run skf-skill-inventory.py <skills-output-folder> --skill <name> "
|
|
2332
|
+
"--purge-check [--purge-version <version>] --forge-data-folder <path>\n"
|
|
2333
|
+
" uv run skf-skill-inventory.py <skills-output-folder> --skill <name> "
|
|
2334
|
+
"--rename-check --forge-data-folder <path>\n"
|
|
2335
|
+
" uv run skf-skill-inventory.py guarded-delete --root <folder> [--root <folder>] "
|
|
2336
|
+
"[<path>...]\n"
|
|
2337
|
+
" uv run skf-skill-inventory.py resolve <skills-output-folder> --skill <name> "
|
|
2338
|
+
"--forge-data-folder <path> [--version <version>]\n"
|
|
2339
|
+
" uv run skf-skill-inventory.py version normalize <version>\n"
|
|
2340
|
+
" uv run skf-skill-inventory.py version order <a> <b>\n"
|
|
2341
|
+
" uv run skf-skill-inventory.py version next-patch <version>\n"
|
|
2342
|
+
" uv run skf-skill-inventory.py version bump --prior <version> "
|
|
2343
|
+
"--prior-libraries <a,b> --libraries <a,c>\n"
|
|
2344
|
+
" uv run skf-skill-inventory.py version primary <candidates.json|->")
|
|
2345
|
+
|
|
2346
|
+
|
|
2347
|
+
def _flag_value(argv, flag, required=False):
|
|
2348
|
+
"""The value after `flag`, or None when the flag is absent.
|
|
2349
|
+
|
|
2350
|
+
A flag without a value reads as absent, as it always has, unless
|
|
2351
|
+
`required`: `--forge-data-folder`, `--write-version` and, in write-check
|
|
2352
|
+
mode, `--skill` raise ValueError instead (a missing value, or the next
|
|
2353
|
+
flag in its place), so a caller never gets a verdict it did not ask for.
|
|
2354
|
+
"""
|
|
2355
|
+
if flag not in argv:
|
|
2356
|
+
return None
|
|
2357
|
+
idx = argv.index(flag)
|
|
2358
|
+
if idx + 1 < len(argv) and not (required and argv[idx + 1].startswith("--")):
|
|
2359
|
+
return argv[idx + 1]
|
|
2360
|
+
if required:
|
|
2361
|
+
raise ValueError(f"{flag} needs a value")
|
|
2362
|
+
return None
|
|
2363
|
+
|
|
2364
|
+
|
|
2365
|
+
def _safe_segment(value):
|
|
2366
|
+
"""True for a single path segment: not empty, `.` or `..`, and no separator."""
|
|
2367
|
+
return bool(value) and value not in (".", "..") and "/" not in value and "\\" not in value
|
|
2368
|
+
|
|
2369
|
+
|
|
2370
|
+
def _usage_error(message):
|
|
2371
|
+
print(json.dumps({"status": "error", "error": message, "code": "USAGE"}, indent=2))
|
|
2372
|
+
print(USAGE, file=sys.stderr)
|
|
2373
|
+
return 1
|
|
2374
|
+
|
|
2375
|
+
|
|
2376
|
+
def _check_flag_pairs(args, flags):
|
|
2377
|
+
"""ValueError for an argument that is not one of `flags` followed by its value."""
|
|
2378
|
+
for i in range(0, len(args), 2):
|
|
2379
|
+
if args[i] not in flags:
|
|
2380
|
+
raise ValueError(f"unexpected argument: {args[i]!r}")
|
|
2381
|
+
|
|
2382
|
+
|
|
2383
|
+
def _main_resolve(argv):
|
|
2384
|
+
"""`resolve <skills-output-folder> --skill <name> --forge-data-folder <path> [--version <v>]`."""
|
|
2385
|
+
try:
|
|
2386
|
+
if not argv or argv[0].startswith("--"):
|
|
2387
|
+
raise ValueError("resolve needs <skills-output-folder> first")
|
|
2388
|
+
skill = _flag_value(argv, "--skill", required=True)
|
|
2389
|
+
forge_data_folder = _flag_value(argv, "--forge-data-folder", required=True)
|
|
2390
|
+
version = _flag_value(argv, "--version", required=True)
|
|
2391
|
+
_check_flag_pairs(argv[1:], ("--skill", "--forge-data-folder", "--version"))
|
|
2392
|
+
if not skill:
|
|
2393
|
+
raise ValueError("resolve needs --skill <name>")
|
|
2394
|
+
if not forge_data_folder:
|
|
2395
|
+
raise ValueError("resolve needs --forge-data-folder <path>")
|
|
2396
|
+
if not _safe_segment(skill):
|
|
2397
|
+
raise ValueError(f"--skill must be one folder name: {skill!r}")
|
|
2398
|
+
if version is not None and not _safe_segment(version):
|
|
2399
|
+
raise ValueError(f"--version must be one folder name: {version!r}")
|
|
2400
|
+
except ValueError as e:
|
|
2401
|
+
return _usage_error(str(e))
|
|
2402
|
+
skills_dir = Path(argv[0])
|
|
2403
|
+
if not skills_dir.is_dir():
|
|
2404
|
+
result = {"status": "error", "error": f"Skills directory not found: {skills_dir}",
|
|
2405
|
+
"code": "DIR_NOT_FOUND"}
|
|
2406
|
+
else:
|
|
2407
|
+
resolved = resolve_skill(skills_dir, skill, forge_data_folder, version)
|
|
2408
|
+
if resolved is None:
|
|
2409
|
+
result = {"status": "error", "code": "SKILL_NOT_FOUND",
|
|
2410
|
+
"error": f"Skill '{skill}' is neither in {skills_dir} nor in its export manifest"}
|
|
2411
|
+
else:
|
|
2412
|
+
result = {"status": "ok", "skills_folder": str(skills_dir),
|
|
2413
|
+
"forge_data_folder": forge_data_folder, "resolve": resolved}
|
|
2414
|
+
print(json.dumps(result, indent=2))
|
|
2415
|
+
return 0 if result["status"] == "ok" else 1
|
|
2416
|
+
|
|
2417
|
+
|
|
2418
|
+
def _library_list(value):
|
|
2419
|
+
return [name.strip() for name in value.split(",") if name.strip()]
|
|
2420
|
+
|
|
2421
|
+
|
|
2422
|
+
def _read_candidates(source):
|
|
2423
|
+
"""The candidate list of `version primary`, from a JSON file or `-` (stdin)."""
|
|
2424
|
+
try:
|
|
2425
|
+
text = sys.stdin.read() if source == "-" else Path(source).read_text(encoding="utf-8")
|
|
2426
|
+
data = json.loads(text)
|
|
2427
|
+
except (OSError, ValueError) as e:
|
|
2428
|
+
raise ValueError(f"cannot read candidates from {source}: {e}") from e
|
|
2429
|
+
if not isinstance(data, list):
|
|
2430
|
+
raise ValueError("candidates must be a JSON array of {name, import_count, version}")
|
|
2431
|
+
return data
|
|
2432
|
+
|
|
2433
|
+
|
|
2434
|
+
def _main_version(argv):
|
|
2435
|
+
"""`version normalize|order|next-patch|bump|primary ...`; exit 1 with
|
|
2436
|
+
`NOT_A_VERSION`, `NOT_INCREASING`, `BAD_INPUT` or `USAGE` on an error."""
|
|
2437
|
+
command, args = (argv[0], argv[1:]) if argv else (None, [])
|
|
2438
|
+
code = "NOT_A_VERSION"
|
|
2439
|
+
try:
|
|
2440
|
+
if command == "normalize" and len(args) == 1:
|
|
2441
|
+
result = normalize_version(args[0])
|
|
2442
|
+
if result["normalized"] is None:
|
|
2443
|
+
print(json.dumps({"status": "error", "error": result["reason"],
|
|
2444
|
+
"code": "NOT_A_VERSION", "command": command, **result}, indent=2))
|
|
2445
|
+
return 1
|
|
2446
|
+
elif command == "order" and len(args) == 2:
|
|
2447
|
+
result = order_versions(args[0], args[1])
|
|
2448
|
+
elif command == "next-patch" and len(args) == 1:
|
|
2449
|
+
result = next_patch(args[0])
|
|
2450
|
+
elif command == "bump":
|
|
2451
|
+
try:
|
|
2452
|
+
prior = _flag_value(args, "--prior", required=True)
|
|
2453
|
+
prior_libraries = _flag_value(args, "--prior-libraries", required=True)
|
|
2454
|
+
libraries = _flag_value(args, "--libraries", required=True)
|
|
2455
|
+
_check_flag_pairs(args, ("--prior", "--prior-libraries", "--libraries"))
|
|
2456
|
+
if prior is None or prior_libraries is None or libraries is None:
|
|
2457
|
+
raise ValueError("bump needs --prior, --prior-libraries and --libraries")
|
|
2458
|
+
except ValueError as e:
|
|
2459
|
+
return _usage_error(str(e))
|
|
2460
|
+
result = compose_bump(prior, _library_list(prior_libraries), _library_list(libraries))
|
|
2461
|
+
elif command == "primary" and len(args) == 1:
|
|
2462
|
+
code = "BAD_INPUT"
|
|
2463
|
+
result = primary_library(_read_candidates(args[0]))
|
|
2464
|
+
else:
|
|
2465
|
+
return _usage_error(f"unknown version command or wrong arguments: {' '.join(argv)!r}")
|
|
2466
|
+
except NotIncreasingError as e:
|
|
2467
|
+
print(json.dumps({"status": "error", "error": str(e), "code": "NOT_INCREASING"}, indent=2))
|
|
2468
|
+
return 1
|
|
2469
|
+
except ValueError as e:
|
|
2470
|
+
print(json.dumps({"status": "error", "error": str(e), "code": code}, indent=2))
|
|
2471
|
+
return 1
|
|
2472
|
+
print(json.dumps({"status": "ok", "command": command, **result}, indent=2))
|
|
2473
|
+
return 0
|
|
2474
|
+
|
|
2475
|
+
|
|
2476
|
+
def _read_name_entries(source):
|
|
2477
|
+
"""The `derive-name --from` entries, from a JSON file or `-` (stdin)."""
|
|
2478
|
+
try:
|
|
2479
|
+
text = sys.stdin.read() if source == "-" else Path(source).read_text(encoding="utf-8")
|
|
2480
|
+
data = json.loads(text)
|
|
2481
|
+
except (OSError, ValueError) as e:
|
|
2482
|
+
raise ValueError(f"cannot read names from {source}: {e}") from e
|
|
2483
|
+
if not isinstance(data, list):
|
|
2484
|
+
raise ValueError('--from must be a JSON array of {"target", "manifest_name", '
|
|
2485
|
+
'"private", "members"}')
|
|
2486
|
+
entries = []
|
|
2487
|
+
for i, raw in enumerate(data):
|
|
2488
|
+
if not isinstance(raw, dict):
|
|
2489
|
+
raise ValueError(f"entry {i} is not an object")
|
|
2490
|
+
target, manifest_name = raw.get("target"), raw.get("manifest_name")
|
|
2491
|
+
for key, value in (("target", target), ("manifest_name", manifest_name)):
|
|
2492
|
+
if value is not None and not isinstance(value, str):
|
|
2493
|
+
raise ValueError(f"entry {i} `{key}` must be a string or null")
|
|
2494
|
+
private, members = raw.get("private"), raw.get("members")
|
|
2495
|
+
if private is not None and not isinstance(private, bool):
|
|
2496
|
+
raise ValueError(f"entry {i} `private` must be true, false or null")
|
|
2497
|
+
if members is not None and not (
|
|
2498
|
+
isinstance(members, list) and all(isinstance(m, str) for m in members)):
|
|
2499
|
+
raise ValueError(f"entry {i} `members` must be a list of names or null")
|
|
2500
|
+
entries.append({"target": target, "manifest_name": manifest_name,
|
|
2501
|
+
"private": bool(private), "members": members or []})
|
|
2502
|
+
return entries
|
|
2503
|
+
|
|
2504
|
+
|
|
2505
|
+
def _main_derive_name(argv):
|
|
2506
|
+
"""`derive-name --target <t> [--manifest-name <n>] [--skills-folder <f>] [--probe-git]` or
|
|
2507
|
+
`derive-name --from <file|-> [--skills-folder <f>]`; exit 1 with `USAGE`,
|
|
2508
|
+
`BAD_INPUT` or (for one --target) `NO_NAME` on an error."""
|
|
2509
|
+
flags = ("--target", "--manifest-name", "--from", "--skills-folder")
|
|
2510
|
+
probe = "--probe-git" in argv
|
|
2511
|
+
argv = [arg for arg in argv if arg != "--probe-git"]
|
|
2512
|
+
try:
|
|
2513
|
+
target, manifest_name, source, skills_folder = (
|
|
2514
|
+
_flag_value(argv, flag, required=True) for flag in flags)
|
|
2515
|
+
_check_flag_pairs(argv, flags)
|
|
2516
|
+
if source is not None and (target is not None or manifest_name is not None):
|
|
2517
|
+
raise ValueError("--from takes no --target or --manifest-name")
|
|
2518
|
+
if source is None and target is None and manifest_name is None:
|
|
2519
|
+
raise ValueError("derive-name needs --target <url-or-path> or --from <names.json|->")
|
|
2520
|
+
if probe and source is not None:
|
|
2521
|
+
raise ValueError("--probe-git takes one --target, not --from")
|
|
2522
|
+
except ValueError as e:
|
|
2523
|
+
return _usage_error(str(e))
|
|
2524
|
+
existing = existing_by_name(skills_folder) if skills_folder is not None else None
|
|
2525
|
+
|
|
2526
|
+
def named(entry):
|
|
2527
|
+
name, basis = derive_name_with_basis(entry["target"], entry["manifest_name"],
|
|
2528
|
+
entry.get("private", False), entry.get("members"))
|
|
2529
|
+
return {"target": entry["target"], "manifest_name": entry["manifest_name"],
|
|
2530
|
+
"name": name or None, "basis": basis}
|
|
2531
|
+
|
|
2532
|
+
def with_existing(out):
|
|
2533
|
+
if existing is not None:
|
|
2534
|
+
out["existing"] = existing.get(out["name"]) if out["name"] else None
|
|
2535
|
+
return out
|
|
2536
|
+
|
|
2537
|
+
if source is None:
|
|
2538
|
+
result = named({"target": target, "manifest_name": manifest_name})
|
|
2539
|
+
result.update(kind=target_kind(target), clone_url=clone_url(target))
|
|
2540
|
+
if not result["name"]:
|
|
2541
|
+
print(json.dumps({"status": "error", "code": "NO_NAME", "command": "derive-name",
|
|
2542
|
+
"error": "no skill name can be derived from the target or manifest name"},
|
|
2543
|
+
indent=2))
|
|
2544
|
+
return 1
|
|
2545
|
+
del result["target"], result["manifest_name"]
|
|
2546
|
+
if probe:
|
|
2547
|
+
verdict = probe_git(target) if result["kind"] == "docs" else None
|
|
2548
|
+
result.update(git_probe=verdict, halt_message=git_probe_halt(target, verdict))
|
|
2549
|
+
print(json.dumps({"status": "ok", "command": "derive-name", **with_existing(result)}, indent=2))
|
|
2550
|
+
return 0
|
|
2551
|
+
try:
|
|
2552
|
+
entries = _read_name_entries(source)
|
|
2553
|
+
except ValueError as e:
|
|
2554
|
+
print(json.dumps({"status": "error", "code": "BAD_INPUT", "command": "derive-name",
|
|
2555
|
+
"error": str(e)}, indent=2))
|
|
2556
|
+
return 1
|
|
2557
|
+
names = [named(entry) for entry in entries]
|
|
2558
|
+
duplicates = _tell_apart(names)
|
|
2559
|
+
names = [with_existing(entry) for entry in names]
|
|
2560
|
+
print(json.dumps({"status": "ok", "command": "derive-name", "names": names,
|
|
2561
|
+
"unnamed": [i for i, entry in enumerate(names) if not entry["name"]],
|
|
2562
|
+
"duplicates": duplicates}, indent=2))
|
|
2563
|
+
return 0
|
|
2564
|
+
|
|
2565
|
+
|
|
2566
|
+
def _main_guarded_delete(argv):
|
|
2567
|
+
"""`guarded-delete --root <folder> [--root <folder>]... [<path>...]`; exit 0 whatever
|
|
2568
|
+
`purge_status` says (no path deletes nothing), exit 1 with `USAGE` on an error."""
|
|
2569
|
+
roots, paths = [], []
|
|
2570
|
+
try:
|
|
2571
|
+
i = 0
|
|
2572
|
+
while i < len(argv):
|
|
2573
|
+
if argv[i] == "--root":
|
|
2574
|
+
if i + 1 >= len(argv) or not argv[i + 1] or argv[i + 1].startswith("--"):
|
|
2575
|
+
raise ValueError("--root needs a folder")
|
|
2576
|
+
roots.append(argv[i + 1])
|
|
2577
|
+
i += 2
|
|
2578
|
+
continue
|
|
2579
|
+
if argv[i].startswith("--"):
|
|
2580
|
+
raise ValueError(f"unexpected argument: {argv[i]!r}")
|
|
2581
|
+
paths.append(argv[i])
|
|
2582
|
+
i += 1
|
|
2583
|
+
if not roots:
|
|
2584
|
+
raise ValueError("guarded-delete needs --root <folder>")
|
|
2585
|
+
except ValueError as e:
|
|
2586
|
+
return _usage_error(str(e))
|
|
2587
|
+
print(json.dumps({"status": "ok", "command": "guarded-delete", **guarded_delete(roots, paths)}, indent=2))
|
|
2588
|
+
return 0
|
|
2589
|
+
|
|
2590
|
+
|
|
2591
|
+
# The verdict flags: at most one per call, each with --skill <name>.
|
|
2592
|
+
CHECK_FLAGS = ("--write-check", "--purge-check", "--rename-check")
|
|
2593
|
+
CHECK_KEYS = {"--write-check": "write_check", "--purge-check": "purge_check", "--rename-check": "rename_check"}
|
|
2594
|
+
|
|
2595
|
+
|
|
2596
|
+
def main(argv):
|
|
2597
|
+
if len(argv) < 1:
|
|
2598
|
+
print(USAGE, file=sys.stderr)
|
|
2599
|
+
return 1
|
|
2600
|
+
if argv[0] == "resolve":
|
|
2601
|
+
return _main_resolve(argv[1:])
|
|
2602
|
+
if argv[0] == "version":
|
|
2603
|
+
return _main_version(argv[1:])
|
|
2604
|
+
if argv[0] == "guarded-delete":
|
|
2605
|
+
return _main_guarded_delete(argv[1:])
|
|
2606
|
+
if argv[0] == "derive-name":
|
|
2607
|
+
return _main_derive_name(argv[1:])
|
|
2608
|
+
folder = argv[0]
|
|
2609
|
+
checks = [flag for flag in CHECK_FLAGS if flag in argv]
|
|
2610
|
+
check = checks[0] if checks else None
|
|
2611
|
+
try:
|
|
2612
|
+
if len(checks) > 1:
|
|
2613
|
+
raise ValueError("pass one of " + ", ".join(CHECK_FLAGS) + ", not several")
|
|
2614
|
+
skill = _flag_value(argv, "--skill", required=check is not None)
|
|
2615
|
+
match_target = _flag_value(argv, "--match-target")
|
|
2616
|
+
near = _flag_value(argv, "--near", required=True)
|
|
2617
|
+
forge_data_folder = _flag_value(argv, "--forge-data-folder", required=True)
|
|
2618
|
+
versions = {flag: _flag_value(argv, flag, required=True)
|
|
2619
|
+
for flag in ("--write-version", "--purge-version")}
|
|
2620
|
+
if check and folder.startswith("--"):
|
|
2621
|
+
raise ValueError(f"{check} needs <skills-output-folder> first")
|
|
2622
|
+
if check and not skill:
|
|
2623
|
+
raise ValueError(f"{check} needs --skill <name>")
|
|
2624
|
+
for flag, needs in (("--write-version", "--write-check"), ("--purge-version", "--purge-check")):
|
|
2625
|
+
if versions[flag] is not None and check != needs:
|
|
2626
|
+
raise ValueError(f"{flag} needs {needs}")
|
|
2627
|
+
if check in ("--purge-check", "--rename-check") and not forge_data_folder:
|
|
2628
|
+
raise ValueError(f"{check} needs --forge-data-folder <path>")
|
|
2629
|
+
if check and not _safe_segment(skill):
|
|
2630
|
+
raise ValueError(f"--skill must be one folder name: {skill!r}")
|
|
2631
|
+
for flag, value in versions.items():
|
|
2632
|
+
if value is not None and not _safe_segment(value):
|
|
2633
|
+
raise ValueError(f"{flag} must be one folder name: {value!r}")
|
|
2634
|
+
except ValueError as e:
|
|
2635
|
+
print(json.dumps({"status": "error", "error": str(e), "code": "USAGE"}, indent=2))
|
|
2636
|
+
print(USAGE, file=sys.stderr)
|
|
2637
|
+
return 1
|
|
2638
|
+
if check:
|
|
2639
|
+
skills_dir = Path(folder)
|
|
2640
|
+
if os.path.lexists(skills_dir) and not skills_dir.is_dir():
|
|
2641
|
+
result = {"status": "error", "error": f"Skills directory not found: {skills_dir}",
|
|
2642
|
+
"code": "DIR_NOT_FOUND"}
|
|
2643
|
+
else:
|
|
2644
|
+
forge_dir = Path(forge_data_folder) if forge_data_folder else None
|
|
2645
|
+
if check == "--write-check":
|
|
2646
|
+
verdict = write_check(skills_dir, skill, versions["--write-version"], forge_data_folder)
|
|
2647
|
+
elif check == "--purge-check":
|
|
2648
|
+
verdict = purge_check(skills_dir, skill, forge_data_folder, versions["--purge-version"])
|
|
2649
|
+
else:
|
|
2650
|
+
verdict = rename_check(skills_dir, skill, forge_data_folder)
|
|
2651
|
+
result = {"status": "ok", "skills_folder": str(skills_dir),
|
|
2652
|
+
"forge_data_folder": forge_data_folder,
|
|
2653
|
+
"same_folder": _same_folder(skills_dir, forge_dir),
|
|
2654
|
+
CHECK_KEYS[check]: verdict}
|
|
2655
|
+
else:
|
|
2656
|
+
result = scan_inventory(
|
|
2657
|
+
folder,
|
|
2658
|
+
skill_filter=skill,
|
|
2659
|
+
manifest_only="--manifest-only" in argv,
|
|
2660
|
+
match_target=match_target,
|
|
2661
|
+
forge_data_folder=forge_data_folder,
|
|
2662
|
+
near=near,
|
|
321
2663
|
)
|
|
322
|
-
sys.exit(1)
|
|
323
|
-
|
|
324
|
-
folder = sys.argv[1]
|
|
325
|
-
skill = None
|
|
326
|
-
manifest_only = "--manifest-only" in sys.argv
|
|
327
|
-
match_target = None
|
|
328
|
-
|
|
329
|
-
if "--skill" in sys.argv:
|
|
330
|
-
idx = sys.argv.index("--skill")
|
|
331
|
-
if idx + 1 < len(sys.argv):
|
|
332
|
-
skill = sys.argv[idx + 1]
|
|
333
|
-
|
|
334
|
-
if "--match-target" in sys.argv:
|
|
335
|
-
idx = sys.argv.index("--match-target")
|
|
336
|
-
if idx + 1 < len(sys.argv):
|
|
337
|
-
match_target = sys.argv[idx + 1]
|
|
338
|
-
|
|
339
|
-
result = scan_inventory(
|
|
340
|
-
folder,
|
|
341
|
-
skill_filter=skill,
|
|
342
|
-
manifest_only=manifest_only,
|
|
343
|
-
match_target=match_target,
|
|
344
|
-
)
|
|
345
2664
|
print(json.dumps(result, indent=2))
|
|
346
|
-
|
|
2665
|
+
return 0 if result["status"] == "ok" else 1
|
|
2666
|
+
|
|
2667
|
+
|
|
2668
|
+
if __name__ == "__main__":
|
|
2669
|
+
sys.exit(main(sys.argv[1:]))
|