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,661 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF GitHub Probe: can this GitHub repository be read, and if not, why.
|
|
6
|
+
|
|
7
|
+
brief-skill checks a GitHub URL before the user spends time on a brief,
|
|
8
|
+
and quick-skill checks a version tag and lists the files it reads. One
|
|
9
|
+
`gh api` call cannot tell a missing gh, a logged-out gh, a repository that
|
|
10
|
+
does not exist and one the account may not read apart, and an
|
|
11
|
+
unauthenticated `curl` answers 404 for every private repository. This
|
|
12
|
+
helper runs the probes in order, stops at the first that reads the
|
|
13
|
+
repository, and otherwise names the cause.
|
|
14
|
+
|
|
15
|
+
CLI:
|
|
16
|
+
uv run skf-github-probe.py repo --repo <repo> [--timeout <seconds>]
|
|
17
|
+
uv run skf-github-probe.py tags --repo <repo> [--want <tag>]... \\
|
|
18
|
+
[--version <version> [--name <name>]] [--limit <n>] \\
|
|
19
|
+
[--timeout <seconds>]
|
|
20
|
+
uv run skf-github-probe.py tree --repo <repo> [--ref <ref>] \\
|
|
21
|
+
[--out <file>] [--timeout <seconds>]
|
|
22
|
+
|
|
23
|
+
--repo takes a github.com repository as skf-resolve-package.py (the
|
|
24
|
+
sibling in this folder, which must sit beside this script) reads one:
|
|
25
|
+
`owner/repo`, `github.com/owner/repo`, `github:owner/repo`, an https,
|
|
26
|
+
http, git, ssh or git+ URL or `git@github.com:owner/repo`, with or
|
|
27
|
+
without `.git`, a query, a fragment and a `/tree/<ref>/...` tail. Only
|
|
28
|
+
github.com repositories are probed.
|
|
29
|
+
|
|
30
|
+
Probes, in order:
|
|
31
|
+
repo `gh api repos/{owner}/{repo}`; the unauthenticated REST API
|
|
32
|
+
(https://api.github.com/repos/{owner}/{repo}), unless gh answered
|
|
33
|
+
404 (a repository an account cannot see is not public either);
|
|
34
|
+
`git ls-remote --symref
|
|
35
|
+
https://github.com/{owner}/{repo} HEAD`, which uses the user's git
|
|
36
|
+
credentials (never a prompt).
|
|
37
|
+
tags `gh api --paginate repos/{owner}/{repo}/tags`; `git ls-remote
|
|
38
|
+
--tags https://github.com/{owner}/{repo}`.
|
|
39
|
+
tree `gh api repos/{owner}/{repo}/git/trees/{ref}?recursive=1`; the
|
|
40
|
+
same path on the unauthenticated REST API, unless gh answered 404
|
|
41
|
+
for the repository. When the tree is not found, the same client
|
|
42
|
+
reads the repository, to tell a missing ref from a missing
|
|
43
|
+
repository: GitHub answers 404 for both, so a tree's 404 alone
|
|
44
|
+
never counts as a missing repository.
|
|
45
|
+
When a probe finds nothing, the owner is looked up (`users/{owner}`), so
|
|
46
|
+
a misspelled user or organization is reported as such.
|
|
47
|
+
|
|
48
|
+
Output (one ASCII JSON line; every key of the command is always present):
|
|
49
|
+
every command:
|
|
50
|
+
status "ok" | "unavailable"
|
|
51
|
+
cause null | "repo-not-found" | "no-access" | "gh-missing" |
|
|
52
|
+
"gh-unauthenticated" | "ref-not-found" | "unreachable" |
|
|
53
|
+
"invalid-repo" (CAUSES)
|
|
54
|
+
message one line for the user naming the cause and the fix, else null
|
|
55
|
+
repo "owner/repo", or null for invalid-repo
|
|
56
|
+
gh "ok" | "missing" | "unauthenticated" | "failed" | "not-run"
|
|
57
|
+
(GH_STATES): gh answered (an HTTP error is an answer), is not
|
|
58
|
+
installed, is not logged in or its token is rejected, failed
|
|
59
|
+
on the network or a time limit, or was not needed
|
|
60
|
+
account the login gh uses, when a message names it, else null
|
|
61
|
+
via "gh" | "api" | "git" | null: the probe that read the repository
|
|
62
|
+
probes [{"probe": "gh"|"api"|"git", "target": "repo"|"tags"|"tree"|
|
|
63
|
+
"owner", "result": ..., "detail": ...}] in the order run;
|
|
64
|
+
result is "ok" | "missing" | "unauthenticated" | "not-found" |
|
|
65
|
+
"forbidden" | "auth-required" | "rate-limited" | "network" |
|
|
66
|
+
"timeout" (PROBE_RESULTS)
|
|
67
|
+
repo: private (true | false | null), default_branch (name or null)
|
|
68
|
+
tags: tags (names, newest version first, then the others by name, at
|
|
69
|
+
most --limit, 20 by default, 0 for all), count (every tag),
|
|
70
|
+
match (the first --want tag that exists, else the first tag
|
|
71
|
+
that names --version, else null), found ({tag: commit} for the
|
|
72
|
+
--want tags that exist), missing (the --want tags the listing
|
|
73
|
+
lacks), nearest (with --version, up to five tags that sort next
|
|
74
|
+
to it by version, newest first, else empty). When the listing
|
|
75
|
+
could not be read, found, missing and nearest are empty: only a
|
|
76
|
+
listing says a tag is absent.
|
|
77
|
+
A tag names --version in the forms skf-source-tree.py's
|
|
78
|
+
match_tags tries, in its order: the version as given,
|
|
79
|
+
`v<version>` and, with --name, `<name>@<v>`,
|
|
80
|
+
`@<scope>/<name>@<v>` (any scope), `<name>/v<v>`, `<name>/<v>`
|
|
81
|
+
and `<name>-v<v>`, where <v> drops a leading `v` of the version.
|
|
82
|
+
A monorepo's package tags (`astro@4.16.0`) match through --name.
|
|
83
|
+
tree: ref (as given; "HEAD", the default branch, for an empty, `null`,
|
|
84
|
+
`none` or `head` --ref in any letter case), tree (file paths;
|
|
85
|
+
hand the listing to skf-detect-language.py or another --tree-file
|
|
86
|
+
reader as a file, through --out, never through echo), count,
|
|
87
|
+
truncated (GitHub cut the list short). With --out <file>, the
|
|
88
|
+
whole line goes to <file>, which every --tree-file reader takes,
|
|
89
|
+
and stdout carries it without `tree`, so a caller reads the
|
|
90
|
+
status, count and truncated of a listing of any size.
|
|
91
|
+
|
|
92
|
+
cause:
|
|
93
|
+
repo-not-found an authenticated probe (gh, or git with the user's
|
|
94
|
+
credentials) found no such repository: it does not
|
|
95
|
+
exist, or it is private and the account cannot see
|
|
96
|
+
it (GitHub answers 404 then), or the owner does not
|
|
97
|
+
exist
|
|
98
|
+
no-access an authenticated probe was refused (HTTP 403: an
|
|
99
|
+
organization's SAML or IP rules, a token without the
|
|
100
|
+
scope)
|
|
101
|
+
gh-missing gh is not installed, and nothing else could read the
|
|
102
|
+
repository: it is private or does not exist
|
|
103
|
+
gh-unauthenticated gh is not logged in (or its token is rejected), and
|
|
104
|
+
nothing else could read the repository
|
|
105
|
+
ref-not-found tree: the repository was read, the ref was not found
|
|
106
|
+
unreachable the probes failed on the network, a time limit or
|
|
107
|
+
the API rate limit
|
|
108
|
+
invalid-repo --repo names no github.com repository
|
|
109
|
+
|
|
110
|
+
The whole call finishes within about --timeout seconds (60 by default);
|
|
111
|
+
one probe gets at most 20 of them, so a slow probe leaves time for the
|
|
112
|
+
next. gh and git run through skf-source-tree.py (the sibling in this
|
|
113
|
+
folder, which must sit beside this script): a call the limit stops is
|
|
114
|
+
stopped with everything it started, and a gh or git in the current
|
|
115
|
+
folder is never run. gh runs with `--hostname github.com`; git runs from
|
|
116
|
+
the system temp folder with LC_ALL=C, no hooks, no terminal or
|
|
117
|
+
credential-manager prompt and the git location variables removed.
|
|
118
|
+
|
|
119
|
+
Exit codes:
|
|
120
|
+
0 status ok
|
|
121
|
+
3 status unavailable (JSON on stdout; the caller decides what to do)
|
|
122
|
+
1 unexpected error: {"status": "error", "message": ...} on stderr
|
|
123
|
+
2 usage error (argparse)
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
from __future__ import annotations
|
|
127
|
+
|
|
128
|
+
import argparse
|
|
129
|
+
import importlib.util
|
|
130
|
+
import json
|
|
131
|
+
import os
|
|
132
|
+
import re
|
|
133
|
+
import socket
|
|
134
|
+
import sys
|
|
135
|
+
import tempfile
|
|
136
|
+
import time
|
|
137
|
+
import urllib.error
|
|
138
|
+
import urllib.parse
|
|
139
|
+
import urllib.request
|
|
140
|
+
from pathlib import Path
|
|
141
|
+
|
|
142
|
+
CAUSES = ("repo-not-found", "no-access", "gh-missing", "gh-unauthenticated", "ref-not-found", "unreachable",
|
|
143
|
+
"invalid-repo")
|
|
144
|
+
GH_STATES = ("ok", "missing", "unauthenticated", "failed", "not-run")
|
|
145
|
+
PROBE_RESULTS = ("ok", "missing", "unauthenticated", "not-found", "forbidden", "auth-required", "rate-limited",
|
|
146
|
+
"network", "timeout")
|
|
147
|
+
API_ROOT = "https://api.github.com"
|
|
148
|
+
WEB_ROOT = "https://github.com"
|
|
149
|
+
USER_AGENT = "skf-github-probe/1.0 (+https://github.com/armelhbobdad/bmad-module-skill-forge)"
|
|
150
|
+
DEFAULT_TIMEOUT_SEC = 60.0
|
|
151
|
+
PROBE_TIMEOUT_SEC = 20.0
|
|
152
|
+
DEFAULT_TAG_LIMIT = 20
|
|
153
|
+
_HTTP_CODE_RE = re.compile(r"\(HTTP (\d{3})\)")
|
|
154
|
+
|
|
155
|
+
_SIBLINGS: dict[str, object] = {}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _load(filename: str):
|
|
159
|
+
"""A script of this folder, loaded once."""
|
|
160
|
+
if filename not in _SIBLINGS:
|
|
161
|
+
path = Path(__file__).resolve().parent / filename
|
|
162
|
+
spec = importlib.util.spec_from_file_location(filename[:-3].replace("-", "_"), path)
|
|
163
|
+
if spec is None or spec.loader is None:
|
|
164
|
+
raise ImportError(f"cannot load {path}")
|
|
165
|
+
module = importlib.util.module_from_spec(spec)
|
|
166
|
+
spec.loader.exec_module(module)
|
|
167
|
+
_SIBLINGS[filename] = module
|
|
168
|
+
return _SIBLINGS[filename]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _sibling():
|
|
172
|
+
"""skf-source-tree.py from this folder, loaded once: gh and git run
|
|
173
|
+
through its runner (_run stops a call and all it started at the time
|
|
174
|
+
limit; _resolve_outside_cwd never takes a gh or git planted in the
|
|
175
|
+
current folder), tags sort with its split_version, a version's tags
|
|
176
|
+
are found with its match_tags and nearest_tags, and refs are checked
|
|
177
|
+
with its REF_RE."""
|
|
178
|
+
return _load("skf-source-tree.py")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# --------------------------------------------------------------------------
|
|
182
|
+
# Repository names
|
|
183
|
+
# --------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def parse_repo(value: str | None) -> tuple[str, str] | None:
|
|
187
|
+
"""(owner, repo) of a github.com repository, else None: read by
|
|
188
|
+
skf-resolve-package.py's github_target, the one GitHub URL grammar
|
|
189
|
+
quick-skill parses targets and registry answers with."""
|
|
190
|
+
loc = _load("skf-resolve-package.py").github_target((value or "").strip())
|
|
191
|
+
return (loc["owner"], loc["repo"]) if loc else None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# --------------------------------------------------------------------------
|
|
195
|
+
# Probes
|
|
196
|
+
# --------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
_deadline: float | None = None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _start_clock(seconds: float | None) -> None:
|
|
203
|
+
global _deadline
|
|
204
|
+
_deadline = None if seconds is None else time.monotonic() + max(0.0, seconds)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _budget() -> float | None:
|
|
208
|
+
"""Seconds the next probe may take: PROBE_TIMEOUT_SEC or what is left; None when nothing is."""
|
|
209
|
+
if _deadline is None:
|
|
210
|
+
return PROBE_TIMEOUT_SEC
|
|
211
|
+
left = _deadline - time.monotonic()
|
|
212
|
+
return min(PROBE_TIMEOUT_SEC, left) if left > 0.5 else None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _last_line(text: str) -> str:
|
|
216
|
+
lines = [line.strip() for line in (text or "").splitlines() if line.strip()]
|
|
217
|
+
for line in reversed(lines):
|
|
218
|
+
if line.startswith(("fatal:", "error:", "gh:")):
|
|
219
|
+
return line
|
|
220
|
+
return lines[-1] if lines else ""
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _gh(*args: str) -> tuple[str, str, str]:
|
|
224
|
+
"""`gh api --hostname github.com <args>`: (result, stdout, detail)."""
|
|
225
|
+
tree = _sibling()
|
|
226
|
+
exe = tree._resolve_outside_cwd("gh")
|
|
227
|
+
if exe is None:
|
|
228
|
+
return "missing", "", "gh is not installed"
|
|
229
|
+
timeout = _budget()
|
|
230
|
+
if timeout is None:
|
|
231
|
+
return "timeout", "", "no time was left to run gh"
|
|
232
|
+
try:
|
|
233
|
+
rc, out, err, _killed = tree._run([exe, "api", "--hostname", "github.com", *args], timeout)
|
|
234
|
+
except (OSError, ValueError) as e:
|
|
235
|
+
return "missing", "", f"gh could not run: {e}"
|
|
236
|
+
if rc is None:
|
|
237
|
+
return "timeout", "", f"gh did not answer within {timeout:g} seconds"
|
|
238
|
+
stdout = out.decode("utf-8", errors="replace")
|
|
239
|
+
detail = _last_line(err.decode("utf-8", errors="replace"))
|
|
240
|
+
if rc == 0:
|
|
241
|
+
return "ok", stdout, ""
|
|
242
|
+
if rc == 4: # gh: authentication required
|
|
243
|
+
return "unauthenticated", "", "gh is not logged in"
|
|
244
|
+
m = _HTTP_CODE_RE.search(detail)
|
|
245
|
+
code = int(m.group(1)) if m else None
|
|
246
|
+
if code == 401:
|
|
247
|
+
return "unauthenticated", "", detail
|
|
248
|
+
if code == 404:
|
|
249
|
+
return "not-found", "", detail
|
|
250
|
+
if code == 429 or (code == 403 and "rate limit" in (detail + stdout).lower()):
|
|
251
|
+
return "rate-limited", "", detail
|
|
252
|
+
if code == 403:
|
|
253
|
+
return "forbidden", "", detail
|
|
254
|
+
return "network", "", detail or f"gh exited {rc}"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _api(path: str) -> tuple[str, str, str]:
|
|
258
|
+
"""GET {API_ROOT}/{path} without credentials: (result, body, detail)."""
|
|
259
|
+
timeout = _budget()
|
|
260
|
+
if timeout is None:
|
|
261
|
+
return "timeout", "", "no time was left to call the GitHub API"
|
|
262
|
+
req = urllib.request.Request(f"{API_ROOT}/{path}", headers={
|
|
263
|
+
"Accept": "application/vnd.github+json", "User-Agent": USER_AGENT})
|
|
264
|
+
try:
|
|
265
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
266
|
+
return "ok", resp.read().decode("utf-8", errors="replace"), ""
|
|
267
|
+
except urllib.error.HTTPError as e:
|
|
268
|
+
try:
|
|
269
|
+
body = e.read().decode("utf-8", errors="replace")
|
|
270
|
+
except OSError:
|
|
271
|
+
body = ""
|
|
272
|
+
detail = f"HTTP {e.code} from {API_ROOT}/{path}"
|
|
273
|
+
if e.code == 404:
|
|
274
|
+
return "not-found", "", detail
|
|
275
|
+
if e.code in (403, 429):
|
|
276
|
+
limited = "rate limit" in body.lower() or e.headers.get("X-RateLimit-Remaining") == "0"
|
|
277
|
+
return ("rate-limited" if limited or e.code == 429 else "forbidden"), "", detail
|
|
278
|
+
if e.code == 451:
|
|
279
|
+
return "forbidden", "", detail
|
|
280
|
+
return "network", "", detail
|
|
281
|
+
except (TimeoutError, socket.timeout):
|
|
282
|
+
return "timeout", "", f"{API_ROOT} did not answer within {timeout:g} seconds"
|
|
283
|
+
except (urllib.error.URLError, OSError, ValueError) as e:
|
|
284
|
+
return "network", "", f"{API_ROOT}: {getattr(e, 'reason', e)}"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _classify_git(stderr: str) -> str:
|
|
288
|
+
"""The probe result a failed `git ls-remote` of a github.com URL reports."""
|
|
289
|
+
text = stderr.lower()
|
|
290
|
+
if "returned error: 403" in text or "saml" in text or ("permission to" in text and "denied" in text):
|
|
291
|
+
return "forbidden"
|
|
292
|
+
if ("repository not found" in text or "returned error: 404" in text
|
|
293
|
+
or ("repository '" in text and "not found" in text)):
|
|
294
|
+
return "not-found"
|
|
295
|
+
if any(sign in text for sign in ("could not read username", "could not read password",
|
|
296
|
+
"terminal prompts disabled", "returned error: 401",
|
|
297
|
+
"authentication failed", "invalid username or password")):
|
|
298
|
+
return "auth-required"
|
|
299
|
+
return "network"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _git(*args: str) -> tuple[str, str, str]:
|
|
303
|
+
"""`git <args>` from the system temp folder: (result, stdout, detail)."""
|
|
304
|
+
tree = _sibling()
|
|
305
|
+
exe = tree._resolve_outside_cwd("git")
|
|
306
|
+
if exe is None:
|
|
307
|
+
return "missing", "", "git is not installed"
|
|
308
|
+
timeout = _budget()
|
|
309
|
+
if timeout is None:
|
|
310
|
+
return "timeout", "", "no time was left to run git"
|
|
311
|
+
try:
|
|
312
|
+
rc, out, err, _killed = tree._run([exe, "-c", f"core.hooksPath={os.devnull}", "-C",
|
|
313
|
+
tempfile.gettempdir(), *args], timeout)
|
|
314
|
+
except (OSError, ValueError) as e:
|
|
315
|
+
return "missing", "", f"git could not run: {e}"
|
|
316
|
+
if rc is None:
|
|
317
|
+
return "timeout", "", f"git did not answer within {timeout:g} seconds"
|
|
318
|
+
if rc == 0:
|
|
319
|
+
return "ok", out.decode("utf-8", errors="replace"), ""
|
|
320
|
+
stderr = err.decode("utf-8", errors="replace")
|
|
321
|
+
return _classify_git(stderr), "", _last_line(stderr) or f"git exited {rc}"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _json(text: str):
|
|
325
|
+
"""A JSON document, or the items of several arrays `gh --paginate` printed one after another."""
|
|
326
|
+
decoder = json.JSONDecoder()
|
|
327
|
+
docs, i, text = [], 0, text.strip()
|
|
328
|
+
while i < len(text):
|
|
329
|
+
doc, end = decoder.raw_decode(text, i)
|
|
330
|
+
docs.append(doc)
|
|
331
|
+
i = end
|
|
332
|
+
while i < len(text) and text[i].isspace():
|
|
333
|
+
i += 1
|
|
334
|
+
if len(docs) == 1:
|
|
335
|
+
return docs[0]
|
|
336
|
+
return [item for doc in docs if isinstance(doc, list) for item in doc]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# --------------------------------------------------------------------------
|
|
340
|
+
# Causes
|
|
341
|
+
# --------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class Probe:
|
|
345
|
+
"""One command's probes, their records and the state of gh."""
|
|
346
|
+
|
|
347
|
+
def __init__(self, owner: str, repo: str):
|
|
348
|
+
self.owner, self.repo = owner, repo
|
|
349
|
+
self.records: list[dict] = []
|
|
350
|
+
self.gh = "not-run"
|
|
351
|
+
|
|
352
|
+
def record(self, probe: str, target: str, result: str, detail: str) -> str:
|
|
353
|
+
self.records.append({"probe": probe, "target": target, "result": result, "detail": detail or None})
|
|
354
|
+
if probe == "gh":
|
|
355
|
+
if result in ("missing", "unauthenticated"):
|
|
356
|
+
self.gh = result
|
|
357
|
+
elif result in ("ok", "not-found", "forbidden"):
|
|
358
|
+
self.gh = "ok"
|
|
359
|
+
elif self.gh == "not-run":
|
|
360
|
+
self.gh = "failed"
|
|
361
|
+
return result
|
|
362
|
+
|
|
363
|
+
def gh_call(self, target: str, *args: str) -> tuple[str, str]:
|
|
364
|
+
result, out, detail = _gh(*args)
|
|
365
|
+
return self.record("gh", target, result, detail), out
|
|
366
|
+
|
|
367
|
+
def api_call(self, target: str, path: str) -> tuple[str, str]:
|
|
368
|
+
result, out, detail = _api(path)
|
|
369
|
+
return self.record("api", target, result, detail), out
|
|
370
|
+
|
|
371
|
+
def git_call(self, target: str, *args: str) -> tuple[str, str]:
|
|
372
|
+
result, out, detail = _git(*args)
|
|
373
|
+
return self.record("git", target, result, detail), out
|
|
374
|
+
|
|
375
|
+
def results(self, probe: str) -> list[str]:
|
|
376
|
+
"""This probe's results, without the owner lookup and without a tree's 404: GitHub
|
|
377
|
+
answers 404 for a missing ref as for a missing repository."""
|
|
378
|
+
return [r["result"] for r in self.records if r["probe"] == probe and r["target"] != "owner"
|
|
379
|
+
and not (r["target"] == "tree" and r["result"] == "not-found")]
|
|
380
|
+
|
|
381
|
+
def owner_missing(self) -> bool:
|
|
382
|
+
"""Look the owner up with gh when it answers, else the API; True only on a 404."""
|
|
383
|
+
if self.gh == "ok":
|
|
384
|
+
result, _out = self.gh_call("owner", f"users/{self.owner}")
|
|
385
|
+
else:
|
|
386
|
+
result, _out = self.api_call("owner", f"users/{self.owner}")
|
|
387
|
+
return result == "not-found"
|
|
388
|
+
|
|
389
|
+
def cause(self) -> str:
|
|
390
|
+
gh, git, api = self.results("gh"), self.results("git"), self.results("api")
|
|
391
|
+
if "forbidden" in gh or "forbidden" in git:
|
|
392
|
+
return "no-access"
|
|
393
|
+
found_nothing = "not-found" in gh or "not-found" in git
|
|
394
|
+
hidden = "not-found" in api or "auth-required" in git
|
|
395
|
+
if (found_nothing or hidden) and self.owner_missing():
|
|
396
|
+
return "repo-not-found"
|
|
397
|
+
if found_nothing:
|
|
398
|
+
return "repo-not-found"
|
|
399
|
+
flaky = any(r in ("network", "timeout", "rate-limited") for r in api + git)
|
|
400
|
+
if self.gh in ("missing", "unauthenticated") and (hidden or not flaky):
|
|
401
|
+
return f"gh-{self.gh}"
|
|
402
|
+
return "unreachable"
|
|
403
|
+
|
|
404
|
+
def account(self) -> str | None:
|
|
405
|
+
if self.gh != "ok":
|
|
406
|
+
return None
|
|
407
|
+
result, out, _detail = _gh("user")
|
|
408
|
+
try:
|
|
409
|
+
login = json.loads(out).get("login") if result == "ok" else None
|
|
410
|
+
except (ValueError, AttributeError):
|
|
411
|
+
login = None
|
|
412
|
+
return login if isinstance(login, str) else None
|
|
413
|
+
|
|
414
|
+
def message(self, cause: str, account: str | None) -> str:
|
|
415
|
+
name = f"{self.owner}/{self.repo}"
|
|
416
|
+
details = "; ".join(dict.fromkeys(r["detail"] for r in self.records
|
|
417
|
+
if r["detail"] and r["result"] != "ok"))
|
|
418
|
+
if cause == "repo-not-found":
|
|
419
|
+
if any(r["target"] == "owner" and r["result"] == "not-found" for r in self.records):
|
|
420
|
+
return f"GitHub has no user or organization named {self.owner}; check the URL."
|
|
421
|
+
who = f"the GitHub account {account}" if account else "your GitHub credentials"
|
|
422
|
+
return (f"{name} was not found with {who}: it does not exist, or it is private and that account "
|
|
423
|
+
"cannot see it. Check the URL, or log in with an account that can read it (gh auth login).")
|
|
424
|
+
if cause == "no-access":
|
|
425
|
+
return (f"GitHub refused access to {name} ({details}). Authorize your token for the organization "
|
|
426
|
+
"(gh auth refresh), or use an account that can read it.")
|
|
427
|
+
if cause == "gh-missing":
|
|
428
|
+
return (f"{name} is private or does not exist: nothing could read it without a GitHub login, and "
|
|
429
|
+
"the GitHub CLI (gh) is not installed. Install gh and run gh auth login, or check the URL.")
|
|
430
|
+
if cause == "gh-unauthenticated":
|
|
431
|
+
return (f"{name} is private or does not exist: nothing could read it without a GitHub login, and "
|
|
432
|
+
"the GitHub CLI (gh) is not logged in. Run gh auth login, or check the URL.")
|
|
433
|
+
return f"Could not reach GitHub to read {name} ({details or 'no probe could run'}); try again later."
|
|
434
|
+
|
|
435
|
+
def output(self, status: str, via: str | None, cause: str | None = None, message: str | None = None,
|
|
436
|
+
account: str | None = None, **extra) -> dict:
|
|
437
|
+
return {"status": status, "cause": cause, "message": message, "repo": f"{self.owner}/{self.repo}",
|
|
438
|
+
"gh": self.gh, "account": account, "via": via, "probes": self.records, **extra}
|
|
439
|
+
|
|
440
|
+
def unavailable(self, **extra) -> dict:
|
|
441
|
+
cause = self.cause()
|
|
442
|
+
owner_gone = any(r["target"] == "owner" and r["result"] == "not-found" for r in self.records)
|
|
443
|
+
account = self.account() if cause == "repo-not-found" and not owner_gone else None
|
|
444
|
+
return self.output("unavailable", None, cause, self.message(cause, account), account, **extra)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# --------------------------------------------------------------------------
|
|
448
|
+
# Commands
|
|
449
|
+
# --------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# What each command reports besides the common keys when it read nothing;
|
|
453
|
+
# found and missing stay empty then, since no listing says a tag is absent.
|
|
454
|
+
_EMPTY = {
|
|
455
|
+
"repo": {"private": None, "default_branch": None},
|
|
456
|
+
"tags": {"tags": [], "count": 0, "match": None, "found": {}, "missing": [], "nearest": []},
|
|
457
|
+
"tree": {"ref": "HEAD", "tree": [], "count": 0, "truncated": False},
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def probe_repo(owner: str, repo: str) -> dict:
|
|
462
|
+
p = Probe(owner, repo)
|
|
463
|
+
|
|
464
|
+
def read(body: str, via: str) -> dict | None:
|
|
465
|
+
try:
|
|
466
|
+
data = json.loads(body)
|
|
467
|
+
except ValueError:
|
|
468
|
+
return None
|
|
469
|
+
if not isinstance(data, dict):
|
|
470
|
+
return None
|
|
471
|
+
private = data.get("private")
|
|
472
|
+
branch = data.get("default_branch")
|
|
473
|
+
return p.output("ok", via, private=private if isinstance(private, bool) else None,
|
|
474
|
+
default_branch=branch if isinstance(branch, str) else None)
|
|
475
|
+
|
|
476
|
+
result, body = p.gh_call("repo", f"repos/{owner}/{repo}")
|
|
477
|
+
if result == "ok" and (out := read(body, "gh")):
|
|
478
|
+
return out
|
|
479
|
+
if result != "not-found":
|
|
480
|
+
result, body = p.api_call("repo", f"repos/{owner}/{repo}")
|
|
481
|
+
if result == "ok" and (out := read(body, "api")):
|
|
482
|
+
return out
|
|
483
|
+
result, text = p.git_call("repo", "ls-remote", "--symref", "--", f"{WEB_ROOT}/{owner}/{repo}", "HEAD")
|
|
484
|
+
if result == "ok":
|
|
485
|
+
branch = None
|
|
486
|
+
for line in text.splitlines():
|
|
487
|
+
left, _, name = line.partition("\t")
|
|
488
|
+
if left.startswith("ref: refs/heads/") and name.strip() == "HEAD":
|
|
489
|
+
branch = left[len("ref: refs/heads/"):].strip()
|
|
490
|
+
private = True if "not-found" in p.results("api") else None
|
|
491
|
+
return p.output("ok", "git", private=private, default_branch=branch)
|
|
492
|
+
return p.unavailable(**_EMPTY["repo"])
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _sorted_tags(names) -> list[str]:
|
|
496
|
+
versioned, other = [], []
|
|
497
|
+
for name in names:
|
|
498
|
+
split = _sibling().split_version(name)
|
|
499
|
+
(versioned if split else other).append((split[1] if split else None, name))
|
|
500
|
+
versioned.sort(key=lambda item: (item[0], item[1]), reverse=True)
|
|
501
|
+
return [name for _key, name in versioned] + sorted(name for _key, name in other)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def probe_tags(owner: str, repo: str, wants: list[str], limit: int, version: str | None = None,
|
|
505
|
+
package: str | None = None) -> dict:
|
|
506
|
+
p = Probe(owner, repo)
|
|
507
|
+
tags: dict[str, str | None] | None = None
|
|
508
|
+
via = None
|
|
509
|
+
result, body = p.gh_call("tags", "--paginate", f"repos/{owner}/{repo}/tags?per_page=100")
|
|
510
|
+
if result == "ok":
|
|
511
|
+
try:
|
|
512
|
+
items = _json(body)
|
|
513
|
+
if isinstance(items, list):
|
|
514
|
+
tags = {}
|
|
515
|
+
for item in items:
|
|
516
|
+
if isinstance(item, dict) and isinstance(item.get("name"), str):
|
|
517
|
+
commit = item.get("commit")
|
|
518
|
+
sha = commit.get("sha") if isinstance(commit, dict) else None
|
|
519
|
+
tags[item["name"]] = sha if isinstance(sha, str) else None
|
|
520
|
+
via = "gh"
|
|
521
|
+
except ValueError:
|
|
522
|
+
tags = None
|
|
523
|
+
if tags is None:
|
|
524
|
+
result, text = p.git_call("tags", "ls-remote", "--tags", "--", f"{WEB_ROOT}/{owner}/{repo}")
|
|
525
|
+
if result == "ok":
|
|
526
|
+
tags = {}
|
|
527
|
+
for line in text.splitlines():
|
|
528
|
+
sha, _, name = line.partition("\t")
|
|
529
|
+
name = name.strip()
|
|
530
|
+
if not name.startswith("refs/tags/"):
|
|
531
|
+
continue
|
|
532
|
+
name = name[len("refs/tags/"):]
|
|
533
|
+
if name.endswith("^{}"):
|
|
534
|
+
tags[name[:-3]] = sha.strip()
|
|
535
|
+
else:
|
|
536
|
+
tags.setdefault(name, sha.strip())
|
|
537
|
+
via = "git"
|
|
538
|
+
if tags is None:
|
|
539
|
+
return p.unavailable(**_EMPTY["tags"])
|
|
540
|
+
ordered = _sorted_tags(tags)
|
|
541
|
+
wanted = list(dict.fromkeys(wants))
|
|
542
|
+
found = {tag: tags[tag] for tag in wanted if tag in tags}
|
|
543
|
+
named = _sibling().match_tags(tags, version, package or None) if version else []
|
|
544
|
+
return p.output("ok", via, tags=ordered[:limit] if limit > 0 else ordered, count=len(ordered),
|
|
545
|
+
match=next((tag for tag in (*wanted, *named) if tag in tags), None), found=found,
|
|
546
|
+
missing=[tag for tag in wanted if tag not in tags],
|
|
547
|
+
nearest=_sibling().nearest_tags(tags, version) if version else [])
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _tree_ref(value: str | None) -> str:
|
|
551
|
+
"""The ref tree reads: "HEAD" for an empty, `null`, `none` or `head` value (any letter case)."""
|
|
552
|
+
ref = (value or "").strip()
|
|
553
|
+
return "HEAD" if ref.lower() in ("", "null", "none", "head") else ref
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def probe_tree(owner: str, repo: str, ref: str) -> dict:
|
|
557
|
+
p = Probe(owner, repo)
|
|
558
|
+
ref = _tree_ref(ref)
|
|
559
|
+
empty = {**_EMPTY["tree"], "ref": ref}
|
|
560
|
+
path = f"repos/{owner}/{repo}/git/trees/{urllib.parse.quote(ref, safe='')}?recursive=1"
|
|
561
|
+
|
|
562
|
+
def read(body: str, via: str) -> dict | None:
|
|
563
|
+
try:
|
|
564
|
+
data = json.loads(body)
|
|
565
|
+
except ValueError:
|
|
566
|
+
return None
|
|
567
|
+
if not isinstance(data, dict) or not isinstance(data.get("tree"), list):
|
|
568
|
+
return None
|
|
569
|
+
files = [e["path"] for e in data["tree"]
|
|
570
|
+
if isinstance(e, dict) and e.get("type") == "blob" and isinstance(e.get("path"), str)]
|
|
571
|
+
return p.output("ok", via, ref=ref, tree=files, count=len(files),
|
|
572
|
+
truncated=data.get("truncated") is True)
|
|
573
|
+
|
|
574
|
+
if ref != "HEAD" and not _sibling().REF_RE.fullmatch(ref):
|
|
575
|
+
return p.output("unavailable", None, "ref-not-found",
|
|
576
|
+
f"{ref} is not a valid branch, tag or commit name.", **empty)
|
|
577
|
+
result, body = p.gh_call("tree", path)
|
|
578
|
+
if result == "ok" and (out := read(body, "gh")):
|
|
579
|
+
return out
|
|
580
|
+
repo_result = None
|
|
581
|
+
if result == "not-found":
|
|
582
|
+
repo_result, _body = p.gh_call("repo", f"repos/{owner}/{repo}")
|
|
583
|
+
if repo_result == "ok":
|
|
584
|
+
return p.output("unavailable", None, "ref-not-found",
|
|
585
|
+
f"{owner}/{repo} has no branch, tag or commit named {ref}.", **empty)
|
|
586
|
+
# Only gh's 404 for the repository itself says the anonymous API cannot read it either.
|
|
587
|
+
if repo_result != "not-found":
|
|
588
|
+
result, body = p.api_call("tree", path)
|
|
589
|
+
if result == "ok" and (out := read(body, "api")):
|
|
590
|
+
return out
|
|
591
|
+
if result == "not-found":
|
|
592
|
+
repo_result, _body = p.api_call("repo", f"repos/{owner}/{repo}")
|
|
593
|
+
if repo_result == "ok":
|
|
594
|
+
return p.output("unavailable", None, "ref-not-found",
|
|
595
|
+
f"{owner}/{repo} has no branch, tag or commit named {ref}.", **empty)
|
|
596
|
+
return p.unavailable(**empty)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
# --------------------------------------------------------------------------
|
|
600
|
+
# CLI
|
|
601
|
+
# --------------------------------------------------------------------------
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
605
|
+
parser = argparse.ArgumentParser(
|
|
606
|
+
prog="skf-github-probe",
|
|
607
|
+
description="Tell whether a GitHub repository can be read and why not; list its tags or files.",
|
|
608
|
+
)
|
|
609
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
610
|
+
|
|
611
|
+
def common(p: argparse.ArgumentParser) -> None:
|
|
612
|
+
p.add_argument("--repo", required=True, help="owner/repo or a github.com URL")
|
|
613
|
+
p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_SEC,
|
|
614
|
+
help="seconds the whole call may take (default 60)")
|
|
615
|
+
|
|
616
|
+
common(sub.add_parser("repo", help="can the repository be read"))
|
|
617
|
+
p_tags = sub.add_parser("tags", help="list the repository's tags")
|
|
618
|
+
common(p_tags)
|
|
619
|
+
p_tags.add_argument("--want", action="append", default=[], help="a tag to look for (repeatable)")
|
|
620
|
+
p_tags.add_argument("--version", help="a version whose tag to look for, in every form a tag writes it in")
|
|
621
|
+
p_tags.add_argument("--name", help="with --version: the package name a monorepo's tags carry")
|
|
622
|
+
p_tags.add_argument("--limit", type=int, default=DEFAULT_TAG_LIMIT,
|
|
623
|
+
help="tags to list, newest first (default 20; 0 for all)")
|
|
624
|
+
p_tree = sub.add_parser("tree", help="list the repository's files at a ref")
|
|
625
|
+
common(p_tree)
|
|
626
|
+
p_tree.add_argument("--ref", default="HEAD", help="branch, tag or commit (default: the default branch)")
|
|
627
|
+
p_tree.add_argument("--out", metavar="FILE",
|
|
628
|
+
help="write the whole listing to FILE and print it without `tree`")
|
|
629
|
+
return parser
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def main(argv: list[str] | None = None) -> int:
|
|
633
|
+
args = _build_parser().parse_args(argv)
|
|
634
|
+
try:
|
|
635
|
+
_start_clock(args.timeout)
|
|
636
|
+
parsed = parse_repo(args.repo)
|
|
637
|
+
if parsed is None:
|
|
638
|
+
out = {"status": "unavailable", "cause": "invalid-repo",
|
|
639
|
+
"message": f"{args.repo} is not a github.com repository (expected owner/repo or a URL).",
|
|
640
|
+
"repo": None, "gh": "not-run", "account": None, "via": None, "probes": [],
|
|
641
|
+
**_EMPTY[args.cmd]}
|
|
642
|
+
if args.cmd == "tree":
|
|
643
|
+
out["ref"] = _tree_ref(args.ref)
|
|
644
|
+
elif args.cmd == "repo":
|
|
645
|
+
out = probe_repo(*parsed)
|
|
646
|
+
elif args.cmd == "tags":
|
|
647
|
+
out = probe_tags(*parsed, args.want, args.limit, args.version, args.name)
|
|
648
|
+
else:
|
|
649
|
+
out = probe_tree(*parsed, args.ref)
|
|
650
|
+
if args.cmd == "tree" and args.out:
|
|
651
|
+
Path(args.out).write_bytes((json.dumps(out) + "\n").encode("utf-8"))
|
|
652
|
+
out = {key: value for key, value in out.items() if key != "tree"}
|
|
653
|
+
except Exception as e: # noqa: BLE001 - one JSON error line, never a traceback
|
|
654
|
+
print(json.dumps({"status": "error", "message": f"{type(e).__name__}: {e}"}), file=sys.stderr)
|
|
655
|
+
return 1
|
|
656
|
+
print(json.dumps(out))
|
|
657
|
+
return 0 if out["status"] == "ok" else 3
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
if __name__ == "__main__":
|
|
661
|
+
raise SystemExit(main())
|