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,674 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Fetch Temporal: a GitHub repository's issues, pull requests, release
|
|
6
|
+
notes and changelog, written as create-skill's temporal feeder.
|
|
7
|
+
|
|
8
|
+
create-skill step 3b (references/sub/fetch-temporal.md) keeps what it fetched
|
|
9
|
+
from GitHub in the skill's `.skf-temporal/` folder under forge_data_folder:
|
|
10
|
+
step 4 searches it through a QMD collection, and step 5c's doc-rot scan reads
|
|
11
|
+
it. The same upstream data must give the same files on every run, so this
|
|
12
|
+
helper owns the fetches, the release loop and the markdown each file holds.
|
|
13
|
+
The step keeps the eligibility checks and what it tells the user.
|
|
14
|
+
|
|
15
|
+
CLI:
|
|
16
|
+
uv run skf-fetch-temporal.py repo --source-repo <repo or local path>
|
|
17
|
+
uv run skf-fetch-temporal.py fetch --repo <owner/repo> --feeder <folder> \\
|
|
18
|
+
[--exports <file>] [--timeout <seconds>]
|
|
19
|
+
|
|
20
|
+
repo
|
|
21
|
+
----
|
|
22
|
+
Which GitHub repository a brief's source_repo names: an https, ssh or git
|
|
23
|
+
URL, `git@github.com:owner/repo` or the `owner/repo` shorthand, read by
|
|
24
|
+
skf-source-tree.py's parse_remote (the sibling in this folder), or a local
|
|
25
|
+
path whose git repository has a GitHub `origin` remote. When it names one,
|
|
26
|
+
the helper also asks whether gh is installed and logged in (`gh auth
|
|
27
|
+
status`, 10 seconds at most).
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
"repo": "owner/repo" | null,
|
|
31
|
+
"via": "url" | "origin" | null,
|
|
32
|
+
"gh": "ok" | "missing" | "unauthenticated" | "failed" | "not-run",
|
|
33
|
+
"skip_reason": null | "not-github" | "not-a-git-repo" | "no-origin"
|
|
34
|
+
| "gh-missing" | "gh-unauthenticated" | "gh-failed"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
skip_reason is null when the repository can be fetched: a GitHub
|
|
38
|
+
repository, and a gh that answers.
|
|
39
|
+
|
|
40
|
+
fetch
|
|
41
|
+
-----
|
|
42
|
+
Fetches into a fresh folder beside the feeder, `<feeder>.new` (one an
|
|
43
|
+
interrupted fetch left behind is removed first), which holds a `.gitignore`
|
|
44
|
+
of `*` so the feeder keeps itself out of git:
|
|
45
|
+
|
|
46
|
+
issues.md the last ISSUE_LIMIT issues, open and closed
|
|
47
|
+
(`gh issue list`)
|
|
48
|
+
prs.md the last PR_LIMIT merged pull requests (`gh pr list`)
|
|
49
|
+
releases.md the notes of the last RELEASE_LIMIT releases, one
|
|
50
|
+
`gh release view` per tag, run one after another; a
|
|
51
|
+
tag whose fetch fails gets a one-line placeholder, and
|
|
52
|
+
a rate limit stops the loop with the releases so far
|
|
53
|
+
changelog.md CHANGELOG.md, else RELEASES.md, at the repository
|
|
54
|
+
root, in any letter case
|
|
55
|
+
targeted-issues.md up to ISSUE_SEARCH_LIMIT issues for each of the first
|
|
56
|
+
TARGETED_NAMES --exports names (`gh search issues`,
|
|
57
|
+
SEARCH_WORKERS at a time); a name keeps only
|
|
58
|
+
[A-Za-z0-9_], and one with nothing left is skipped
|
|
59
|
+
|
|
60
|
+
The four list fetches run at the same time, then the release loop, then
|
|
61
|
+
the searches. A file is written only when its fetch returned something: a
|
|
62
|
+
placeholder is not, so releases.md needs the notes of one release at
|
|
63
|
+
least, and targeted-issues.md an issue one search found. When at least one
|
|
64
|
+
file was written, the fetch folder replaces the feeder; otherwise it is
|
|
65
|
+
removed and the feeder, if any, stays as the last good fetch left it.
|
|
66
|
+
--feeder must be a folder named `.skf-temporal`: the helper deletes it and
|
|
67
|
+
`.skf-temporal.new` beside it, and nothing else.
|
|
68
|
+
|
|
69
|
+
Each file but changelog.md (the file as the repository holds it) is
|
|
70
|
+
markdown with one `##` section per item, items in a fixed order (issues
|
|
71
|
+
and pull requests by number, newest first; releases as GitHub lists them;
|
|
72
|
+
search names by name), with no timestamp of the run: the same upstream data
|
|
73
|
+
gives byte-identical files. Bodies keep their text, with line endings made
|
|
74
|
+
`\\n`.
|
|
75
|
+
|
|
76
|
+
--exports names the extraction inventory's top_exports: a JSON list of
|
|
77
|
+
names, or an object with a `top_exports` list, in a file or on stdin (`-`).
|
|
78
|
+
Without it, or with an empty list, no search runs and no
|
|
79
|
+
targeted-issues.md is written. --timeout caps each gh call (default
|
|
80
|
+
DEFAULT_TIMEOUT_SEC seconds).
|
|
81
|
+
|
|
82
|
+
{
|
|
83
|
+
"status": "replaced" | "kept",
|
|
84
|
+
"repo": "owner/repo",
|
|
85
|
+
"feeder": "<folder>",
|
|
86
|
+
"files": ["<name>.md", ...], # the feeder's .md files after the run
|
|
87
|
+
"fetches": {
|
|
88
|
+
"issues": {"status": "ok" | "empty" | "failed", "count": N,
|
|
89
|
+
"detail": null | "..."},
|
|
90
|
+
"prs": {...same...},
|
|
91
|
+
"releases": {"status": "ok" | "empty" | "failed" | "stopped",
|
|
92
|
+
"count": N, "fetched": N, "failed": N,
|
|
93
|
+
"stopped_at": null | N, "detail": null | "..."},
|
|
94
|
+
"changelog": {"status": "ok" | "none" | "failed",
|
|
95
|
+
"file": "<name>" | null, "detail": null | "..."},
|
|
96
|
+
"targeted": {"status": "ok" | "none" | "unsupported" | "failed"
|
|
97
|
+
| "stopped",
|
|
98
|
+
"names": N, "searched": N, "failed": N,
|
|
99
|
+
"skipped": ["<name>", ...], "stopped_at": null | N,
|
|
100
|
+
"detail": null | "..."}
|
|
101
|
+
},
|
|
102
|
+
"warnings": ["..."]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
gh and git run through skf-source-tree.py (the sibling in this folder): a
|
|
106
|
+
call the time limit stops is stopped with everything it started, and a gh
|
|
107
|
+
or git in the current folder is never run. Every gh call but the search
|
|
108
|
+
names github.com (HOST), the host `repo` checks the login for, whatever
|
|
109
|
+
GH_HOST says.
|
|
110
|
+
|
|
111
|
+
Exit codes:
|
|
112
|
+
0 repo: JSON printed; fetch: the feeder now holds this fetch
|
|
113
|
+
3 fetch: nothing was fetched, the feeder is as it was
|
|
114
|
+
2 usage error: a malformed --repo, a --feeder not named .skf-temporal,
|
|
115
|
+
an unreadable --exports
|
|
116
|
+
1 unexpected error: {"status": "error", "message": ...} on stderr
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
from __future__ import annotations
|
|
120
|
+
|
|
121
|
+
import argparse
|
|
122
|
+
import concurrent.futures
|
|
123
|
+
import importlib.util
|
|
124
|
+
import json
|
|
125
|
+
import os
|
|
126
|
+
import re
|
|
127
|
+
import sys
|
|
128
|
+
from pathlib import Path
|
|
129
|
+
|
|
130
|
+
HOST = "github.com"
|
|
131
|
+
FEEDER_NAME = ".skf-temporal"
|
|
132
|
+
FETCH_SUFFIX = ".new"
|
|
133
|
+
ISSUE_LIMIT = 100
|
|
134
|
+
PR_LIMIT = 100
|
|
135
|
+
RELEASE_LIMIT = 10
|
|
136
|
+
TARGETED_NAMES = 10
|
|
137
|
+
ISSUE_SEARCH_LIMIT = 5
|
|
138
|
+
SEARCH_WORKERS = 5
|
|
139
|
+
DEFAULT_TIMEOUT_SEC = 60.0
|
|
140
|
+
AUTH_TIMEOUT_SEC = 10.0
|
|
141
|
+
CHANGELOG_NAMES = ("CHANGELOG.md", "RELEASES.md")
|
|
142
|
+
FEEDER_FILES = ("issues.md", "prs.md", "releases.md", "changelog.md", "targeted-issues.md")
|
|
143
|
+
|
|
144
|
+
_REPO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9._-]+$")
|
|
145
|
+
_HTTP_CODE_RE = re.compile(r"HTTP (\d{3})")
|
|
146
|
+
_SIBLING = None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class UsageError(Exception):
|
|
150
|
+
"""An input the helper cannot use (exit 2)."""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _sibling():
|
|
154
|
+
"""skf-source-tree.py from this folder, loaded once: gh and git run
|
|
155
|
+
through its runner (_run stops a call and all it started at the time
|
|
156
|
+
limit; _resolve_outside_cwd never takes a gh or git planted in the
|
|
157
|
+
current folder), and parse_remote reads a repository URL."""
|
|
158
|
+
global _SIBLING
|
|
159
|
+
if _SIBLING is None:
|
|
160
|
+
path = Path(__file__).resolve().parent / "skf-source-tree.py"
|
|
161
|
+
spec = importlib.util.spec_from_file_location("skf_source_tree", path)
|
|
162
|
+
if spec is None or spec.loader is None:
|
|
163
|
+
raise ImportError(f"cannot load {path}")
|
|
164
|
+
module = importlib.util.module_from_spec(spec)
|
|
165
|
+
spec.loader.exec_module(module)
|
|
166
|
+
_SIBLING = module
|
|
167
|
+
return _SIBLING
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --------------------------------------------------------------------------
|
|
171
|
+
# gh and git
|
|
172
|
+
# --------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _last_line(text: str) -> str:
|
|
176
|
+
lines = [line.strip() for line in (text or "").splitlines() if line.strip()]
|
|
177
|
+
return lines[-1] if lines else ""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _gh(args: list[str], timeout: float) -> tuple[str, str, str]:
|
|
181
|
+
"""`gh <args>`: (result, stdout, detail). result is ok, missing,
|
|
182
|
+
unauthenticated, not-found, rate-limited, unsupported, timeout or failed."""
|
|
183
|
+
tree = _sibling()
|
|
184
|
+
exe = tree._resolve_outside_cwd("gh")
|
|
185
|
+
if exe is None:
|
|
186
|
+
return "missing", "", "gh is not installed"
|
|
187
|
+
try:
|
|
188
|
+
rc, out, err, _killed = tree._run([exe, *args], timeout)
|
|
189
|
+
except (OSError, ValueError) as exc:
|
|
190
|
+
return "missing", "", f"gh could not run: {exc}"
|
|
191
|
+
if rc is None:
|
|
192
|
+
return "timeout", "", f"gh did not answer within {timeout:g} seconds"
|
|
193
|
+
stdout = out.decode("utf-8", errors="replace")
|
|
194
|
+
stderr = err.decode("utf-8", errors="replace")
|
|
195
|
+
if rc == 0:
|
|
196
|
+
return "ok", stdout, ""
|
|
197
|
+
detail = _last_line(stderr) or f"gh exited {rc}"
|
|
198
|
+
text = (stderr + stdout).lower()
|
|
199
|
+
m = _HTTP_CODE_RE.search(stderr)
|
|
200
|
+
code = int(m.group(1)) if m else None
|
|
201
|
+
if rc == 4:
|
|
202
|
+
return "unauthenticated", "", "gh is not logged in"
|
|
203
|
+
if code == 429 or "rate limit" in text:
|
|
204
|
+
return "rate-limited", "", detail
|
|
205
|
+
if code == 404:
|
|
206
|
+
return "not-found", "", detail
|
|
207
|
+
if "unknown command" in text or "unknown flag" in text:
|
|
208
|
+
return "unsupported", "", detail
|
|
209
|
+
return "failed", "", detail
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _git(cwd: str, *args: str) -> tuple[int | None, str]:
|
|
213
|
+
"""`git -C cwd <args>`: (returncode, stdout); returncode None when git cannot run."""
|
|
214
|
+
tree = _sibling()
|
|
215
|
+
exe = tree._resolve_outside_cwd("git")
|
|
216
|
+
if exe is None:
|
|
217
|
+
return None, ""
|
|
218
|
+
try:
|
|
219
|
+
rc, out, _err, _killed = tree._run([exe, "-c", f"core.hooksPath={os.devnull}", "-C", cwd, *args],
|
|
220
|
+
AUTH_TIMEOUT_SEC)
|
|
221
|
+
except (OSError, ValueError):
|
|
222
|
+
return None, ""
|
|
223
|
+
return rc, out.decode("utf-8", errors="replace").strip()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# --------------------------------------------------------------------------
|
|
227
|
+
# repo
|
|
228
|
+
# --------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def github_repo(value: str | None) -> str | None:
|
|
232
|
+
"""`owner/repo` of the github.com repository a URL or shorthand names, else None."""
|
|
233
|
+
parsed = _sibling().parse_remote(value)
|
|
234
|
+
if parsed is None:
|
|
235
|
+
return None
|
|
236
|
+
host, owners, repo = parsed
|
|
237
|
+
if host != "github.com" or len(owners) != 1:
|
|
238
|
+
return None
|
|
239
|
+
return f"{owners[0]}/{repo}"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def resolve_repo(source_repo: str) -> dict:
|
|
243
|
+
"""The repo result (see the repo section of the module docstring)."""
|
|
244
|
+
result = {"repo": None, "via": None, "gh": "not-run", "skip_reason": None}
|
|
245
|
+
value = (source_repo or "").strip()
|
|
246
|
+
if _sibling().parse_remote(value) is not None:
|
|
247
|
+
repo, via = github_repo(value), "url"
|
|
248
|
+
if repo is None:
|
|
249
|
+
result["skip_reason"] = "not-github"
|
|
250
|
+
return result
|
|
251
|
+
else:
|
|
252
|
+
path = os.path.expanduser(value) if value else ""
|
|
253
|
+
rc, inside = _git(path, "rev-parse", "--is-inside-work-tree") if path and os.path.isdir(path) else (1, "")
|
|
254
|
+
if rc != 0 or inside != "true":
|
|
255
|
+
result["skip_reason"] = "not-a-git-repo"
|
|
256
|
+
return result
|
|
257
|
+
rc, origin = _git(path, "remote", "get-url", "origin")
|
|
258
|
+
if rc != 0 or not origin:
|
|
259
|
+
result["skip_reason"] = "no-origin"
|
|
260
|
+
return result
|
|
261
|
+
repo, via = github_repo(origin), "origin"
|
|
262
|
+
if repo is None:
|
|
263
|
+
result["skip_reason"] = "not-github"
|
|
264
|
+
return result
|
|
265
|
+
result["repo"], result["via"] = repo, via
|
|
266
|
+
status, _out, _detail = _gh(["auth", "status", "--hostname", HOST], AUTH_TIMEOUT_SEC)
|
|
267
|
+
# gh auth status exits 1 when no account is logged in or its token is rejected.
|
|
268
|
+
result["gh"] = {"ok": "ok", "missing": "missing", "unauthenticated": "unauthenticated",
|
|
269
|
+
"failed": "unauthenticated"}.get(status, "failed")
|
|
270
|
+
if result["gh"] != "ok":
|
|
271
|
+
result["skip_reason"] = f"gh-{result['gh']}"
|
|
272
|
+
return result
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# --------------------------------------------------------------------------
|
|
276
|
+
# Markdown
|
|
277
|
+
# --------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _text(value) -> str:
|
|
281
|
+
"""A body or title as the feeder holds it: `\\n` line endings, no trailing space."""
|
|
282
|
+
text = value if isinstance(value, str) else ""
|
|
283
|
+
return text.replace("\r\n", "\n").replace("\r", "\n").strip()
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _labels(item: dict) -> str:
|
|
287
|
+
names = sorted(label.get("name", "") for label in item.get("labels") or [] if isinstance(label, dict))
|
|
288
|
+
names = [name for name in names if name]
|
|
289
|
+
return ", ".join(names) if names else "none"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _by_number(items: list) -> list[dict]:
|
|
293
|
+
items = [item for item in items if isinstance(item, dict)]
|
|
294
|
+
return sorted(items, key=lambda item: -int(item.get("number") or 0))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _document(title: str, sections: list[str]) -> str:
|
|
298
|
+
return "\n\n".join([f"# {title}", *sections]) + "\n"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def render_issues(repo: str, issues: list) -> str:
|
|
302
|
+
sections = []
|
|
303
|
+
for item in _by_number(issues):
|
|
304
|
+
lines = [f"## #{item.get('number')}: {_text(item.get('title'))}", "",
|
|
305
|
+
f"- State: {_text(item.get('state')).lower()}",
|
|
306
|
+
f"- Labels: {_labels(item)}",
|
|
307
|
+
f"- Created: {_text(item.get('createdAt'))}"]
|
|
308
|
+
if item.get("closedAt"):
|
|
309
|
+
lines.append(f"- Closed: {_text(item.get('closedAt'))}")
|
|
310
|
+
body = _text(item.get("body"))
|
|
311
|
+
sections.append("\n".join(lines) + (f"\n\n{body}" if body else ""))
|
|
312
|
+
return _document(f"Issues: {repo}", sections)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def render_prs(repo: str, prs: list) -> str:
|
|
316
|
+
sections = []
|
|
317
|
+
for item in _by_number(prs):
|
|
318
|
+
lines = [f"## #{item.get('number')}: {_text(item.get('title'))}", "",
|
|
319
|
+
f"- Merged: {_text(item.get('mergedAt'))}",
|
|
320
|
+
f"- Labels: {_labels(item)}"]
|
|
321
|
+
body = _text(item.get("body"))
|
|
322
|
+
sections.append("\n".join(lines) + (f"\n\n{body}" if body else ""))
|
|
323
|
+
return _document(f"Merged pull requests: {repo}", sections)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def render_release(release: dict) -> str:
|
|
327
|
+
tag = _text(release.get("tagName"))
|
|
328
|
+
name = _text(release.get("name"))
|
|
329
|
+
heading = f"## {tag}: {name}" if name and name != tag else f"## {tag}"
|
|
330
|
+
lines = [heading, "", f"- Published: {_text(release.get('publishedAt'))}"]
|
|
331
|
+
body = _text(release.get("body"))
|
|
332
|
+
return "\n".join(lines) + (f"\n\n{body}" if body else "")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def render_releases(repo: str, sections: list[str]) -> str:
|
|
336
|
+
return _document(f"Releases: {repo}", sections)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def render_targeted(repo: str, results: dict[str, list | None]) -> str:
|
|
340
|
+
sections = []
|
|
341
|
+
for name in sorted(results):
|
|
342
|
+
found = results[name]
|
|
343
|
+
if found is None:
|
|
344
|
+
sections.append(f"## {name} (fetch failed)")
|
|
345
|
+
continue
|
|
346
|
+
if not found:
|
|
347
|
+
sections.append(f"## {name} (no issues found)")
|
|
348
|
+
continue
|
|
349
|
+
parts = [f"## {name}"]
|
|
350
|
+
for item in _by_number(found):
|
|
351
|
+
heading = f"### #{item.get('number')}: {_text(item.get('title'))} ({_text(item.get('state')).lower()})"
|
|
352
|
+
body = _text(item.get("body"))
|
|
353
|
+
parts.append(heading + (f"\n\n{body}" if body else ""))
|
|
354
|
+
sections.append("\n\n".join(parts))
|
|
355
|
+
return _document(f"Issues naming exported functions: {repo}", sections)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def render_changelog(text: str) -> str:
|
|
359
|
+
return _text(text) + "\n"
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# --------------------------------------------------------------------------
|
|
363
|
+
# fetch
|
|
364
|
+
# --------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _json_list(stdout: str) -> list | None:
|
|
368
|
+
try:
|
|
369
|
+
data = json.loads(stdout or "[]")
|
|
370
|
+
except ValueError:
|
|
371
|
+
return None
|
|
372
|
+
return data if isinstance(data, list) else None
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def safe_names(names: list) -> tuple[list[str], list[str]]:
|
|
376
|
+
"""(the first TARGETED_NAMES search terms, the names before the last of
|
|
377
|
+
them that nothing was left of)."""
|
|
378
|
+
kept: list[str] = []
|
|
379
|
+
skipped: list[str] = []
|
|
380
|
+
for name in names:
|
|
381
|
+
if len(kept) == TARGETED_NAMES:
|
|
382
|
+
break
|
|
383
|
+
if not isinstance(name, str):
|
|
384
|
+
continue
|
|
385
|
+
safe = re.sub(r"[^A-Za-z0-9_]", "", name)
|
|
386
|
+
if not safe:
|
|
387
|
+
skipped.append(name)
|
|
388
|
+
elif safe not in kept:
|
|
389
|
+
kept.append(safe)
|
|
390
|
+
return kept, skipped
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _list_fetch(args: list[str], timeout: float) -> tuple[dict, list]:
|
|
394
|
+
status, out, detail = _gh(args, timeout)
|
|
395
|
+
if status != "ok":
|
|
396
|
+
return {"status": "failed", "count": 0, "detail": detail or status}, []
|
|
397
|
+
items = _json_list(out)
|
|
398
|
+
if items is None:
|
|
399
|
+
return {"status": "failed", "count": 0, "detail": "gh printed no JSON list"}, []
|
|
400
|
+
return {"status": "ok" if items else "empty", "count": len(items), "detail": None}, items
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _changelog(repo: str, timeout: float) -> tuple[dict, str | None]:
|
|
404
|
+
status, out, detail = _gh(["api", "--hostname", HOST, f"repos/{repo}/contents"], timeout)
|
|
405
|
+
if status != "ok":
|
|
406
|
+
return {"status": "failed", "file": None, "detail": detail or status}, None
|
|
407
|
+
entries = _json_list(out) or []
|
|
408
|
+
names = {e.get("name", "").lower(): e.get("name") for e in entries
|
|
409
|
+
if isinstance(e, dict) and e.get("type") == "file" and isinstance(e.get("name"), str)}
|
|
410
|
+
for wanted in CHANGELOG_NAMES:
|
|
411
|
+
name = names.get(wanted.lower())
|
|
412
|
+
if name is None:
|
|
413
|
+
continue
|
|
414
|
+
status, out, detail = _gh(["api", "--hostname", HOST, "-H", "Accept: application/vnd.github.raw",
|
|
415
|
+
f"repos/{repo}/contents/{name}"], timeout)
|
|
416
|
+
if status != "ok":
|
|
417
|
+
return {"status": "failed", "file": name, "detail": detail or status}, None
|
|
418
|
+
return {"status": "ok", "file": name, "detail": None}, out
|
|
419
|
+
return {"status": "none", "file": None, "detail": None}, None
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _release_loop(repo: str, tags: list[dict], timeout: float, warnings: list[str]) -> tuple[dict, list[str]]:
|
|
423
|
+
sections: list[str] = []
|
|
424
|
+
record = {"status": "ok" if tags else "empty", "count": len(tags), "fetched": 0, "failed": 0,
|
|
425
|
+
"stopped_at": None, "detail": None}
|
|
426
|
+
for index, tag_item in enumerate(tags, start=1):
|
|
427
|
+
tag = _text(tag_item.get("tagName")) if isinstance(tag_item, dict) else ""
|
|
428
|
+
if not tag:
|
|
429
|
+
continue
|
|
430
|
+
status, out, detail = _gh(["release", "view", tag, "-R", f"{HOST}/{repo}", "--json",
|
|
431
|
+
"tagName,name,publishedAt,body"], timeout)
|
|
432
|
+
if status == "rate-limited":
|
|
433
|
+
record["status"], record["stopped_at"] = "stopped", index
|
|
434
|
+
break
|
|
435
|
+
try:
|
|
436
|
+
release = json.loads(out) if status == "ok" else None
|
|
437
|
+
except ValueError:
|
|
438
|
+
release = None
|
|
439
|
+
if isinstance(release, dict):
|
|
440
|
+
sections.append(render_release(release))
|
|
441
|
+
record["fetched"] += 1
|
|
442
|
+
else:
|
|
443
|
+
sections.append(f"## {tag} (fetch failed: {detail or 'gh printed no JSON'})")
|
|
444
|
+
record["failed"] += 1
|
|
445
|
+
if record["stopped_at"] is not None:
|
|
446
|
+
kept = "partial releases.md kept" if record["fetched"] else "no release notes fetched"
|
|
447
|
+
warnings.append(f"Release fetch stopped at tag {record['stopped_at']}/{len(tags)} due to rate limiting: "
|
|
448
|
+
f"{kept}.")
|
|
449
|
+
if not record["fetched"]:
|
|
450
|
+
# Placeholders alone are no fetched content: they never make a releases.md.
|
|
451
|
+
if record["failed"] and record["status"] == "ok":
|
|
452
|
+
record["status"], record["detail"] = "failed", "every release fetch failed"
|
|
453
|
+
sections = []
|
|
454
|
+
return record, sections
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _searches(repo: str, names: list[str], timeout: float, warnings: list[str]) -> tuple[dict, dict]:
|
|
458
|
+
record = {"status": "ok", "names": len(names), "searched": 0, "failed": 0, "skipped": [],
|
|
459
|
+
"stopped_at": None, "detail": None}
|
|
460
|
+
results: dict[str, list | None] = {}
|
|
461
|
+
stop = False
|
|
462
|
+
|
|
463
|
+
def search(name: str) -> tuple[str, str, str]:
|
|
464
|
+
return _gh(["search", "issues", "--repo", repo, name, "--limit", str(ISSUE_SEARCH_LIMIT),
|
|
465
|
+
"--json", "number,title,state,body"], timeout)
|
|
466
|
+
|
|
467
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=SEARCH_WORKERS) as pool:
|
|
468
|
+
pending = {}
|
|
469
|
+
queue = list(names)
|
|
470
|
+
while (queue or pending) and not stop:
|
|
471
|
+
while queue and len(pending) < SEARCH_WORKERS:
|
|
472
|
+
name = queue.pop(0)
|
|
473
|
+
pending[pool.submit(search, name)] = name
|
|
474
|
+
done, _ = concurrent.futures.wait(pending, return_when=concurrent.futures.FIRST_COMPLETED)
|
|
475
|
+
for future in sorted(done, key=lambda f: names.index(pending[f])):
|
|
476
|
+
name = pending.pop(future)
|
|
477
|
+
status, out, detail = future.result()
|
|
478
|
+
if status == "unsupported":
|
|
479
|
+
record["status"], record["detail"] = "unsupported", detail
|
|
480
|
+
stop = True
|
|
481
|
+
elif status == "rate-limited":
|
|
482
|
+
if record["stopped_at"] is None:
|
|
483
|
+
record["status"], record["stopped_at"] = "stopped", names.index(name) + 1
|
|
484
|
+
warnings.append(f"Targeted search stopped at function {names.index(name) + 1}/{len(names)} "
|
|
485
|
+
"due to rate limiting.")
|
|
486
|
+
stop = True
|
|
487
|
+
else:
|
|
488
|
+
items = _json_list(out) if status == "ok" else None
|
|
489
|
+
results[name] = items
|
|
490
|
+
record["searched"] += 1
|
|
491
|
+
if items is None:
|
|
492
|
+
record["failed"] += 1
|
|
493
|
+
for future, name in pending.items(): # finished or cancelled once a stop was seen
|
|
494
|
+
future.cancel()
|
|
495
|
+
if record["status"] == "unsupported":
|
|
496
|
+
return record, {}
|
|
497
|
+
if record["searched"] and record["failed"] == record["searched"] and record["status"] == "ok":
|
|
498
|
+
record["status"], record["detail"] = "failed", "every search failed"
|
|
499
|
+
if not any(results.values()):
|
|
500
|
+
# Only an issue a search found is fetched content: "(fetch failed)" and
|
|
501
|
+
# "(no issues found)" sections alone never make a targeted-issues.md.
|
|
502
|
+
return record, {}
|
|
503
|
+
return record, results
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _remove(path: Path) -> None:
|
|
507
|
+
"""Delete a folder the helper owns; a link or junction there is removed, never followed."""
|
|
508
|
+
tree = _sibling()
|
|
509
|
+
if tree._is_link_or_junction(path):
|
|
510
|
+
if path.is_dir() and not path.is_symlink():
|
|
511
|
+
path.rmdir() # a Windows junction: rmdir drops the link, not its target
|
|
512
|
+
else:
|
|
513
|
+
path.unlink()
|
|
514
|
+
elif path.is_dir():
|
|
515
|
+
tree._rmtree(path)
|
|
516
|
+
elif path.exists():
|
|
517
|
+
path.unlink()
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def fetch(repo: str, feeder: Path, exports: list | None, timeout: float) -> tuple[dict, int]:
|
|
521
|
+
"""Fetch into `<feeder>.new` and replace the feeder when a file was written: (result, exit code)."""
|
|
522
|
+
staging = feeder.with_name(FEEDER_NAME + FETCH_SUFFIX)
|
|
523
|
+
warnings: list[str] = []
|
|
524
|
+
_remove(staging)
|
|
525
|
+
staging.mkdir(parents=True)
|
|
526
|
+
(staging / ".gitignore").write_bytes(b"*\n")
|
|
527
|
+
|
|
528
|
+
lists = {
|
|
529
|
+
"issues": ["issue", "list", "-R", f"{HOST}/{repo}", "--state", "all", "--limit", str(ISSUE_LIMIT),
|
|
530
|
+
"--json", "number,title,state,labels,createdAt,closedAt,body"],
|
|
531
|
+
"prs": ["pr", "list", "-R", f"{HOST}/{repo}", "--state", "merged", "--limit", str(PR_LIMIT),
|
|
532
|
+
"--json", "number,title,mergedAt,labels,body"],
|
|
533
|
+
"releases": ["release", "list", "-R", f"{HOST}/{repo}", "--limit", str(RELEASE_LIMIT),
|
|
534
|
+
"--json", "tagName,name,publishedAt"],
|
|
535
|
+
}
|
|
536
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
|
|
537
|
+
listed = {key: pool.submit(_list_fetch, args, timeout) for key, args in lists.items()}
|
|
538
|
+
changelog_future = pool.submit(_changelog, repo, timeout)
|
|
539
|
+
results = {key: future.result() for key, future in listed.items()}
|
|
540
|
+
changelog_record, changelog_text = changelog_future.result()
|
|
541
|
+
|
|
542
|
+
files: dict[str, str] = {}
|
|
543
|
+
fetches: dict[str, dict] = {}
|
|
544
|
+
for key, render in (("issues", render_issues), ("prs", render_prs)):
|
|
545
|
+
record, items = results[key]
|
|
546
|
+
fetches[key] = record
|
|
547
|
+
if items:
|
|
548
|
+
files[f"{key}.md"] = render(repo, items)
|
|
549
|
+
|
|
550
|
+
tags_record, tags = results["releases"]
|
|
551
|
+
if tags_record["status"] == "failed":
|
|
552
|
+
fetches["releases"] = {**tags_record, "fetched": 0, "failed": 0, "stopped_at": None}
|
|
553
|
+
else:
|
|
554
|
+
fetches["releases"], sections = _release_loop(repo, tags, timeout, warnings)
|
|
555
|
+
if sections:
|
|
556
|
+
files["releases.md"] = render_releases(repo, sections)
|
|
557
|
+
|
|
558
|
+
fetches["changelog"] = changelog_record
|
|
559
|
+
if changelog_text is not None and _text(changelog_text):
|
|
560
|
+
files["changelog.md"] = render_changelog(changelog_text)
|
|
561
|
+
|
|
562
|
+
names, skipped = safe_names(exports or [])
|
|
563
|
+
if names:
|
|
564
|
+
fetches["targeted"], searched = _searches(repo, names, timeout, warnings)
|
|
565
|
+
if searched:
|
|
566
|
+
files["targeted-issues.md"] = render_targeted(repo, searched)
|
|
567
|
+
else:
|
|
568
|
+
fetches["targeted"] = {"status": "none", "names": 0, "searched": 0, "failed": 0, "skipped": [],
|
|
569
|
+
"stopped_at": None, "detail": None}
|
|
570
|
+
fetches["targeted"]["skipped"] = skipped
|
|
571
|
+
for name in skipped:
|
|
572
|
+
warnings.append(f"Targeted search skipped {name!r}: no letter, digit or underscore to search for.")
|
|
573
|
+
|
|
574
|
+
for name, text in files.items():
|
|
575
|
+
(staging / name).write_bytes(text.encode("utf-8"))
|
|
576
|
+
|
|
577
|
+
if files:
|
|
578
|
+
_remove(feeder)
|
|
579
|
+
os.replace(staging, feeder)
|
|
580
|
+
status, code = "replaced", 0
|
|
581
|
+
else:
|
|
582
|
+
_remove(staging)
|
|
583
|
+
warnings.append("No temporal context could be fetched: the feeder keeps what the last good fetch left.")
|
|
584
|
+
status, code = "kept", 3
|
|
585
|
+
present = sorted(p.name for p in feeder.glob("*.md")) if feeder.is_dir() else []
|
|
586
|
+
return {"status": status, "repo": repo, "feeder": str(feeder), "files": present,
|
|
587
|
+
"fetches": fetches, "warnings": warnings}, code
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
# --------------------------------------------------------------------------
|
|
591
|
+
# CLI
|
|
592
|
+
# --------------------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def read_exports(source: str) -> list:
|
|
596
|
+
"""The --exports names: a JSON list, or an object with a top_exports list."""
|
|
597
|
+
try:
|
|
598
|
+
text = sys.stdin.read() if source == "-" else Path(source).read_text(encoding="utf-8")
|
|
599
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
600
|
+
raise UsageError(f"cannot read --exports {source}: {exc}") from exc
|
|
601
|
+
try:
|
|
602
|
+
data = json.loads(text) if text.strip() else []
|
|
603
|
+
except ValueError as exc:
|
|
604
|
+
raise UsageError(f"--exports {source} is not JSON: {exc}") from exc
|
|
605
|
+
if isinstance(data, dict):
|
|
606
|
+
data = data.get("top_exports") or []
|
|
607
|
+
if not isinstance(data, list):
|
|
608
|
+
raise UsageError(f"--exports {source} must be a JSON list of names or hold a top_exports list")
|
|
609
|
+
return data
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
613
|
+
parser = argparse.ArgumentParser(
|
|
614
|
+
prog="skf-fetch-temporal.py",
|
|
615
|
+
description=__doc__,
|
|
616
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
617
|
+
)
|
|
618
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
619
|
+
repo = sub.add_parser("repo", help="which GitHub repository a source names, and whether gh can read it")
|
|
620
|
+
repo.add_argument("--source-repo", required=True, help="the brief's source_repo: a URL, owner/repo or a local path")
|
|
621
|
+
fetch_cmd = sub.add_parser("fetch", help="fetch issues, pull requests, releases and the changelog")
|
|
622
|
+
fetch_cmd.add_argument("--repo", required=True, help="owner/repo, as the repo command printed it")
|
|
623
|
+
fetch_cmd.add_argument("--feeder", required=True, help="the feeder folder, named .skf-temporal")
|
|
624
|
+
fetch_cmd.add_argument("--exports", metavar="FILE",
|
|
625
|
+
help="the top_exports names as JSON (a file, or - for stdin)")
|
|
626
|
+
fetch_cmd.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_SEC,
|
|
627
|
+
help=f"seconds each gh call may take (default {DEFAULT_TIMEOUT_SEC:g})")
|
|
628
|
+
return parser
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def main(argv: list[str] | None = None) -> int:
|
|
632
|
+
args = _build_parser().parse_args(argv)
|
|
633
|
+
try:
|
|
634
|
+
if args.command == "repo":
|
|
635
|
+
result, code = resolve_repo(args.source_repo), 0
|
|
636
|
+
else:
|
|
637
|
+
if not _REPO_RE.match(args.repo or ""):
|
|
638
|
+
raise UsageError(f"--repo must be owner/repo, got {args.repo!r}")
|
|
639
|
+
feeder = Path(args.feeder)
|
|
640
|
+
if feeder.name != FEEDER_NAME:
|
|
641
|
+
raise UsageError(f"--feeder must be a folder named {FEEDER_NAME}, got {args.feeder!r}")
|
|
642
|
+
if args.timeout <= 0:
|
|
643
|
+
raise UsageError("--timeout must be more than 0")
|
|
644
|
+
exports = read_exports(args.exports) if args.exports else None
|
|
645
|
+
result, code = fetch(args.repo, feeder, exports, args.timeout)
|
|
646
|
+
except UsageError as exc:
|
|
647
|
+
sys.stderr.write(f"skf-fetch-temporal: {exc}\n")
|
|
648
|
+
return 2
|
|
649
|
+
except Exception as exc: # never a traceback in place of the JSON the step reads
|
|
650
|
+
sys.stderr.write(json.dumps({"status": "error", "message": f"{type(exc).__name__}: {exc}"}) + "\n")
|
|
651
|
+
return 1
|
|
652
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
653
|
+
return code
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _force_utf8(*streams) -> None:
|
|
657
|
+
"""Reconfigure stdin, stdout and stderr to UTF-8, keeping each stream's error handler.
|
|
658
|
+
|
|
659
|
+
A Windows console pipes them as cp1252, which cannot carry every
|
|
660
|
+
character of an issue title in the JSON, nor read UTF-8 export names
|
|
661
|
+
piped to stdin (reconfigured here, before the first read).
|
|
662
|
+
"""
|
|
663
|
+
for stream in streams:
|
|
664
|
+
if hasattr(stream, "reconfigure"):
|
|
665
|
+
errors = getattr(stream, "errors", None)
|
|
666
|
+
if errors is None:
|
|
667
|
+
stream.reconfigure(encoding="utf-8")
|
|
668
|
+
else:
|
|
669
|
+
stream.reconfigure(encoding="utf-8", errors=errors)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
if __name__ == "__main__":
|
|
673
|
+
_force_utf8(sys.stdin, sys.stdout, sys.stderr)
|
|
674
|
+
sys.exit(main())
|