bmad-module-skill-forge 2.1.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 +51 -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 +14 -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 +117 -9
- package/src/shared/scripts/skf-detect-docs.py +892 -4
- 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 +215 -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 +17 -6
- 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 +55 -18
- package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
- package/src/skf-create-skill/references/sub/fetch-docs.md +106 -57
- 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 -89
- package/src/skf-setup/references/detect-and-tier.md +58 -66
- 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 -645
- 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
|
|
|
@@ -74,20 +74,219 @@ Each list is stably sorted by url. Stored hashes are normalized (leading
|
|
|
74
74
|
`algo:` prefix stripped) before comparison so a bare-hex writer form still
|
|
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
|
+
|
|
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
|
+
|
|
102
|
+
---------------------------------------------------------------------------
|
|
103
|
+
Subcommand: hash-urls
|
|
104
|
+
---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
Compile-time `doc_sources[]` for docs-only skills (create-skill step 5a).
|
|
107
|
+
The detect path above needs a GitHub repository; a docs-only brief has none
|
|
108
|
+
— its `source_repo` is the documentation site and its corpus is the brief's
|
|
109
|
+
`doc_urls`. This subcommand fetches each URL and hashes the raw response
|
|
110
|
+
bytes with the SAME primitive `compare-hashes` uses, so a hash recorded here
|
|
111
|
+
compares byte-for-byte at audit time. (Hashing the markdown a web-fetch tool
|
|
112
|
+
rendered would not: the audit re-fetches the raw bytes.)
|
|
113
|
+
|
|
114
|
+
uv run src/shared/scripts/skf-detect-docs.py hash-urls <source>
|
|
115
|
+
|
|
116
|
+
Input `<source>`:
|
|
117
|
+
- a path to a JSON file that is EITHER an array whose items are URL
|
|
118
|
+
strings or `{url, ...}` objects (the brief's `doc_urls[]` entry shape)
|
|
119
|
+
OR an object with a `doc_urls` array (the brief block itself), OR
|
|
120
|
+
- `-` to read that JSON from stdin.
|
|
121
|
+
|
|
122
|
+
Output (JSON object on stdout):
|
|
123
|
+
{
|
|
124
|
+
"doc_sources": [{"url", "detected_via": "brief_doc_urls",
|
|
125
|
+
"content_hash": "sha256:..."|null, "recorded_at"}],
|
|
126
|
+
"fetch_failed": [{"url", "reason"}],
|
|
127
|
+
"stats": {"total", "hashed", "fetch_failed", "invalid"}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
URLs keep their input order and are deduplicated on first occurrence; an
|
|
131
|
+
entry without a usable url string is dropped and counted under
|
|
132
|
+
`stats.invalid`. A URL that cannot be fetched still gets an entry with
|
|
133
|
+
`content_hash: null` — the audit side files it under `skipped_null_hash`
|
|
134
|
+
instead of reporting drift. `doc_sources` is already in the metadata.json
|
|
135
|
+
schema, and the whole object is valid `compare-hashes` input. Exit 0 on any
|
|
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.
|
|
77
271
|
"""
|
|
78
272
|
|
|
79
273
|
from __future__ import annotations
|
|
80
274
|
|
|
81
275
|
import argparse
|
|
82
276
|
import base64
|
|
277
|
+
import gzip
|
|
83
278
|
import hashlib
|
|
84
279
|
import json
|
|
280
|
+
import os
|
|
85
281
|
import re
|
|
86
282
|
import socket
|
|
87
283
|
import subprocess
|
|
88
284
|
import sys
|
|
89
285
|
import urllib.error
|
|
286
|
+
import urllib.parse
|
|
90
287
|
import urllib.request
|
|
288
|
+
import zlib
|
|
289
|
+
from datetime import datetime, timezone
|
|
91
290
|
from pathlib import Path
|
|
92
291
|
from typing import Any, Dict, List, Optional, Tuple
|
|
93
292
|
|
|
@@ -252,8 +451,10 @@ def _fetch_and_hash(url: str) -> Optional[str]:
|
|
|
252
451
|
return "sha256:" + hashlib.sha256(content).hexdigest()
|
|
253
452
|
except (OSError, IOError):
|
|
254
453
|
return None
|
|
255
|
-
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
256
454
|
try:
|
|
455
|
+
# Request() itself raises ValueError on a scheme-less or relative URL;
|
|
456
|
+
# it must sit inside the guard so a bad entry degrades to None.
|
|
457
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
257
458
|
with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
|
|
258
459
|
content = resp.read()
|
|
259
460
|
return "sha256:" + hashlib.sha256(content).hexdigest()
|
|
@@ -294,7 +495,14 @@ def _fetch_and_hash_reason(url: str) -> Tuple[Optional[str], Optional[str]]:
|
|
|
294
495
|
except OSError as exc:
|
|
295
496
|
return None, f"local read failed: {exc}"
|
|
296
497
|
return "sha256:" + hashlib.sha256(content).hexdigest(), None
|
|
297
|
-
|
|
498
|
+
try:
|
|
499
|
+
# Request() raises ValueError ("unknown url type") on a scheme-less or
|
|
500
|
+
# relative URL — a legal brief entry, since the brief schema only
|
|
501
|
+
# requires a non-empty string. Keep it inside the guard so one bad
|
|
502
|
+
# entry lands in fetch_failed instead of aborting the whole run.
|
|
503
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
504
|
+
except ValueError as exc:
|
|
505
|
+
return None, f"invalid URL: {exc}"
|
|
298
506
|
try:
|
|
299
507
|
with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
|
|
300
508
|
content = resp.read()
|
|
@@ -601,16 +809,696 @@ def _cmd_compare_hashes(argv: List[str]) -> int:
|
|
|
601
809
|
return 0
|
|
602
810
|
|
|
603
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
|
+
|
|
880
|
+
# ---------------------------------------------------------------------------
|
|
881
|
+
# Subcommand: hash-urls — docs-only doc_sources from the brief's doc_urls
|
|
882
|
+
# ---------------------------------------------------------------------------
|
|
883
|
+
|
|
884
|
+
# `doc_sources[].detected_via` value for entries that come from the brief's
|
|
885
|
+
# `doc_urls` (and the subpages step 3c fetched from them) rather than from
|
|
886
|
+
# repository detection. Listed in skf-create-skill/assets/skill-sections.md.
|
|
887
|
+
BRIEF_DOC_URLS_DETECTED_VIA = "brief_doc_urls"
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _load_url_list(raw_text: str, source_label: str) -> List[Any]:
|
|
891
|
+
"""Parse a hash-urls input blob into a list of URL entries.
|
|
892
|
+
|
|
893
|
+
Accepts a top-level JSON array whose items are URL strings or objects
|
|
894
|
+
with a `url` key (the brief's `doc_urls[]` entry shape), or a top-level
|
|
895
|
+
object with a `doc_urls` array (the brief block itself). An object without
|
|
896
|
+
`doc_urls` yields an empty list (nothing to hash).
|
|
897
|
+
|
|
898
|
+
Raises ValueError on malformed JSON or an unexpected top-level shape.
|
|
899
|
+
"""
|
|
900
|
+
try:
|
|
901
|
+
data = json.loads(raw_text)
|
|
902
|
+
except json.JSONDecodeError as exc:
|
|
903
|
+
raise ValueError(f"malformed JSON in {source_label}: {exc}") from exc
|
|
904
|
+
if isinstance(data, list):
|
|
905
|
+
return data
|
|
906
|
+
if isinstance(data, dict):
|
|
907
|
+
entries = data.get("doc_urls")
|
|
908
|
+
if entries is None:
|
|
909
|
+
return []
|
|
910
|
+
if not isinstance(entries, list):
|
|
911
|
+
raise ValueError(f"`doc_urls` in {source_label} is not an array")
|
|
912
|
+
return entries
|
|
913
|
+
raise ValueError(
|
|
914
|
+
f"{source_label} must be a JSON array or an object with a "
|
|
915
|
+
f"`doc_urls` array; got {type(data).__name__}"
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def hash_urls(entries: List[Any], recorded_at: Optional[str] = None) -> Dict[str, Any]:
|
|
920
|
+
"""Fetch and hash each URL into a compile-time `doc_sources[]` entry.
|
|
921
|
+
|
|
922
|
+
Byte-symmetric with `compare_doc_hashes`: both go through
|
|
923
|
+
`_fetch_and_hash_reason`, so a hash recorded here reads as `unchanged` at
|
|
924
|
+
audit time for unchanged bytes. URLs keep their input order (brief order,
|
|
925
|
+
then discovered subpages) and are deduplicated on first occurrence. A URL
|
|
926
|
+
that cannot be fetched still gets an entry with `content_hash: None`, which
|
|
927
|
+
the audit side skips rather than reporting as drift.
|
|
928
|
+
"""
|
|
929
|
+
stamp = recorded_at or datetime.now(timezone.utc).isoformat()
|
|
930
|
+
doc_sources: List[Dict[str, Any]] = []
|
|
931
|
+
fetch_failed: List[Dict[str, Any]] = []
|
|
932
|
+
seen: set = set()
|
|
933
|
+
invalid = 0
|
|
934
|
+
for entry in entries:
|
|
935
|
+
url = entry.get("url") if isinstance(entry, dict) else entry
|
|
936
|
+
if not isinstance(url, str) or not url.strip():
|
|
937
|
+
invalid += 1
|
|
938
|
+
continue
|
|
939
|
+
if url in seen:
|
|
940
|
+
continue
|
|
941
|
+
seen.add(url)
|
|
942
|
+
content_hash, reason = _fetch_and_hash_reason(url)
|
|
943
|
+
doc_sources.append({
|
|
944
|
+
"url": url,
|
|
945
|
+
"detected_via": BRIEF_DOC_URLS_DETECTED_VIA,
|
|
946
|
+
"content_hash": content_hash,
|
|
947
|
+
"recorded_at": stamp,
|
|
948
|
+
})
|
|
949
|
+
if content_hash is None:
|
|
950
|
+
fetch_failed.append({"url": url, "reason": reason})
|
|
951
|
+
return {
|
|
952
|
+
"doc_sources": doc_sources,
|
|
953
|
+
"fetch_failed": fetch_failed,
|
|
954
|
+
"stats": {
|
|
955
|
+
"total": len(doc_sources),
|
|
956
|
+
"hashed": len(doc_sources) - len(fetch_failed),
|
|
957
|
+
"fetch_failed": len(fetch_failed),
|
|
958
|
+
"invalid": invalid,
|
|
959
|
+
},
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _cmd_hash_urls(argv: List[str]) -> int:
|
|
964
|
+
parser = argparse.ArgumentParser(
|
|
965
|
+
prog="skf-detect-docs.py hash-urls",
|
|
966
|
+
description=(
|
|
967
|
+
"Fetch each documentation URL, hash the response bytes, and emit "
|
|
968
|
+
"compile-time doc_sources entries (detected_via: brief_doc_urls) "
|
|
969
|
+
"for a docs-only skill."
|
|
970
|
+
),
|
|
971
|
+
)
|
|
972
|
+
parser.add_argument(
|
|
973
|
+
"source",
|
|
974
|
+
help=(
|
|
975
|
+
"path to a JSON file (array of URL strings or {url, ...} objects, "
|
|
976
|
+
"or an object with a doc_urls array such as the brief's block), "
|
|
977
|
+
"or - to read that JSON from stdin"
|
|
978
|
+
),
|
|
979
|
+
)
|
|
980
|
+
args = parser.parse_args(argv)
|
|
981
|
+
|
|
982
|
+
if args.source == "-":
|
|
983
|
+
raw = sys.stdin.read()
|
|
984
|
+
label = "<stdin>"
|
|
985
|
+
else:
|
|
986
|
+
path = Path(args.source)
|
|
987
|
+
try:
|
|
988
|
+
raw = path.read_text(encoding="utf-8")
|
|
989
|
+
except OSError as exc:
|
|
990
|
+
json.dump({"error": f"cannot read {path}: {exc}", "code": "READ_ERROR"}, sys.stderr)
|
|
991
|
+
sys.stderr.write("\n")
|
|
992
|
+
return 2
|
|
993
|
+
label = str(path)
|
|
994
|
+
|
|
995
|
+
try:
|
|
996
|
+
entries = _load_url_list(raw, label)
|
|
997
|
+
except ValueError as exc:
|
|
998
|
+
json.dump({"error": str(exc), "code": "INVALID_JSON"}, sys.stderr)
|
|
999
|
+
sys.stderr.write("\n")
|
|
1000
|
+
return 2
|
|
1001
|
+
|
|
1002
|
+
result = hash_urls(entries)
|
|
1003
|
+
json.dump(result, sys.stdout, separators=(",", ":"))
|
|
1004
|
+
sys.stdout.write("\n")
|
|
1005
|
+
return 0
|
|
1006
|
+
|
|
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
|
+
|
|
604
1462
|
# ---------------------------------------------------------------------------
|
|
605
1463
|
# CLI entry point
|
|
606
1464
|
# ---------------------------------------------------------------------------
|
|
607
1465
|
|
|
1466
|
+
def _force_utf8(*streams) -> None:
|
|
1467
|
+
"""Reconfigure JSON-carrying streams to UTF-8 (issue #465).
|
|
1468
|
+
|
|
1469
|
+
A default Windows console decodes stdio as cp1252, which cannot carry
|
|
1470
|
+
non-ASCII JSON (raw UTF-8 input on stdin for `compare-hashes -` and
|
|
1471
|
+
`hash-urls -`). Preserves each stream's existing error handler —
|
|
1472
|
+
reconfigure(encoding=...) alone would reset it to 'strict', downgrading
|
|
1473
|
+
e.g. an already-UTF-8 stderr on Linux. For stdin this must run before the
|
|
1474
|
+
first read. Skips in-process test doubles without reconfigure().
|
|
1475
|
+
"""
|
|
1476
|
+
for stream in streams:
|
|
1477
|
+
if hasattr(stream, "reconfigure"):
|
|
1478
|
+
errors = getattr(stream, "errors", None)
|
|
1479
|
+
if errors is None:
|
|
1480
|
+
stream.reconfigure(encoding="utf-8")
|
|
1481
|
+
else:
|
|
1482
|
+
stream.reconfigure(encoding="utf-8", errors=errors)
|
|
1483
|
+
|
|
1484
|
+
|
|
608
1485
|
def main() -> int:
|
|
1486
|
+
_force_utf8(sys.stdin, sys.stdout)
|
|
609
1487
|
# Additive subcommand: route `compare-hashes ...` to the doc-drift path
|
|
610
1488
|
# before the existing detect parser runs. The default (no subcommand)
|
|
611
1489
|
# invocation `--repo-url <url> ...` is unchanged.
|
|
612
1490
|
if len(sys.argv) > 1 and sys.argv[1] == "compare-hashes":
|
|
613
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:])
|
|
1494
|
+
if len(sys.argv) > 1 and sys.argv[1] == "hash-urls":
|
|
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:])
|
|
614
1502
|
|
|
615
1503
|
parser = argparse.ArgumentParser(
|
|
616
1504
|
description="Detect documentation URLs for a GitHub repository.",
|