bmad-module-skill-forge 2.2.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +49 -49
- package/docs/404.md +10 -8
- package/docs/_data/pinned.yaml +22 -13
- package/docs/_internal/STABILITY.md +58 -35
- package/docs/agents.md +39 -53
- package/docs/architecture.md +65 -51
- package/docs/bmad-synergy.md +53 -51
- package/docs/campaign.md +127 -75
- package/docs/concepts.md +34 -28
- package/docs/examples.md +70 -68
- package/docs/forge-auto.md +34 -31
- package/docs/getting-started.md +118 -57
- package/docs/how-it-works.md +25 -21
- package/docs/index.md +15 -15
- package/docs/skill-model.md +70 -59
- package/docs/troubleshooting.md +135 -18
- package/docs/verifying-a-skill.md +83 -57
- package/docs/why-skf.md +24 -22
- package/docs/workflows.md +274 -110
- package/package.json +18 -10
- package/src/forger/preferences.yaml +8 -1
- package/src/knowledge/agentskills-spec.md +2 -0
- package/src/knowledge/ccc-bridge.md +40 -24
- package/src/knowledge/confidence-tiers.md +9 -5
- package/src/knowledge/overview.md +3 -3
- package/src/knowledge/progressive-capability.md +8 -8
- package/src/knowledge/provenance-tracking.md +9 -5
- package/src/knowledge/qmd-registry.md +2 -2
- package/src/knowledge/skf-knowledge-index.csv +2 -2
- package/src/knowledge/skill-lifecycle.md +18 -11
- package/src/knowledge/split-body-strategy.md +0 -3
- package/src/knowledge/tool-resolution.md +4 -2
- package/src/knowledge/version-paths.md +101 -15
- package/src/module-help.csv +3 -3
- package/src/module.yaml +10 -0
- package/src/shared/data/ast-grep-recipes.yaml +608 -0
- package/src/shared/data/ide-context-files.json +30 -0
- package/src/shared/health-check.md +32 -12
- package/src/shared/references/description-guard-protocol.md +12 -9
- package/src/shared/references/feasibility-report-schema.md +19 -13
- package/src/shared/references/headless-gate-convention.md +55 -3
- package/src/shared/references/output-contract-schema.md +19 -8
- package/src/shared/references/pipeline-contracts.md +94 -29
- package/src/shared/references/tessl-review.md +77 -0
- package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
- package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
- package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
- package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
- package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
- package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
- package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
- package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
- package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
- package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
- package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
- package/src/shared/scripts/skf-atomic-write.py +30 -14
- package/src/shared/scripts/skf-build-change-manifest.py +2203 -24
- package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
- package/src/shared/scripts/skf-check-unit-records.py +184 -0
- package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
- package/src/shared/scripts/skf-classify-changed-files.py +694 -0
- package/src/shared/scripts/skf-comention-pairs.py +1333 -123
- package/src/shared/scripts/skf-compare-file-hashes.py +1 -1
- package/src/shared/scripts/skf-count-imports.py +1867 -0
- package/src/shared/scripts/skf-count-tokens.py +1 -1
- package/src/shared/scripts/skf-derive-assembly-shape.py +3 -2
- package/src/shared/scripts/skf-description-guard.py +99 -11
- package/src/shared/scripts/skf-detect-docs.py +694 -2
- package/src/shared/scripts/skf-detect-language.py +486 -107
- package/src/shared/scripts/skf-detect-registry.py +887 -0
- package/src/shared/scripts/skf-detect-scripts-assets.py +1 -1
- package/src/shared/scripts/skf-detect-tools.py +209 -27
- package/src/shared/scripts/skf-detect-workspaces.py +506 -30
- package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
- package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
- package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
- package/src/shared/scripts/skf-extract-public-api.py +4084 -85
- package/src/shared/scripts/skf-extraction-inventory.py +746 -0
- package/src/shared/scripts/skf-extraction-snapshot.py +539 -0
- package/src/shared/scripts/skf-fetch-temporal.py +674 -0
- package/src/shared/scripts/skf-find-cycles.py +97 -29
- package/src/shared/scripts/skf-find-test-report.py +476 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +444 -203
- package/src/shared/scripts/skf-github-fetch.py +365 -0
- package/src/shared/scripts/skf-github-probe.py +661 -0
- package/src/shared/scripts/skf-hash-content.py +166 -12
- package/src/shared/scripts/skf-language-corpora.py +1 -1
- package/src/shared/scripts/skf-load-provenance.py +159 -27
- package/src/shared/scripts/skf-manifest-ops.py +1 -1
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
- package/src/shared/scripts/skf-merge-doc-urls.py +1 -1
- package/src/shared/scripts/skf-names-present.py +221 -0
- package/src/shared/scripts/skf-pair-intersect.py +119 -28
- package/src/shared/scripts/skf-parse-gaps.py +1384 -0
- package/src/shared/scripts/skf-preapply.py +1 -1
- package/src/shared/scripts/skf-preflight.py +161 -27
- package/src/shared/scripts/skf-promote-staged.py +430 -0
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +39 -7
- package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
- package/src/shared/scripts/skf-quick-batch.py +774 -0
- package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
- package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
- package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
- package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
- package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
- package/src/shared/scripts/skf-resolve-authoritative-files.py +128 -13
- package/src/shared/scripts/skf-resolve-package.py +523 -106
- package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
- package/src/shared/scripts/skf-run-lock.py +577 -0
- package/src/shared/scripts/skf-scan-manifests.py +998 -57
- package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
- package/src/shared/scripts/skf-severity-classify.py +712 -108
- package/src/shared/scripts/skf-shape-detect.py +298 -204
- package/src/shared/scripts/skf-shard-body.py +9 -8
- package/src/shared/scripts/skf-skill-inventory.py +2412 -89
- package/src/shared/scripts/skf-skills-module.py +472 -0
- package/src/shared/scripts/skf-source-tree.py +1796 -0
- package/src/shared/scripts/skf-structural-diff.py +670 -119
- package/src/shared/scripts/skf-tessl-review.py +706 -0
- package/src/shared/scripts/skf-update-active-symlink.py +5 -5
- package/src/shared/scripts/skf-update-run-state.py +624 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
- package/src/shared/scripts/skf-validate-brief-schema.py +4 -1
- package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
- package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
- package/src/shared/scripts/skf-validate-output.py +712 -64
- package/src/shared/scripts/skf-validate-pins.py +1 -1
- package/src/shared/scripts/skf-verify-no-trace.py +116 -10
- package/src/shared/scripts/skf-verify-provenance-completeness.py +2919 -54
- package/src/shared/scripts/skf-write-skill-brief.py +367 -19
- package/src/shared/tool-requirements.yaml +201 -0
- package/src/skf-analyze-source/SKILL.md +41 -36
- package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
- package/src/skf-analyze-source/customize.toml +36 -24
- package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
- package/src/skf-analyze-source/references/continue.md +2 -13
- package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
- package/src/skf-analyze-source/references/generate-briefs.md +121 -55
- package/src/skf-analyze-source/references/headless-contract.md +42 -15
- package/src/skf-analyze-source/references/identify-units.md +96 -110
- package/src/skf-analyze-source/references/init.md +73 -71
- package/src/skf-analyze-source/references/map-and-detect.md +123 -105
- package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
- package/src/skf-analyze-source/references/recommend.md +51 -70
- package/src/skf-analyze-source/references/scan-project.md +60 -84
- package/src/skf-analyze-source/references/scan-root.md +39 -0
- package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
- package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
- package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
- package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
- package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
- package/src/skf-audit-skill/SKILL.md +43 -51
- package/src/skf-audit-skill/assets/drift-report-template.md +27 -3
- package/src/skf-audit-skill/customize.toml +41 -20
- package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
- package/src/skf-audit-skill/references/doc-drift.md +141 -0
- package/src/skf-audit-skill/references/headless-contract.md +29 -0
- package/src/skf-audit-skill/references/init.md +148 -210
- package/src/skf-audit-skill/references/re-index.md +96 -108
- package/src/skf-audit-skill/references/report.md +96 -33
- package/src/skf-audit-skill/references/semantic-diff.md +27 -18
- package/src/skf-audit-skill/references/severity-classify.md +79 -65
- package/src/skf-audit-skill/references/severity-rules.md +15 -6
- package/src/skf-audit-skill/references/structural-diff.md +75 -90
- package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
- package/src/skf-audit-skill/scripts/render-drift-tables.py +685 -0
- package/src/skf-brief-skill/SKILL.md +36 -25
- package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +17 -24
- package/src/skf-brief-skill/assets/skill-brief-schema.md +29 -15
- package/src/skf-brief-skill/customize.toml +48 -20
- package/src/skf-brief-skill/references/analyze-target.md +142 -125
- package/src/skf-brief-skill/references/confirm-brief.md +10 -8
- package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
- package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
- package/src/skf-brief-skill/references/gather-intent.md +87 -182
- package/src/skf-brief-skill/references/headless-args.md +84 -16
- package/src/skf-brief-skill/references/health-check.md +1 -1
- package/src/skf-brief-skill/references/invocation-contract.md +18 -13
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
- package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
- package/src/skf-brief-skill/references/scope-definition.md +123 -47
- package/src/skf-brief-skill/references/step-auto-brief.md +24 -80
- package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
- package/src/skf-brief-skill/references/version-resolution.md +3 -2
- package/src/skf-brief-skill/references/write-brief.md +66 -57
- package/src/skf-campaign/SKILL.md +31 -34
- package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
- package/src/skf-campaign/customize.toml +47 -16
- package/src/skf-campaign/references/campaign-contracts.md +39 -29
- package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
- package/src/skf-campaign/references/health-check.md +7 -16
- package/src/skf-campaign/references/step-01-setup.md +46 -84
- package/src/skf-campaign/references/step-02-strategy.md +31 -30
- package/src/skf-campaign/references/step-03-pins.md +26 -18
- package/src/skf-campaign/references/step-04-provenance.md +24 -23
- package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
- package/src/skf-campaign/references/step-06-batch.md +41 -34
- package/src/skf-campaign/references/step-07-capstone.md +40 -25
- package/src/skf-campaign/references/step-08-verify.md +28 -29
- package/src/skf-campaign/references/step-09-refine.md +29 -30
- package/src/skf-campaign/references/step-10-export.md +48 -36
- package/src/skf-campaign/references/step-11-maintenance.md +31 -34
- package/src/skf-campaign/references/step-resume.md +37 -68
- package/src/skf-campaign/scripts/campaign-deps.py +57 -18
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
- package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
- package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
- package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
- package/src/skf-campaign/scripts/campaign-report.py +204 -37
- package/src/skf-campaign/scripts/campaign-state.py +1128 -0
- package/src/skf-campaign/scripts/campaign-status.py +10 -74
- package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
- package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
- package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
- package/src/skf-campaign/templates/campaign-report-template.md +5 -1
- package/src/skf-campaign/templates/kickoff-template.md +0 -14
- package/src/skf-create-skill/SKILL.md +27 -20
- package/src/skf-create-skill/assets/compile-assembly-rules.md +6 -5
- package/src/skf-create-skill/assets/skill-sections.md +15 -5
- package/src/skf-create-skill/customize.toml +20 -8
- package/src/skf-create-skill/references/authoritative-files-protocol.md +41 -42
- package/src/skf-create-skill/references/batch-mode.md +81 -0
- package/src/skf-create-skill/references/ccc-index-check.md +27 -0
- package/src/skf-create-skill/references/compile.md +107 -29
- package/src/skf-create-skill/references/component-extraction.md +113 -91
- package/src/skf-create-skill/references/enrich.md +41 -23
- package/src/skf-create-skill/references/entry-points-by-hand.md +20 -0
- package/src/skf-create-skill/references/extract.md +200 -161
- package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +2 -2
- package/src/skf-create-skill/references/extraction-patterns.md +584 -196
- package/src/skf-create-skill/references/generate-artifacts.md +145 -93
- package/src/skf-create-skill/references/health-check.md +1 -1
- package/src/skf-create-skill/references/load-brief.md +53 -36
- package/src/skf-create-skill/references/relabel-rule.md +12 -0
- package/src/skf-create-skill/references/report.md +28 -59
- package/src/skf-create-skill/references/source-resolution-protocols.md +49 -155
- package/src/skf-create-skill/references/step-auto-shard.md +20 -93
- package/src/skf-create-skill/references/step-doc-rot.md +44 -60
- package/src/skf-create-skill/references/step-doc-sources.md +28 -19
- package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
- package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
- package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
- package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
- package/src/skf-create-skill/references/validate.md +173 -99
- package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
- package/src/skf-create-stack-skill/SKILL.md +29 -56
- package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
- package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
- package/src/skf-create-stack-skill/customize.toml +23 -12
- package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
- package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
- package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
- package/src/skf-create-stack-skill/references/generate-output.md +138 -128
- package/src/skf-create-stack-skill/references/init.md +90 -32
- package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
- package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
- package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
- package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
- package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
- package/src/skf-create-stack-skill/references/report.md +39 -33
- package/src/skf-create-stack-skill/references/validate.md +20 -39
- package/src/skf-drop-skill/SKILL.md +28 -51
- package/src/skf-drop-skill/customize.toml +44 -30
- package/src/skf-drop-skill/references/execute.md +79 -146
- package/src/skf-drop-skill/references/invocation-contract.md +64 -0
- package/src/skf-drop-skill/references/report.md +46 -30
- package/src/skf-drop-skill/references/select.md +118 -124
- package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
- package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
- package/src/skf-export-skill/SKILL.md +40 -62
- package/src/skf-export-skill/assets/managed-section-format.md +14 -50
- package/src/skf-export-skill/assets/snippet-format.md +3 -1
- package/src/skf-export-skill/customize.toml +42 -13
- package/src/skf-export-skill/references/generate-snippet.md +54 -46
- package/src/skf-export-skill/references/health-check.md +2 -0
- package/src/skf-export-skill/references/invocation-contract.md +15 -0
- package/src/skf-export-skill/references/load-skill.md +88 -62
- package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
- package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
- package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
- package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
- package/src/skf-export-skill/references/package.md +15 -73
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
- package/src/skf-export-skill/references/result-envelope.md +31 -16
- package/src/skf-export-skill/references/summary.md +41 -17
- package/src/skf-export-skill/references/token-report.md +4 -12
- package/src/skf-export-skill/references/update-context.md +118 -237
- package/src/skf-forger/SKILL.md +61 -37
- package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
- package/src/skf-forger/customize.toml +43 -0
- package/src/skf-forger/references/pipeline-mode.md +73 -18
- package/src/skf-forger/scripts/forge-status.py +299 -0
- package/src/skf-forger/scripts/parse-pipeline.py +322 -54
- package/src/skf-forger/scripts/pipeline-gate.py +428 -0
- package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
- package/src/skf-quick-skill/SKILL.md +54 -53
- package/src/skf-quick-skill/assets/skill-template.md +4 -2
- package/src/skf-quick-skill/customize.toml +51 -20
- package/src/skf-quick-skill/references/batch-mode.md +74 -21
- package/src/skf-quick-skill/references/compile.md +30 -31
- package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
- package/src/skf-quick-skill/references/finalize.md +69 -18
- package/src/skf-quick-skill/references/halt-contract.md +41 -29
- package/src/skf-quick-skill/references/health-check.md +5 -3
- package/src/skf-quick-skill/references/quick-extract.md +114 -45
- package/src/skf-quick-skill/references/registry-resolution.md +9 -64
- package/src/skf-quick-skill/references/resolve-target.md +116 -66
- package/src/skf-quick-skill/references/write-and-validate.md +79 -40
- package/src/skf-refine-architecture/SKILL.md +43 -53
- package/src/skf-refine-architecture/customize.toml +49 -20
- package/src/skf-refine-architecture/references/compile.md +97 -54
- package/src/skf-refine-architecture/references/exit-codes.md +30 -0
- package/src/skf-refine-architecture/references/finding-storage.md +21 -0
- package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
- package/src/skf-refine-architecture/references/improvements.md +14 -20
- package/src/skf-refine-architecture/references/init.md +98 -57
- package/src/skf-refine-architecture/references/issue-detection.md +32 -39
- package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
- package/src/skf-refine-architecture/references/report.md +65 -54
- package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
- package/src/skf-rename-skill/SKILL.md +19 -54
- package/src/skf-rename-skill/customize.toml +32 -26
- package/src/skf-rename-skill/references/execute.md +230 -184
- package/src/skf-rename-skill/references/exit-codes.md +7 -7
- package/src/skf-rename-skill/references/health-check.md +1 -7
- package/src/skf-rename-skill/references/invocation-contract.md +25 -0
- package/src/skf-rename-skill/references/report.md +63 -52
- package/src/skf-rename-skill/references/select.md +197 -115
- package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
- package/src/skf-setup/SKILL.md +26 -26
- package/src/skf-setup/customize.toml +14 -10
- package/src/skf-setup/references/auto-index.md +50 -63
- package/src/skf-setup/references/ccc-index.md +29 -90
- package/src/skf-setup/references/detect-and-tier.md +58 -67
- package/src/skf-setup/references/health-check.md +2 -0
- package/src/skf-setup/references/invocation-contract.md +14 -0
- package/src/skf-setup/references/report.md +43 -166
- package/src/skf-setup/references/tier-rules.md +5 -5
- package/src/skf-setup/references/write-config.md +34 -57
- package/src/skf-test-skill/SKILL.md +17 -57
- package/src/skf-test-skill/assets/output-section-formats.md +33 -34
- package/src/skf-test-skill/customize.toml +27 -13
- package/src/skf-test-skill/references/coherence-check.md +134 -62
- package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
- package/src/skf-test-skill/references/coverage-check.md +217 -254
- package/src/skf-test-skill/references/detect-mode.md +5 -5
- package/src/skf-test-skill/references/external-validators.md +94 -82
- package/src/skf-test-skill/references/health-check.md +6 -7
- package/src/skf-test-skill/references/init.md +163 -88
- package/src/skf-test-skill/references/invocation-contract.md +66 -0
- package/src/skf-test-skill/references/migration-section-rules.md +5 -2
- package/src/skf-test-skill/references/report.md +162 -142
- package/src/skf-test-skill/references/score.md +80 -107
- package/src/skf-test-skill/references/scoring-rules.md +41 -155
- package/src/skf-test-skill/references/source-access-protocol.md +19 -92
- package/src/skf-test-skill/references/step-hard-gate.md +51 -33
- package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
- package/src/skf-test-skill/scripts/build-result-context.py +287 -0
- package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
- package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
- package/src/skf-test-skill/scripts/compute-score.py +223 -45
- package/src/skf-test-skill/scripts/gap-ledger.py +1381 -0
- package/src/skf-test-skill/scripts/hard-gate.py +203 -0
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +760 -0
- package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
- package/src/skf-test-skill/scripts/reconcile-coverage.py +273 -79
- package/src/skf-test-skill/scripts/score-signatures.py +459 -0
- package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
- package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +127 -52
- package/src/skf-test-skill/templates/test-report-template.md +21 -15
- package/src/skf-update-skill/SKILL.md +38 -25
- package/src/skf-update-skill/customize.toml +27 -12
- package/src/skf-update-skill/references/detect-changes.md +233 -222
- package/src/skf-update-skill/references/gap-driven.md +336 -0
- package/src/skf-update-skill/references/health-check.md +31 -5
- package/src/skf-update-skill/references/init.md +250 -84
- package/src/skf-update-skill/references/invocation-contract.md +12 -0
- package/src/skf-update-skill/references/manual-section-rules.md +6 -23
- package/src/skf-update-skill/references/merge.md +180 -47
- package/src/skf-update-skill/references/re-extract.md +53 -262
- package/src/skf-update-skill/references/report.md +100 -35
- package/src/skf-update-skill/references/write.md +331 -149
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +29 -7
- package/src/skf-verify-stack/SKILL.md +23 -42
- package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
- package/src/skf-verify-stack/customize.toml +55 -22
- package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
- package/src/skf-verify-stack/references/coverage.md +67 -64
- package/src/skf-verify-stack/references/exit-codes.md +21 -8
- package/src/skf-verify-stack/references/init.md +107 -62
- package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
- package/src/skf-verify-stack/references/integrations.md +106 -65
- package/src/skf-verify-stack/references/report.md +59 -42
- package/src/skf-verify-stack/references/requirements.md +32 -9
- package/src/skf-verify-stack/references/synthesize.md +53 -39
- package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
- package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
- package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
- package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
- package/tools/cli/commands/install.js +11 -4
- package/tools/cli/commands/status.js +15 -3
- package/tools/cli/commands/update.js +11 -4
- package/tools/cli/lib/tool-check.js +320 -0
- package/tools/cli/lib/version-check.js +22 -9
- package/tools/cli/skf-cli.js +4 -1
- package/.gitattributes +0 -16
- package/.nvmrc +0 -1
- package/docs/_internal/RELEASING.md +0 -679
- package/release-audits/v1.0.0-launch-audit.md +0 -728
- package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
- package/src/skf-campaign/manifest.yaml +0 -15
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
- package/src/skf-create-skill/references/ecosystem-check.md +0 -74
- package/src/skf-drop-skill/references/headless-contract.md +0 -18
- package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
- package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
- package/src/skf-update-skill/references/validate.md +0 -124
- package/tools/validate-docs-drift.js +0 -233
- package/tools/validate-docs-links.js +0 -665
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# /// script
|
|
2
|
-
# requires-python = ">=3.
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
3
|
# dependencies = []
|
|
4
4
|
# ///
|
|
5
5
|
"""SKF Detect Docs — discover documentation URLs for a GitHub repository.
|
|
@@ -48,7 +48,7 @@ Doc-drift detection for audit-skill. Given a set of tracked doc sources
|
|
|
48
48
|
the SAME fetch+sha256 primitive the compile side used, and classify each entry
|
|
49
49
|
as changed / unchanged / fetch_failed / skipped_null_hash. This replaces the
|
|
50
50
|
per-URL fetch/hash/compare/count prose at
|
|
51
|
-
`src/skf-audit-skill/references/
|
|
51
|
+
`src/skf-audit-skill/references/doc-drift.md` §2-3: the model no longer
|
|
52
52
|
orchestrates HTTP GETs or computes sha256 by hand, and correctness no longer
|
|
53
53
|
depends on whether a WebFetch tool is wired.
|
|
54
54
|
|
|
@@ -75,6 +75,30 @@ Each list is stably sorted by url. Stored hashes are normalized (leading
|
|
|
75
75
|
matches. Exit 0 on any well-formed input (never blocks the informational
|
|
76
76
|
audit); exit 2 on malformed args or JSON.
|
|
77
77
|
|
|
78
|
+
---------------------------------------------------------------------------
|
|
79
|
+
Subcommand: refresh-hashes
|
|
80
|
+
---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
Records the new hashes of the documents a `compare-hashes` run found
|
|
83
|
+
changed, once update-skill has re-read them (update-skill write.md §2), so
|
|
84
|
+
the next comparison starts from the documents this update used.
|
|
85
|
+
|
|
86
|
+
uv run src/shared/scripts/skf-detect-docs.py refresh-hashes \\
|
|
87
|
+
<metadata.json> --compare <compare-hashes output>
|
|
88
|
+
|
|
89
|
+
Each `doc_sources[]` entry whose url is in the comparison's `changed[]`
|
|
90
|
+
takes that entry's `new_hash` as its `content_hash` and the current UTC
|
|
91
|
+
time as its `recorded_at`; every other entry and every other key of the
|
|
92
|
+
file stays as it was. The file is rewritten through a temporary file and
|
|
93
|
+
one rename.
|
|
94
|
+
|
|
95
|
+
Output (JSON object on stdout):
|
|
96
|
+
{"metadata": <path>, "refreshed": [urls], "not_found": [changed urls
|
|
97
|
+
that metadata.json does not track]}
|
|
98
|
+
|
|
99
|
+
Exit 0 once written; exit 2 on malformed args, JSON that is not the shape
|
|
100
|
+
named, or a file that cannot be read or written.
|
|
101
|
+
|
|
78
102
|
---------------------------------------------------------------------------
|
|
79
103
|
Subcommand: hash-urls
|
|
80
104
|
---------------------------------------------------------------------------
|
|
@@ -110,20 +134,158 @@ entry without a usable url string is dropped and counted under
|
|
|
110
134
|
instead of reporting drift. `doc_sources` is already in the metadata.json
|
|
111
135
|
schema, and the whole object is valid `compare-hashes` input. Exit 0 on any
|
|
112
136
|
well-formed input; exit 2 on malformed args or JSON.
|
|
137
|
+
|
|
138
|
+
---------------------------------------------------------------------------
|
|
139
|
+
Subcommand: readme-entry
|
|
140
|
+
---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
The README entry create-skill step 5a adds to `doc_sources[]` when detection
|
|
143
|
+
found none. The helper picks the README, builds its URL and hashes it with
|
|
144
|
+
the `hash-urls` primitive, so the model never parses the repository or
|
|
145
|
+
builds the URL by hand.
|
|
146
|
+
|
|
147
|
+
uv run src/shared/scripts/skf-detect-docs.py readme-entry \\
|
|
148
|
+
--source-repo <repo> --ref <ref> [--local-root <path>]
|
|
149
|
+
|
|
150
|
+
Input:
|
|
151
|
+
--source-repo the brief's source_repo
|
|
152
|
+
--ref the ref the skill was built from: a tag, branch or commit,
|
|
153
|
+
`HEAD` for the default branch (also for an empty, `null` or
|
|
154
|
+
`none` value), `local` for a local source
|
|
155
|
+
--local-root the folder the source was read from, when it is a local
|
|
156
|
+
folder (a local source, or the tree create-skill read a
|
|
157
|
+
remote one into); the README is picked from its files
|
|
158
|
+
|
|
159
|
+
A local source (`--ref local`) records `file://` and the absolute path of
|
|
160
|
+
its README, with `/` separators and no percent-encoding, as
|
|
161
|
+
`_fetch_and_hash` reads it back; its folder is --local-root, else
|
|
162
|
+
--source-repo. A GitHub source (an https, ssh or git URL, the scp-like
|
|
163
|
+
`git@github.com:owner/repo`, or the `owner/repo` shorthand; a trailing
|
|
164
|
+
`.git` is dropped) records the raw file at --ref:
|
|
165
|
+
`https://raw.githubusercontent.com/<owner>/<repo>/<ref>/<README>`. The
|
|
166
|
+
README is picked from the top-level files of --local-root, or, without a
|
|
167
|
+
local folder, from the repository's top level at --ref through the GitHub
|
|
168
|
+
API: `README.md` in any letter case first, then a README with no language
|
|
169
|
+
suffix (`README.rst`, `README.markdown`, `README`), then the first other
|
|
170
|
+
README in name order (a translation such as `README-ja.md`, which sorts
|
|
171
|
+
before `README.md`). With none listed it is `README.md`. Any other source
|
|
172
|
+
gets no entry.
|
|
173
|
+
|
|
174
|
+
Output (JSON object on stdout): the `hash-urls` object for the one URL,
|
|
175
|
+
with `detected_via: "readme_always"` on its entry, plus
|
|
176
|
+
"skip_reason": null | "not-github" (no entry: a source on another host)
|
|
177
|
+
|
|
178
|
+
Exit 0 once JSON is printed; exit 2 on malformed args.
|
|
179
|
+
|
|
180
|
+
---------------------------------------------------------------------------
|
|
181
|
+
Subcommand: page-metrics
|
|
182
|
+
---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
create-skill step 3c fetches each `doc_urls` page and looks for its
|
|
185
|
+
subpages when the page is a documentation root with little API content of
|
|
186
|
+
its own (a Mintlify, Docusaurus, ReadTheDocs or GitBook landing page). This
|
|
187
|
+
subcommand measures a fetched page, saved as markdown, so the step reads the
|
|
188
|
+
decision instead of counting words and link lines by eye.
|
|
189
|
+
|
|
190
|
+
uv run src/shared/scripts/skf-detect-docs.py page-metrics \\
|
|
191
|
+
--url <url> <file>
|
|
192
|
+
|
|
193
|
+
Input: --url is the page's URL; <file> is its markdown, or `-` for stdin.
|
|
194
|
+
|
|
195
|
+
Output (JSON object on stdout):
|
|
196
|
+
{
|
|
197
|
+
"url": "<url>",
|
|
198
|
+
"path_segments": N, # non-empty segments of the URL path
|
|
199
|
+
"root_like": bool, # N <= 1, or the path ends in `/`, `/index`
|
|
200
|
+
# or `/index.html`
|
|
201
|
+
"code_fences": N, # fenced code blocks (``` or ~~~)
|
|
202
|
+
"table_rows": N, # table separator rows (`|---|`), one a table
|
|
203
|
+
"signature_hits": N, # `def `, `function `, `fn `, `func `,
|
|
204
|
+
# `export ` as words
|
|
205
|
+
"link_lines": N, # non-empty lines holding only markdown links
|
|
206
|
+
"non_empty_lines": N,
|
|
207
|
+
"link_line_ratio": 0.0-1.0, # link_lines / non_empty_lines, 4 places
|
|
208
|
+
"word_count": N, # words, link targets left out
|
|
209
|
+
"trigger1": bool, # root_like, no code fence, table row or
|
|
210
|
+
# signature, and link_line_ratio > 0.7
|
|
211
|
+
"trigger2": bool, # root_like and word_count < 2000
|
|
212
|
+
"discover_subpages": bool # trigger1 or trigger2
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
A link line is a line whose text, once its list marker, quote marker or
|
|
216
|
+
heading marks and its `[text](url)` links are taken out, holds no letter or
|
|
217
|
+
digit. Exit 0 once JSON is printed; exit 2 on malformed args or an input
|
|
218
|
+
that cannot be read or decoded as UTF-8.
|
|
219
|
+
|
|
220
|
+
---------------------------------------------------------------------------
|
|
221
|
+
Subcommand: filter-urls
|
|
222
|
+
---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
The subpages step 3c may fetch for a root page: the URLs a site map, a
|
|
225
|
+
sitemap.xml or a crawl found, cut to the root page's own site.
|
|
226
|
+
|
|
227
|
+
uv run src/shared/scripts/skf-detect-docs.py filter-urls \\
|
|
228
|
+
--root <url> <source>
|
|
229
|
+
|
|
230
|
+
Input `<source>` (a file, or `-` for stdin), read as UTF-8 and gunzipped
|
|
231
|
+
first when it is gzip compressed (a `.xml.gz` sitemap):
|
|
232
|
+
- a sitemap.xml (its `<loc>` values), or
|
|
233
|
+
- a JSON array of URL strings or `{url, title?}` objects, or an object
|
|
234
|
+
with such an array under `links` or `urls` (a map tool's result), or
|
|
235
|
+
- one URL per line.
|
|
236
|
+
A URL given as a path from the site root (`/docs/api`) is read against
|
|
237
|
+
the root URL.
|
|
238
|
+
|
|
239
|
+
A URL is kept when it is http or https, on the root URL's registrable
|
|
240
|
+
domain (any subdomain of it), not the root page itself, not a repeat once
|
|
241
|
+
its `#fragment` is dropped (scheme and host compared in any letter case),
|
|
242
|
+
and has no path segment, less its file extension, that is one of
|
|
243
|
+
SUBPAGE_EXCLUDED_WORDS (`blog`, `changelog`, `pricing`, `about`,
|
|
244
|
+
`careers`): such a segment names a section with no API content, while a
|
|
245
|
+
longer one (`about-the-rest-api`) can head a section of API pages. The
|
|
246
|
+
registrable domain is worked out without a public suffix list: the last two
|
|
247
|
+
labels of the host, or the last three when the last two are a hosting
|
|
248
|
+
suffix (HOSTED_SUFFIXES: `github.io`, `readthedocs.io` and the like, where
|
|
249
|
+
each subdomain is another owner's site) or a country-code second level
|
|
250
|
+
(`co.uk`, `com.au`). An IP address or a one-label host is its own domain.
|
|
251
|
+
|
|
252
|
+
Output (JSON object on stdout):
|
|
253
|
+
{
|
|
254
|
+
"root": "<url>",
|
|
255
|
+
"registrable_domain": "example.com",
|
|
256
|
+
"kept": [{"url", "title": "..." | null,
|
|
257
|
+
"terms": ["api", "reference", ...]}], # input order
|
|
258
|
+
"sitemaps": ["<url>", ...], # same-site .xml / .xml.gz URLs
|
|
259
|
+
"stats": {"input", "kept", "other_domain", "excluded_path",
|
|
260
|
+
"not_http", "duplicate", "root", "sitemap"}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
`terms` lists the API words (API_TERMS) the URL path or title holds, to
|
|
264
|
+
help choose the most relevant subpages; choosing them is the caller's call.
|
|
265
|
+
A same-site URL ending in `.xml` or `.xml.gz` is a sitemap, not a page (a
|
|
266
|
+
sitemap index lists them): it goes to `sitemaps`, for the caller to fetch
|
|
267
|
+
and filter in turn.
|
|
268
|
+
Exit 0 once JSON is printed; exit 2 on malformed args, an input that
|
|
269
|
+
cannot be read or decoded as UTF-8, malformed JSON or a root URL that is
|
|
270
|
+
not http or https.
|
|
113
271
|
"""
|
|
114
272
|
|
|
115
273
|
from __future__ import annotations
|
|
116
274
|
|
|
117
275
|
import argparse
|
|
118
276
|
import base64
|
|
277
|
+
import gzip
|
|
119
278
|
import hashlib
|
|
120
279
|
import json
|
|
280
|
+
import os
|
|
121
281
|
import re
|
|
122
282
|
import socket
|
|
123
283
|
import subprocess
|
|
124
284
|
import sys
|
|
125
285
|
import urllib.error
|
|
286
|
+
import urllib.parse
|
|
126
287
|
import urllib.request
|
|
288
|
+
import zlib
|
|
127
289
|
from datetime import datetime, timezone
|
|
128
290
|
from pathlib import Path
|
|
129
291
|
from typing import Any, Dict, List, Optional, Tuple
|
|
@@ -647,6 +809,74 @@ def _cmd_compare_hashes(argv: List[str]) -> int:
|
|
|
647
809
|
return 0
|
|
648
810
|
|
|
649
811
|
|
|
812
|
+
# ---------------------------------------------------------------------------
|
|
813
|
+
# Subcommand: refresh-hashes: record the hashes compare-hashes found changed
|
|
814
|
+
# ---------------------------------------------------------------------------
|
|
815
|
+
|
|
816
|
+
def refresh_doc_hashes(
|
|
817
|
+
metadata: Dict[str, Any], comparison: Dict[str, Any], recorded_at: Optional[str] = None
|
|
818
|
+
) -> Tuple[Dict[str, Any], List[str], List[str]]:
|
|
819
|
+
"""(metadata with the changed documents' new hashes, refreshed urls, urls it does not track).
|
|
820
|
+
|
|
821
|
+
Raises ValueError when `doc_sources` or `changed` is not an array.
|
|
822
|
+
"""
|
|
823
|
+
entries = metadata.get("doc_sources")
|
|
824
|
+
if entries is None:
|
|
825
|
+
entries = []
|
|
826
|
+
if not isinstance(entries, list):
|
|
827
|
+
raise ValueError("`doc_sources` in the metadata is not an array")
|
|
828
|
+
changed = comparison.get("changed")
|
|
829
|
+
if not isinstance(changed, list):
|
|
830
|
+
raise ValueError("the comparison has no `changed` array: is it compare-hashes output?")
|
|
831
|
+
new_hashes = {
|
|
832
|
+
e["url"]: e["new_hash"] for e in changed
|
|
833
|
+
if isinstance(e, dict) and isinstance(e.get("url"), str) and isinstance(e.get("new_hash"), str)
|
|
834
|
+
}
|
|
835
|
+
stamp = recorded_at or datetime.now(timezone.utc).isoformat()
|
|
836
|
+
refreshed: List[str] = []
|
|
837
|
+
out: List[Any] = []
|
|
838
|
+
for entry in entries:
|
|
839
|
+
if isinstance(entry, dict) and entry.get("url") in new_hashes:
|
|
840
|
+
entry = {**entry, "content_hash": new_hashes[entry["url"]], "recorded_at": stamp}
|
|
841
|
+
refreshed.append(entry["url"])
|
|
842
|
+
out.append(entry)
|
|
843
|
+
not_found = sorted(url for url in new_hashes if url not in refreshed)
|
|
844
|
+
return {**metadata, "doc_sources": out}, refreshed, not_found
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _cmd_refresh_hashes(argv: List[str]) -> int:
|
|
848
|
+
parser = argparse.ArgumentParser(
|
|
849
|
+
prog="skf-detect-docs.py refresh-hashes",
|
|
850
|
+
description=(
|
|
851
|
+
"Record in metadata.json's doc_sources the new content_hash of each "
|
|
852
|
+
"document a compare-hashes run found changed."
|
|
853
|
+
),
|
|
854
|
+
)
|
|
855
|
+
parser.add_argument("metadata", help="the skill's metadata.json, rewritten in place")
|
|
856
|
+
parser.add_argument("--compare", required=True, help="the compare-hashes output for that metadata.json")
|
|
857
|
+
args = parser.parse_args(argv)
|
|
858
|
+
|
|
859
|
+
path = Path(args.metadata)
|
|
860
|
+
tmp = path.with_name(f".{path.name}.skf-{os.getpid()}-tmp")
|
|
861
|
+
try:
|
|
862
|
+
metadata = json.loads(path.read_text(encoding="utf-8"))
|
|
863
|
+
comparison = json.loads(Path(args.compare).read_text(encoding="utf-8"))
|
|
864
|
+
if not isinstance(metadata, dict) or not isinstance(comparison, dict):
|
|
865
|
+
raise ValueError("metadata.json and the comparison must each hold a JSON object")
|
|
866
|
+
updated, refreshed, not_found = refresh_doc_hashes(metadata, comparison)
|
|
867
|
+
tmp.write_bytes((json.dumps(updated, indent=2, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
868
|
+
os.replace(tmp, path)
|
|
869
|
+
except (OSError, ValueError) as exc:
|
|
870
|
+
if tmp.exists():
|
|
871
|
+
tmp.unlink()
|
|
872
|
+
json.dump({"error": str(exc), "code": "REFRESH_FAILED"}, sys.stderr)
|
|
873
|
+
sys.stderr.write("\n")
|
|
874
|
+
return 2
|
|
875
|
+
json.dump({"metadata": path.as_posix(), "refreshed": refreshed, "not_found": not_found}, sys.stdout)
|
|
876
|
+
sys.stdout.write("\n")
|
|
877
|
+
return 0
|
|
878
|
+
|
|
879
|
+
|
|
650
880
|
# ---------------------------------------------------------------------------
|
|
651
881
|
# Subcommand: hash-urls — docs-only doc_sources from the brief's doc_urls
|
|
652
882
|
# ---------------------------------------------------------------------------
|
|
@@ -775,6 +1005,460 @@ def _cmd_hash_urls(argv: List[str]) -> int:
|
|
|
775
1005
|
return 0
|
|
776
1006
|
|
|
777
1007
|
|
|
1008
|
+
# ---------------------------------------------------------------------------
|
|
1009
|
+
# Subcommand: readme-entry, the README entry create-skill step 5a adds
|
|
1010
|
+
# ---------------------------------------------------------------------------
|
|
1011
|
+
|
|
1012
|
+
# `doc_sources[].detected_via` of that entry. Listed in
|
|
1013
|
+
# skf-create-skill/assets/skill-sections.md.
|
|
1014
|
+
README_ALWAYS_DETECTED_VIA = "readme_always"
|
|
1015
|
+
|
|
1016
|
+
# Serves a GitHub repository's files as they are, one path per ref.
|
|
1017
|
+
RAW_GITHUB = "https://raw.githubusercontent.com"
|
|
1018
|
+
|
|
1019
|
+
# A GitHub repository as a brief's source_repo names it: an https, ssh or git
|
|
1020
|
+
# URL, the scp-like `git@github.com:owner/repo`, `github.com/owner/repo`, or
|
|
1021
|
+
# the `owner/repo` shorthand. A trailing `.git` or `/` is not part of the
|
|
1022
|
+
# name, which may itself hold dots (`vercel/next.js`).
|
|
1023
|
+
_GITHUB_REPO_RE = re.compile(
|
|
1024
|
+
r"^(?:(?:https?|ssh|git)://(?:[^@/\s]+@)?(?:www\.)?github\.com(?::\d+)?/"
|
|
1025
|
+
r"|[^@/\s:]+@github\.com:"
|
|
1026
|
+
r"|(?:www\.)?github\.com/)?"
|
|
1027
|
+
r"([A-Za-z0-9][A-Za-z0-9-]*)/([A-Za-z0-9._-]+?)(?:\.git)?/?$",
|
|
1028
|
+
re.IGNORECASE,
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
# The README a folder shows, in the order it is picked: README.md in any
|
|
1032
|
+
# letter case, then a README with no language suffix (README.rst,
|
|
1033
|
+
# README.markdown, README), then any other README, such as a translation
|
|
1034
|
+
# (README-ja.md), which sorts before README.md by name.
|
|
1035
|
+
_README_PREFERENCE = (
|
|
1036
|
+
re.compile(r"^readme\.md$", re.IGNORECASE),
|
|
1037
|
+
re.compile(r"^readme(?:\.[A-Za-z0-9]+)?$", re.IGNORECASE),
|
|
1038
|
+
re.compile(r"^readme", re.IGNORECASE),
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def parse_github_repo(source_repo: str) -> Optional[Tuple[str, str]]:
|
|
1043
|
+
"""(owner, repo) of the GitHub repository `source_repo` names, else None."""
|
|
1044
|
+
m = _GITHUB_REPO_RE.match((source_repo or "").strip())
|
|
1045
|
+
if not m or m.group(2) in (".", ".."):
|
|
1046
|
+
return None
|
|
1047
|
+
return m.group(1), m.group(2)
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def pick_readme(names: List[str]) -> Optional[str]:
|
|
1051
|
+
"""The README among a folder's top-level file names, by _README_PREFERENCE."""
|
|
1052
|
+
ordered = sorted(names)
|
|
1053
|
+
for pattern in _README_PREFERENCE:
|
|
1054
|
+
for name in ordered:
|
|
1055
|
+
if pattern.match(name):
|
|
1056
|
+
return name
|
|
1057
|
+
return None
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def _local_files(folder: Path) -> List[str]:
|
|
1061
|
+
try:
|
|
1062
|
+
return [p.name for p in folder.iterdir() if p.is_file()]
|
|
1063
|
+
except OSError:
|
|
1064
|
+
return []
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _github_files(owner: str, repo: str, ref: str) -> List[str]:
|
|
1068
|
+
"""Names of the files at the top of the repository at `ref`; [] when gh cannot list them."""
|
|
1069
|
+
query = "" if ref == "HEAD" else "?ref=" + urllib.parse.quote(ref, safe="")
|
|
1070
|
+
raw = _run_gh(["api", f"repos/{owner}/{repo}/contents{query}",
|
|
1071
|
+
"--jq", '.[] | select(.type == "file") | .name'])
|
|
1072
|
+
return raw.splitlines() if raw else []
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def readme_url(source_repo: str, ref: str, local_root: Optional[str] = None) -> Optional[str]:
|
|
1076
|
+
"""The URL of the README entry, or None for a source that is neither local nor on GitHub.
|
|
1077
|
+
|
|
1078
|
+
See the readme-entry section of the module docstring.
|
|
1079
|
+
"""
|
|
1080
|
+
ref = (ref or "").strip()
|
|
1081
|
+
if ref.lower() in ("", "null", "none", "head"):
|
|
1082
|
+
ref = "HEAD"
|
|
1083
|
+
root = Path(local_root.strip()) if local_root and local_root.strip() else None
|
|
1084
|
+
if ref.lower() == "local":
|
|
1085
|
+
# Written as _fetch_and_hash reads it back: `file://` and the path, never percent-encoded.
|
|
1086
|
+
folder = Path(os.path.abspath(root if root is not None else (source_repo or "").strip()))
|
|
1087
|
+
return "file://" + (folder / (pick_readme(_local_files(folder)) or "README.md")).as_posix()
|
|
1088
|
+
parsed = parse_github_repo(source_repo)
|
|
1089
|
+
if parsed is None:
|
|
1090
|
+
return None
|
|
1091
|
+
owner, repo = parsed
|
|
1092
|
+
names = _local_files(root) if root is not None and root.is_dir() else _github_files(owner, repo, ref)
|
|
1093
|
+
name = pick_readme(names) or "README.md"
|
|
1094
|
+
return (f"{RAW_GITHUB}/{owner}/{repo}/{urllib.parse.quote(ref, safe='/@+')}/"
|
|
1095
|
+
f"{urllib.parse.quote(name, safe='')}")
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def readme_entry(source_repo: str, ref: str, local_root: Optional[str] = None,
|
|
1099
|
+
recorded_at: Optional[str] = None) -> Dict[str, Any]:
|
|
1100
|
+
"""The readme-entry output: hash_urls for the README URL, its entry marked readme_always."""
|
|
1101
|
+
url = readme_url(source_repo, ref, local_root)
|
|
1102
|
+
if url is None:
|
|
1103
|
+
result = hash_urls([], recorded_at)
|
|
1104
|
+
result["skip_reason"] = "not-github"
|
|
1105
|
+
return result
|
|
1106
|
+
result = hash_urls([url], recorded_at)
|
|
1107
|
+
for entry in result["doc_sources"]:
|
|
1108
|
+
entry["detected_via"] = README_ALWAYS_DETECTED_VIA
|
|
1109
|
+
result["skip_reason"] = None
|
|
1110
|
+
return result
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def _cmd_readme_entry(argv: List[str]) -> int:
|
|
1114
|
+
parser = argparse.ArgumentParser(
|
|
1115
|
+
prog="skf-detect-docs.py readme-entry",
|
|
1116
|
+
description=(
|
|
1117
|
+
"Pick a skill source's README and emit its doc_sources entry "
|
|
1118
|
+
"(detected_via: readme_always): the raw GitHub file at --ref, or a "
|
|
1119
|
+
"file:// URL for a local source, hashed as compare-hashes hashes it."
|
|
1120
|
+
),
|
|
1121
|
+
)
|
|
1122
|
+
parser.add_argument("--source-repo", required=True, help="the brief's source_repo")
|
|
1123
|
+
parser.add_argument(
|
|
1124
|
+
"--ref", required=True,
|
|
1125
|
+
help="the ref the skill was built from: a tag, branch or commit, HEAD "
|
|
1126
|
+
"for the default branch, local for a local source",
|
|
1127
|
+
)
|
|
1128
|
+
parser.add_argument(
|
|
1129
|
+
"--local-root", default=None,
|
|
1130
|
+
help="the folder the source was read from, when it is a local folder; "
|
|
1131
|
+
"the README is picked from its files",
|
|
1132
|
+
)
|
|
1133
|
+
args = parser.parse_args(argv)
|
|
1134
|
+
|
|
1135
|
+
result = readme_entry(args.source_repo, args.ref, args.local_root)
|
|
1136
|
+
json.dump(result, sys.stdout, separators=(",", ":"))
|
|
1137
|
+
sys.stdout.write("\n")
|
|
1138
|
+
return 0
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
# ---------------------------------------------------------------------------
|
|
1142
|
+
# Subcommand: page-metrics, the root-page test create-skill step 3c runs
|
|
1143
|
+
# ---------------------------------------------------------------------------
|
|
1144
|
+
|
|
1145
|
+
# A page is a navigation hub when more than this share of its non-empty
|
|
1146
|
+
# lines are links (trigger 1), or a landing page when it has fewer words
|
|
1147
|
+
# than ROOT_WORD_LIMIT (trigger 2).
|
|
1148
|
+
LINK_RATIO_THRESHOLD = 0.7
|
|
1149
|
+
ROOT_WORD_LIMIT = 2000
|
|
1150
|
+
|
|
1151
|
+
_FENCE_OPEN_RE = re.compile(r"^\s*(`{3,}|~{3,})")
|
|
1152
|
+
_SIGNATURE_RE = re.compile(r"(?<![A-Za-z0-9_])(?:def|function|fn|func|export) ")
|
|
1153
|
+
# A markdown link that is not an image: `[text](target)`, the target
|
|
1154
|
+
# optionally followed by a quoted title.
|
|
1155
|
+
_LINK_RE = re.compile(r"(?<!!)\[([^\]]*)\]\(\s*[^)\s]*(?:\s+\"[^\"]*\")?\s*\)")
|
|
1156
|
+
_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\([^)]*\)")
|
|
1157
|
+
_LINE_MARKER_RE = re.compile(r"^(?:>\s*)*(?:#{1,6}\s+|[-*+]\s+|\d+[.)]\s+)?")
|
|
1158
|
+
_WORD_RE = re.compile(r"[^\W_]+(?:['’-][^\W_]+)*")
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _table_separator(line: str) -> bool:
|
|
1162
|
+
"""A markdown table's separator row: pipes, dashes, colons and spaces
|
|
1163
|
+
only, with at least one pipe and three dashes in a row."""
|
|
1164
|
+
text = line.strip()
|
|
1165
|
+
return "|" in text and "---" in text and set(text) <= set("|-: \t")
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def _link_line(line: str) -> bool:
|
|
1169
|
+
"""True for a line that holds markdown links and no other text."""
|
|
1170
|
+
text = _LINE_MARKER_RE.sub("", line.strip(), count=1)
|
|
1171
|
+
if not _LINK_RE.search(text):
|
|
1172
|
+
return False
|
|
1173
|
+
return not re.search(r"[^\W_]", _LINK_RE.sub("", text))
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _root_like(path: str) -> Tuple[int, bool]:
|
|
1177
|
+
"""(path segments, root-like) for a URL path."""
|
|
1178
|
+
segments = [s for s in path.split("/") if s]
|
|
1179
|
+
return len(segments), len(segments) <= 1 or path.endswith(("/", "/index", "/index.html"))
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def page_metrics(url: str, markdown: str) -> Dict[str, Any]:
|
|
1183
|
+
"""Measure a fetched page for the step 3c subpage decision."""
|
|
1184
|
+
segments, root_like = _root_like(urllib.parse.urlsplit(url.strip()).path)
|
|
1185
|
+
lines = markdown.splitlines()
|
|
1186
|
+
fences = 0
|
|
1187
|
+
open_fence: Optional[str] = None
|
|
1188
|
+
for line in lines:
|
|
1189
|
+
m = _FENCE_OPEN_RE.match(line)
|
|
1190
|
+
if not m:
|
|
1191
|
+
continue
|
|
1192
|
+
mark = m.group(1)
|
|
1193
|
+
if open_fence is None:
|
|
1194
|
+
open_fence = mark
|
|
1195
|
+
fences += 1
|
|
1196
|
+
elif mark[0] == open_fence[0] and len(mark) >= len(open_fence):
|
|
1197
|
+
open_fence = None
|
|
1198
|
+
table_rows = sum(1 for line in lines if _table_separator(line))
|
|
1199
|
+
signature_hits = len(_SIGNATURE_RE.findall(markdown))
|
|
1200
|
+
non_empty = [line for line in lines if line.strip()]
|
|
1201
|
+
link_lines = sum(1 for line in non_empty if _link_line(line))
|
|
1202
|
+
ratio = link_lines / len(non_empty) if non_empty else 0.0
|
|
1203
|
+
visible = _LINK_RE.sub(lambda m: " " + m.group(1) + " ", _IMAGE_RE.sub(lambda m: " " + m.group(1) + " ", markdown))
|
|
1204
|
+
words = len(_WORD_RE.findall(visible))
|
|
1205
|
+
trigger1 = (root_like and fences == 0 and table_rows == 0 and signature_hits == 0
|
|
1206
|
+
and ratio > LINK_RATIO_THRESHOLD)
|
|
1207
|
+
trigger2 = root_like and words < ROOT_WORD_LIMIT
|
|
1208
|
+
return {
|
|
1209
|
+
"url": url,
|
|
1210
|
+
"path_segments": segments,
|
|
1211
|
+
"root_like": root_like,
|
|
1212
|
+
"code_fences": fences,
|
|
1213
|
+
"table_rows": table_rows,
|
|
1214
|
+
"signature_hits": signature_hits,
|
|
1215
|
+
"link_lines": link_lines,
|
|
1216
|
+
"non_empty_lines": len(non_empty),
|
|
1217
|
+
"link_line_ratio": round(ratio, 4),
|
|
1218
|
+
"word_count": words,
|
|
1219
|
+
"trigger1": trigger1,
|
|
1220
|
+
"trigger2": trigger2,
|
|
1221
|
+
"discover_subpages": trigger1 or trigger2,
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def _read_source(source: str) -> Tuple[Optional[str], Optional[str]]:
|
|
1226
|
+
"""(text, None) of a file or of stdin for `-`, gunzipped first when it is
|
|
1227
|
+
gzip compressed (a `.xml.gz` sitemap); (None, error) when it cannot be
|
|
1228
|
+
read or decoded as UTF-8."""
|
|
1229
|
+
label = "<stdin>" if source == "-" else source
|
|
1230
|
+
try:
|
|
1231
|
+
if source == "-":
|
|
1232
|
+
stream = getattr(sys.stdin, "buffer", None) # an in-process test double has none
|
|
1233
|
+
data = stream.read() if stream is not None else sys.stdin.read().encode("utf-8")
|
|
1234
|
+
else:
|
|
1235
|
+
data = Path(source).read_bytes()
|
|
1236
|
+
if data[:2] == b"\x1f\x8b":
|
|
1237
|
+
data = gzip.decompress(data)
|
|
1238
|
+
return data.decode("utf-8-sig"), None
|
|
1239
|
+
except (OSError, EOFError, zlib.error, UnicodeDecodeError) as exc:
|
|
1240
|
+
return None, f"cannot read {label}: {exc}"
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def _doc_section(name: str) -> str:
|
|
1244
|
+
"""The module docstring's section on one subcommand, shown by its --help."""
|
|
1245
|
+
m = re.search(rf"^Subcommand: {re.escape(name)}\n-+\n(.*?)(?=^-{{10,}}\n|\Z)", __doc__ or "", re.S | re.M)
|
|
1246
|
+
return m.group(1).strip() if m else ""
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def _cmd_page_metrics(argv: List[str]) -> int:
|
|
1250
|
+
parser = argparse.ArgumentParser(
|
|
1251
|
+
prog="skf-detect-docs.py page-metrics",
|
|
1252
|
+
description=(
|
|
1253
|
+
"Measure a fetched documentation page (saved as markdown) and say "
|
|
1254
|
+
"whether create-skill step 3c should look for its subpages."
|
|
1255
|
+
),
|
|
1256
|
+
epilog=_doc_section("page-metrics"),
|
|
1257
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1258
|
+
)
|
|
1259
|
+
parser.add_argument("--url", required=True, help="the URL the page was fetched from")
|
|
1260
|
+
parser.add_argument("source", help="the page's markdown file, or - to read it from stdin")
|
|
1261
|
+
args = parser.parse_args(argv)
|
|
1262
|
+
|
|
1263
|
+
text, error = _read_source(args.source)
|
|
1264
|
+
if text is None:
|
|
1265
|
+
json.dump({"error": error, "code": "READ_ERROR"}, sys.stderr)
|
|
1266
|
+
sys.stderr.write("\n")
|
|
1267
|
+
return 2
|
|
1268
|
+
json.dump(page_metrics(args.url, text), sys.stdout, separators=(",", ":"))
|
|
1269
|
+
sys.stdout.write("\n")
|
|
1270
|
+
return 0
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
# ---------------------------------------------------------------------------
|
|
1274
|
+
# Subcommand: filter-urls, the subpages step 3c may fetch for a root page
|
|
1275
|
+
# ---------------------------------------------------------------------------
|
|
1276
|
+
|
|
1277
|
+
# Multi-label suffixes a documentation site is often hosted under: each name
|
|
1278
|
+
# directly below one of them is another owner's site
|
|
1279
|
+
# (`project.readthedocs.io` and `other.readthedocs.io` are two sites). A
|
|
1280
|
+
# small stand-in for the public suffix list, which the script does not ship.
|
|
1281
|
+
HOSTED_SUFFIXES = frozenset({
|
|
1282
|
+
"github.io", "gitlab.io", "codeberg.page", "readthedocs.io", "readthedocs-hosted.com",
|
|
1283
|
+
"netlify.app", "vercel.app", "pages.dev", "workers.dev", "web.app", "firebaseapp.com",
|
|
1284
|
+
"herokuapp.com", "gitbook.io", "mintlify.app", "azurewebsites.net", "cloudfront.net",
|
|
1285
|
+
"surge.sh", "fly.dev", "onrender.com", "blogspot.com", "appspot.com", "hf.space",
|
|
1286
|
+
"streamlit.app", "glitch.me", "sourceforge.io",
|
|
1287
|
+
})
|
|
1288
|
+
# Second-level labels country-code domains register names under (`co.uk`,
|
|
1289
|
+
# `com.au`): the registrable name is one label further left.
|
|
1290
|
+
_CCTLD_SECOND_LEVELS = frozenset({
|
|
1291
|
+
"ac", "co", "com", "edu", "go", "gob", "gov", "ltd", "mil", "ne", "net", "nom", "or", "org", "plc",
|
|
1292
|
+
})
|
|
1293
|
+
# Path segments that name a page with no API content.
|
|
1294
|
+
SUBPAGE_EXCLUDED_WORDS = ("blog", "changelog", "pricing", "about", "careers")
|
|
1295
|
+
# Words in a URL path or title that point at API documentation. A word
|
|
1296
|
+
# matches when it starts with one (`configuration`, `methods`).
|
|
1297
|
+
API_TERMS = ("api", "reference", "quickstart", "setup", "config", "getting-started", "guide", "sdk",
|
|
1298
|
+
"method", "function")
|
|
1299
|
+
|
|
1300
|
+
_LOC_RE = re.compile(r"<loc>\s*([^<\s]+)\s*</loc>", re.IGNORECASE)
|
|
1301
|
+
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def registrable_domain(host: str) -> str:
|
|
1305
|
+
"""The registrable domain of a host name, by the heuristic in the
|
|
1306
|
+
filter-urls section of the module docstring."""
|
|
1307
|
+
host = (host or "").strip().lower().rstrip(".")
|
|
1308
|
+
labels = host.split(".")
|
|
1309
|
+
if ":" in host or _IPV4_RE.match(host) or len(labels) <= 2:
|
|
1310
|
+
return host
|
|
1311
|
+
last_two = ".".join(labels[-2:])
|
|
1312
|
+
if last_two in HOSTED_SUFFIXES or (len(labels[-1]) == 2 and labels[-2] in _CCTLD_SECOND_LEVELS):
|
|
1313
|
+
return ".".join(labels[-3:])
|
|
1314
|
+
return last_two
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def _excluded_path(path: str) -> bool:
|
|
1318
|
+
"""True when a path segment, less its file extension, is one of SUBPAGE_EXCLUDED_WORDS."""
|
|
1319
|
+
for segment in path.lower().split("/"):
|
|
1320
|
+
stem = segment.rsplit(".", 1)[0] if "." in segment else segment
|
|
1321
|
+
if stem in SUBPAGE_EXCLUDED_WORDS:
|
|
1322
|
+
return True
|
|
1323
|
+
return False
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def _api_terms(path: str, title: str) -> List[str]:
|
|
1327
|
+
"""The API_TERMS a URL path or title holds, as whole words (a word may go on: `configuration`)."""
|
|
1328
|
+
words = re.findall(r"[a-z0-9]+", (path + " " + title).lower())
|
|
1329
|
+
found = []
|
|
1330
|
+
for term in API_TERMS:
|
|
1331
|
+
parts = term.split("-")
|
|
1332
|
+
for i in range(len(words) - len(parts) + 1):
|
|
1333
|
+
head = words[i:i + len(parts)]
|
|
1334
|
+
if head[:-1] == parts[:-1] and head[-1].startswith(parts[-1]):
|
|
1335
|
+
found.append(term)
|
|
1336
|
+
break
|
|
1337
|
+
return found
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def _candidate_urls(text: str, source_label: str) -> List[Tuple[str, Optional[str]]]:
|
|
1341
|
+
"""(url, title) pairs from a sitemap, a JSON list or map result, or one URL per line.
|
|
1342
|
+
|
|
1343
|
+
Raises ValueError on malformed JSON or a JSON shape with no URL list.
|
|
1344
|
+
"""
|
|
1345
|
+
stripped = text.lstrip()
|
|
1346
|
+
if stripped.startswith("<"):
|
|
1347
|
+
return [(m.group(1).replace("&", "&"), None) for m in _LOC_RE.finditer(text)]
|
|
1348
|
+
if stripped.startswith(("[", "{")):
|
|
1349
|
+
try:
|
|
1350
|
+
data = json.loads(text)
|
|
1351
|
+
except json.JSONDecodeError as exc:
|
|
1352
|
+
raise ValueError(f"malformed JSON in {source_label}: {exc}") from exc
|
|
1353
|
+
if isinstance(data, dict):
|
|
1354
|
+
items = next((data[k] for k in ("links", "urls") if isinstance(data.get(k), list)), None)
|
|
1355
|
+
if items is None:
|
|
1356
|
+
raise ValueError(f"{source_label} holds no `links` or `urls` array")
|
|
1357
|
+
data = items
|
|
1358
|
+
if not isinstance(data, list):
|
|
1359
|
+
raise ValueError(f"{source_label} must be a JSON array of URLs")
|
|
1360
|
+
out: List[Tuple[str, Optional[str]]] = []
|
|
1361
|
+
for item in data:
|
|
1362
|
+
if isinstance(item, str):
|
|
1363
|
+
out.append((item, None))
|
|
1364
|
+
elif isinstance(item, dict) and isinstance(item.get("url"), str):
|
|
1365
|
+
title = item.get("title")
|
|
1366
|
+
out.append((item["url"], title if isinstance(title, str) else None))
|
|
1367
|
+
return out
|
|
1368
|
+
return [(line.strip(), None) for line in text.splitlines() if line.strip()]
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
def _url_key(parts: urllib.parse.SplitResult) -> str:
|
|
1372
|
+
"""A URL as filter-urls compares it: scheme and host in lower case, no `#fragment`."""
|
|
1373
|
+
return parts._replace(scheme=parts.scheme.lower(), netloc=parts.netloc.lower(), fragment="").geturl()
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def filter_urls(root: str, candidates: List[Tuple[str, Optional[str]]]) -> Dict[str, Any]:
|
|
1377
|
+
"""Cut subpage candidates to the root page's site and drop non-API pages."""
|
|
1378
|
+
root_parts = urllib.parse.urlsplit(root.strip())
|
|
1379
|
+
if root_parts.scheme.lower() not in ("http", "https") or not root_parts.hostname:
|
|
1380
|
+
raise ValueError(f"root is not an http or https URL: {root!r}")
|
|
1381
|
+
domain = registrable_domain(root_parts.hostname)
|
|
1382
|
+
root_key = _url_key(root_parts).rstrip("/")
|
|
1383
|
+
stats = {"input": len(candidates), "kept": 0, "other_domain": 0, "excluded_path": 0,
|
|
1384
|
+
"not_http": 0, "duplicate": 0, "root": 0, "sitemap": 0}
|
|
1385
|
+
kept: List[Dict[str, Any]] = []
|
|
1386
|
+
sitemaps: List[str] = []
|
|
1387
|
+
seen: set = set()
|
|
1388
|
+
for url, title in candidates:
|
|
1389
|
+
url = url.strip()
|
|
1390
|
+
if url.startswith("/"):
|
|
1391
|
+
# A crawl may list a page by its path: read it against the root.
|
|
1392
|
+
url = urllib.parse.urljoin(root.strip(), url)
|
|
1393
|
+
try:
|
|
1394
|
+
parts = urllib.parse.urlsplit(url)
|
|
1395
|
+
host = parts.hostname
|
|
1396
|
+
except ValueError:
|
|
1397
|
+
parts, host = None, None
|
|
1398
|
+
if parts is None or parts.scheme.lower() not in ("http", "https") or not host:
|
|
1399
|
+
stats["not_http"] += 1
|
|
1400
|
+
continue
|
|
1401
|
+
key = _url_key(parts)
|
|
1402
|
+
if key.rstrip("/") == root_key:
|
|
1403
|
+
stats["root"] += 1
|
|
1404
|
+
continue
|
|
1405
|
+
if key in seen:
|
|
1406
|
+
stats["duplicate"] += 1
|
|
1407
|
+
continue
|
|
1408
|
+
seen.add(key)
|
|
1409
|
+
if registrable_domain(host) != domain:
|
|
1410
|
+
stats["other_domain"] += 1
|
|
1411
|
+
continue
|
|
1412
|
+
if parts.path.lower().endswith((".xml", ".xml.gz")):
|
|
1413
|
+
# A sitemap index lists sitemaps, not pages.
|
|
1414
|
+
sitemaps.append(key)
|
|
1415
|
+
stats["sitemap"] += 1
|
|
1416
|
+
continue
|
|
1417
|
+
if _excluded_path(parts.path):
|
|
1418
|
+
stats["excluded_path"] += 1
|
|
1419
|
+
continue
|
|
1420
|
+
kept.append({"url": key, "title": title, "terms": _api_terms(parts.path, title or "")})
|
|
1421
|
+
stats["kept"] = len(kept)
|
|
1422
|
+
return {"root": root, "registrable_domain": domain, "kept": kept, "sitemaps": sitemaps, "stats": stats}
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
def _cmd_filter_urls(argv: List[str]) -> int:
|
|
1426
|
+
parser = argparse.ArgumentParser(
|
|
1427
|
+
prog="skf-detect-docs.py filter-urls",
|
|
1428
|
+
description=(
|
|
1429
|
+
"Keep the subpage URLs on a documentation root page's own site "
|
|
1430
|
+
"(its registrable domain) and drop pages with no API content."
|
|
1431
|
+
),
|
|
1432
|
+
epilog=_doc_section("filter-urls"),
|
|
1433
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1434
|
+
)
|
|
1435
|
+
parser.add_argument("--root", required=True, help="the root page's URL")
|
|
1436
|
+
parser.add_argument(
|
|
1437
|
+
"source",
|
|
1438
|
+
help=(
|
|
1439
|
+
"a sitemap.xml, a JSON array of URLs or {url, title} objects (or an "
|
|
1440
|
+
"object with a links or urls array), or one URL per line; - for stdin"
|
|
1441
|
+
),
|
|
1442
|
+
)
|
|
1443
|
+
args = parser.parse_args(argv)
|
|
1444
|
+
|
|
1445
|
+
text, error = _read_source(args.source)
|
|
1446
|
+
if text is None:
|
|
1447
|
+
json.dump({"error": error, "code": "READ_ERROR"}, sys.stderr)
|
|
1448
|
+
sys.stderr.write("\n")
|
|
1449
|
+
return 2
|
|
1450
|
+
label = "<stdin>" if args.source == "-" else args.source
|
|
1451
|
+
try:
|
|
1452
|
+
result = filter_urls(args.root, _candidate_urls(text, label))
|
|
1453
|
+
except ValueError as exc:
|
|
1454
|
+
json.dump({"error": str(exc), "code": "INVALID_INPUT"}, sys.stderr)
|
|
1455
|
+
sys.stderr.write("\n")
|
|
1456
|
+
return 2
|
|
1457
|
+
json.dump(result, sys.stdout, separators=(",", ":"))
|
|
1458
|
+
sys.stdout.write("\n")
|
|
1459
|
+
return 0
|
|
1460
|
+
|
|
1461
|
+
|
|
778
1462
|
# ---------------------------------------------------------------------------
|
|
779
1463
|
# CLI entry point
|
|
780
1464
|
# ---------------------------------------------------------------------------
|
|
@@ -805,8 +1489,16 @@ def main() -> int:
|
|
|
805
1489
|
# invocation `--repo-url <url> ...` is unchanged.
|
|
806
1490
|
if len(sys.argv) > 1 and sys.argv[1] == "compare-hashes":
|
|
807
1491
|
return _cmd_compare_hashes(sys.argv[2:])
|
|
1492
|
+
if len(sys.argv) > 1 and sys.argv[1] == "refresh-hashes":
|
|
1493
|
+
return _cmd_refresh_hashes(sys.argv[2:])
|
|
808
1494
|
if len(sys.argv) > 1 and sys.argv[1] == "hash-urls":
|
|
809
1495
|
return _cmd_hash_urls(sys.argv[2:])
|
|
1496
|
+
if len(sys.argv) > 1 and sys.argv[1] == "readme-entry":
|
|
1497
|
+
return _cmd_readme_entry(sys.argv[2:])
|
|
1498
|
+
if len(sys.argv) > 1 and sys.argv[1] == "page-metrics":
|
|
1499
|
+
return _cmd_page_metrics(sys.argv[2:])
|
|
1500
|
+
if len(sys.argv) > 1 and sys.argv[1] == "filter-urls":
|
|
1501
|
+
return _cmd_filter_urls(sys.argv[2:])
|
|
810
1502
|
|
|
811
1503
|
parser = argparse.ArgumentParser(
|
|
812
1504
|
description="Detect documentation URLs for a GitHub repository.",
|