bmad-module-skill-forge 2.2.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +49 -49
- package/docs/404.md +10 -8
- package/docs/_data/pinned.yaml +22 -13
- package/docs/_internal/STABILITY.md +58 -35
- package/docs/agents.md +39 -53
- package/docs/architecture.md +65 -51
- package/docs/bmad-synergy.md +53 -51
- package/docs/campaign.md +127 -75
- package/docs/concepts.md +34 -28
- package/docs/examples.md +70 -68
- package/docs/forge-auto.md +34 -31
- package/docs/getting-started.md +118 -57
- package/docs/how-it-works.md +25 -21
- package/docs/index.md +15 -15
- package/docs/skill-model.md +70 -59
- package/docs/troubleshooting.md +135 -18
- package/docs/verifying-a-skill.md +83 -57
- package/docs/why-skf.md +24 -22
- package/docs/workflows.md +274 -110
- package/package.json +18 -10
- package/src/forger/preferences.yaml +8 -1
- package/src/knowledge/agentskills-spec.md +2 -0
- package/src/knowledge/ccc-bridge.md +40 -24
- package/src/knowledge/confidence-tiers.md +9 -5
- package/src/knowledge/overview.md +3 -3
- package/src/knowledge/progressive-capability.md +8 -8
- package/src/knowledge/provenance-tracking.md +9 -5
- package/src/knowledge/qmd-registry.md +2 -2
- package/src/knowledge/skf-knowledge-index.csv +2 -2
- package/src/knowledge/skill-lifecycle.md +18 -11
- package/src/knowledge/split-body-strategy.md +0 -3
- package/src/knowledge/tool-resolution.md +4 -2
- package/src/knowledge/version-paths.md +101 -15
- package/src/module-help.csv +3 -3
- package/src/module.yaml +10 -0
- package/src/shared/data/ast-grep-recipes.yaml +608 -0
- package/src/shared/data/ide-context-files.json +30 -0
- package/src/shared/health-check.md +32 -12
- package/src/shared/references/description-guard-protocol.md +12 -9
- package/src/shared/references/feasibility-report-schema.md +19 -13
- package/src/shared/references/headless-gate-convention.md +55 -3
- package/src/shared/references/output-contract-schema.md +19 -8
- package/src/shared/references/pipeline-contracts.md +94 -29
- package/src/shared/references/tessl-review.md +77 -0
- package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
- package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
- package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
- package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
- package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
- package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
- package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
- package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
- package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
- package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
- package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
- package/src/shared/scripts/skf-atomic-write.py +30 -14
- package/src/shared/scripts/skf-build-change-manifest.py +2203 -24
- package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
- package/src/shared/scripts/skf-check-unit-records.py +184 -0
- package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
- package/src/shared/scripts/skf-classify-changed-files.py +694 -0
- package/src/shared/scripts/skf-comention-pairs.py +1333 -123
- package/src/shared/scripts/skf-compare-file-hashes.py +1 -1
- package/src/shared/scripts/skf-count-imports.py +1867 -0
- package/src/shared/scripts/skf-count-tokens.py +1 -1
- package/src/shared/scripts/skf-derive-assembly-shape.py +3 -2
- package/src/shared/scripts/skf-description-guard.py +99 -11
- package/src/shared/scripts/skf-detect-docs.py +694 -2
- package/src/shared/scripts/skf-detect-language.py +486 -107
- package/src/shared/scripts/skf-detect-registry.py +887 -0
- package/src/shared/scripts/skf-detect-scripts-assets.py +1 -1
- package/src/shared/scripts/skf-detect-tools.py +209 -27
- package/src/shared/scripts/skf-detect-workspaces.py +506 -30
- package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
- package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
- package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
- package/src/shared/scripts/skf-extract-public-api.py +4084 -85
- package/src/shared/scripts/skf-extraction-inventory.py +746 -0
- package/src/shared/scripts/skf-extraction-snapshot.py +539 -0
- package/src/shared/scripts/skf-fetch-temporal.py +674 -0
- package/src/shared/scripts/skf-find-cycles.py +97 -29
- package/src/shared/scripts/skf-find-test-report.py +476 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +444 -203
- package/src/shared/scripts/skf-github-fetch.py +365 -0
- package/src/shared/scripts/skf-github-probe.py +661 -0
- package/src/shared/scripts/skf-hash-content.py +166 -12
- package/src/shared/scripts/skf-language-corpora.py +1 -1
- package/src/shared/scripts/skf-load-provenance.py +159 -27
- package/src/shared/scripts/skf-manifest-ops.py +1 -1
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
- package/src/shared/scripts/skf-merge-doc-urls.py +1 -1
- package/src/shared/scripts/skf-names-present.py +221 -0
- package/src/shared/scripts/skf-pair-intersect.py +119 -28
- package/src/shared/scripts/skf-parse-gaps.py +1384 -0
- package/src/shared/scripts/skf-preapply.py +1 -1
- package/src/shared/scripts/skf-preflight.py +161 -27
- package/src/shared/scripts/skf-promote-staged.py +430 -0
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +39 -7
- package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
- package/src/shared/scripts/skf-quick-batch.py +774 -0
- package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
- package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
- package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
- package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
- package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
- package/src/shared/scripts/skf-resolve-authoritative-files.py +128 -13
- package/src/shared/scripts/skf-resolve-package.py +523 -106
- package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
- package/src/shared/scripts/skf-run-lock.py +577 -0
- package/src/shared/scripts/skf-scan-manifests.py +998 -57
- package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
- package/src/shared/scripts/skf-severity-classify.py +712 -108
- package/src/shared/scripts/skf-shape-detect.py +298 -204
- package/src/shared/scripts/skf-shard-body.py +9 -8
- package/src/shared/scripts/skf-skill-inventory.py +2412 -89
- package/src/shared/scripts/skf-skills-module.py +472 -0
- package/src/shared/scripts/skf-source-tree.py +1796 -0
- package/src/shared/scripts/skf-structural-diff.py +670 -119
- package/src/shared/scripts/skf-tessl-review.py +706 -0
- package/src/shared/scripts/skf-update-active-symlink.py +5 -5
- package/src/shared/scripts/skf-update-run-state.py +624 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
- package/src/shared/scripts/skf-validate-brief-schema.py +4 -1
- package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
- package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
- package/src/shared/scripts/skf-validate-output.py +712 -64
- package/src/shared/scripts/skf-validate-pins.py +1 -1
- package/src/shared/scripts/skf-verify-no-trace.py +116 -10
- package/src/shared/scripts/skf-verify-provenance-completeness.py +2919 -54
- package/src/shared/scripts/skf-write-skill-brief.py +367 -19
- package/src/shared/tool-requirements.yaml +201 -0
- package/src/skf-analyze-source/SKILL.md +41 -36
- package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
- package/src/skf-analyze-source/customize.toml +36 -24
- package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
- package/src/skf-analyze-source/references/continue.md +2 -13
- package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
- package/src/skf-analyze-source/references/generate-briefs.md +121 -55
- package/src/skf-analyze-source/references/headless-contract.md +42 -15
- package/src/skf-analyze-source/references/identify-units.md +96 -110
- package/src/skf-analyze-source/references/init.md +73 -71
- package/src/skf-analyze-source/references/map-and-detect.md +123 -105
- package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
- package/src/skf-analyze-source/references/recommend.md +51 -70
- package/src/skf-analyze-source/references/scan-project.md +60 -84
- package/src/skf-analyze-source/references/scan-root.md +39 -0
- package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
- package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
- package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
- package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
- package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
- package/src/skf-audit-skill/SKILL.md +43 -51
- package/src/skf-audit-skill/assets/drift-report-template.md +27 -3
- package/src/skf-audit-skill/customize.toml +41 -20
- package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
- package/src/skf-audit-skill/references/doc-drift.md +141 -0
- package/src/skf-audit-skill/references/headless-contract.md +29 -0
- package/src/skf-audit-skill/references/init.md +148 -210
- package/src/skf-audit-skill/references/re-index.md +96 -108
- package/src/skf-audit-skill/references/report.md +96 -33
- package/src/skf-audit-skill/references/semantic-diff.md +27 -18
- package/src/skf-audit-skill/references/severity-classify.md +79 -65
- package/src/skf-audit-skill/references/severity-rules.md +15 -6
- package/src/skf-audit-skill/references/structural-diff.md +75 -90
- package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
- package/src/skf-audit-skill/scripts/render-drift-tables.py +685 -0
- package/src/skf-brief-skill/SKILL.md +36 -25
- package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +17 -24
- package/src/skf-brief-skill/assets/skill-brief-schema.md +29 -15
- package/src/skf-brief-skill/customize.toml +48 -20
- package/src/skf-brief-skill/references/analyze-target.md +142 -125
- package/src/skf-brief-skill/references/confirm-brief.md +10 -8
- package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
- package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
- package/src/skf-brief-skill/references/gather-intent.md +87 -182
- package/src/skf-brief-skill/references/headless-args.md +84 -16
- package/src/skf-brief-skill/references/health-check.md +1 -1
- package/src/skf-brief-skill/references/invocation-contract.md +18 -13
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
- package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
- package/src/skf-brief-skill/references/scope-definition.md +123 -47
- package/src/skf-brief-skill/references/step-auto-brief.md +24 -80
- package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
- package/src/skf-brief-skill/references/version-resolution.md +3 -2
- package/src/skf-brief-skill/references/write-brief.md +66 -57
- package/src/skf-campaign/SKILL.md +31 -34
- package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
- package/src/skf-campaign/customize.toml +47 -16
- package/src/skf-campaign/references/campaign-contracts.md +39 -29
- package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
- package/src/skf-campaign/references/health-check.md +7 -16
- package/src/skf-campaign/references/step-01-setup.md +46 -84
- package/src/skf-campaign/references/step-02-strategy.md +31 -30
- package/src/skf-campaign/references/step-03-pins.md +26 -18
- package/src/skf-campaign/references/step-04-provenance.md +24 -23
- package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
- package/src/skf-campaign/references/step-06-batch.md +41 -34
- package/src/skf-campaign/references/step-07-capstone.md +40 -25
- package/src/skf-campaign/references/step-08-verify.md +28 -29
- package/src/skf-campaign/references/step-09-refine.md +29 -30
- package/src/skf-campaign/references/step-10-export.md +48 -36
- package/src/skf-campaign/references/step-11-maintenance.md +31 -34
- package/src/skf-campaign/references/step-resume.md +37 -68
- package/src/skf-campaign/scripts/campaign-deps.py +57 -18
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
- package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
- package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
- package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
- package/src/skf-campaign/scripts/campaign-report.py +204 -37
- package/src/skf-campaign/scripts/campaign-state.py +1128 -0
- package/src/skf-campaign/scripts/campaign-status.py +10 -74
- package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
- package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
- package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
- package/src/skf-campaign/templates/campaign-report-template.md +5 -1
- package/src/skf-campaign/templates/kickoff-template.md +0 -14
- package/src/skf-create-skill/SKILL.md +27 -20
- package/src/skf-create-skill/assets/compile-assembly-rules.md +6 -5
- package/src/skf-create-skill/assets/skill-sections.md +15 -5
- package/src/skf-create-skill/customize.toml +20 -8
- package/src/skf-create-skill/references/authoritative-files-protocol.md +41 -42
- package/src/skf-create-skill/references/batch-mode.md +81 -0
- package/src/skf-create-skill/references/ccc-index-check.md +27 -0
- package/src/skf-create-skill/references/compile.md +107 -29
- package/src/skf-create-skill/references/component-extraction.md +113 -91
- package/src/skf-create-skill/references/enrich.md +41 -23
- package/src/skf-create-skill/references/entry-points-by-hand.md +20 -0
- package/src/skf-create-skill/references/extract.md +200 -161
- package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +2 -2
- package/src/skf-create-skill/references/extraction-patterns.md +584 -196
- package/src/skf-create-skill/references/generate-artifacts.md +145 -93
- package/src/skf-create-skill/references/health-check.md +1 -1
- package/src/skf-create-skill/references/load-brief.md +53 -36
- package/src/skf-create-skill/references/relabel-rule.md +12 -0
- package/src/skf-create-skill/references/report.md +28 -59
- package/src/skf-create-skill/references/source-resolution-protocols.md +49 -155
- package/src/skf-create-skill/references/step-auto-shard.md +20 -93
- package/src/skf-create-skill/references/step-doc-rot.md +44 -60
- package/src/skf-create-skill/references/step-doc-sources.md +28 -19
- package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
- package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
- package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
- package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
- package/src/skf-create-skill/references/validate.md +173 -99
- package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
- package/src/skf-create-stack-skill/SKILL.md +29 -56
- package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
- package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
- package/src/skf-create-stack-skill/customize.toml +23 -12
- package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
- package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
- package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
- package/src/skf-create-stack-skill/references/generate-output.md +138 -128
- package/src/skf-create-stack-skill/references/init.md +90 -32
- package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
- package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
- package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
- package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
- package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
- package/src/skf-create-stack-skill/references/report.md +39 -33
- package/src/skf-create-stack-skill/references/validate.md +20 -39
- package/src/skf-drop-skill/SKILL.md +28 -51
- package/src/skf-drop-skill/customize.toml +44 -30
- package/src/skf-drop-skill/references/execute.md +79 -146
- package/src/skf-drop-skill/references/invocation-contract.md +64 -0
- package/src/skf-drop-skill/references/report.md +46 -30
- package/src/skf-drop-skill/references/select.md +118 -124
- package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
- package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
- package/src/skf-export-skill/SKILL.md +40 -62
- package/src/skf-export-skill/assets/managed-section-format.md +14 -50
- package/src/skf-export-skill/assets/snippet-format.md +3 -1
- package/src/skf-export-skill/customize.toml +42 -13
- package/src/skf-export-skill/references/generate-snippet.md +54 -46
- package/src/skf-export-skill/references/health-check.md +2 -0
- package/src/skf-export-skill/references/invocation-contract.md +15 -0
- package/src/skf-export-skill/references/load-skill.md +88 -62
- package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
- package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
- package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
- package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
- package/src/skf-export-skill/references/package.md +15 -73
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
- package/src/skf-export-skill/references/result-envelope.md +31 -16
- package/src/skf-export-skill/references/summary.md +41 -17
- package/src/skf-export-skill/references/token-report.md +4 -12
- package/src/skf-export-skill/references/update-context.md +118 -237
- package/src/skf-forger/SKILL.md +61 -37
- package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
- package/src/skf-forger/customize.toml +43 -0
- package/src/skf-forger/references/pipeline-mode.md +73 -18
- package/src/skf-forger/scripts/forge-status.py +299 -0
- package/src/skf-forger/scripts/parse-pipeline.py +322 -54
- package/src/skf-forger/scripts/pipeline-gate.py +428 -0
- package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
- package/src/skf-quick-skill/SKILL.md +54 -53
- package/src/skf-quick-skill/assets/skill-template.md +4 -2
- package/src/skf-quick-skill/customize.toml +51 -20
- package/src/skf-quick-skill/references/batch-mode.md +74 -21
- package/src/skf-quick-skill/references/compile.md +30 -31
- package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
- package/src/skf-quick-skill/references/finalize.md +69 -18
- package/src/skf-quick-skill/references/halt-contract.md +41 -29
- package/src/skf-quick-skill/references/health-check.md +5 -3
- package/src/skf-quick-skill/references/quick-extract.md +114 -45
- package/src/skf-quick-skill/references/registry-resolution.md +9 -64
- package/src/skf-quick-skill/references/resolve-target.md +116 -66
- package/src/skf-quick-skill/references/write-and-validate.md +79 -40
- package/src/skf-refine-architecture/SKILL.md +43 -53
- package/src/skf-refine-architecture/customize.toml +49 -20
- package/src/skf-refine-architecture/references/compile.md +97 -54
- package/src/skf-refine-architecture/references/exit-codes.md +30 -0
- package/src/skf-refine-architecture/references/finding-storage.md +21 -0
- package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
- package/src/skf-refine-architecture/references/improvements.md +14 -20
- package/src/skf-refine-architecture/references/init.md +98 -57
- package/src/skf-refine-architecture/references/issue-detection.md +32 -39
- package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
- package/src/skf-refine-architecture/references/report.md +65 -54
- package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
- package/src/skf-rename-skill/SKILL.md +19 -54
- package/src/skf-rename-skill/customize.toml +32 -26
- package/src/skf-rename-skill/references/execute.md +230 -184
- package/src/skf-rename-skill/references/exit-codes.md +7 -7
- package/src/skf-rename-skill/references/health-check.md +1 -7
- package/src/skf-rename-skill/references/invocation-contract.md +25 -0
- package/src/skf-rename-skill/references/report.md +63 -52
- package/src/skf-rename-skill/references/select.md +197 -115
- package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
- package/src/skf-setup/SKILL.md +26 -26
- package/src/skf-setup/customize.toml +14 -10
- package/src/skf-setup/references/auto-index.md +50 -63
- package/src/skf-setup/references/ccc-index.md +29 -90
- package/src/skf-setup/references/detect-and-tier.md +58 -67
- package/src/skf-setup/references/health-check.md +2 -0
- package/src/skf-setup/references/invocation-contract.md +14 -0
- package/src/skf-setup/references/report.md +43 -166
- package/src/skf-setup/references/tier-rules.md +5 -5
- package/src/skf-setup/references/write-config.md +34 -57
- package/src/skf-test-skill/SKILL.md +17 -57
- package/src/skf-test-skill/assets/output-section-formats.md +33 -34
- package/src/skf-test-skill/customize.toml +27 -13
- package/src/skf-test-skill/references/coherence-check.md +134 -62
- package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
- package/src/skf-test-skill/references/coverage-check.md +217 -254
- package/src/skf-test-skill/references/detect-mode.md +5 -5
- package/src/skf-test-skill/references/external-validators.md +94 -82
- package/src/skf-test-skill/references/health-check.md +6 -7
- package/src/skf-test-skill/references/init.md +163 -88
- package/src/skf-test-skill/references/invocation-contract.md +66 -0
- package/src/skf-test-skill/references/migration-section-rules.md +5 -2
- package/src/skf-test-skill/references/report.md +162 -142
- package/src/skf-test-skill/references/score.md +80 -107
- package/src/skf-test-skill/references/scoring-rules.md +41 -155
- package/src/skf-test-skill/references/source-access-protocol.md +19 -92
- package/src/skf-test-skill/references/step-hard-gate.md +51 -33
- package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
- package/src/skf-test-skill/scripts/build-result-context.py +287 -0
- package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
- package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
- package/src/skf-test-skill/scripts/compute-score.py +223 -45
- package/src/skf-test-skill/scripts/gap-ledger.py +1381 -0
- package/src/skf-test-skill/scripts/hard-gate.py +203 -0
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +760 -0
- package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
- package/src/skf-test-skill/scripts/reconcile-coverage.py +273 -79
- package/src/skf-test-skill/scripts/score-signatures.py +459 -0
- package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
- package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +127 -52
- package/src/skf-test-skill/templates/test-report-template.md +21 -15
- package/src/skf-update-skill/SKILL.md +38 -25
- package/src/skf-update-skill/customize.toml +27 -12
- package/src/skf-update-skill/references/detect-changes.md +233 -222
- package/src/skf-update-skill/references/gap-driven.md +336 -0
- package/src/skf-update-skill/references/health-check.md +31 -5
- package/src/skf-update-skill/references/init.md +250 -84
- package/src/skf-update-skill/references/invocation-contract.md +12 -0
- package/src/skf-update-skill/references/manual-section-rules.md +6 -23
- package/src/skf-update-skill/references/merge.md +180 -47
- package/src/skf-update-skill/references/re-extract.md +53 -262
- package/src/skf-update-skill/references/report.md +100 -35
- package/src/skf-update-skill/references/write.md +331 -149
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +29 -7
- package/src/skf-verify-stack/SKILL.md +23 -42
- package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
- package/src/skf-verify-stack/customize.toml +55 -22
- package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
- package/src/skf-verify-stack/references/coverage.md +67 -64
- package/src/skf-verify-stack/references/exit-codes.md +21 -8
- package/src/skf-verify-stack/references/init.md +107 -62
- package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
- package/src/skf-verify-stack/references/integrations.md +106 -65
- package/src/skf-verify-stack/references/report.md +59 -42
- package/src/skf-verify-stack/references/requirements.md +32 -9
- package/src/skf-verify-stack/references/synthesize.md +53 -39
- package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
- package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
- package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
- package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
- package/tools/cli/commands/install.js +11 -4
- package/tools/cli/commands/status.js +15 -3
- package/tools/cli/commands/update.js +11 -4
- package/tools/cli/lib/tool-check.js +320 -0
- package/tools/cli/lib/version-check.js +22 -9
- package/tools/cli/skf-cli.js +4 -1
- package/.gitattributes +0 -16
- package/.nvmrc +0 -1
- package/docs/_internal/RELEASING.md +0 -679
- package/release-audits/v1.0.0-launch-audit.md +0 -728
- package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
- package/src/skf-campaign/manifest.yaml +0 -15
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
- package/src/skf-create-skill/references/ecosystem-check.md +0 -74
- package/src/skf-drop-skill/references/headless-contract.md +0 -18
- package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
- package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
- package/src/skf-update-skill/references/validate.md +0 -124
- package/tools/validate-docs-drift.js +0 -233
- package/tools/validate-docs-links.js +0 -665
|
@@ -0,0 +1,1796 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Source Tree — read a remote skill's source at one commit.
|
|
6
|
+
|
|
7
|
+
A skill forged from a remote repository at Forge tier or above records, as
|
|
8
|
+
its `source_root`, the clone SKF keeps for that repository (the Compute
|
|
9
|
+
workspace path rule of create-skill's source-resolution-protocols.md:
|
|
10
|
+
`{SKF_WORKSPACE or ~/.skf/workspace}/repos/<host>/<owner...>/<repo>`). Every
|
|
11
|
+
SKF run on the repository shares that clone and leaves it at the commit it
|
|
12
|
+
needed, so update-skill never reads it as it stands. This helper gives each
|
|
13
|
+
update-skill run a private tree at one commit, moves the shared clone to
|
|
14
|
+
the commit the update recorded, and removes the tree at the end. resolve
|
|
15
|
+
does the same for a run that starts from a brief or a chosen ref
|
|
16
|
+
(create-skill, audit-skill): it turns a version into a tag, reads that
|
|
17
|
+
tag into a private tree, and moves the shared clone there when asked.
|
|
18
|
+
|
|
19
|
+
CLI:
|
|
20
|
+
|
|
21
|
+
uv run skf-source-tree.py open --source-repo <repo> --source-root <path> \\
|
|
22
|
+
--source-ref <ref> --pinned-commit <sha> [--target-ref <ref>] \\
|
|
23
|
+
[--timeout <seconds>]
|
|
24
|
+
uv run skf-source-tree.py resolve --source-repo <repo> \\
|
|
25
|
+
[--source-root <path>] [--target-ref <ref>] \\
|
|
26
|
+
[--version <version> [--name <name>] [--implicit]] \\
|
|
27
|
+
[--update-clone] [--hygiene-helper <path>] \\
|
|
28
|
+
[--lock-timeout <seconds>] [--timeout <seconds>]
|
|
29
|
+
uv run skf-source-tree.py advance --clone <path> --source-repo <repo> \\
|
|
30
|
+
--target <sha> [--expect-commit <sha>] [--target-ref <ref>] \\
|
|
31
|
+
[--tree <path>] [--hygiene-helper <path>] [--drift-helper <path>] \\
|
|
32
|
+
[--lock-timeout <seconds>] [--timeout <seconds>]
|
|
33
|
+
uv run skf-source-tree.py close --tree <path>
|
|
34
|
+
|
|
35
|
+
--tree (advance and close) takes the `tree` path open or resolve printed,
|
|
36
|
+
or the run folder that holds it.
|
|
37
|
+
|
|
38
|
+
Time limit. open, resolve and advance finish within --timeout seconds
|
|
39
|
+
(100 by default, so a shell tool that stops commands after two minutes
|
|
40
|
+
still gets the JSON): every git call and sibling helper is cut to the
|
|
41
|
+
time left, a network call leaves the last 15 seconds to the local steps
|
|
42
|
+
after it, a local call leaves the last 5 to the final read of the clone's
|
|
43
|
+
HEAD, and a step that runs out of time reports `timed-out`. A call the limit stops is
|
|
44
|
+
stopped with everything it started (git's transport, a filter it runs):
|
|
45
|
+
on POSIX SIGTERM to its process group first, so git removes its lock
|
|
46
|
+
files, then SIGKILL; on Windows `taskkill /T /F`. Its output goes to
|
|
47
|
+
temporary files, never pipes, so a child that outlives it cannot hold the
|
|
48
|
+
helper past the limit.
|
|
49
|
+
|
|
50
|
+
open
|
|
51
|
+
|
|
52
|
+
Takes a source only when --source-repo names a remote repository and
|
|
53
|
+
--source-root is SKF's clone of it: the workspace path computed from
|
|
54
|
+
--source-repo (compared as resolved paths, so a trailing slash or `~`
|
|
55
|
+
does not matter), or a clone of the same repository under
|
|
56
|
+
`repos/<host>/<owner...>/<repo>` of any SKF workspace folder, whatever
|
|
57
|
+
the letter case of those folder names. The clone's `remote.origin.url`
|
|
58
|
+
must name the same repository (read with `git config`, so `insteadOf`
|
|
59
|
+
does not apply). Anything else is `skipped`.
|
|
60
|
+
|
|
61
|
+
The target ref is --target-ref when given, else --source-ref; an empty,
|
|
62
|
+
`null`, `none` or `HEAD` ref means the remote's default branch. `git
|
|
63
|
+
ls-remote` names the commit the ref points to now: a tag wins over a
|
|
64
|
+
branch of the same name, and an annotated tag resolves to its commit. A
|
|
65
|
+
full 40-character commit is used as given. The helper then creates a
|
|
66
|
+
private git repository in a run folder of its own, fetches that one
|
|
67
|
+
commit into it (from the shared clone when the clone holds it, which
|
|
68
|
+
only reads the clone, otherwise from the remote), checks it out and lists
|
|
69
|
+
the files git changed between --pinned-commit and that commit.
|
|
70
|
+
|
|
71
|
+
open never writes to the shared clone: it never fetches into it, checks
|
|
72
|
+
it out, creates it, locks it or runs the hygiene helper there.
|
|
73
|
+
|
|
74
|
+
When the remote cannot be reached or the commit cannot be fetched,
|
|
75
|
+
--target-ref was not given and the clone holds --pinned-commit, the tree
|
|
76
|
+
is built at the pinned commit instead (`offline`).
|
|
77
|
+
|
|
78
|
+
Output (one ASCII line; every key is always present):
|
|
79
|
+
status "ready" | "offline" | "skipped" | "unavailable"
|
|
80
|
+
skip_reason null | "not-remote" | "not-workspace-clone"
|
|
81
|
+
reason null | "invalid-ref" | "git-unavailable" |
|
|
82
|
+
"upstream-unreachable" | "ref-not-found" |
|
|
83
|
+
"fetch-failed" | "checkout-failed" |
|
|
84
|
+
"tree-folder-failed" | "timed-out" (OPEN_REASONS)
|
|
85
|
+
message one line for the user (offline and unavailable), else null
|
|
86
|
+
tree absolute path of this run's tree (ready, offline), else null
|
|
87
|
+
clone the workspace clone advance may update (SKF's clone of
|
|
88
|
+
--source-repo, present or missing); null when skipped or
|
|
89
|
+
when that folder is not SKF's clone
|
|
90
|
+
target_ref the ref compared ("HEAD" for the default branch)
|
|
91
|
+
ref_kind "tag" | "branch" | "head" | "commit" | "pinned" | null
|
|
92
|
+
target_commit 40-hex commit the tree holds, else null
|
|
93
|
+
moved true | false | null (null without a pinned commit: an
|
|
94
|
+
empty, `local`, `null` or `none` --pinned-commit)
|
|
95
|
+
diff_status "ok" | "unavailable" | null (null unless ready or offline)
|
|
96
|
+
changed_files path of changed-files.json, else null
|
|
97
|
+
changed_counts {"added": n, "modified": n, "deleted": n}, else null
|
|
98
|
+
warnings list of strings
|
|
99
|
+
|
|
100
|
+
Run folder: `skf-tree-<UTC stamp>-<8 characters>/` under
|
|
101
|
+
`{SKF_WORKSPACE or ~/.skf/workspace}/trees/`, or under the system temp
|
|
102
|
+
folder when that folder cannot be created. It holds:
|
|
103
|
+
run.json {tool, created, tree, source_repo, clone, target_commit}
|
|
104
|
+
<repo>/ the tree: a private repository whose refs/skf/target
|
|
105
|
+
(and refs/skf/base) hold the commits it fetched
|
|
106
|
+
changed-files.json {"base": sha, "target": sha,
|
|
107
|
+
"files": [{"status": "A"|"M"|"D", "path": "a/b"}]}
|
|
108
|
+
|
|
109
|
+
Every open first removes run folders an earlier run left behind whose
|
|
110
|
+
name stamp is seven days old or more: only folders whose name matches,
|
|
111
|
+
never a link, and on POSIX only folders the current user owns. Folder
|
|
112
|
+
times are never used.
|
|
113
|
+
|
|
114
|
+
resolve
|
|
115
|
+
|
|
116
|
+
Takes a source when --source-repo names a remote repository (anything
|
|
117
|
+
else is `skipped` with `not-remote`: read the local path as it is), and
|
|
118
|
+
picks the ref to read:
|
|
119
|
+
|
|
120
|
+
1. --target-ref: `HEAD` (any letter case) for the remote's default
|
|
121
|
+
branch, a 40-character commit, or a tag or branch. A commit is taken
|
|
122
|
+
as given, without `git ls-remote`, and one the remote does not have
|
|
123
|
+
ends `unavailable` with `fetch-failed`. A tag or branch is looked up
|
|
124
|
+
with `git ls-remote` (a tag wins over a branch of the same name); one
|
|
125
|
+
it does not find adds a warning and falls through.
|
|
126
|
+
2. --version, matched against the remote's tags. --implicit (a version
|
|
127
|
+
the brief only carries as a hint) tries two forms, the version as
|
|
128
|
+
given and `v<version>`; otherwise seven, adding, with --name,
|
|
129
|
+
`<name>@<version>`, `@<scope>/<name>@<version>` (any scope),
|
|
130
|
+
`<name>/v<version>`, `<name>/<version>` and `<name>-v<version>` (a
|
|
131
|
+
leading `v` of --version is dropped in the forms after the first).
|
|
132
|
+
One match is read. Several are `ambiguous`: nothing is read, and
|
|
133
|
+
tag_resolution.candidates lists them in that order; run resolve again
|
|
134
|
+
with the chosen one as --target-ref. None falls back to `HEAD`, with
|
|
135
|
+
the five tags nearest the version in tag_resolution.nearest.
|
|
136
|
+
3. Otherwise `HEAD`, the remote's default branch.
|
|
137
|
+
|
|
138
|
+
An empty, `null` or `none` value (any letter case: a JSON null written
|
|
139
|
+
out) of --target-ref, --version, --name or --source-root counts as not
|
|
140
|
+
given, so a brief's `target_ref: null` falls through to its version.
|
|
141
|
+
Only `HEAD` asks for the default branch.
|
|
142
|
+
|
|
143
|
+
It then builds a private tree at that commit exactly as open does (same
|
|
144
|
+
run folder, run.json and refs/skf/target; no changed-files.json), from
|
|
145
|
+
SKF's clone when the clone holds the commit, else from the remote.
|
|
146
|
+
Without --source-root, the clone is the workspace path computed from
|
|
147
|
+
--source-repo. With --source-root, the clone is that folder when it is
|
|
148
|
+
SKF's clone of --source-repo as open decides (the workspace path,
|
|
149
|
+
present or missing, or a clone of the same repository under another
|
|
150
|
+
workspace folder). A folder that is not SKF's clone, at either place,
|
|
151
|
+
is never used: the tree comes from the remote and --update-clone skips
|
|
152
|
+
with `not-a-clone`.
|
|
153
|
+
|
|
154
|
+
With --update-clone it then moves that clone to the commit, as advance
|
|
155
|
+
does but without --expect-commit (this run owns the move): inside one
|
|
156
|
+
process it clones a missing clone, takes `.skf-workspace.lock`, runs
|
|
157
|
+
--hygiene-helper, leaves a clone with local changes to tracked files
|
|
158
|
+
alone and never forces a checkout. It also leaves alone a clone whose
|
|
159
|
+
HEAD does not resolve (`head-unverified`): that may be another run's
|
|
160
|
+
clone still being written, since create-skill clones before it locks.
|
|
161
|
+
The tree is read either way, so a clone that could not be moved never
|
|
162
|
+
changes what the run reads.
|
|
163
|
+
|
|
164
|
+
Output (one ASCII line; every key is always present):
|
|
165
|
+
status "ready" | "ambiguous" | "skipped" | "unavailable"
|
|
166
|
+
skip_reason null | "not-remote"
|
|
167
|
+
reason null | one of OPEN_REASONS (unavailable)
|
|
168
|
+
message one line for the user (ambiguous, unavailable), else null
|
|
169
|
+
workspace_path SKF's clone path for --source-repo (the Compute
|
|
170
|
+
workspace path rule), null when skipped
|
|
171
|
+
clone SKF's clone it read from or moved, present or
|
|
172
|
+
missing; null when that folder is not SKF's clone
|
|
173
|
+
source_ref the ref picked ("HEAD" for the default branch), else null
|
|
174
|
+
ref_kind "tag" | "branch" | "head" | "commit" | null
|
|
175
|
+
source_commit 40-hex commit the tree holds, else null
|
|
176
|
+
tree absolute path of this run's tree (ready), else null
|
|
177
|
+
tag_resolution {"status": "target-ref" | "matched" | "ambiguous" |
|
|
178
|
+
"fallback-head" | "none",
|
|
179
|
+
"mode": "explicit" | "implicit" | null,
|
|
180
|
+
"requested": the --version (or --target-ref) or null,
|
|
181
|
+
"candidates": [matching tags],
|
|
182
|
+
"nearest": [up to five tags, newest first],
|
|
183
|
+
"reason": null | "no-matching-tag" |
|
|
184
|
+
"target-ref-not-found"}
|
|
185
|
+
clone_status null (no --update-clone) | "advanced" | "ok" | "skipped"
|
|
186
|
+
clone_skip_reason null | one of ADVANCE_SKIP_REASONS
|
|
187
|
+
clone_created true when this call cloned the missing clone
|
|
188
|
+
warnings list of strings
|
|
189
|
+
|
|
190
|
+
Remove the tree with close once the run no longer reads it.
|
|
191
|
+
|
|
192
|
+
advance
|
|
193
|
+
|
|
194
|
+
Moves the shared clone (--clone) to --target, the commit the update
|
|
195
|
+
recorded, so test-skill and audit-skill read that commit there. It takes
|
|
196
|
+
the clone's `.skf-workspace.lock`, the lock create-skill takes
|
|
197
|
+
(`fcntl.flock` on POSIX, the lock `flock -x` takes, and
|
|
198
|
+
`msvcrt.locking` on Windows), waiting up to --lock-timeout seconds, and
|
|
199
|
+
runs the hygiene helper inside it. It then does nothing when the clone
|
|
200
|
+
already holds --target; otherwise it checks the clone out at --target
|
|
201
|
+
only when the drift helper confirms the clone still holds --expect-commit
|
|
202
|
+
and no tracked file has local changes, taking the commit from the tree
|
|
203
|
+
--tree names before the remote. It never forces a checkout. A missing
|
|
204
|
+
clone is cloned first, as create-skill does (clone first, lock after),
|
|
205
|
+
into a folder beside it that is renamed to --clone once the clone is
|
|
206
|
+
complete: no other run sees it half made, and when another run made
|
|
207
|
+
--clone meanwhile, this call removes only its own folder
|
|
208
|
+
(`clone-failed`).
|
|
209
|
+
|
|
210
|
+
refs/skf/advance, the ref a fetch into the clone writes, is always
|
|
211
|
+
deleted again. When the time limit stops a fetch or the checkout in the
|
|
212
|
+
clone and git has to be killed, so it cannot remove the lock file it
|
|
213
|
+
held there (`shallow.lock`, `index.lock`), that file is removed; a
|
|
214
|
+
checkout stopped part way reports `checkout-interrupted`, since the
|
|
215
|
+
clone's files may then belong to both commits, and its warning gives
|
|
216
|
+
the fetch and the forced checkout that finish it (the clone had no
|
|
217
|
+
local changes when it began, and no ref keeps the commit there).
|
|
218
|
+
|
|
219
|
+
Output (one ASCII line):
|
|
220
|
+
status "advanced" | "ok" | "skipped"
|
|
221
|
+
skip_reason null | "invalid-target" | "not-a-clone" | "clone-failed" |
|
|
222
|
+
"lock-busy" | "head-moved" | "head-unverified" |
|
|
223
|
+
"local-changes" | "target-missing" | "checkout-failed" |
|
|
224
|
+
"checkout-interrupted" | "timed-out" (ADVANCE_SKIP_REASONS)
|
|
225
|
+
head_sha the clone's HEAD after the call, or null
|
|
226
|
+
created true when this call cloned the missing clone
|
|
227
|
+
lock "held" | "busy" | "unavailable" | "not-taken"
|
|
228
|
+
hygiene "ran" | "failed" | "skipped"
|
|
229
|
+
log_message one line
|
|
230
|
+
warnings list of strings
|
|
231
|
+
|
|
232
|
+
close
|
|
233
|
+
|
|
234
|
+
Removes the run folder that holds the tree --tree names, or the run
|
|
235
|
+
folder --tree names, only when its name matches the run-folder pattern,
|
|
236
|
+
it is not a link, on POSIX the current user owns it, and its run.json
|
|
237
|
+
names this tool and a tree inside it (the one --tree names, when --tree
|
|
238
|
+
names a tree).
|
|
239
|
+
|
|
240
|
+
Output (one ASCII line):
|
|
241
|
+
{"status": "removed" | "missing" | "left" | "refused",
|
|
242
|
+
"tree": <--tree>, "warnings": [...]}
|
|
243
|
+
|
|
244
|
+
Exit codes:
|
|
245
|
+
0 open: ready, offline or skipped; resolve: ready, ambiguous or
|
|
246
|
+
skipped; advance and close: always once JSON is printed (they never
|
|
247
|
+
gate)
|
|
248
|
+
3 open, resolve: unavailable (JSON on stdout; the caller halts)
|
|
249
|
+
1 unexpected error: {"status": "error", "message": ...} on stderr,
|
|
250
|
+
nothing on stdout
|
|
251
|
+
2 usage error (argparse)
|
|
252
|
+
|
|
253
|
+
git runs with the git location variables (GIT_DIR, GIT_INDEX_FILE, ...)
|
|
254
|
+
removed from its environment. A git hook or `git rebase --exec` exports
|
|
255
|
+
them (GIT_DIR too when run in a linked worktree); inherited by
|
|
256
|
+
`git -C <clone>`, they would make the helper read or write another
|
|
257
|
+
repository. git also runs with LC_ALL=C, no terminal prompt, no
|
|
258
|
+
credential-manager prompt and no hooks. It needs git 2.15 or newer.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
from __future__ import annotations
|
|
262
|
+
|
|
263
|
+
import argparse
|
|
264
|
+
import bisect
|
|
265
|
+
import errno
|
|
266
|
+
import json
|
|
267
|
+
import os
|
|
268
|
+
import re
|
|
269
|
+
import shutil
|
|
270
|
+
import signal
|
|
271
|
+
import stat
|
|
272
|
+
import subprocess
|
|
273
|
+
import sys
|
|
274
|
+
import tempfile
|
|
275
|
+
import time
|
|
276
|
+
from datetime import datetime, timedelta, timezone
|
|
277
|
+
from pathlib import Path
|
|
278
|
+
|
|
279
|
+
if os.name == "nt":
|
|
280
|
+
import msvcrt
|
|
281
|
+
else:
|
|
282
|
+
import fcntl
|
|
283
|
+
|
|
284
|
+
TOOL = "skf-source-tree"
|
|
285
|
+
TREE_PREFIX = "skf-tree-"
|
|
286
|
+
TREE_NAME_RE = re.compile(r"^skf-tree-(\d{8}T\d{6}Z)-[a-z0-9_]{8}$")
|
|
287
|
+
RUN_FILE = "run.json"
|
|
288
|
+
CHANGED_FILE = "changed-files.json"
|
|
289
|
+
LOCK_NAME = ".skf-workspace.lock"
|
|
290
|
+
ADVANCE_REF = "refs/skf/advance"
|
|
291
|
+
STALE_TREE_DAYS = 7
|
|
292
|
+
LOCK_WAIT_SEC = 60.0
|
|
293
|
+
LOCK_POLL_SEC = 0.25
|
|
294
|
+
LOCAL_TIMEOUT_SEC = 60
|
|
295
|
+
LS_REMOTE_TIMEOUT_SEC = 120
|
|
296
|
+
NETWORK_TIMEOUT_SEC = 600 # also used for checkout and clone
|
|
297
|
+
SIBLING_TIMEOUT_SEC = 120
|
|
298
|
+
# open and advance finish within --timeout seconds; a network call leaves
|
|
299
|
+
# the last NETWORK_RESERVE_SEC of it to the local steps that follow, and a
|
|
300
|
+
# local call the last FINAL_READ_SEC to the unlimited final reads.
|
|
301
|
+
DEFAULT_TIMEOUT_SEC = 100.0
|
|
302
|
+
NETWORK_RESERVE_SEC = 15.0
|
|
303
|
+
FINAL_READ_SEC = 5.0
|
|
304
|
+
KILL_WAIT_SEC = 2.0 # each wait after the time limit stops a call: SIGTERM or taskkill, then the child
|
|
305
|
+
OPEN_REASONS = ("invalid-ref", "git-unavailable", "upstream-unreachable", "ref-not-found",
|
|
306
|
+
"fetch-failed", "checkout-failed", "tree-folder-failed", "timed-out")
|
|
307
|
+
ADVANCE_SKIP_REASONS = ("invalid-target", "not-a-clone", "clone-failed", "lock-busy", "head-moved",
|
|
308
|
+
"head-unverified", "local-changes", "target-missing", "checkout-failed",
|
|
309
|
+
"checkout-interrupted", "timed-out")
|
|
310
|
+
# The failures a call cut short by the time limit can cause: by a network
|
|
311
|
+
# call (cut NETWORK_RESERVE_SEC early) or by a local one.
|
|
312
|
+
_OPEN_NETWORK_LATE = ("upstream-unreachable", "fetch-failed")
|
|
313
|
+
_OPEN_LOCAL_LATE = ("invalid-ref", "checkout-failed")
|
|
314
|
+
_ADVANCE_NETWORK_LATE = ("clone-failed", "target-missing")
|
|
315
|
+
_ADVANCE_LOCAL_LATE = ("not-a-clone", "lock-busy", "head-unverified", "local-changes", "checkout-failed")
|
|
316
|
+
SHA40_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
317
|
+
# No leading `-`; a scoped `@scope/pkg@1.0.0` tag is allowed.
|
|
318
|
+
REF_RE = re.compile(r"^[A-Za-z0-9_@][A-Za-z0-9._/+@-]*$")
|
|
319
|
+
HEAD_ALIASES = {"", "null", "none", "head"}
|
|
320
|
+
STAMP_FORMAT = "%Y%m%dT%H%M%SZ"
|
|
321
|
+
# Every git call that writes a checkout (checkout, clone) passes these.
|
|
322
|
+
CHECKOUT_OPTS = ("-c", "core.longpaths=true", "-c", "advice.detachedHead=false")
|
|
323
|
+
|
|
324
|
+
_URL_RE = re.compile(
|
|
325
|
+
r"^(?:https?|ssh|git)://(?:[^@/]+@)?(?P<host>[^/:]+)(?::\d+)?/(?P<path>.+?)/?$",
|
|
326
|
+
re.IGNORECASE,
|
|
327
|
+
)
|
|
328
|
+
_SCP_RE = re.compile(r"^(?:[^@/:]+@)?(?P<host>[^/:]{2,}):(?P<path>[^/].*?)/?$")
|
|
329
|
+
_SHORTHAND_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
330
|
+
_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
|
331
|
+
# Characters that stop a host, owner or repository name from naming one
|
|
332
|
+
# folder: Windows reads `\` as a separator and `C:` as a drive.
|
|
333
|
+
_NOT_A_NAME_RE = re.compile(r"[\\:\x00-\x1f\x7f]")
|
|
334
|
+
_VERSION_TAIL_RE = re.compile(
|
|
335
|
+
r"(?P<nums>\d+(?:\.\d+)*)(?P<pre>(?:-|(?=[A-Za-z]))[0-9A-Za-z][0-9A-Za-z.-]*)?(?:\+[0-9A-Za-z.-]+)?")
|
|
336
|
+
NEAREST_TAGS = 5
|
|
337
|
+
|
|
338
|
+
# Keep identical to GIT_LOCATION_VARS in skf-check-workspace-drift.py.
|
|
339
|
+
GIT_LOCATION_VARS = (
|
|
340
|
+
"GIT_DIR",
|
|
341
|
+
"GIT_WORK_TREE",
|
|
342
|
+
"GIT_INDEX_FILE",
|
|
343
|
+
"GIT_PREFIX",
|
|
344
|
+
"GIT_COMMON_DIR",
|
|
345
|
+
"GIT_OBJECT_DIRECTORY",
|
|
346
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
347
|
+
"GIT_NAMESPACE",
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# --------------------------------------------------------------------------
|
|
352
|
+
# Pinned copies
|
|
353
|
+
# --------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# Keep identical to _is_link_or_junction in skf-skill-inventory.py
|
|
357
|
+
# (test/test-skf-skill-inventory.py pins the copies).
|
|
358
|
+
def _is_link_or_junction(p: Path) -> bool:
|
|
359
|
+
"""True for POSIX symlinks AND Windows junctions/symlinks.
|
|
360
|
+
|
|
361
|
+
`Path.is_symlink()` is False for Windows junctions; os.readlink succeeds
|
|
362
|
+
for both symlinks and junctions (since CPython 3.8 on Windows). A regular
|
|
363
|
+
directory raises OSError on readlink, which is the signal we want to
|
|
364
|
+
refuse replacement. On Windows, any other reparse point (a cloud-sync
|
|
365
|
+
placeholder, a deduplicated file, an app execution alias) raises
|
|
366
|
+
ValueError: it does not redirect to another path, so it is not a link.
|
|
367
|
+
"""
|
|
368
|
+
if p.is_symlink():
|
|
369
|
+
return True
|
|
370
|
+
if not p.exists() and not p.is_symlink():
|
|
371
|
+
return False
|
|
372
|
+
try:
|
|
373
|
+
os.readlink(p)
|
|
374
|
+
return True
|
|
375
|
+
except (OSError, ValueError):
|
|
376
|
+
return False
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# Keep identical to _acquire_lock in skf-atomic-write.py
|
|
380
|
+
# (test/test-skf-source-tree.py pins the copy).
|
|
381
|
+
def _acquire_lock(fd: int) -> None:
|
|
382
|
+
"""Acquire an exclusive non-blocking lock on fd (auto-released on close/exit)."""
|
|
383
|
+
if os.name == "nt":
|
|
384
|
+
try:
|
|
385
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
386
|
+
except OSError as e:
|
|
387
|
+
if e.errno in (errno.EAGAIN, errno.EACCES, errno.EDEADLK):
|
|
388
|
+
raise OSError(errno.EAGAIN, "lock held") from e
|
|
389
|
+
raise
|
|
390
|
+
else:
|
|
391
|
+
try:
|
|
392
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
393
|
+
except OSError as e:
|
|
394
|
+
if e.errno in (errno.EAGAIN, errno.EACCES):
|
|
395
|
+
raise OSError(errno.EAGAIN, "lock held") from e
|
|
396
|
+
raise
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# Keep identical to _release_lock in skf-atomic-write.py
|
|
400
|
+
# (test/test-skf-source-tree.py pins the copy).
|
|
401
|
+
def _release_lock(fd: int) -> None:
|
|
402
|
+
if os.name == "nt":
|
|
403
|
+
try:
|
|
404
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
405
|
+
except OSError:
|
|
406
|
+
pass
|
|
407
|
+
else:
|
|
408
|
+
try:
|
|
409
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
410
|
+
except OSError:
|
|
411
|
+
pass
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# Keep identical to _same_path in skf-ccc-git-hygiene.py
|
|
415
|
+
# (test/test-skf-source-tree.py pins the copy).
|
|
416
|
+
def _same_path(a: str | Path, b: str | Path) -> bool:
|
|
417
|
+
"""Path identity that covers Windows case and 8.3 names, forward-slash
|
|
418
|
+
git output and macOS /private."""
|
|
419
|
+
return os.path.normcase(os.path.realpath(a)) == os.path.normcase(os.path.realpath(b))
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _resolve_outside_cwd(command: str) -> str | None:
|
|
423
|
+
"""shutil.which with a CWD-shim guard. Returns the resolved path or None.
|
|
424
|
+
|
|
425
|
+
shutil.which on Windows searches the current directory ahead of PATH,
|
|
426
|
+
and CWD here may be a repository SKF does not control — a bare-name
|
|
427
|
+
lookup resolving into CWD would execute a planted shim (e.g. git.exe).
|
|
428
|
+
Such a resolution is treated as not-found. Explicit paths supplied by
|
|
429
|
+
callers (containing a separator) are honored as-is. Keep the code
|
|
430
|
+
identical to the sibling guards in skf-merge-ccc-exclusions.py,
|
|
431
|
+
skf-detect-tools.py, skf-qmd-classify-collections.py,
|
|
432
|
+
skf-ccc-git-hygiene.py, skf-tessl-review.py and
|
|
433
|
+
skf-verify-provenance-completeness.py
|
|
434
|
+
(test/test-skf-source-tree.py pins it against
|
|
435
|
+
skf-merge-ccc-exclusions.py).
|
|
436
|
+
"""
|
|
437
|
+
resolved = shutil.which(command)
|
|
438
|
+
if resolved is None:
|
|
439
|
+
return None
|
|
440
|
+
if os.sep in command or (os.altsep and os.altsep in command):
|
|
441
|
+
return resolved
|
|
442
|
+
resolved_dir = os.path.dirname(resolved)
|
|
443
|
+
if resolved_dir:
|
|
444
|
+
cwd = os.path.normcase(os.path.abspath(os.getcwd()))
|
|
445
|
+
if os.path.normcase(os.path.abspath(resolved_dir)) == cwd:
|
|
446
|
+
return None
|
|
447
|
+
return resolved
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
# skf-check-workspace-drift.py (upstream), skf-github-probe.py and
|
|
451
|
+
# skf-classify-changed-files.py load this file for split_version, match_tags,
|
|
452
|
+
# nearest_tags, _run,
|
|
453
|
+
# _resolve_outside_cwd, _last_error, _stat_dir, SHA40_RE and REF_RE, and
|
|
454
|
+
# skf-fetch-temporal.py for parse_remote, _run, _resolve_outside_cwd, _rmtree
|
|
455
|
+
# and _is_link_or_junction, and skf-detect-registry.py for _git: keep those
|
|
456
|
+
# names and what they take and return.
|
|
457
|
+
def split_version(name: str) -> tuple[str, tuple] | None:
|
|
458
|
+
"""(prefix, sort key) of a tag name that ends in a version, else None.
|
|
459
|
+
|
|
460
|
+
The version is a dotted number with an optional pre-release (`-rc.1`,
|
|
461
|
+
`rc1`) and build (`+build`) after it; the prefix is what comes before
|
|
462
|
+
it and never ends in a digit or a dot (`v`, `pkg@`, `tokio/v`). When a
|
|
463
|
+
name holds several, the one with the most numbers wins, then the first
|
|
464
|
+
(`lib2-v1.0` is `lib2-v` and 1.0, `v1.0.0-rc1` is `v` and 1.0.0-rc1).
|
|
465
|
+
Keys sort by version: trailing zeros do not count (1.2 == 1.2.0), a
|
|
466
|
+
release sorts after its pre-releases, pre-release numbers compare as
|
|
467
|
+
numbers. key[1] is 0 for a pre-release, 1 for a release.
|
|
468
|
+
"""
|
|
469
|
+
best = None
|
|
470
|
+
for i, ch in enumerate(name):
|
|
471
|
+
if not ch.isdigit() or (i and name[i - 1] in "0123456789."):
|
|
472
|
+
continue
|
|
473
|
+
m = _VERSION_TAIL_RE.fullmatch(name, i)
|
|
474
|
+
if m is None:
|
|
475
|
+
continue
|
|
476
|
+
nums = [int(part) for part in m.group("nums").split(".")]
|
|
477
|
+
if best is None or len(nums) > len(best[1]):
|
|
478
|
+
best = (name[:i], nums, m.group("pre"))
|
|
479
|
+
if best is None:
|
|
480
|
+
return None
|
|
481
|
+
prefix, nums, pre = best
|
|
482
|
+
while len(nums) > 1 and nums[-1] == 0:
|
|
483
|
+
nums.pop()
|
|
484
|
+
if pre is None:
|
|
485
|
+
return prefix, (tuple(nums), 1, ())
|
|
486
|
+
ids = tuple((0, int(tok), "") if tok.isdigit() else (1, 0, tok.lower())
|
|
487
|
+
for tok in re.findall(r"\d+|[A-Za-z]+", pre))
|
|
488
|
+
return prefix, (tuple(nums), 0, ids)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# Keep identical to is_skippable_pinned in skf-check-workspace-drift.py
|
|
492
|
+
# (test/test-skf-source-tree.py pins the copy).
|
|
493
|
+
def is_skippable_pinned(pinned_commit: str) -> bool:
|
|
494
|
+
"""Recognize the scalar values that mean 'no pinned commit'."""
|
|
495
|
+
if pinned_commit is None:
|
|
496
|
+
return True
|
|
497
|
+
stripped = pinned_commit.strip()
|
|
498
|
+
return stripped == "" or stripped.lower() == "local"
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
# Keep identical to classify_match in skf-check-workspace-drift.py
|
|
502
|
+
# (test/test-skf-source-tree.py pins the copy).
|
|
503
|
+
def classify_match(pinned: str, head: str) -> str | None:
|
|
504
|
+
"""Return 'full' if pinned == head, 'short-prefix' if pinned is a prefix
|
|
505
|
+
of head (≥ 7 chars to avoid coincidental collisions on short SHAs).
|
|
506
|
+
Returns None on no match."""
|
|
507
|
+
if pinned == head:
|
|
508
|
+
return "full"
|
|
509
|
+
if len(pinned) >= 7 and head.startswith(pinned):
|
|
510
|
+
return "short-prefix"
|
|
511
|
+
return None
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# --------------------------------------------------------------------------
|
|
515
|
+
# git
|
|
516
|
+
# --------------------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _child_env() -> dict:
|
|
520
|
+
"""os.environ without the git location variables, and with no prompts."""
|
|
521
|
+
env = {k: v for k, v in os.environ.items() if k not in GIT_LOCATION_VARS}
|
|
522
|
+
env["LC_ALL"] = "C"
|
|
523
|
+
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
524
|
+
env["GCM_INTERACTIVE"] = "never"
|
|
525
|
+
return env
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
_deadline: float | None = None
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _start_clock(seconds: float | None) -> None:
|
|
532
|
+
"""Start the time limit of this open or advance (None: no limit)."""
|
|
533
|
+
global _deadline
|
|
534
|
+
_deadline = None if seconds is None else time.monotonic() + max(0.0, seconds)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _cap(timeout: float, network: bool = False) -> float | None:
|
|
538
|
+
"""timeout cut to the time the limit leaves; None when none is left.
|
|
539
|
+
|
|
540
|
+
A network call leaves the last NETWORK_RESERVE_SEC to the local steps,
|
|
541
|
+
a local call the last FINAL_READ_SEC to the final reads.
|
|
542
|
+
"""
|
|
543
|
+
if _deadline is None:
|
|
544
|
+
return timeout
|
|
545
|
+
left = _deadline - time.monotonic() - (NETWORK_RESERVE_SEC if network else FINAL_READ_SEC)
|
|
546
|
+
return min(timeout, left) if left > 0 else None
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _expired() -> bool:
|
|
550
|
+
"""True once no network call may start: the time limit is (nearly) spent."""
|
|
551
|
+
return _cap(1.0, network=True) is None
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _out_of_time() -> bool:
|
|
555
|
+
"""True once no call at all may start."""
|
|
556
|
+
return _cap(1.0) is None
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _late(reason: str | None, network_reasons: tuple, local_reasons: tuple) -> bool:
|
|
560
|
+
"""True when the time limit, not the reason itself, explains a failure."""
|
|
561
|
+
return (reason in network_reasons and _expired()) or (reason in local_reasons and _out_of_time())
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _stop(proc: subprocess.Popen, windows: bool = os.name == "nt") -> bool:
|
|
565
|
+
"""Stop proc and everything it started once the time limit runs out.
|
|
566
|
+
|
|
567
|
+
POSIX sends SIGTERM to its process group first, so git removes its lock
|
|
568
|
+
files, then SIGKILL to whatever is left; Windows runs taskkill /T /F.
|
|
569
|
+
Returns True when proc itself had to be killed (taskkill /F, or SIGKILL
|
|
570
|
+
once SIGTERM had not ended it) and has ended: the lock files it held
|
|
571
|
+
stay behind. False when SIGTERM ended it, or when it has not ended.
|
|
572
|
+
"""
|
|
573
|
+
killed = windows
|
|
574
|
+
try:
|
|
575
|
+
if windows:
|
|
576
|
+
taskkill = _resolve_outside_cwd("taskkill")
|
|
577
|
+
if taskkill:
|
|
578
|
+
subprocess.run([taskkill, "/F", "/T", "/PID", str(proc.pid)], stdin=subprocess.DEVNULL,
|
|
579
|
+
capture_output=True, timeout=KILL_WAIT_SEC, check=False)
|
|
580
|
+
proc.kill()
|
|
581
|
+
else:
|
|
582
|
+
os.killpg(proc.pid, signal.SIGTERM)
|
|
583
|
+
try:
|
|
584
|
+
proc.wait(timeout=KILL_WAIT_SEC)
|
|
585
|
+
ended = True
|
|
586
|
+
except subprocess.TimeoutExpired:
|
|
587
|
+
ended = False
|
|
588
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
589
|
+
killed = not ended
|
|
590
|
+
except (OSError, ValueError, subprocess.TimeoutExpired):
|
|
591
|
+
pass
|
|
592
|
+
try:
|
|
593
|
+
proc.wait(timeout=KILL_WAIT_SEC)
|
|
594
|
+
except subprocess.TimeoutExpired:
|
|
595
|
+
return False
|
|
596
|
+
return killed
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _run(argv: list[str], timeout: float | None) -> tuple[int | None, bytes, bytes, bool]:
|
|
600
|
+
"""Run argv with no stdin and _child_env(); (returncode, stdout, stderr, killed).
|
|
601
|
+
|
|
602
|
+
returncode is None when the time limit stopped the call, and killed is
|
|
603
|
+
True when it then had to be killed (see _stop). Output goes to
|
|
604
|
+
temporary files, not pipes: on Windows a child git starts (ssh, the
|
|
605
|
+
https transport) inherits the pipes and keeps them open after git is
|
|
606
|
+
stopped, and reading a pipe to its end would wait for it. The call gets
|
|
607
|
+
a process group (a POSIX session) of its own, so _stop reaches all it
|
|
608
|
+
started. Raises OSError or ValueError when argv cannot start.
|
|
609
|
+
"""
|
|
610
|
+
kwargs = {}
|
|
611
|
+
if os.name == "nt":
|
|
612
|
+
kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
613
|
+
else:
|
|
614
|
+
kwargs["start_new_session"] = True
|
|
615
|
+
with tempfile.TemporaryFile() as out_f, tempfile.TemporaryFile() as err_f:
|
|
616
|
+
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=out_f, stderr=err_f, env=_child_env(),
|
|
617
|
+
**kwargs)
|
|
618
|
+
killed = False
|
|
619
|
+
try:
|
|
620
|
+
returncode = proc.wait(timeout=timeout)
|
|
621
|
+
except subprocess.TimeoutExpired:
|
|
622
|
+
killed = _stop(proc)
|
|
623
|
+
returncode = None
|
|
624
|
+
out_f.seek(0)
|
|
625
|
+
err_f.seek(0)
|
|
626
|
+
return returncode, out_f.read(), err_f.read(), killed
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
# _git's returncode for a call the time limit stopped once it had started:
|
|
630
|
+
# STOPPED when SIGTERM ended it, so git removed its lock files itself, and
|
|
631
|
+
# KILLED when it had to be killed (see _stop), so they stay behind.
|
|
632
|
+
STOPPED = -2
|
|
633
|
+
KILLED = -3
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _git(cwd: str | Path, *args: str, timeout: float = LOCAL_TIMEOUT_SEC, network: bool = False,
|
|
637
|
+
limited: bool = True) -> tuple[int, bytes, str] | None:
|
|
638
|
+
"""Run `git -C <cwd> <args>` with no hooks and the git location variables removed.
|
|
639
|
+
|
|
640
|
+
Returns None when git cannot be found (or only as a shim in CWD) or
|
|
641
|
+
cannot start, (-1, b"", "timeout") when the time limit leaves no time
|
|
642
|
+
to start the call (`limited`; see _cap), (STOPPED or KILLED, stdout
|
|
643
|
+
bytes, "timeout") when the limit stopped it, and otherwise (returncode,
|
|
644
|
+
stdout bytes, stderr text). stdout is not stripped: the `-z` outputs
|
|
645
|
+
need the raw bytes.
|
|
646
|
+
"""
|
|
647
|
+
exe = _resolve_outside_cwd("git")
|
|
648
|
+
if exe is None:
|
|
649
|
+
return None
|
|
650
|
+
if limited:
|
|
651
|
+
timeout = _cap(timeout, network)
|
|
652
|
+
if timeout is None:
|
|
653
|
+
return -1, b"", "timeout"
|
|
654
|
+
else:
|
|
655
|
+
timeout = min(timeout, FINAL_READ_SEC)
|
|
656
|
+
try:
|
|
657
|
+
returncode, stdout, stderr, killed = _run([exe, "-c", f"core.hooksPath={os.devnull}", "-C", str(cwd),
|
|
658
|
+
*args], timeout)
|
|
659
|
+
except (OSError, ValueError):
|
|
660
|
+
return None
|
|
661
|
+
if returncode is None:
|
|
662
|
+
return (KILLED if killed else STOPPED), stdout, "timeout"
|
|
663
|
+
return returncode, stdout, stderr.decode("utf-8", errors="replace")
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _ok(res: tuple[int, bytes, str] | None) -> bool:
|
|
667
|
+
return res is not None and res[0] == 0
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _out(res: tuple[int, bytes, str] | None) -> str:
|
|
671
|
+
"""The first stdout line of a successful call, else ""."""
|
|
672
|
+
if not _ok(res):
|
|
673
|
+
return ""
|
|
674
|
+
lines = res[1].decode("utf-8", errors="replace").strip().splitlines()
|
|
675
|
+
return lines[0].strip() if lines else ""
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _last_error(stderr: str | None) -> str:
|
|
679
|
+
"""The last `fatal:`/`error:` line of git's stderr, else its last line."""
|
|
680
|
+
lines = [line.strip() for line in (stderr or "").splitlines() if line.strip()]
|
|
681
|
+
for line in reversed(lines):
|
|
682
|
+
if line.startswith(("fatal:", "error:")):
|
|
683
|
+
return line
|
|
684
|
+
return lines[-1] if lines else "git failed"
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _err(res: tuple[int, bytes, str] | None) -> str:
|
|
688
|
+
if res is None:
|
|
689
|
+
return "git could not run"
|
|
690
|
+
return _last_error(res[2])
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _rev(cwd: str | Path, rev: str, limited: bool = True) -> str | None:
|
|
694
|
+
"""The full commit `rev` names in the repository at cwd, else None."""
|
|
695
|
+
sha = _out(_git(cwd, "rev-parse", "--verify", "--quiet", f"{rev}^{{commit}}", limited=limited))
|
|
696
|
+
return sha if SHA40_RE.match(sha) else None
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
# --------------------------------------------------------------------------
|
|
700
|
+
# Which sources this helper takes
|
|
701
|
+
# --------------------------------------------------------------------------
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _one_name(part: str) -> bool:
|
|
705
|
+
"""True when part names one folder inside its parent on every platform.
|
|
706
|
+
|
|
707
|
+
Not `\\`, `:` or a control character, and not only dots and spaces:
|
|
708
|
+
Windows drops a name's trailing dots and spaces, so `.. ` is `..` there.
|
|
709
|
+
"""
|
|
710
|
+
return bool(part.rstrip(". ")) and not _NOT_A_NAME_RE.search(part)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def parse_remote(value: str | None) -> tuple[str, tuple[str, ...], str] | None:
|
|
714
|
+
"""(host, owner_parts, repo) for a remote repository, else None.
|
|
715
|
+
|
|
716
|
+
Accepts https/http/ssh/git URLs, scp-like `user@host:owner/repo` and the
|
|
717
|
+
`owner/repo` shorthand (github.com; only when no such local path
|
|
718
|
+
exists). A local path, `file:` URL or `<transport>::` address is None,
|
|
719
|
+
and so is a host, owner or repository part that is not one folder name
|
|
720
|
+
(see _one_name): SKF's clone and this run's tree are folders named
|
|
721
|
+
after them.
|
|
722
|
+
"""
|
|
723
|
+
if not value:
|
|
724
|
+
return None
|
|
725
|
+
value = value.strip()
|
|
726
|
+
if (
|
|
727
|
+
not value
|
|
728
|
+
or value.startswith(("-", "/", "./", "../", "~"))
|
|
729
|
+
or value.lower().startswith("file:")
|
|
730
|
+
or "::" in value
|
|
731
|
+
or _DRIVE_RE.match(value)
|
|
732
|
+
):
|
|
733
|
+
return None
|
|
734
|
+
m = _URL_RE.match(value)
|
|
735
|
+
if m:
|
|
736
|
+
host, path = m.group("host"), m.group("path")
|
|
737
|
+
else:
|
|
738
|
+
m = _SCP_RE.match(value)
|
|
739
|
+
if m:
|
|
740
|
+
host, path = m.group("host"), m.group("path")
|
|
741
|
+
elif _SHORTHAND_RE.match(value) and not os.path.exists(value):
|
|
742
|
+
host, path = "github.com", value
|
|
743
|
+
else:
|
|
744
|
+
return None
|
|
745
|
+
parts = path.split("/")
|
|
746
|
+
if parts and parts[-1].endswith(".git"):
|
|
747
|
+
parts[-1] = parts[-1][: -len(".git")]
|
|
748
|
+
if len(parts) < 2 or not all(_one_name(part) for part in (host, *parts)):
|
|
749
|
+
return None
|
|
750
|
+
return host.lower(), tuple(parts[:-1]), parts[-1]
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _key(parsed: tuple[str, tuple[str, ...], str]) -> tuple:
|
|
754
|
+
host, owner, repo = parsed
|
|
755
|
+
return host.casefold(), tuple(part.casefold() for part in owner), repo.casefold()
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _url(source_repo: str, parsed: tuple[str, tuple[str, ...], str]) -> str:
|
|
759
|
+
"""The URL git reads: source_repo itself, or https for the shorthand."""
|
|
760
|
+
value = source_repo.strip()
|
|
761
|
+
if "://" in value or (_SCP_RE.match(value) and not _SHORTHAND_RE.match(value)):
|
|
762
|
+
return value
|
|
763
|
+
host, owner, repo = parsed
|
|
764
|
+
return f"https://{host}/{'/'.join(owner)}/{repo}"
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def workspace_root() -> Path:
|
|
768
|
+
return Path(os.path.expanduser(os.environ.get("SKF_WORKSPACE") or "~/.skf/workspace"))
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def clone_path(parsed: tuple[str, tuple[str, ...], str]) -> Path:
|
|
772
|
+
host, owner, repo = parsed
|
|
773
|
+
return workspace_root().joinpath("repos", host, *owner, repo)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _stays_inside(parsed: tuple[str, tuple[str, ...], str]) -> bool:
|
|
777
|
+
"""A second guard behind parse_remote: SKF's clone path for parsed lies
|
|
778
|
+
inside `repos/` of the workspace, and the repository name, which names
|
|
779
|
+
this run's tree inside its run folder, is one folder name."""
|
|
780
|
+
repos = os.path.normcase(os.path.abspath(workspace_root() / "repos"))
|
|
781
|
+
clone = os.path.normcase(os.path.abspath(clone_path(parsed)))
|
|
782
|
+
try:
|
|
783
|
+
inside = clone != repos and os.path.commonpath([repos, clone]) == repos
|
|
784
|
+
except ValueError: # another drive
|
|
785
|
+
return False
|
|
786
|
+
repo = parsed[2]
|
|
787
|
+
return inside and repo not in (".", "..") and os.path.dirname(os.path.join("run", repo)) == "run"
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _stat_dir(p: str | Path) -> bool | None:
|
|
791
|
+
"""True for a folder, False when nothing is there, None when unreadable.
|
|
792
|
+
|
|
793
|
+
os.stat, not pathlib: from Python 3.14 pathlib answers False inside a
|
|
794
|
+
folder it cannot search instead of raising.
|
|
795
|
+
"""
|
|
796
|
+
try:
|
|
797
|
+
st = os.stat(p)
|
|
798
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
799
|
+
return False
|
|
800
|
+
except OSError:
|
|
801
|
+
return None
|
|
802
|
+
return stat.S_ISDIR(st.st_mode)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _clone_matches(path: str | Path, parsed: tuple[str, tuple[str, ...], str]) -> bool:
|
|
806
|
+
"""True when path is the top of a clone whose origin is the same repository."""
|
|
807
|
+
top = _out(_git(path, "rev-parse", "--show-toplevel"))
|
|
808
|
+
if not top or not _same_path(top, path):
|
|
809
|
+
return False
|
|
810
|
+
origin = parse_remote(_out(_git(path, "config", "--get", "remote.origin.url")))
|
|
811
|
+
return origin is not None and _key(origin) == _key(parsed)
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def classify(source_repo: str, source_root: str) -> dict:
|
|
815
|
+
"""Decide whether source_root is SKF's clone of source_repo."""
|
|
816
|
+
out = {"parsed": None, "clone": None, "usable": False, "skip_reason": None, "warnings": []}
|
|
817
|
+
parsed = parse_remote(source_repo)
|
|
818
|
+
if parsed is None or not _stays_inside(parsed):
|
|
819
|
+
out["skip_reason"] = "not-remote"
|
|
820
|
+
return out
|
|
821
|
+
out["parsed"] = parsed
|
|
822
|
+
root = os.path.expanduser(source_root.strip()) if source_root else ""
|
|
823
|
+
if not root:
|
|
824
|
+
out["skip_reason"] = "not-workspace-clone"
|
|
825
|
+
return out
|
|
826
|
+
workspace_clone = clone_path(parsed)
|
|
827
|
+
if _same_path(root, workspace_clone):
|
|
828
|
+
state = _stat_dir(workspace_clone)
|
|
829
|
+
if state is False:
|
|
830
|
+
out["clone"] = str(workspace_clone)
|
|
831
|
+
elif state is True and _clone_matches(workspace_clone, parsed):
|
|
832
|
+
out["clone"] = str(workspace_clone)
|
|
833
|
+
out["usable"] = True
|
|
834
|
+
else:
|
|
835
|
+
out["warnings"].append(
|
|
836
|
+
f"{workspace_clone} is not SKF's clone of {source_repo}; "
|
|
837
|
+
"this run reads a fresh copy and leaves that folder alone"
|
|
838
|
+
)
|
|
839
|
+
return out
|
|
840
|
+
host, owner, repo = parsed
|
|
841
|
+
# Letter case never matters here: create-skill copies the host as the
|
|
842
|
+
# URL spells it, and _clone_matches checks the origin.
|
|
843
|
+
tail = tuple(p.casefold() for p in ("repos", host, *owner, repo))
|
|
844
|
+
parts = Path(os.path.abspath(root)).parts
|
|
845
|
+
if (
|
|
846
|
+
_stat_dir(root) is True
|
|
847
|
+
and len(parts) >= len(tail)
|
|
848
|
+
and tuple(p.casefold() for p in parts[-len(tail):]) == tail
|
|
849
|
+
and _clone_matches(root, parsed)
|
|
850
|
+
):
|
|
851
|
+
out["clone"] = root
|
|
852
|
+
out["usable"] = True
|
|
853
|
+
return out
|
|
854
|
+
out["skip_reason"] = "not-workspace-clone"
|
|
855
|
+
return out
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
# --------------------------------------------------------------------------
|
|
859
|
+
# Refs
|
|
860
|
+
# --------------------------------------------------------------------------
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _ref_ok(ref: str) -> bool:
|
|
864
|
+
if not REF_RE.fullmatch(ref) or ref == "@":
|
|
865
|
+
return False
|
|
866
|
+
return _ok(_git(tempfile.gettempdir(), "check-ref-format", "--allow-onelevel", f"refs/tags/{ref}"))
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def resolve_ref(url: str, ref: str) -> tuple[str, str | None, str | None, str | None, str | None]:
|
|
870
|
+
"""(state, kind, sha, remote_ref, error) for ref on the remote at url.
|
|
871
|
+
|
|
872
|
+
state is "ok", "ref-not-found" or "unreachable" (error then holds git's
|
|
873
|
+
last error line). A full commit needs no network.
|
|
874
|
+
"""
|
|
875
|
+
if SHA40_RE.match(ref):
|
|
876
|
+
return "ok", "commit", ref, ref, None
|
|
877
|
+
if ref == "HEAD":
|
|
878
|
+
patterns = ["HEAD"]
|
|
879
|
+
else:
|
|
880
|
+
patterns = [f"refs/tags/{ref}", f"refs/tags/{ref}^{{}}", f"refs/heads/{ref}"]
|
|
881
|
+
res = _git(tempfile.gettempdir(), "ls-remote", "--", url, *patterns, timeout=LS_REMOTE_TIMEOUT_SEC,
|
|
882
|
+
network=True)
|
|
883
|
+
if not _ok(res):
|
|
884
|
+
return "unreachable", None, None, None, _err(res)
|
|
885
|
+
refs = {}
|
|
886
|
+
for line in res[1].decode("utf-8", errors="replace").splitlines():
|
|
887
|
+
sha, _, name = line.partition("\t")
|
|
888
|
+
if SHA40_RE.match(sha.strip()) and name.strip():
|
|
889
|
+
refs[name.strip()] = sha.strip()
|
|
890
|
+
for name, kind, remote_ref in (
|
|
891
|
+
(f"refs/tags/{ref}^{{}}", "tag", f"refs/tags/{ref}"),
|
|
892
|
+
(f"refs/tags/{ref}", "tag", f"refs/tags/{ref}"),
|
|
893
|
+
(f"refs/heads/{ref}", "branch", f"refs/heads/{ref}"),
|
|
894
|
+
("HEAD", "head", "HEAD"),
|
|
895
|
+
):
|
|
896
|
+
if name in refs and (ref == "HEAD") == (name == "HEAD"):
|
|
897
|
+
return "ok", kind, refs[name], remote_ref, None
|
|
898
|
+
return "ref-not-found", None, None, None, None
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def list_tags(url: str) -> tuple[dict | None, str | None]:
|
|
902
|
+
"""({tag: commit}, None) for the remote at url, or (None, git's last error line).
|
|
903
|
+
|
|
904
|
+
An annotated tag maps to the commit it names (its peeled `^{}` line).
|
|
905
|
+
"""
|
|
906
|
+
res = _git(tempfile.gettempdir(), "ls-remote", "--tags", "--", url, timeout=LS_REMOTE_TIMEOUT_SEC,
|
|
907
|
+
network=True)
|
|
908
|
+
if not _ok(res):
|
|
909
|
+
return None, _err(res)
|
|
910
|
+
tags = {}
|
|
911
|
+
for line in res[1].decode("utf-8", errors="replace").splitlines():
|
|
912
|
+
sha, _, name = line.partition("\t")
|
|
913
|
+
sha, name = sha.strip(), name.strip()
|
|
914
|
+
if not SHA40_RE.match(sha) or not name.startswith("refs/tags/"):
|
|
915
|
+
continue
|
|
916
|
+
name = name[len("refs/tags/"):]
|
|
917
|
+
if name.endswith("^{}"):
|
|
918
|
+
tags[name[:-3]] = sha
|
|
919
|
+
else:
|
|
920
|
+
tags.setdefault(name, sha)
|
|
921
|
+
return tags, None
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def match_tags(tags, version: str, name: str | None = None, implicit: bool = False) -> list[str]:
|
|
925
|
+
"""The tags that name `version`, in the order the forms are tried.
|
|
926
|
+
|
|
927
|
+
Implicit: the version as given and `v<version>`. Otherwise also, with
|
|
928
|
+
name, `<name>@<v>`, `@<scope>/<name>@<v>` (any scope, by name),
|
|
929
|
+
`<name>/v<v>`, `<name>/<v>` and `<name>-v<v>`, where <v> drops a
|
|
930
|
+
leading `v` of the version.
|
|
931
|
+
"""
|
|
932
|
+
bare = version[1:] if re.match(r"^[vV]\d", version) else version
|
|
933
|
+
forms = [version, f"v{bare}"]
|
|
934
|
+
if name and not implicit:
|
|
935
|
+
scoped = re.compile(rf"@[^/@]+/{re.escape(name)}@{re.escape(bare)}")
|
|
936
|
+
forms += [f"{name}@{bare}", *sorted(t for t in tags if scoped.fullmatch(t)),
|
|
937
|
+
f"{name}/v{bare}", f"{name}/{bare}", f"{name}-v{bare}"]
|
|
938
|
+
return [tag for tag in dict.fromkeys(forms) if tag in tags]
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def nearest_tags(tags, version: str, count: int = NEAREST_TAGS) -> list[str]:
|
|
942
|
+
"""Up to `count` tags that sort next to `version` by version, newest first."""
|
|
943
|
+
wanted = split_version(version)
|
|
944
|
+
if wanted is None:
|
|
945
|
+
return []
|
|
946
|
+
keyed = sorted((split[1], tag) for tag in tags if (split := split_version(tag)) is not None)
|
|
947
|
+
at = bisect.bisect_left([key for key, _tag in keyed], wanted[1])
|
|
948
|
+
lo, hi, picked = at - 1, at, []
|
|
949
|
+
while len(picked) < count and (lo >= 0 or hi < len(keyed)):
|
|
950
|
+
if hi < len(keyed) and (lo < 0 or hi - at <= at - 1 - lo):
|
|
951
|
+
picked.append(keyed[hi])
|
|
952
|
+
hi += 1
|
|
953
|
+
else:
|
|
954
|
+
picked.append(keyed[lo])
|
|
955
|
+
lo -= 1
|
|
956
|
+
return [tag for _key, tag in sorted(picked, reverse=True)]
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
# --------------------------------------------------------------------------
|
|
960
|
+
# Run folders
|
|
961
|
+
# --------------------------------------------------------------------------
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _on_rm_error(func, path, _exc) -> None:
|
|
965
|
+
"""Make a read-only entry writable (Windows git pack files), then retry.
|
|
966
|
+
|
|
967
|
+
A link is never chmod-ed: that would change the file it points to.
|
|
968
|
+
"""
|
|
969
|
+
try:
|
|
970
|
+
if not os.path.islink(path):
|
|
971
|
+
os.chmod(path, stat.S_IMODE(os.lstat(path).st_mode) | stat.S_IWRITE)
|
|
972
|
+
func(path)
|
|
973
|
+
except OSError:
|
|
974
|
+
pass
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def _long_path(path: str) -> str:
|
|
978
|
+
"""The extended-length form of an absolute Windows path (`\\\\?\\C:\\...`,
|
|
979
|
+
`\\\\?\\UNC\\server\\...`), which Windows accepts past MAX_PATH (260
|
|
980
|
+
characters) even where long paths are not enabled."""
|
|
981
|
+
if path.startswith("\\\\?\\"):
|
|
982
|
+
return path
|
|
983
|
+
if path.startswith("\\\\"):
|
|
984
|
+
return "\\\\?\\UNC\\" + path[2:]
|
|
985
|
+
return "\\\\?\\" + path
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _rmtree(path: str | Path, windows: bool = os.name == "nt") -> bool:
|
|
989
|
+
"""Remove a folder tree; True when nothing is left at path.
|
|
990
|
+
|
|
991
|
+
On Windows through the extended-length path: the checkout passes
|
|
992
|
+
core.longpaths, so git writes files past MAX_PATH that a plain path
|
|
993
|
+
can neither list nor remove.
|
|
994
|
+
"""
|
|
995
|
+
target = _long_path(os.path.abspath(path)) if windows else path
|
|
996
|
+
if sys.version_info >= (3, 12):
|
|
997
|
+
shutil.rmtree(target, onexc=_on_rm_error)
|
|
998
|
+
else:
|
|
999
|
+
shutil.rmtree(target, onerror=_on_rm_error)
|
|
1000
|
+
return not os.path.lexists(path)
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _tree_roots() -> list[Path]:
|
|
1004
|
+
return [workspace_root() / "trees", Path(tempfile.gettempdir())]
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def sweep(warnings: list[str]) -> None:
|
|
1008
|
+
"""Remove run folders whose name stamp is STALE_TREE_DAYS old or more."""
|
|
1009
|
+
now = datetime.now(timezone.utc)
|
|
1010
|
+
for root in _tree_roots():
|
|
1011
|
+
try:
|
|
1012
|
+
names = os.listdir(root)
|
|
1013
|
+
except OSError:
|
|
1014
|
+
continue
|
|
1015
|
+
for name in names:
|
|
1016
|
+
m = TREE_NAME_RE.match(name)
|
|
1017
|
+
if not m:
|
|
1018
|
+
continue
|
|
1019
|
+
try:
|
|
1020
|
+
stamp = datetime.strptime(m.group(1), STAMP_FORMAT).replace(tzinfo=timezone.utc)
|
|
1021
|
+
except ValueError:
|
|
1022
|
+
continue
|
|
1023
|
+
if now - stamp < timedelta(days=STALE_TREE_DAYS):
|
|
1024
|
+
continue # recent, or stamped in the future
|
|
1025
|
+
path = root / name
|
|
1026
|
+
if _is_link_or_junction(path):
|
|
1027
|
+
continue
|
|
1028
|
+
try:
|
|
1029
|
+
st = os.lstat(path)
|
|
1030
|
+
except OSError:
|
|
1031
|
+
continue
|
|
1032
|
+
if not stat.S_ISDIR(st.st_mode):
|
|
1033
|
+
continue
|
|
1034
|
+
if os.name != "nt" and st.st_uid != os.getuid():
|
|
1035
|
+
continue
|
|
1036
|
+
if not _rmtree(path):
|
|
1037
|
+
warnings.append(f"could not remove {path}")
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _new_run_dir(warnings: list[str]) -> Path | None:
|
|
1041
|
+
prefix = f"{TREE_PREFIX}{datetime.now(timezone.utc).strftime(STAMP_FORMAT)}-"
|
|
1042
|
+
trees = workspace_root() / "trees"
|
|
1043
|
+
try:
|
|
1044
|
+
os.makedirs(trees, exist_ok=True)
|
|
1045
|
+
return Path(tempfile.mkdtemp(prefix=prefix, dir=trees))
|
|
1046
|
+
except OSError:
|
|
1047
|
+
warnings.append(f"could not create a tree folder under {trees}; using the system temp folder")
|
|
1048
|
+
try:
|
|
1049
|
+
return Path(tempfile.mkdtemp(prefix=prefix, dir=tempfile.gettempdir()))
|
|
1050
|
+
except OSError:
|
|
1051
|
+
return None
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def _write_json(path: str | Path, obj: dict) -> None:
|
|
1055
|
+
# O_BINARY (Windows only; 0 elsewhere) suppresses the text-mode \n -> \r\n
|
|
1056
|
+
# translation that would otherwise corrupt verbatim writes on Windows.
|
|
1057
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0), 0o644)
|
|
1058
|
+
try:
|
|
1059
|
+
os.write(fd, json.dumps(obj).encode("utf-8"))
|
|
1060
|
+
finally:
|
|
1061
|
+
os.close(fd)
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _read_record(run: Path) -> dict | None:
|
|
1065
|
+
"""run.json of run folder `run` when it names this tool and a tree inside the folder, else None."""
|
|
1066
|
+
try:
|
|
1067
|
+
with open(run / RUN_FILE, encoding="utf-8") as fh:
|
|
1068
|
+
record = json.load(fh)
|
|
1069
|
+
except (OSError, ValueError):
|
|
1070
|
+
return None
|
|
1071
|
+
if not isinstance(record, dict) or record.get("tool") != TOOL or not isinstance(record.get("tree"), str):
|
|
1072
|
+
return None
|
|
1073
|
+
if not _same_path(Path(record["tree"]).parent, run):
|
|
1074
|
+
return None
|
|
1075
|
+
return record
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _locate_run(given: Path) -> tuple[Path, Path | None]:
|
|
1079
|
+
"""(run folder, tree) for a --tree value: the tree open printed, or its run folder (tree None).
|
|
1080
|
+
|
|
1081
|
+
An agent may bind the run folder, where changed-files.json sits, rather
|
|
1082
|
+
than the tree: a folder named like a run folder that holds run.json (or
|
|
1083
|
+
is gone) is taken as one.
|
|
1084
|
+
"""
|
|
1085
|
+
if TREE_NAME_RE.match(given.name) and (not os.path.lexists(given) or os.path.isfile(given / RUN_FILE)):
|
|
1086
|
+
return given, None
|
|
1087
|
+
return given.parent, given
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def _seed(tree: Path, sha: str, name: str, clone: str | None, usable: bool, url: str,
|
|
1091
|
+
remote_ref: str | None) -> bool:
|
|
1092
|
+
"""Fetch one commit into refs/skf/<name> of the tree.
|
|
1093
|
+
|
|
1094
|
+
From the shared clone when it holds the commit (a local fetch only reads
|
|
1095
|
+
the clone), otherwise from the remote. True when the ref names a commit.
|
|
1096
|
+
"""
|
|
1097
|
+
dest = f"refs/skf/{name}"
|
|
1098
|
+
if usable and clone and sha and _ok(_git(clone, "cat-file", "-e", f"{sha}^{{commit}}")):
|
|
1099
|
+
_git(tree, "fetch", "--quiet", "--no-tags", "--depth", "1", "--", str(clone), f"+{sha}:{dest}")
|
|
1100
|
+
if _rev(tree, dest):
|
|
1101
|
+
return True
|
|
1102
|
+
if remote_ref:
|
|
1103
|
+
_git(tree, "fetch", "--quiet", "--no-tags", "--depth", "1", "--", url, f"+{remote_ref}:{dest}",
|
|
1104
|
+
timeout=NETWORK_TIMEOUT_SEC, network=True)
|
|
1105
|
+
return _rev(tree, dest) is not None
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def _make_tree(source_repo: str, repo: str, sha: str, clone: str | None, usable: bool, url: str,
|
|
1109
|
+
remote_ref: str | None, warnings: list[str]) -> tuple[Path | None, dict | None, str | None]:
|
|
1110
|
+
"""A new run folder whose tree holds sha in refs/skf/target: (tree, run.json record, None).
|
|
1111
|
+
|
|
1112
|
+
(None, None, None) when no run folder could be made, and (None, None,
|
|
1113
|
+
error line) when the commit could not be fetched; the folder is then
|
|
1114
|
+
removed again.
|
|
1115
|
+
"""
|
|
1116
|
+
run = _new_run_dir(warnings)
|
|
1117
|
+
if run is None:
|
|
1118
|
+
return None, None, None
|
|
1119
|
+
tree = run / repo
|
|
1120
|
+
record = {"tool": TOOL, "created": datetime.now(timezone.utc).strftime(STAMP_FORMAT), "tree": str(tree),
|
|
1121
|
+
"source_repo": source_repo, "clone": clone}
|
|
1122
|
+
_write_json(run / RUN_FILE, record)
|
|
1123
|
+
init = _git(run, "init", "--quiet", "--template=", repo)
|
|
1124
|
+
if _ok(init) and _seed(tree, sha, "target", clone, usable, url, remote_ref):
|
|
1125
|
+
return tree, record, None
|
|
1126
|
+
_rmtree(run)
|
|
1127
|
+
return None, None, _err(init) if not _ok(init) else "the commit could not be fetched"
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def _check_out(tree: Path) -> tuple[str | None, tuple[int, bytes, str] | None]:
|
|
1131
|
+
"""Check the tree out at refs/skf/target: (the commit, or None when that failed; git's result)."""
|
|
1132
|
+
target_commit = _rev(tree, "refs/skf/target")
|
|
1133
|
+
checkout = _git(tree, *CHECKOUT_OPTS, "checkout", "--quiet", "--detach", target_commit or "",
|
|
1134
|
+
timeout=NETWORK_TIMEOUT_SEC)
|
|
1135
|
+
if not target_commit or not _ok(checkout) or _rev(tree, "HEAD") != target_commit:
|
|
1136
|
+
return None, checkout
|
|
1137
|
+
return target_commit, checkout
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _unavailable(out: dict, reason: str, message: str) -> tuple[int, dict]:
|
|
1141
|
+
"""Mark out unavailable and null the result keys the command reports (open or resolve)."""
|
|
1142
|
+
out.update(status="unavailable", reason=reason, message=message)
|
|
1143
|
+
for key in ("tree", "target_commit", "moved", "diff_status", "changed_files", "changed_counts",
|
|
1144
|
+
"source_commit"):
|
|
1145
|
+
if key in out:
|
|
1146
|
+
out[key] = None
|
|
1147
|
+
return 3, out
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
# --------------------------------------------------------------------------
|
|
1151
|
+
# open
|
|
1152
|
+
# --------------------------------------------------------------------------
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def _diff(tree: Path, base: str, target: str) -> list[dict] | None:
|
|
1156
|
+
if base == target:
|
|
1157
|
+
return []
|
|
1158
|
+
res = _git(tree, "diff", "--name-status", "-z", "--no-renames", base, target)
|
|
1159
|
+
if not _ok(res):
|
|
1160
|
+
return None
|
|
1161
|
+
fields = res[1].split(b"\0")
|
|
1162
|
+
files = []
|
|
1163
|
+
for i in range(0, len(fields) - 1, 2):
|
|
1164
|
+
status_letter = fields[i].decode("ascii", errors="replace")[:1]
|
|
1165
|
+
path = fields[i + 1].decode("utf-8", errors="surrogateescape")
|
|
1166
|
+
status_letter = "M" if status_letter == "T" else status_letter
|
|
1167
|
+
if status_letter in ("A", "M", "D") and path:
|
|
1168
|
+
files.append({"status": status_letter, "path": path})
|
|
1169
|
+
return sorted(files, key=lambda f: f["path"])
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def open_tree(args: argparse.Namespace) -> tuple[int, dict]:
|
|
1173
|
+
out = {
|
|
1174
|
+
"status": None, "skip_reason": None, "reason": None, "message": None, "tree": None,
|
|
1175
|
+
"clone": None, "target_ref": None, "ref_kind": None, "target_commit": None,
|
|
1176
|
+
"moved": None, "diff_status": None, "changed_files": None, "changed_counts": None,
|
|
1177
|
+
"warnings": [],
|
|
1178
|
+
}
|
|
1179
|
+
_start_clock(args.timeout)
|
|
1180
|
+
source_repo = args.source_repo.strip()
|
|
1181
|
+
ref = (args.target_ref or args.source_ref or "").strip()
|
|
1182
|
+
if ref.lower() in HEAD_ALIASES:
|
|
1183
|
+
ref = "HEAD"
|
|
1184
|
+
elif re.fullmatch(r"[0-9A-Fa-f]{40}", ref):
|
|
1185
|
+
ref = ref.lower()
|
|
1186
|
+
|
|
1187
|
+
def _unavailable_or_late(reason: str, message: str) -> tuple[int, dict]:
|
|
1188
|
+
if _late(reason, _OPEN_NETWORK_LATE, _OPEN_LOCAL_LATE):
|
|
1189
|
+
reason = "timed-out"
|
|
1190
|
+
message = (f"Reading {ref} of {source_repo} did not finish within the "
|
|
1191
|
+
f"{args.timeout:g}-second time limit; a first fetch of a large repository is slow")
|
|
1192
|
+
return _unavailable(out, reason, message)
|
|
1193
|
+
|
|
1194
|
+
found = classify(source_repo, args.source_root)
|
|
1195
|
+
out["warnings"].extend(found["warnings"])
|
|
1196
|
+
if found["skip_reason"]:
|
|
1197
|
+
if found["parsed"] is not None and _out_of_time():
|
|
1198
|
+
# The clone checks were cut short: never read the folder as it stands.
|
|
1199
|
+
out["target_ref"] = ref
|
|
1200
|
+
return _unavailable_or_late("upstream-unreachable", "")
|
|
1201
|
+
out.update(status="skipped", skip_reason=found["skip_reason"])
|
|
1202
|
+
return 0, out
|
|
1203
|
+
parsed, clone, usable = found["parsed"], found["clone"], found["usable"]
|
|
1204
|
+
out["clone"] = clone
|
|
1205
|
+
out["target_ref"] = ref
|
|
1206
|
+
if _resolve_outside_cwd("git") is None:
|
|
1207
|
+
return _unavailable(out, "git-unavailable",
|
|
1208
|
+
f"git is not installed; update-skill needs it to read {source_repo}")
|
|
1209
|
+
if ref != "HEAD" and not SHA40_RE.match(ref) and not _ref_ok(ref):
|
|
1210
|
+
return _unavailable_or_late("invalid-ref", f"{ref} is not a valid tag or branch name")
|
|
1211
|
+
|
|
1212
|
+
sweep(out["warnings"])
|
|
1213
|
+
|
|
1214
|
+
# `null` and `none` are a JSON null written out, as for --source-ref.
|
|
1215
|
+
pinned = (args.pinned_commit or "").strip().lower()
|
|
1216
|
+
pin = "" if is_skippable_pinned(pinned) or pinned in ("null", "none") else pinned
|
|
1217
|
+
pin_full, pin_local = None, False
|
|
1218
|
+
if pin and usable:
|
|
1219
|
+
pin_full = _rev(clone, pin)
|
|
1220
|
+
pin_local = pin_full is not None
|
|
1221
|
+
if pin_full is None and SHA40_RE.match(pin):
|
|
1222
|
+
pin_full = pin
|
|
1223
|
+
|
|
1224
|
+
url = _url(source_repo, parsed)
|
|
1225
|
+
state, kind, sha, remote_ref, error = resolve_ref(url, ref)
|
|
1226
|
+
offline_message = None
|
|
1227
|
+
if state == "ref-not-found":
|
|
1228
|
+
return _unavailable(out, "ref-not-found",
|
|
1229
|
+
f"{ref} is neither a tag nor a branch of {source_repo}; "
|
|
1230
|
+
"pass the ref to use with --target-ref")
|
|
1231
|
+
if state == "unreachable":
|
|
1232
|
+
if args.target_ref:
|
|
1233
|
+
return _unavailable_or_late("upstream-unreachable",
|
|
1234
|
+
f"Could not reach {source_repo} ({error}), so {ref} could not be read")
|
|
1235
|
+
if not pin_local:
|
|
1236
|
+
pinned = pin[:12] or "(none recorded)"
|
|
1237
|
+
return _unavailable_or_late("upstream-unreachable",
|
|
1238
|
+
f"Could not reach {source_repo} ({error}), and no local copy of the "
|
|
1239
|
+
f"pinned commit {pinned} exists")
|
|
1240
|
+
sha, kind, remote_ref = pin_full, "pinned", None
|
|
1241
|
+
offline_message = (f"Could not reach {source_repo} ({error}); read the pinned commit "
|
|
1242
|
+
f"{pin_full[:12]} from the local copy, so upstream changes were not checked")
|
|
1243
|
+
|
|
1244
|
+
while True:
|
|
1245
|
+
tree, record, fetch_error = _make_tree(source_repo, parsed[2], sha, clone, usable, url, remote_ref,
|
|
1246
|
+
out["warnings"])
|
|
1247
|
+
if tree is not None:
|
|
1248
|
+
break
|
|
1249
|
+
if fetch_error is None:
|
|
1250
|
+
return _unavailable(out, "tree-folder-failed",
|
|
1251
|
+
"Could not create a folder for this run's source tree in the SKF "
|
|
1252
|
+
"workspace or the system temp folder")
|
|
1253
|
+
if offline_message is None and not args.target_ref and pin_local:
|
|
1254
|
+
sha, kind, remote_ref = pin_full, "pinned", None
|
|
1255
|
+
offline_message = (f"Could not fetch {ref} from {source_repo}; read the pinned commit "
|
|
1256
|
+
f"{pin_full[:12]} from the local copy, so upstream changes were not checked")
|
|
1257
|
+
continue
|
|
1258
|
+
return _unavailable_or_late("fetch-failed", f"Could not fetch {ref} from {source_repo}: {fetch_error}")
|
|
1259
|
+
|
|
1260
|
+
run = tree.parent
|
|
1261
|
+
target_commit, checkout = _check_out(tree)
|
|
1262
|
+
if target_commit is None:
|
|
1263
|
+
_rmtree(run)
|
|
1264
|
+
return _unavailable_or_late("checkout-failed",
|
|
1265
|
+
f"Could not check out {ref} of {source_repo}: {_err(checkout)}")
|
|
1266
|
+
|
|
1267
|
+
moved = None if not pin else classify_match(pin, target_commit) is None
|
|
1268
|
+
base = None
|
|
1269
|
+
if moved is False:
|
|
1270
|
+
base = target_commit
|
|
1271
|
+
elif pin_full and _seed(tree, pin_full, "base", clone, usable, url,
|
|
1272
|
+
pin_full if SHA40_RE.match(pin_full) else None):
|
|
1273
|
+
base = _rev(tree, "refs/skf/base")
|
|
1274
|
+
|
|
1275
|
+
files = _diff(tree, base, target_commit) if base else None
|
|
1276
|
+
if files is None:
|
|
1277
|
+
if pin_full and _expired():
|
|
1278
|
+
out["warnings"].append(f"the list of files changed since {pin_full[:12]} was not built within "
|
|
1279
|
+
f"the {args.timeout:g}-second time limit")
|
|
1280
|
+
out.update(diff_status="unavailable", changed_files=None, changed_counts=None)
|
|
1281
|
+
else:
|
|
1282
|
+
_write_json(run / CHANGED_FILE, {"base": base, "target": target_commit, "files": files})
|
|
1283
|
+
counts = {"added": 0, "modified": 0, "deleted": 0}
|
|
1284
|
+
for entry in files:
|
|
1285
|
+
counts[{"A": "added", "M": "modified", "D": "deleted"}[entry["status"]]] += 1
|
|
1286
|
+
out.update(diff_status="ok", changed_files=str(run / CHANGED_FILE), changed_counts=counts)
|
|
1287
|
+
|
|
1288
|
+
record["target_commit"] = target_commit
|
|
1289
|
+
_write_json(run / RUN_FILE, record)
|
|
1290
|
+
out.update(status="offline" if offline_message else "ready", message=offline_message,
|
|
1291
|
+
tree=str(tree), ref_kind=kind, target_commit=target_commit, moved=moved)
|
|
1292
|
+
return 0, out
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
# --------------------------------------------------------------------------
|
|
1296
|
+
# resolve
|
|
1297
|
+
# --------------------------------------------------------------------------
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def _given(value: str | None) -> str:
|
|
1301
|
+
"""value stripped; "" for an empty, `null` or `none` value (a JSON null written out)."""
|
|
1302
|
+
value = (value or "").strip()
|
|
1303
|
+
return "" if value.lower() in ("null", "none") else value
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def resolve(args: argparse.Namespace) -> tuple[int, dict]:
|
|
1307
|
+
out = {
|
|
1308
|
+
"status": None, "skip_reason": None, "reason": None, "message": None, "workspace_path": None,
|
|
1309
|
+
"clone": None, "source_ref": None, "ref_kind": None, "source_commit": None, "tree": None,
|
|
1310
|
+
"tag_resolution": {"status": "none", "mode": None, "requested": None, "candidates": [], "nearest": [],
|
|
1311
|
+
"reason": None},
|
|
1312
|
+
"clone_status": None, "clone_skip_reason": None, "clone_created": False, "warnings": [],
|
|
1313
|
+
}
|
|
1314
|
+
tags_out = out["tag_resolution"]
|
|
1315
|
+
_start_clock(args.timeout)
|
|
1316
|
+
source_repo = args.source_repo.strip()
|
|
1317
|
+
target_ref, version, name = _given(args.target_ref), _given(args.version), _given(args.name)
|
|
1318
|
+
source_root = _given(args.source_root)
|
|
1319
|
+
ref = None
|
|
1320
|
+
|
|
1321
|
+
def _unavailable_or_late(reason: str, message: str) -> tuple[int, dict]:
|
|
1322
|
+
if _late(reason, _OPEN_NETWORK_LATE, _OPEN_LOCAL_LATE):
|
|
1323
|
+
reason = "timed-out"
|
|
1324
|
+
message = (f"Reading {ref or target_ref or version or 'HEAD'} of {source_repo} did not finish "
|
|
1325
|
+
f"within the {args.timeout:g}-second time limit; a first fetch of a large repository "
|
|
1326
|
+
"is slow")
|
|
1327
|
+
return _unavailable(out, reason, message)
|
|
1328
|
+
|
|
1329
|
+
parsed = parse_remote(source_repo)
|
|
1330
|
+
if parsed is None or not _stays_inside(parsed):
|
|
1331
|
+
out.update(status="skipped", skip_reason="not-remote")
|
|
1332
|
+
return 0, out
|
|
1333
|
+
workspace = clone_path(parsed)
|
|
1334
|
+
out["workspace_path"] = str(workspace)
|
|
1335
|
+
found = classify(source_repo, source_root or str(workspace))
|
|
1336
|
+
out["warnings"].extend(found["warnings"])
|
|
1337
|
+
clone, usable = found["clone"], found["usable"]
|
|
1338
|
+
if clone is None and not found["warnings"]:
|
|
1339
|
+
out["warnings"].append(f"{source_root} is not SKF's clone of {source_repo}; "
|
|
1340
|
+
"the tree is read from the remote and that folder is left alone")
|
|
1341
|
+
out["clone"] = clone
|
|
1342
|
+
if _resolve_outside_cwd("git") is None:
|
|
1343
|
+
return _unavailable(out, "git-unavailable", f"git is not installed; it is needed to read {source_repo}")
|
|
1344
|
+
url = _url(source_repo, parsed)
|
|
1345
|
+
|
|
1346
|
+
kind = sha = remote_ref = None
|
|
1347
|
+
if target_ref:
|
|
1348
|
+
tags_out["requested"] = target_ref
|
|
1349
|
+
wanted = "HEAD" if target_ref.lower() == "head" else target_ref
|
|
1350
|
+
wanted = wanted.lower() if re.fullmatch(r"[0-9A-Fa-f]{40}", wanted) else wanted
|
|
1351
|
+
state = "ref-not-found"
|
|
1352
|
+
if wanted == "HEAD" or SHA40_RE.match(wanted) or _ref_ok(wanted):
|
|
1353
|
+
state, kind, sha, remote_ref, error = resolve_ref(url, wanted)
|
|
1354
|
+
if state == "unreachable":
|
|
1355
|
+
return _unavailable_or_late("upstream-unreachable", f"Could not reach {source_repo} ({error}), "
|
|
1356
|
+
f"so {target_ref} could not be read")
|
|
1357
|
+
if state == "ok":
|
|
1358
|
+
ref = wanted
|
|
1359
|
+
tags_out["status"] = "target-ref"
|
|
1360
|
+
else:
|
|
1361
|
+
tags_out.update(status="fallback-head", reason="target-ref-not-found")
|
|
1362
|
+
out["warnings"].append(f"target_ref ({target_ref}) does not resolve in {source_repo}; "
|
|
1363
|
+
+ ("falling back to version matching" if version else "reading HEAD"))
|
|
1364
|
+
|
|
1365
|
+
if ref is None and version:
|
|
1366
|
+
mode = "implicit" if args.implicit else "explicit"
|
|
1367
|
+
tags_out.update(mode=mode, requested=version, reason=None)
|
|
1368
|
+
tags, error = list_tags(url)
|
|
1369
|
+
if tags is None:
|
|
1370
|
+
return _unavailable_or_late("upstream-unreachable",
|
|
1371
|
+
f"Could not list the tags of {source_repo} ({error})")
|
|
1372
|
+
candidates = match_tags(tags, version, name or None, implicit=args.implicit)
|
|
1373
|
+
tags_out["candidates"] = candidates
|
|
1374
|
+
if len(candidates) > 1:
|
|
1375
|
+
tags_out["status"] = "ambiguous"
|
|
1376
|
+
out.update(status="ambiguous",
|
|
1377
|
+
message=(f"Several tags of {source_repo} match version {version}: "
|
|
1378
|
+
f"{', '.join(candidates)}. Run resolve again with the one to use as "
|
|
1379
|
+
"--target-ref."))
|
|
1380
|
+
return 0, out
|
|
1381
|
+
if candidates:
|
|
1382
|
+
ref, kind, sha, remote_ref = candidates[0], "tag", tags[candidates[0]], f"refs/tags/{candidates[0]}"
|
|
1383
|
+
tags_out["status"] = "matched"
|
|
1384
|
+
else:
|
|
1385
|
+
nearest = nearest_tags(tags, version)
|
|
1386
|
+
tags_out.update(status="fallback-head", reason="no-matching-tag", nearest=nearest)
|
|
1387
|
+
out["warnings"].append(f"no tag of {source_repo} matches version {version}; reading HEAD, the "
|
|
1388
|
+
f"default branch (nearest tags: {', '.join(nearest) or 'none'})")
|
|
1389
|
+
|
|
1390
|
+
if ref is None:
|
|
1391
|
+
state, kind, sha, remote_ref, error = resolve_ref(url, "HEAD")
|
|
1392
|
+
if state == "unreachable":
|
|
1393
|
+
return _unavailable_or_late("upstream-unreachable",
|
|
1394
|
+
f"Could not reach {source_repo} ({error}), so HEAD could not be read")
|
|
1395
|
+
if state != "ok":
|
|
1396
|
+
return _unavailable(out, "ref-not-found", f"{source_repo} has no default branch to read")
|
|
1397
|
+
ref = "HEAD"
|
|
1398
|
+
out.update(source_ref=ref, ref_kind=kind)
|
|
1399
|
+
|
|
1400
|
+
sweep(out["warnings"])
|
|
1401
|
+
tree, record, fetch_error = _make_tree(source_repo, parsed[2], sha, clone, usable, url, remote_ref,
|
|
1402
|
+
out["warnings"])
|
|
1403
|
+
if tree is None and fetch_error is None:
|
|
1404
|
+
return _unavailable(out, "tree-folder-failed",
|
|
1405
|
+
"Could not create a folder for this run's source tree in the SKF "
|
|
1406
|
+
"workspace or the system temp folder")
|
|
1407
|
+
if tree is None:
|
|
1408
|
+
return _unavailable_or_late("fetch-failed", f"Could not fetch {ref} from {source_repo}: {fetch_error}")
|
|
1409
|
+
source_commit, checkout = _check_out(tree)
|
|
1410
|
+
if source_commit is None:
|
|
1411
|
+
_rmtree(tree.parent)
|
|
1412
|
+
return _unavailable_or_late("checkout-failed",
|
|
1413
|
+
f"Could not check out {ref} of {source_repo}: {_err(checkout)}")
|
|
1414
|
+
record["target_commit"] = source_commit
|
|
1415
|
+
_write_json(tree.parent / RUN_FILE, record)
|
|
1416
|
+
out.update(status="ready", tree=str(tree), source_commit=source_commit)
|
|
1417
|
+
|
|
1418
|
+
if args.update_clone:
|
|
1419
|
+
if clone is None:
|
|
1420
|
+
out.update(clone_status="skipped", clone_skip_reason="not-a-clone")
|
|
1421
|
+
else:
|
|
1422
|
+
moved = _advance(argparse.Namespace(
|
|
1423
|
+
clone=clone, source_repo=source_repo, target=source_commit, expect_commit="", target_ref=ref,
|
|
1424
|
+
tree=str(tree), hygiene_helper=args.hygiene_helper, drift_helper="",
|
|
1425
|
+
lock_timeout=args.lock_timeout, timeout=args.timeout), verify=False)
|
|
1426
|
+
out.update(clone_status=moved["status"], clone_skip_reason=moved["skip_reason"],
|
|
1427
|
+
clone_created=moved["created"])
|
|
1428
|
+
out["warnings"].extend(f"clone: {w}" for w in moved["warnings"])
|
|
1429
|
+
return 0, out
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
# --------------------------------------------------------------------------
|
|
1433
|
+
# advance
|
|
1434
|
+
# --------------------------------------------------------------------------
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
def _run_sibling(helper: str, *args: str) -> tuple[int, str] | None:
|
|
1438
|
+
"""Run a sibling SKF helper with this interpreter; None when it cannot run."""
|
|
1439
|
+
timeout = _cap(SIBLING_TIMEOUT_SEC)
|
|
1440
|
+
if timeout is None:
|
|
1441
|
+
return None
|
|
1442
|
+
try:
|
|
1443
|
+
returncode, stdout, _stderr, _killed = _run([sys.executable, helper, *args], timeout)
|
|
1444
|
+
except (OSError, ValueError):
|
|
1445
|
+
return None
|
|
1446
|
+
if returncode is None:
|
|
1447
|
+
return None
|
|
1448
|
+
return returncode, stdout.decode("utf-8", errors="replace")
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def _clear_lock(clone: Path, res: tuple[int, bytes, str] | None, name: str, warnings: list[str]) -> None:
|
|
1452
|
+
"""Remove the lock file `name` a git call the time limit killed left in clone's .git.
|
|
1453
|
+
|
|
1454
|
+
Only after KILLED: git held that lock while it ran (it fails at once
|
|
1455
|
+
when the lock exists), taskkill /F or SIGKILL kept it from removing
|
|
1456
|
+
it, and no other git can take it while the file is there. After
|
|
1457
|
+
STOPPED, SIGTERM let git remove it itself, so a lock of that name is
|
|
1458
|
+
another git's by then.
|
|
1459
|
+
"""
|
|
1460
|
+
if res is None or res[0] != KILLED:
|
|
1461
|
+
return
|
|
1462
|
+
lock = clone / ".git" / name
|
|
1463
|
+
try:
|
|
1464
|
+
if stat.S_ISREG(os.lstat(lock).st_mode):
|
|
1465
|
+
os.remove(lock)
|
|
1466
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
1467
|
+
pass
|
|
1468
|
+
except OSError as e:
|
|
1469
|
+
warnings.append(f"could not remove {lock} ({e}); remove it once no git command runs in {clone}")
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
def _helper_file(path: str) -> str | None:
|
|
1473
|
+
if not path:
|
|
1474
|
+
return None
|
|
1475
|
+
expanded = os.path.expanduser(path)
|
|
1476
|
+
try:
|
|
1477
|
+
return expanded if stat.S_ISREG(os.stat(expanded).st_mode) else None
|
|
1478
|
+
except OSError:
|
|
1479
|
+
return None
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
def _clone_new(clone: Path, url: str, branch: list[str], warnings: list[str]) -> tuple[int, bytes, str] | None:
|
|
1483
|
+
"""Clone url to the missing folder clone, through a folder of this call's own.
|
|
1484
|
+
|
|
1485
|
+
git writes the clone to `.<repo>.skf-clone-*/<repo>` beside clone, and
|
|
1486
|
+
that folder is renamed to clone once the clone is complete: no other
|
|
1487
|
+
run ever sees a clone half made, and when another run made clone
|
|
1488
|
+
meanwhile, only this call's folder is removed. git's result, or (1, b"",
|
|
1489
|
+
error line) when a folder could not be made or moved.
|
|
1490
|
+
"""
|
|
1491
|
+
try:
|
|
1492
|
+
os.makedirs(clone.parent, exist_ok=True)
|
|
1493
|
+
holder = Path(tempfile.mkdtemp(prefix=f".{clone.name}.skf-clone-", dir=clone.parent))
|
|
1494
|
+
except OSError as e:
|
|
1495
|
+
return 1, b"", f"error: {e}"
|
|
1496
|
+
try:
|
|
1497
|
+
made = holder / clone.name
|
|
1498
|
+
res = _git(holder, *CHECKOUT_OPTS, "clone", "--quiet", "--depth", "1", *branch, "--", url, str(made),
|
|
1499
|
+
timeout=NETWORK_TIMEOUT_SEC, network=True)
|
|
1500
|
+
if not _ok(res):
|
|
1501
|
+
return res
|
|
1502
|
+
# On POSIX os.rename replaces an empty folder: never take the place of a clone another run just began.
|
|
1503
|
+
if not os.path.lexists(clone):
|
|
1504
|
+
try:
|
|
1505
|
+
os.rename(made, clone)
|
|
1506
|
+
return res
|
|
1507
|
+
except OSError as e:
|
|
1508
|
+
if not os.path.lexists(clone):
|
|
1509
|
+
return 1, b"", f"error: could not move the new clone to {clone}: {e}"
|
|
1510
|
+
return 1, b"", f"error: another run created {clone} while this one cloned it"
|
|
1511
|
+
finally:
|
|
1512
|
+
if not _rmtree(holder):
|
|
1513
|
+
warnings.append(f"could not remove {holder}")
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
def advance(args: argparse.Namespace) -> dict:
|
|
1517
|
+
_start_clock(args.timeout)
|
|
1518
|
+
return _advance(args)
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
def _advance(args: argparse.Namespace, verify: bool = True) -> dict:
|
|
1522
|
+
"""advance within the time limit already started; verify=False (resolve)
|
|
1523
|
+
moves a clone at any commit, since that run owns the move, once its HEAD
|
|
1524
|
+
resolves."""
|
|
1525
|
+
out = {"status": "skipped", "skip_reason": None, "head_sha": None, "created": False,
|
|
1526
|
+
"lock": "not-taken", "hygiene": "skipped", "log_message": "", "warnings": []}
|
|
1527
|
+
target = args.target.strip().lower()
|
|
1528
|
+
clone = Path(os.path.expanduser(args.clone.strip())) if args.clone.strip() else None
|
|
1529
|
+
|
|
1530
|
+
def finish(status: str, reason: str | None = None) -> dict:
|
|
1531
|
+
if _late(reason, _ADVANCE_NETWORK_LATE, _ADVANCE_LOCAL_LATE):
|
|
1532
|
+
reason = "timed-out"
|
|
1533
|
+
if reason == "timed-out":
|
|
1534
|
+
out["warnings"].append(f"the {args.timeout:g}-second time limit ran out before the clone was moved")
|
|
1535
|
+
head = _rev(clone, "HEAD", limited=False) if clone is not None and _stat_dir(clone) is True else None
|
|
1536
|
+
out.update(status=status, skip_reason=reason, head_sha=head)
|
|
1537
|
+
short = head[:7] if head else "no commit"
|
|
1538
|
+
if status == "advanced":
|
|
1539
|
+
out["log_message"] = f"source_tree_advance: advanced {out.pop('_old', 'none')} -> {short}"
|
|
1540
|
+
elif status == "ok":
|
|
1541
|
+
out["log_message"] = f"source_tree_advance: ok ({short})"
|
|
1542
|
+
else:
|
|
1543
|
+
out["log_message"] = f"source_tree_advance: skipped ({reason}) at {short}"
|
|
1544
|
+
out.pop("_old", None)
|
|
1545
|
+
return out
|
|
1546
|
+
|
|
1547
|
+
if not SHA40_RE.match(target):
|
|
1548
|
+
return finish("skipped", "invalid-target")
|
|
1549
|
+
parsed = parse_remote(args.source_repo)
|
|
1550
|
+
if parsed is None or clone is None or not _stays_inside(parsed):
|
|
1551
|
+
return finish("skipped", "not-a-clone")
|
|
1552
|
+
url = _url(args.source_repo, parsed)
|
|
1553
|
+
|
|
1554
|
+
state = _stat_dir(clone)
|
|
1555
|
+
clone_head = None
|
|
1556
|
+
if state is False:
|
|
1557
|
+
target_ref = (args.target_ref or "").strip()
|
|
1558
|
+
branch = []
|
|
1559
|
+
if target_ref.lower() not in HEAD_ALIASES and not SHA40_RE.match(target_ref.lower()):
|
|
1560
|
+
branch = [f"--branch={target_ref}"]
|
|
1561
|
+
res = _clone_new(clone, url, branch, out["warnings"])
|
|
1562
|
+
if not _ok(res):
|
|
1563
|
+
out["warnings"].append(_err(res))
|
|
1564
|
+
return finish("skipped", "clone-failed")
|
|
1565
|
+
out["created"] = True
|
|
1566
|
+
clone_head = _rev(clone, "HEAD")
|
|
1567
|
+
elif state is None or not _clone_matches(clone, parsed):
|
|
1568
|
+
return finish("skipped", "not-a-clone")
|
|
1569
|
+
|
|
1570
|
+
fd = None
|
|
1571
|
+
try:
|
|
1572
|
+
try:
|
|
1573
|
+
# O_BINARY (Windows only; 0 elsewhere) suppresses the text-mode \n -> \r\n
|
|
1574
|
+
# translation; the lock file is never written, only locked.
|
|
1575
|
+
fd = os.open(clone / LOCK_NAME, os.O_RDWR | os.O_CREAT | getattr(os, "O_BINARY", 0), 0o644)
|
|
1576
|
+
deadline = time.monotonic() + (_cap(max(0.0, args.lock_timeout)) or 0.0)
|
|
1577
|
+
while True:
|
|
1578
|
+
try:
|
|
1579
|
+
_acquire_lock(fd)
|
|
1580
|
+
out["lock"] = "held"
|
|
1581
|
+
break
|
|
1582
|
+
except OSError as e:
|
|
1583
|
+
if e.errno != errno.EAGAIN:
|
|
1584
|
+
raise
|
|
1585
|
+
if time.monotonic() >= deadline:
|
|
1586
|
+
out["lock"] = "busy"
|
|
1587
|
+
return finish("skipped", "lock-busy")
|
|
1588
|
+
time.sleep(LOCK_POLL_SEC)
|
|
1589
|
+
except OSError as e:
|
|
1590
|
+
out["warnings"].append(f"concurrency guard unavailable ({e}); continuing without it")
|
|
1591
|
+
out["lock"] = "unavailable"
|
|
1592
|
+
if fd is not None:
|
|
1593
|
+
os.close(fd)
|
|
1594
|
+
fd = None
|
|
1595
|
+
|
|
1596
|
+
head = _rev(clone, "HEAD")
|
|
1597
|
+
if head is None and not verify:
|
|
1598
|
+
# This run owns the move, but a HEAD that does not resolve may be another run's clone still
|
|
1599
|
+
# being written (create-skill clones before it locks): leave that clone to its run.
|
|
1600
|
+
return finish("skipped", "head-unverified")
|
|
1601
|
+
|
|
1602
|
+
hygiene = _helper_file(args.hygiene_helper)
|
|
1603
|
+
if hygiene:
|
|
1604
|
+
ran = _run_sibling(hygiene, "workspace", "--repo", str(clone))
|
|
1605
|
+
out["hygiene"] = "ran" if ran is not None and ran[0] == 0 else "failed"
|
|
1606
|
+
|
|
1607
|
+
out["_old"] = head[:7] if head else "none"
|
|
1608
|
+
if head == target:
|
|
1609
|
+
return finish("ok")
|
|
1610
|
+
if verify and out["created"]:
|
|
1611
|
+
if head != clone_head:
|
|
1612
|
+
return finish("skipped", "head-moved")
|
|
1613
|
+
elif verify:
|
|
1614
|
+
drift = _helper_file(args.drift_helper)
|
|
1615
|
+
if not drift:
|
|
1616
|
+
return finish("skipped", "head-unverified")
|
|
1617
|
+
ran = _run_sibling(drift, str(clone), "--pinned-commit", args.expect_commit)
|
|
1618
|
+
try:
|
|
1619
|
+
verdict = json.loads(ran[1]).get("status") if ran is not None else None
|
|
1620
|
+
except (ValueError, AttributeError):
|
|
1621
|
+
verdict = None
|
|
1622
|
+
if verdict == "mismatch":
|
|
1623
|
+
return finish("skipped", "head-moved")
|
|
1624
|
+
if verdict != "ok":
|
|
1625
|
+
return finish("skipped", "head-unverified")
|
|
1626
|
+
|
|
1627
|
+
status = _git(clone, "--no-optional-locks", "status", "--porcelain=v1", "-z", "--untracked-files=no")
|
|
1628
|
+
if not _ok(status) or status[1]:
|
|
1629
|
+
return finish("skipped", "local-changes")
|
|
1630
|
+
|
|
1631
|
+
# A fetch or checkout cut short in the shared clone could leave a lock
|
|
1632
|
+
# file there: start one only with the local reserve still ahead.
|
|
1633
|
+
if _expired():
|
|
1634
|
+
return finish("skipped", "timed-out")
|
|
1635
|
+
if not _ok(_git(clone, "cat-file", "-e", f"{target}^{{commit}}")):
|
|
1636
|
+
tree = os.path.expanduser(args.tree.strip()) if args.tree.strip() else ""
|
|
1637
|
+
if tree:
|
|
1638
|
+
run, named = _locate_run(Path(tree))
|
|
1639
|
+
record = _read_record(run) if named is None else None
|
|
1640
|
+
tree = record["tree"] if record else tree
|
|
1641
|
+
if tree and _stat_dir(tree) is True:
|
|
1642
|
+
fetched = _git(clone, "fetch", "--quiet", "--no-tags", "--depth", "1", "--", tree,
|
|
1643
|
+
f"+{target}:{ADVANCE_REF}")
|
|
1644
|
+
_clear_lock(clone, fetched, "shallow.lock", out["warnings"])
|
|
1645
|
+
if not _ok(_git(clone, "cat-file", "-e", f"{target}^{{commit}}")):
|
|
1646
|
+
fetched = _git(clone, "fetch", "--quiet", "--no-tags", "--depth", "1", "--", url,
|
|
1647
|
+
f"+{target}:{ADVANCE_REF}", timeout=NETWORK_TIMEOUT_SEC, network=True)
|
|
1648
|
+
_clear_lock(clone, fetched, "shallow.lock", out["warnings"])
|
|
1649
|
+
if not _ok(_git(clone, "cat-file", "-e", f"{target}^{{commit}}")):
|
|
1650
|
+
_git(clone, "update-ref", "-d", ADVANCE_REF, limited=False)
|
|
1651
|
+
return finish("skipped", "target-missing")
|
|
1652
|
+
|
|
1653
|
+
if _expired():
|
|
1654
|
+
_git(clone, "update-ref", "-d", ADVANCE_REF, limited=False)
|
|
1655
|
+
return finish("skipped", "timed-out")
|
|
1656
|
+
checkout = _git(clone, *CHECKOUT_OPTS, "checkout", "--quiet", "--detach", target,
|
|
1657
|
+
timeout=NETWORK_TIMEOUT_SEC)
|
|
1658
|
+
_clear_lock(clone, checkout, "index.lock", out["warnings"])
|
|
1659
|
+
_git(clone, "update-ref", "-d", ADVANCE_REF, limited=False)
|
|
1660
|
+
if checkout is not None and checkout[0] in (STOPPED, KILLED):
|
|
1661
|
+
# git moves HEAD last: a checkout stopped after it did so had finished.
|
|
1662
|
+
if _rev(clone, "HEAD", limited=False) == target:
|
|
1663
|
+
return finish("advanced")
|
|
1664
|
+
out["warnings"].append(
|
|
1665
|
+
f"the {args.timeout:g}-second time limit stopped the checkout of {target[:12]} part way, so "
|
|
1666
|
+
f"{clone} may hold files of both commits; it had no local changes when the checkout began, and "
|
|
1667
|
+
f'these commands finish it: git -C "{clone}" fetch --depth 1 origin {target} and then '
|
|
1668
|
+
f'git -C "{clone}" checkout --force --detach {target}')
|
|
1669
|
+
return finish("skipped", "checkout-interrupted")
|
|
1670
|
+
if not _ok(checkout):
|
|
1671
|
+
out["warnings"].append(_err(checkout))
|
|
1672
|
+
return finish("skipped", "checkout-failed")
|
|
1673
|
+
if _rev(clone, "HEAD") != target:
|
|
1674
|
+
return finish("skipped", "checkout-failed")
|
|
1675
|
+
return finish("advanced")
|
|
1676
|
+
finally:
|
|
1677
|
+
if fd is not None:
|
|
1678
|
+
_release_lock(fd)
|
|
1679
|
+
os.close(fd)
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
# --------------------------------------------------------------------------
|
|
1683
|
+
# close
|
|
1684
|
+
# --------------------------------------------------------------------------
|
|
1685
|
+
|
|
1686
|
+
|
|
1687
|
+
def close(args: argparse.Namespace) -> dict:
|
|
1688
|
+
out = {"status": "refused", "tree": args.tree, "warnings": []}
|
|
1689
|
+
given = Path(os.path.expanduser(args.tree.strip())) if args.tree.strip() else None
|
|
1690
|
+
if given is None:
|
|
1691
|
+
return out
|
|
1692
|
+
run, tree = _locate_run(given)
|
|
1693
|
+
if not TREE_NAME_RE.match(run.name):
|
|
1694
|
+
return out
|
|
1695
|
+
if not os.path.lexists(run):
|
|
1696
|
+
out["status"] = "missing"
|
|
1697
|
+
return out
|
|
1698
|
+
if _is_link_or_junction(run):
|
|
1699
|
+
return out
|
|
1700
|
+
try:
|
|
1701
|
+
st = os.lstat(run)
|
|
1702
|
+
except OSError:
|
|
1703
|
+
return out
|
|
1704
|
+
if not stat.S_ISDIR(st.st_mode) or (os.name != "nt" and st.st_uid != os.getuid()):
|
|
1705
|
+
return out
|
|
1706
|
+
record = _read_record(run)
|
|
1707
|
+
if record is None or (tree is not None and not _same_path(record["tree"], tree)):
|
|
1708
|
+
return out
|
|
1709
|
+
if _rmtree(run):
|
|
1710
|
+
out["status"] = "removed"
|
|
1711
|
+
else:
|
|
1712
|
+
out["status"] = "left"
|
|
1713
|
+
out["warnings"].append(f"could not remove {run}; a later run removes it once it is "
|
|
1714
|
+
f"{STALE_TREE_DAYS} days old")
|
|
1715
|
+
return out
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
# --------------------------------------------------------------------------
|
|
1719
|
+
# CLI
|
|
1720
|
+
# --------------------------------------------------------------------------
|
|
1721
|
+
|
|
1722
|
+
|
|
1723
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
1724
|
+
parser = argparse.ArgumentParser(
|
|
1725
|
+
prog="skf-source-tree",
|
|
1726
|
+
description="Read a remote skill's source at one commit, in a private tree.",
|
|
1727
|
+
)
|
|
1728
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
1729
|
+
|
|
1730
|
+
p_open = sub.add_parser("open", help="prepare this run's private source tree")
|
|
1731
|
+
p_open.add_argument("--source-repo", default="", help="metadata.json source_repo")
|
|
1732
|
+
p_open.add_argument("--source-root", default="", help="metadata.json source_root")
|
|
1733
|
+
p_open.add_argument("--source-ref", default="", help="metadata.json source_ref")
|
|
1734
|
+
p_open.add_argument("--pinned-commit", default="", help="metadata.json source_commit")
|
|
1735
|
+
p_open.add_argument("--target-ref", default="", help="tag, branch, HEAD or commit to read instead")
|
|
1736
|
+
p_open.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_SEC,
|
|
1737
|
+
help="seconds open may take (default 100)")
|
|
1738
|
+
|
|
1739
|
+
p_resolve = sub.add_parser("resolve",
|
|
1740
|
+
help="pick the ref a new run reads and prepare its private source tree")
|
|
1741
|
+
p_resolve.add_argument("--source-repo", required=True, help="the remote repository (URL or owner/repo)")
|
|
1742
|
+
p_resolve.add_argument("--source-root", default="",
|
|
1743
|
+
help="SKF's clone of it, when not at the workspace path (metadata.json source_root)")
|
|
1744
|
+
p_resolve.add_argument("--target-ref", default="", help="tag, branch, HEAD or commit to read")
|
|
1745
|
+
p_resolve.add_argument("--version", default="", help="version to find among the tags")
|
|
1746
|
+
p_resolve.add_argument("--name", default="", help="skill name, for the monorepo tag forms")
|
|
1747
|
+
p_resolve.add_argument("--implicit", action="store_true",
|
|
1748
|
+
help="--version is only a hint: try the version and v<version> alone")
|
|
1749
|
+
p_resolve.add_argument("--update-clone", action="store_true",
|
|
1750
|
+
help="also move SKF's clone to the commit read (clone it when missing)")
|
|
1751
|
+
p_resolve.add_argument("--hygiene-helper", default="", help="path of skf-ccc-git-hygiene.py")
|
|
1752
|
+
p_resolve.add_argument("--lock-timeout", type=float, default=LOCK_WAIT_SEC,
|
|
1753
|
+
help="seconds to wait for the workspace lock")
|
|
1754
|
+
p_resolve.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_SEC,
|
|
1755
|
+
help="seconds resolve may take (default 100)")
|
|
1756
|
+
|
|
1757
|
+
p_advance = sub.add_parser("advance", help="move the workspace clone to the recorded commit")
|
|
1758
|
+
p_advance.add_argument("--clone", required=True, help="the workspace clone open reported")
|
|
1759
|
+
p_advance.add_argument("--source-repo", required=True, help="metadata.json source_repo")
|
|
1760
|
+
p_advance.add_argument("--target", required=True, help="the 40-hex commit the update recorded")
|
|
1761
|
+
p_advance.add_argument("--expect-commit", default="", help="the skill's previous source_commit")
|
|
1762
|
+
p_advance.add_argument("--target-ref", default="", help="the ref the update read")
|
|
1763
|
+
p_advance.add_argument("--tree", default="",
|
|
1764
|
+
help="this run's tree (or its run folder), the first place to take the commit from")
|
|
1765
|
+
p_advance.add_argument("--hygiene-helper", default="", help="path of skf-ccc-git-hygiene.py")
|
|
1766
|
+
p_advance.add_argument("--drift-helper", default="", help="path of skf-check-workspace-drift.py")
|
|
1767
|
+
p_advance.add_argument("--lock-timeout", type=float, default=LOCK_WAIT_SEC,
|
|
1768
|
+
help="seconds to wait for the workspace lock")
|
|
1769
|
+
p_advance.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_SEC,
|
|
1770
|
+
help="seconds advance may take (default 100)")
|
|
1771
|
+
|
|
1772
|
+
p_close = sub.add_parser("close", help="remove this run's private source tree")
|
|
1773
|
+
p_close.add_argument("--tree", required=True, help="the tree open reported, or its run folder")
|
|
1774
|
+
return parser
|
|
1775
|
+
|
|
1776
|
+
|
|
1777
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1778
|
+
args = _build_parser().parse_args(argv)
|
|
1779
|
+
try:
|
|
1780
|
+
if args.cmd == "open":
|
|
1781
|
+
code, out = open_tree(args)
|
|
1782
|
+
elif args.cmd == "resolve":
|
|
1783
|
+
code, out = resolve(args)
|
|
1784
|
+
elif args.cmd == "advance":
|
|
1785
|
+
code, out = 0, advance(args)
|
|
1786
|
+
else:
|
|
1787
|
+
code, out = 0, close(args)
|
|
1788
|
+
except Exception as e: # noqa: BLE001 - one JSON error line, never a traceback
|
|
1789
|
+
print(json.dumps({"status": "error", "message": f"{type(e).__name__}: {e}"}), file=sys.stderr)
|
|
1790
|
+
return 1
|
|
1791
|
+
print(json.dumps(out))
|
|
1792
|
+
return code
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
if __name__ == "__main__":
|
|
1796
|
+
raise SystemExit(main())
|