bmad-module-skill-forge 2.2.0 → 4.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 +143 -18
- package/docs/verifying-a-skill.md +87 -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 +3 -3
- 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 +31 -59
- 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 +233 -64
- package/src/shared/scripts/skf-build-change-manifest.py +2753 -28
- 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 +772 -0
- package/src/shared/scripts/skf-comention-pairs.py +1333 -123
- package/src/shared/scripts/skf-compare-file-hashes.py +267 -105
- 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 +21 -3
- 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 +197 -55
- 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 +5247 -96
- package/src/shared/scripts/skf-extraction-inventory.py +1280 -0
- package/src/shared/scripts/skf-extraction-snapshot.py +664 -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 +469 -205
- package/src/shared/scripts/skf-github-fetch.py +365 -0
- package/src/shared/scripts/skf-github-probe.py +700 -0
- package/src/shared/scripts/skf-hash-content.py +180 -20
- package/src/shared/scripts/skf-language-corpora.py +1 -1
- package/src/shared/scripts/skf-load-provenance.py +161 -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 +143 -20
- 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 +176 -28
- package/src/shared/scripts/skf-promote-staged.py +430 -0
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +64 -8
- 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 +155 -14
- 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 +787 -121
- package/src/shared/scripts/skf-tessl-review.py +706 -0
- package/src/shared/scripts/skf-update-active-symlink.py +120 -67
- 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 +23 -2
- 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 +3945 -152
- package/src/shared/scripts/skf-write-skill-brief.py +406 -25
- 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 +28 -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/health-check.md +2 -0
- package/src/skf-audit-skill/references/init.md +149 -210
- package/src/skf-audit-skill/references/re-index.md +103 -110
- package/src/skf-audit-skill/references/report.md +96 -33
- package/src/skf-audit-skill/references/semantic-diff.md +34 -25
- 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 +80 -90
- package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
- package/src/skf-audit-skill/scripts/render-drift-tables.py +712 -0
- package/src/skf-brief-skill/SKILL.md +37 -26
- package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +18 -25
- package/src/skf-brief-skill/assets/skill-brief-schema.md +30 -23
- package/src/skf-brief-skill/customize.toml +48 -20
- package/src/skf-brief-skill/references/analyze-target.md +144 -127
- package/src/skf-brief-skill/references/confirm-brief.md +11 -9
- 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 +26 -100
- package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
- package/src/skf-brief-skill/references/version-resolution.md +7 -10
- 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 +11 -8
- package/src/skf-create-skill/assets/skill-sections.md +21 -11
- package/src/skf-create-skill/customize.toml +20 -8
- package/src/skf-create-skill/references/authoritative-files-protocol.md +36 -58
- 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 +119 -70
- 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 +37 -0
- package/src/skf-create-skill/references/extract.md +204 -162
- package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +11 -11
- package/src/skf-create-skill/references/extraction-patterns.md +588 -201
- 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 +37 -57
- package/src/skf-create-skill/references/source-resolution-protocols.md +58 -169
- 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 +83 -146
- package/src/skf-drop-skill/references/health-check.md +2 -0
- 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 +122 -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 +209 -264
- 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 +164 -91
- 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 +164 -142
- package/src/skf-test-skill/references/score.md +80 -107
- package/src/skf-test-skill/references/scoring-rules.md +42 -155
- package/src/skf-test-skill/references/source-access-protocol.md +18 -94
- 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 +380 -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 +1666 -0
- package/src/skf-test-skill/scripts/hard-gate.py +203 -0
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +1198 -0
- package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
- package/src/skf-test-skill/scripts/reconcile-coverage.py +547 -79
- package/src/skf-test-skill/scripts/score-signatures.py +479 -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 +133 -56
- 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 +253 -223
- 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 +254 -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 +182 -49
- package/src/skf-update-skill/references/re-extract.md +53 -262
- package/src/skf-update-skill/references/report.md +102 -35
- package/src/skf-update-skill/references/write.md +357 -155
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +183 -46
- 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 Scan Skill.md Structure — deterministic structural checks for SKILL.md.
|
|
@@ -8,23 +8,34 @@ Replaces the synonym-grep loops, bash fence recipes, and inline Python
|
|
|
8
8
|
table-parser in `references/coherence-check.md` §§2.1 / 2.2 / 2.3 / 2.6 with
|
|
9
9
|
a single subprocess invocation that emits JSON.
|
|
10
10
|
|
|
11
|
+
The usage-scope, cross-reference and reference-check subcommands emit the
|
|
12
|
+
facts that test-skill's coherence check (§2.4 zero-usage exports, §§3-4
|
|
13
|
+
file, script and asset references) and verify-stack's Check 4 (literal
|
|
14
|
+
citations between skills) would otherwise count, grep and compare by hand,
|
|
15
|
+
so two runs over the same files always agree.
|
|
16
|
+
|
|
11
17
|
Subcommands:
|
|
12
18
|
scan <skill-md>
|
|
13
19
|
Emit JSON describing fence balance, bare opening fences (no language
|
|
14
|
-
tag),
|
|
20
|
+
tag), table column-count drift and the Scripts & Assets section (see
|
|
21
|
+
"Scripts & Assets section" below):
|
|
15
22
|
{
|
|
16
23
|
"unbalanced_fences": <bool>,
|
|
17
24
|
"fence_count": N,
|
|
18
25
|
"bare_opening_fences": [{"line": N, "text": "..."}, ...],
|
|
19
26
|
"table_drift": [{"line": N, "section": "<heading>",
|
|
20
27
|
"expected_cols": N, "actual_cols": N,
|
|
21
|
-
"row": "..."}]
|
|
28
|
+
"row": "..."}],
|
|
29
|
+
"scripts_assets": {"folders": ["assets", "scripts"],
|
|
30
|
+
"section": {"heading": "...", "line": N} | null,
|
|
31
|
+
"missing": <bool>}
|
|
22
32
|
}
|
|
23
33
|
|
|
24
34
|
scan <skill-md> --required-sections
|
|
25
35
|
Emit JSON describing which of the three required section families
|
|
26
36
|
(description / usage / api_surface) are present, and which synonym
|
|
27
|
-
satisfied the requirement (
|
|
37
|
+
satisfied the requirement (see "Heading match rule" below), with the
|
|
38
|
+
outline a caller reads when a family is not satisfied:
|
|
28
39
|
{
|
|
29
40
|
"description": {"satisfied": <bool>,
|
|
30
41
|
"matched_synonym": "<heading>" | null,
|
|
@@ -34,14 +45,129 @@ Subcommands:
|
|
|
34
45
|
"tried": [...]},
|
|
35
46
|
"api_surface": {"satisfied": <bool>,
|
|
36
47
|
"matched_synonym": "..." | null,
|
|
37
|
-
"tried": [...]}
|
|
48
|
+
"tried": [...]},
|
|
49
|
+
"frontmatter_description": <bool>,
|
|
50
|
+
"outline": [{"line": N, "level": N, "text": "<heading>"}, ...]
|
|
51
|
+
}
|
|
52
|
+
`outline` lists every heading of the body in document order (the
|
|
53
|
+
Line model's headings: outside the frontmatter and fenced code).
|
|
54
|
+
`frontmatter_description` is true when the frontmatter has a
|
|
55
|
+
`description` key with a non-empty value: inline, or on the
|
|
56
|
+
indented lines under it (a `>` or `|` block, say).
|
|
57
|
+
|
|
58
|
+
usage-scope <skill-md> --exports <json-file-or-'-'> [--kinds <k1,k2,...>]
|
|
59
|
+
[--body auto|single|split]
|
|
60
|
+
Emit JSON counting, for each export, the lines that use its name in
|
|
61
|
+
the usage scope (see "Usage scope" and "Export match" below):
|
|
62
|
+
{
|
|
63
|
+
"scope": {
|
|
64
|
+
"body": "single" | "split",
|
|
65
|
+
"sections": [{"heading": "<text>", "line": N,
|
|
66
|
+
"end_line": N}, ...],
|
|
67
|
+
"reference_files": ["references/<file>.md", ...]
|
|
68
|
+
},
|
|
69
|
+
"exports": [{"name": "...", "kind": "..." | null, "count": N,
|
|
70
|
+
"first_hit": {"file": "...", "line": N} | null},
|
|
71
|
+
...],
|
|
72
|
+
"zero_usage": [{"name": "...", "kind": "..." | null}, ...],
|
|
73
|
+
"warnings": ["references/<file>.md: cannot read: ...", ...]
|
|
74
|
+
}
|
|
75
|
+
--exports holds a JSON array of names or {"name", "kind"} objects,
|
|
76
|
+
an object with an `exports` array (test-skill's step 3 inventory),
|
|
77
|
+
or the validate-inventory.py result (its `inventory.exports`).
|
|
78
|
+
--kinds counts only the exports whose kind it lists; an export
|
|
79
|
+
given without a kind is always counted. --body picks the scope
|
|
80
|
+
(default auto; see "Usage scope"). `exports[]` is sorted by name,
|
|
81
|
+
then kind, with duplicates dropped; `zero_usage[]` lists the
|
|
82
|
+
exports whose count is 0.
|
|
83
|
+
|
|
84
|
+
cross-reference --skills <json-file-or-'-'> [--skills-root <dir>]
|
|
85
|
+
[--pairs <json-file-or-'-'>]
|
|
86
|
+
Emit JSON listing where one skill's SKILL.md literally cites another
|
|
87
|
+
skill (see "Citation match" below):
|
|
88
|
+
{
|
|
89
|
+
"citations": [{"from": "<a>", "to": "<b>",
|
|
90
|
+
"substring": "<as written>", "line": N,
|
|
91
|
+
"count": N, "excerpt": "...",
|
|
92
|
+
"hits": [{"line": N, "substring": "...",
|
|
93
|
+
"excerpt": "..."}, ...]}, ...],
|
|
94
|
+
"edges": [["<a>", "<b>"], ...],
|
|
95
|
+
"pairs": [{"library_a": "<a>", "library_b": "<b>",
|
|
96
|
+
"a_cites_b": <bool>, "b_cites_a": <bool>,
|
|
97
|
+
"cited": <bool>}, ...],
|
|
98
|
+
"warnings": ["<name>: ...", ...]
|
|
99
|
+
}
|
|
100
|
+
--skills holds a JSON array of {"name", "skill_md" | "path",
|
|
101
|
+
"aliases"} entries (`aliases` optional), or an object with a
|
|
102
|
+
`skills` array: the skf-enumerate-stack-skills.py inventory, whose
|
|
103
|
+
`path` is relative to the skills folder, so pass that folder as
|
|
104
|
+
--skills-root. An entry's `source_repo_basename` and
|
|
105
|
+
`source_root_basename` (the inventory gives both) are aliases too,
|
|
106
|
+
unless another entry has the same term as its name, an alias or a
|
|
107
|
+
source basename, since one word would then cite two skills.
|
|
108
|
+
`path` names the skill folder, `skill_md` the file;
|
|
109
|
+
a relative one resolves against --skills-root, else the working
|
|
110
|
+
directory, and a --skills-root that is not a directory is an
|
|
111
|
+
error. `citations[]` holds one entry per citing direction, sorted
|
|
112
|
+
by (from, to): `hits[]` lists every citing line in line order,
|
|
113
|
+
`count` is their number, and `substring`, `line` and `excerpt`
|
|
114
|
+
repeat the first hit. `edges[]` is the same set in the shape
|
|
115
|
+
skf-find-cycles.py reads ({"edges": [[from, to], ...]}), so this
|
|
116
|
+
output pipes into `find --edges -` as is. --pairs (a JSON array of
|
|
117
|
+
[a, b] or {"library_a", "library_b"} entries, or an object with a
|
|
118
|
+
`pairs` array) keeps only the citations between those pairs and
|
|
119
|
+
adds `pairs[]`, sorted, where `cited` means either direction
|
|
120
|
+
cites. A SKILL.md that cannot be read is a warning (its skill can
|
|
121
|
+
still be cited), and so is a pair that names a skill --skills
|
|
122
|
+
lacks.
|
|
123
|
+
|
|
124
|
+
reference-check <skill-md> [--source-root <dir>] [--skills-root <dir>]
|
|
125
|
+
Emit JSON checking every file, script and asset reference in the
|
|
126
|
+
SKILL.md body (see "Reference extraction" below):
|
|
127
|
+
{
|
|
128
|
+
"roots": {"skill": "<dir>", "source": "<dir>" | null,
|
|
129
|
+
"skills": "<dir>" | null},
|
|
130
|
+
"references": [{"line": N,
|
|
131
|
+
"type": "file-path" | "script-asset",
|
|
132
|
+
"form": "link" | "mention",
|
|
133
|
+
"target": "<as written>",
|
|
134
|
+
"canonical": "<realpath>",
|
|
135
|
+
"status": "ok" | "missing" | "escapes",
|
|
136
|
+
"root": "skill" | "source" | "skills" | null},
|
|
137
|
+
...],
|
|
138
|
+
"counts": {"total": N, "ok": N, "missing": N, "escapes": N},
|
|
139
|
+
"scripts_assets": {...},
|
|
140
|
+
"warnings": ["--source-root is not a directory: <dir>", ...]
|
|
38
141
|
}
|
|
142
|
+
A target resolves against the SKILL.md folder and through every
|
|
143
|
+
symlink (os.path.realpath) before the root check. The roots are
|
|
144
|
+
the SKILL.md folder, --source-root (the extraction tree,
|
|
145
|
+
metadata.json `source_root`) and --skills-root (the skills output
|
|
146
|
+
folder, for a stack skill whose references reach its constituent
|
|
147
|
+
skills); `root` names the first one that holds the target.
|
|
148
|
+
`escapes`: outside every root (whether it exists is not checked,
|
|
149
|
+
the escape is the finding); `missing`: inside a root, nothing
|
|
150
|
+
there; `ok`: inside a root and present. `references[]` is in
|
|
151
|
+
document order; the same canonical target twice on one line is one
|
|
152
|
+
reference. A --source-root or --skills-root that is not a
|
|
153
|
+
directory (a stale metadata.json `source_root`, say) is a warning,
|
|
154
|
+
not an error.
|
|
155
|
+
|
|
156
|
+
Scripts & Assets section:
|
|
157
|
+
`folders` lists the `scripts` and `assets` folders beside SKILL.md,
|
|
158
|
+
sorted; `section` is the first heading outside the frontmatter and
|
|
159
|
+
fenced code whose text starts with `Scripts` (case-insensitive, any
|
|
160
|
+
level: the section the reference extraction below also reads), with its
|
|
161
|
+
text and line, or null; `missing` is true when a folder exists and no
|
|
162
|
+
such section does, the gap test-skill's coherence check records.
|
|
39
163
|
|
|
40
164
|
Heading match rule:
|
|
41
|
-
Match the first
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
165
|
+
Match the first heading of the body (the Line model's headings: any
|
|
166
|
+
level, a closing `#` sequence dropped, never a `#` line inside fenced or
|
|
167
|
+
indented code or the frontmatter) whose full text, case-insensitive,
|
|
168
|
+
is a synonym of a family. The reported `matched_synonym` is the
|
|
169
|
+
canonical synonym from the list, not the heading text as it appears in
|
|
170
|
+
the file.
|
|
45
171
|
|
|
46
172
|
Fence balance:
|
|
47
173
|
Count triple-backtick (```) fence lines (`^```` at start of line, ignoring
|
|
@@ -74,20 +200,107 @@ Empty SKILL.md:
|
|
|
74
200
|
An empty file yields `fence_count: 0`, `unbalanced_fences: false`,
|
|
75
201
|
empty `bare_opening_fences`, empty `table_drift`; for
|
|
76
202
|
`--required-sections`, all three families have `satisfied: false`,
|
|
77
|
-
`matched_synonym: null
|
|
203
|
+
`matched_synonym: null`, with `frontmatter_description: false` and an
|
|
204
|
+
empty `outline`.
|
|
205
|
+
|
|
206
|
+
Line model (scan --required-sections, usage-scope, cross-reference,
|
|
207
|
+
reference-check):
|
|
208
|
+
A file is read as UTF-8, a leading byte order mark dropped, and split
|
|
209
|
+
on `\\n` alone (a `\\r\\n` pair loses its `\\r`, a lone `\\r` stays in
|
|
210
|
+
its line), so every `line` matches `grep -n`. The frontmatter is a
|
|
211
|
+
leading `---` ... `---` block. A fence opens on a line of three or more
|
|
212
|
+
backticks or tildes (any indent) and closes on the same character
|
|
213
|
+
repeated at least as often with nothing after it; an unclosed fence
|
|
214
|
+
runs to the end of the file. A heading is an ATX heading outside the
|
|
215
|
+
frontmatter and fenced code: at most three spaces of indent (four make
|
|
216
|
+
indented code), one to six `#`, then a space, a tab or the end of the
|
|
217
|
+
line, with a closing `#` sequence dropped from its text. Its section
|
|
218
|
+
runs to the line before the next heading of the same or a higher level.
|
|
219
|
+
|
|
220
|
+
Usage scope:
|
|
221
|
+
Single-body (no `*.md` file under `references/`): the section of the
|
|
222
|
+
first heading whose text is a usage synonym (REQUIRED_SYNONYMS["usage"],
|
|
223
|
+
the anchor coherence-check.md §2.1 matches), fenced code inside it
|
|
224
|
+
included. The Key Exports list is outside it, so listing an export
|
|
225
|
+
there does not count as using it. With no such heading the scope is
|
|
226
|
+
empty and every export is zero-usage.
|
|
227
|
+
Split-body (a `*.md` file under `references/`): every usage-family
|
|
228
|
+
section of SKILL.md (the headings in USAGE_FAMILY_HEADINGS: every usage
|
|
229
|
+
synonym plus `Key API Summary`, `Pattern Surface` and `Key Exports`,
|
|
230
|
+
full text, case-insensitive, any level), fenced code inside them
|
|
231
|
+
included, plus every `*.md` file under `references/` at any depth, in
|
|
232
|
+
path order. Overlapping sections count each line once.
|
|
233
|
+
--body auto (the default) decides by the `references/` test alone.
|
|
234
|
+
coherence-check.md §2.4 also calls a package with `references/`
|
|
235
|
+
single-body when its `## Full*` sections carry real content rather than
|
|
236
|
+
stubs: that is the caller's judgment, passed as --body single.
|
|
237
|
+
|
|
238
|
+
Export match:
|
|
239
|
+
The name as a fixed string, case-sensitive, never inside a longer
|
|
240
|
+
identifier: on a side where the name ends in a letter, digit, `_` or
|
|
241
|
+
`$`, the next character may not be one of those. So `$state` and `a.b`
|
|
242
|
+
match only themselves (no shell expansion, no regex wildcard), `get`
|
|
243
|
+
does not match `getAll`, and `.then` still matches `promise.then`.
|
|
244
|
+
`count` is the number of matching lines (the `grep -c` count).
|
|
245
|
+
|
|
246
|
+
Citation match:
|
|
247
|
+
A skill's name and aliases, case-insensitive, never inside a longer
|
|
248
|
+
name: on a side where the term ends in a letter, digit, `_` or `-`, the
|
|
249
|
+
next character may not be one of those. So `React` and `react/client`
|
|
250
|
+
cite `react`, while `react-dom`, `preact` and `reactive` do not. The
|
|
251
|
+
whole SKILL.md is searched, frontmatter and code included, and a skill
|
|
252
|
+
never cites itself. A hit's `substring` is the match as written (the
|
|
253
|
+
longest term at the leftmost match of its line) and its `excerpt` that
|
|
254
|
+
line around all of its matches. A match is literal, not a judgment: a
|
|
255
|
+
skill named after a common word (`next`, `requests`) also matches
|
|
256
|
+
prose such as `the next step` or a `sending requests` description, so
|
|
257
|
+
the caller picks from `hits[]` the line it quotes as the citation.
|
|
258
|
+
|
|
259
|
+
Reference extraction:
|
|
260
|
+
Outside the frontmatter and fenced code, two forms count:
|
|
261
|
+
- link: a markdown link, image or reference definition (`[id]: target`,
|
|
262
|
+
footnotes aside) outside inline code, unless its target is a URL
|
|
263
|
+
(`scheme:` or `//`), a bare `#anchor`, or holds a placeholder or glob
|
|
264
|
+
character (`{}<>*?$`). The target may hold one level of balanced
|
|
265
|
+
parentheses; its backslash escapes and entity references are
|
|
266
|
+
resolved, the `#fragment` and `?query` dropped and `%XX` escapes
|
|
267
|
+
decoded.
|
|
268
|
+
- mention: a `references/`, `scripts/` or `assets/` path, optionally
|
|
269
|
+
`./`-prefixed, in prose or inline code, not preceded by a letter,
|
|
270
|
+
digit, `_`, `-`, `.`, `/`, `@` or `:` (so `src/assets/x`, URLs and
|
|
271
|
+
`[SRC:scripts/x.sh:L1]` provenance citations are not references).
|
|
272
|
+
Trailing punctuation, a `#fragment` and a `:L12` line suffix are
|
|
273
|
+
dropped; a bare folder or a placeholder is not a reference. A
|
|
274
|
+
`scripts/` or `assets/` mention counts only when the package has that
|
|
275
|
+
folder or it sits in a section whose heading starts with `Scripts`
|
|
276
|
+
(the Scripts & Assets section): elsewhere it can name a path in the
|
|
277
|
+
reader's own project, as framework docs do.
|
|
278
|
+
A target under `scripts/` or `assets/` is `script-asset`; any other
|
|
279
|
+
target is `file-path`.
|
|
280
|
+
Known limits: a mention is taken as written (no escape is resolved) and
|
|
281
|
+
ends at whitespace, so `references/my file.md` in inline code reads as
|
|
282
|
+
`references/my`; parentheses nest one level deep in a link target; and
|
|
283
|
+
a one-word line such as `[Note]: see-below` is a reference definition,
|
|
284
|
+
as CommonMark reads it.
|
|
78
285
|
|
|
79
286
|
Exit codes:
|
|
80
|
-
0
|
|
81
|
-
1
|
|
287
|
+
0: operation succeeded
|
|
288
|
+
1: user error (file not found or unreadable, malformed JSON input, both
|
|
289
|
+
--skills and --pairs on stdin, a cross-reference --skills-root that
|
|
290
|
+
is not a directory)
|
|
291
|
+
2: usage error (argparse: missing or unknown argument or subcommand)
|
|
82
292
|
"""
|
|
83
293
|
|
|
84
294
|
from __future__ import annotations
|
|
85
295
|
|
|
86
296
|
import argparse
|
|
297
|
+
import html
|
|
87
298
|
import json
|
|
299
|
+
import os
|
|
88
300
|
import re
|
|
89
301
|
import sys
|
|
90
302
|
from pathlib import Path
|
|
303
|
+
from urllib.parse import unquote
|
|
91
304
|
|
|
92
305
|
|
|
93
306
|
# --------------------------------------------------------------------------
|
|
@@ -95,17 +308,19 @@ from pathlib import Path
|
|
|
95
308
|
# --------------------------------------------------------------------------
|
|
96
309
|
|
|
97
310
|
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
# (`
|
|
104
|
-
# the
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
311
|
+
# The synonyms test-skill's coherence check (coherence-check.md §2.1)
|
|
312
|
+
# matches, with the SKF-template headings folded in so they are
|
|
313
|
+
# first-class matches rather than literal-name misses: the Deep/create-skill
|
|
314
|
+
# template (`Overview`, `Quick Start`, `Common Workflows`, `Key API
|
|
315
|
+
# Summary`), the quick-skill template (`Usage Patterns`, `Key Exports`),
|
|
316
|
+
# the component-library override (`Component Catalog` replaces Key API
|
|
317
|
+
# Summary for api_surface) and the reference-app overrides (`Adoption
|
|
318
|
+
# Steps` replaces Common Workflows for usage; `Pattern Surface` replaces
|
|
319
|
+
# Key API Summary for api_surface), since headings are matched on the full
|
|
320
|
+
# heading text, not a substring. A template heading is never renamed: a
|
|
321
|
+
# new one widens this list (test-skf-scan-skill-md-structure.py checks the
|
|
322
|
+
# templates against it). A heading outside the list leaves its family
|
|
323
|
+
# unsatisfied, and §2.1 then judges the `outline`.
|
|
109
324
|
REQUIRED_SYNONYMS: dict[str, list[str]] = {
|
|
110
325
|
"description": ["Description", "Overview", "Purpose", "Summary"],
|
|
111
326
|
"usage": [
|
|
@@ -129,6 +344,7 @@ REQUIRED_SYNONYMS: dict[str, list[str]] = {
|
|
|
129
344
|
"Reference",
|
|
130
345
|
"Key API Summary",
|
|
131
346
|
"Pattern Surface",
|
|
347
|
+
"Component Catalog",
|
|
132
348
|
],
|
|
133
349
|
}
|
|
134
350
|
|
|
@@ -138,13 +354,39 @@ REQUIRED_SYNONYMS: dict[str, list[str]] = {
|
|
|
138
354
|
# --------------------------------------------------------------------------
|
|
139
355
|
|
|
140
356
|
|
|
357
|
+
# `scan` (table drift) and skf-shard-body.py read headings with this one.
|
|
141
358
|
_HEADING_RE = re.compile(r"^\s*(#+)\s+(.*?)\s*$")
|
|
359
|
+
_FRONTMATTER_DESCRIPTION_RE = re.compile(r"^description[ \t]*:(.*)$")
|
|
360
|
+
# A YAML block scalar header (`>`, `|-`, `>2` ...) holds no text itself.
|
|
361
|
+
_BLOCK_SCALAR_RE = re.compile(r"^[|>](?:[1-9][+-]?|[+-][1-9]?)?$")
|
|
362
|
+
_EMPTY_SCALARS = frozenset({"", '""', "''", "~", "null"})
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def frontmatter_description(lines: list[str], start: int) -> bool:
|
|
366
|
+
"""True when the frontmatter (`lines[:start]`) has a `description` key
|
|
367
|
+
with a non-empty value: inline, or on the indented lines under it."""
|
|
368
|
+
for i in range(1, max(start - 1, 1)):
|
|
369
|
+
m = _FRONTMATTER_DESCRIPTION_RE.match(lines[i])
|
|
370
|
+
if not m:
|
|
371
|
+
continue
|
|
372
|
+
value = m.group(1).split(" #", 1)[0].strip()
|
|
373
|
+
if value.lower() not in _EMPTY_SCALARS and not _BLOCK_SCALAR_RE.match(value):
|
|
374
|
+
return True
|
|
375
|
+
for nxt in lines[i + 1:start - 1]:
|
|
376
|
+
if not nxt.strip():
|
|
377
|
+
continue
|
|
378
|
+
if nxt[:1] not in (" ", "\t"):
|
|
379
|
+
break
|
|
380
|
+
return True
|
|
381
|
+
return False
|
|
382
|
+
return False
|
|
142
383
|
|
|
143
384
|
|
|
144
|
-
def find_required_sections(text: str) -> dict[str,
|
|
385
|
+
def find_required_sections(text: str) -> dict[str, object]:
|
|
145
386
|
"""For each family, find the first matching heading.
|
|
146
387
|
|
|
147
|
-
|
|
388
|
+
Reads the body's headings once (the Line model: outside the frontmatter
|
|
389
|
+
and fenced code), lower-cases each heading text, and looks it up
|
|
148
390
|
against pre-lowered synonym sets. Returns the structure described in
|
|
149
391
|
the module docstring.
|
|
150
392
|
"""
|
|
@@ -155,13 +397,13 @@ def find_required_sections(text: str) -> dict[str, dict]:
|
|
|
155
397
|
for syn in synonyms:
|
|
156
398
|
lookup[syn.lower()] = (family, syn)
|
|
157
399
|
|
|
400
|
+
lines = _split_lines(text)
|
|
401
|
+
start = _frontmatter_end(lines)
|
|
402
|
+
code = _code_lines(lines, start)
|
|
403
|
+
headings = _body_headings(lines, start, code)
|
|
158
404
|
matched: dict[str, str] = {}
|
|
159
|
-
for
|
|
160
|
-
|
|
161
|
-
if not m:
|
|
162
|
-
continue
|
|
163
|
-
heading_text = m.group(2).strip().lower()
|
|
164
|
-
hit = lookup.get(heading_text)
|
|
405
|
+
for _, _, heading in headings:
|
|
406
|
+
hit = lookup.get(heading.lower())
|
|
165
407
|
if hit is None:
|
|
166
408
|
continue
|
|
167
409
|
family, canonical = hit
|
|
@@ -170,7 +412,7 @@ def find_required_sections(text: str) -> dict[str, dict]:
|
|
|
170
412
|
continue
|
|
171
413
|
matched[family] = canonical
|
|
172
414
|
|
|
173
|
-
result: dict[str,
|
|
415
|
+
result: dict[str, object] = {}
|
|
174
416
|
for family, synonyms in REQUIRED_SYNONYMS.items():
|
|
175
417
|
if family in matched:
|
|
176
418
|
result[family] = {
|
|
@@ -184,6 +426,11 @@ def find_required_sections(text: str) -> dict[str, dict]:
|
|
|
184
426
|
"matched_synonym": None,
|
|
185
427
|
"tried": list(synonyms),
|
|
186
428
|
}
|
|
429
|
+
result["frontmatter_description"] = frontmatter_description(lines, start)
|
|
430
|
+
result["outline"] = [
|
|
431
|
+
{"line": idx + 1, "level": level, "text": heading}
|
|
432
|
+
for idx, level, heading in headings
|
|
433
|
+
]
|
|
187
434
|
return result
|
|
188
435
|
|
|
189
436
|
|
|
@@ -316,6 +563,636 @@ def find_table_drift(text: str) -> list[dict]:
|
|
|
316
563
|
return findings
|
|
317
564
|
|
|
318
565
|
|
|
566
|
+
# --------------------------------------------------------------------------
|
|
567
|
+
# Line model shared by usage-scope, cross-reference, reference-check and
|
|
568
|
+
# scan --required-sections
|
|
569
|
+
# --------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
_FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})(.*)$")
|
|
573
|
+
# A CommonMark ATX heading. `_HEADING_RE` stays as it is for `scan` and
|
|
574
|
+
# skf-shard-body.py, which import it.
|
|
575
|
+
_BODY_HEADING_RE = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$")
|
|
576
|
+
_CLOSING_HASHES_RE = re.compile(r"(?:^|[ \t]+)#+$")
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _split_lines(text: str) -> list[str]:
|
|
580
|
+
"""Split on newlines only, so line numbers match `grep -n`.
|
|
581
|
+
|
|
582
|
+
`str.splitlines()` also breaks on form feeds and U+2028, which would
|
|
583
|
+
shift every later line number away from what an editor or grep shows.
|
|
584
|
+
"""
|
|
585
|
+
lines = text.replace("\r\n", "\n").split("\n")
|
|
586
|
+
if lines and lines[-1] == "":
|
|
587
|
+
lines.pop()
|
|
588
|
+
return lines
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _frontmatter_end(lines: list[str]) -> int:
|
|
592
|
+
"""Index of the first body line, past a leading `---` ... `---` block."""
|
|
593
|
+
if lines and lines[0].strip() == "---":
|
|
594
|
+
for i in range(1, len(lines)):
|
|
595
|
+
if lines[i].strip() == "---":
|
|
596
|
+
return i + 1
|
|
597
|
+
return 0
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _code_lines(lines: list[str], start: int = 0) -> list[bool]:
|
|
601
|
+
"""Flag every fence line and every line between a fence and its close.
|
|
602
|
+
|
|
603
|
+
A backtick line whose info string holds a backtick is inline code, not
|
|
604
|
+
a fence (CommonMark), and an unclosed fence runs to the end of the file.
|
|
605
|
+
"""
|
|
606
|
+
code = [False] * len(lines)
|
|
607
|
+
fence: tuple[str, int] | None = None
|
|
608
|
+
for i in range(start, len(lines)):
|
|
609
|
+
m = _FENCE_RE.match(lines[i])
|
|
610
|
+
if fence is None:
|
|
611
|
+
if m and not (m.group(1)[0] == "`" and "`" in m.group(2)):
|
|
612
|
+
fence = (m.group(1)[0], len(m.group(1)))
|
|
613
|
+
code[i] = True
|
|
614
|
+
continue
|
|
615
|
+
code[i] = True
|
|
616
|
+
if (
|
|
617
|
+
m
|
|
618
|
+
and m.group(1)[0] == fence[0]
|
|
619
|
+
and len(m.group(1)) >= fence[1]
|
|
620
|
+
and not m.group(2).strip()
|
|
621
|
+
):
|
|
622
|
+
fence = None
|
|
623
|
+
return code
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _body_headings(
|
|
627
|
+
lines: list[str], start: int, code: list[bool]
|
|
628
|
+
) -> list[tuple[int, int, str]]:
|
|
629
|
+
"""(index, level, text) of each heading outside frontmatter and code.
|
|
630
|
+
|
|
631
|
+
A `#` line indented four or more spaces is indented code, and a
|
|
632
|
+
closing sequence (`## Usage ##`) is not part of the text.
|
|
633
|
+
"""
|
|
634
|
+
headings: list[tuple[int, int, str]] = []
|
|
635
|
+
for i in range(start, len(lines)):
|
|
636
|
+
if code[i]:
|
|
637
|
+
continue
|
|
638
|
+
m = _BODY_HEADING_RE.match(lines[i])
|
|
639
|
+
if m:
|
|
640
|
+
text = _CLOSING_HASHES_RE.sub("", m.group(2) or "").strip()
|
|
641
|
+
headings.append((i, len(m.group(1)), text))
|
|
642
|
+
return headings
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _section_spans(
|
|
646
|
+
headings: list[tuple[int, int, str]], total: int, wanted
|
|
647
|
+
) -> list[tuple[int, int, str]]:
|
|
648
|
+
"""(start, end, heading) of each section whose heading `wanted` accepts.
|
|
649
|
+
|
|
650
|
+
A section runs from its heading line to the line before the next
|
|
651
|
+
heading of the same or a higher level, or to the end of the file;
|
|
652
|
+
`end` is exclusive.
|
|
653
|
+
"""
|
|
654
|
+
spans: list[tuple[int, int, str]] = []
|
|
655
|
+
for pos, (idx, level, text) in enumerate(headings):
|
|
656
|
+
if not wanted(text):
|
|
657
|
+
continue
|
|
658
|
+
end = total
|
|
659
|
+
for next_idx, next_level, _ in headings[pos + 1:]:
|
|
660
|
+
if next_level <= level:
|
|
661
|
+
end = next_idx
|
|
662
|
+
break
|
|
663
|
+
spans.append((idx, end, text))
|
|
664
|
+
return spans
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _bounded(term: str, word_chars: str) -> str:
|
|
668
|
+
"""Regex for `term` as a fixed string that is never part of a longer word.
|
|
669
|
+
|
|
670
|
+
`word_chars` is a character-class body such as `\\w$`. The guard only
|
|
671
|
+
applies on a side where the term itself ends in such a character, so
|
|
672
|
+
`.then` still matches inside `promise.then`.
|
|
673
|
+
"""
|
|
674
|
+
word = re.compile(f"[{word_chars}]")
|
|
675
|
+
body = re.escape(term)
|
|
676
|
+
if word.match(term[0]):
|
|
677
|
+
body = f"(?<![{word_chars}])" + body
|
|
678
|
+
if word.match(term[-1]):
|
|
679
|
+
body += f"(?![{word_chars}])"
|
|
680
|
+
return body
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
# --------------------------------------------------------------------------
|
|
684
|
+
# Usage scope: per-export usage counts
|
|
685
|
+
# --------------------------------------------------------------------------
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
# coherence-check.md §2.4 counts a split-body skill over every usage-family
|
|
689
|
+
# heading: the usage synonyms above plus the api-surface headings that carry
|
|
690
|
+
# call examples in the SKF templates. A single-body skill counts over its
|
|
691
|
+
# first usage-synonym section alone, so an export does not count as used
|
|
692
|
+
# because Key Exports lists it. Derived from REQUIRED_SYNONYMS so a usage
|
|
693
|
+
# synonym added there joins both scopes.
|
|
694
|
+
USAGE_FAMILY_HEADINGS: list[str] = [
|
|
695
|
+
*REQUIRED_SYNONYMS["usage"],
|
|
696
|
+
"Key API Summary",
|
|
697
|
+
"Pattern Surface",
|
|
698
|
+
"Key Exports",
|
|
699
|
+
]
|
|
700
|
+
|
|
701
|
+
# The --body choices after `auto` is resolved.
|
|
702
|
+
_BODIES = ("single", "split")
|
|
703
|
+
|
|
704
|
+
# Characters that continue an identifier in the languages SKF documents
|
|
705
|
+
# (`$` for JS/TS names such as `$state`).
|
|
706
|
+
_IDENT_CHARS = r"\w$"
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def usage_scope(
|
|
710
|
+
skill_text: str,
|
|
711
|
+
reference_texts: list[tuple[str, str]],
|
|
712
|
+
exports: list[dict],
|
|
713
|
+
kinds: set[str] | None = None,
|
|
714
|
+
skill_label: str = "SKILL.md",
|
|
715
|
+
body: str | None = None,
|
|
716
|
+
) -> dict:
|
|
717
|
+
"""Count the lines that use each export over the usage scope.
|
|
718
|
+
|
|
719
|
+
`reference_texts` holds (relative path, text) for each reference file,
|
|
720
|
+
in path order. `exports` holds {"name", "kind"} dicts (kind may be
|
|
721
|
+
None); `kinds`, when given, drops the exports whose kind is known and
|
|
722
|
+
not listed. `body` is "single" or "split"; None picks "split" when
|
|
723
|
+
there is a reference text and "single" otherwise, and a single-body
|
|
724
|
+
scope reads no reference text. Returns the structure described in the
|
|
725
|
+
module docstring, without `warnings` (the caller owns file reading).
|
|
726
|
+
"""
|
|
727
|
+
if body is None:
|
|
728
|
+
body = "split" if reference_texts else "single"
|
|
729
|
+
if body not in _BODIES:
|
|
730
|
+
raise ValueError(f"body must be one of {_BODIES}, not {body!r}")
|
|
731
|
+
lines = _split_lines(skill_text)
|
|
732
|
+
start = _frontmatter_end(lines)
|
|
733
|
+
code = _code_lines(lines, start)
|
|
734
|
+
headings = _body_headings(lines, start, code)
|
|
735
|
+
if body == "single":
|
|
736
|
+
usage = {h.lower() for h in REQUIRED_SYNONYMS["usage"]}
|
|
737
|
+
spans = _section_spans(
|
|
738
|
+
headings, len(lines), lambda text: text.lower() in usage
|
|
739
|
+
)[:1]
|
|
740
|
+
reference_texts = []
|
|
741
|
+
else:
|
|
742
|
+
family = {h.lower() for h in USAGE_FAMILY_HEADINGS}
|
|
743
|
+
spans = _section_spans(
|
|
744
|
+
headings, len(lines), lambda text: text.lower() in family
|
|
745
|
+
)
|
|
746
|
+
in_scope = sorted({i for s, e, _ in spans for i in range(s, e)})
|
|
747
|
+
|
|
748
|
+
# (file, 1-based line, text) in scope order: SKILL.md first, then the
|
|
749
|
+
# reference files, so `first_hit` is deterministic.
|
|
750
|
+
corpus: list[tuple[str, int, str]] = [
|
|
751
|
+
(skill_label, i + 1, lines[i]) for i in in_scope
|
|
752
|
+
]
|
|
753
|
+
for rel, text in reference_texts:
|
|
754
|
+
corpus.extend(
|
|
755
|
+
(rel, n, line) for n, line in enumerate(_split_lines(text), start=1)
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
selected = {
|
|
759
|
+
(e["name"], e["kind"])
|
|
760
|
+
for e in exports
|
|
761
|
+
if kinds is None or e["kind"] is None or e["kind"] in kinds
|
|
762
|
+
}
|
|
763
|
+
results: list[dict] = []
|
|
764
|
+
for name, kind in sorted(selected, key=lambda k: (k[0], k[1] or "")):
|
|
765
|
+
pattern = re.compile(_bounded(name, _IDENT_CHARS))
|
|
766
|
+
count = 0
|
|
767
|
+
first_hit = None
|
|
768
|
+
for label, lineno, line in corpus:
|
|
769
|
+
# the substring test is a cheap pre-filter; the regex decides
|
|
770
|
+
if name in line and pattern.search(line):
|
|
771
|
+
count += 1
|
|
772
|
+
if first_hit is None:
|
|
773
|
+
first_hit = {"file": label, "line": lineno}
|
|
774
|
+
results.append({
|
|
775
|
+
"name": name,
|
|
776
|
+
"kind": kind,
|
|
777
|
+
"count": count,
|
|
778
|
+
"first_hit": first_hit,
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
return {
|
|
782
|
+
"scope": {
|
|
783
|
+
"body": body,
|
|
784
|
+
"sections": [
|
|
785
|
+
{"heading": text, "line": s + 1, "end_line": e}
|
|
786
|
+
for s, e, text in spans
|
|
787
|
+
],
|
|
788
|
+
"reference_files": [rel for rel, _ in reference_texts],
|
|
789
|
+
},
|
|
790
|
+
"exports": results,
|
|
791
|
+
"zero_usage": [
|
|
792
|
+
{"name": r["name"], "kind": r["kind"]}
|
|
793
|
+
for r in results
|
|
794
|
+
if r["count"] == 0
|
|
795
|
+
],
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
# --------------------------------------------------------------------------
|
|
800
|
+
# Cross-reference: literal citations between skills
|
|
801
|
+
# --------------------------------------------------------------------------
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
# Characters that continue a package or skill name (`react-dom` is not
|
|
805
|
+
# `react`).
|
|
806
|
+
_NAME_CHARS = r"\w-"
|
|
807
|
+
_EXCERPT_CONTEXT = 60
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _citation_pattern(terms: list[str]) -> re.Pattern[str]:
|
|
811
|
+
"""One case-insensitive regex for a skill's terms, longest term first."""
|
|
812
|
+
ordered = sorted(terms, key=lambda t: (-len(t), t.lower()))
|
|
813
|
+
return re.compile(
|
|
814
|
+
"|".join(_bounded(t, _NAME_CHARS) for t in ordered), re.IGNORECASE
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _excerpt(line: str, start: int, end: int) -> str:
|
|
819
|
+
"""The line around [start, end), whitespace collapsed, `...` at a cut.
|
|
820
|
+
|
|
821
|
+
[start, end) runs from a line's first match to its last, so every
|
|
822
|
+
match on the line shows.
|
|
823
|
+
"""
|
|
824
|
+
lo = max(0, start - _EXCERPT_CONTEXT)
|
|
825
|
+
hi = min(len(line), end + _EXCERPT_CONTEXT)
|
|
826
|
+
text = " ".join(line[lo:hi].split())
|
|
827
|
+
if lo > 0:
|
|
828
|
+
text = "..." + text
|
|
829
|
+
if hi < len(line):
|
|
830
|
+
text += "..."
|
|
831
|
+
return text
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def cross_references(
|
|
835
|
+
skills: list[dict], pairs: list[tuple[str, str]] | None = None
|
|
836
|
+
) -> dict:
|
|
837
|
+
"""Find where each skill's SKILL.md literally cites another skill.
|
|
838
|
+
|
|
839
|
+
`skills` holds {"name", "terms", "text"} dicts, `text` None when the
|
|
840
|
+
SKILL.md could not be read (the skill can still be cited). `pairs`,
|
|
841
|
+
when given, keeps only the citations between those unordered pairs and
|
|
842
|
+
adds `pairs[]`. Each citation lists every citing line in `hits`, since
|
|
843
|
+
the first one can be a common word rather than the citation. Returns
|
|
844
|
+
citations, edges, pairs and pair warnings.
|
|
845
|
+
"""
|
|
846
|
+
patterns = {s["name"]: _citation_pattern(s["terms"]) for s in skills}
|
|
847
|
+
names = sorted(patterns)
|
|
848
|
+
wanted: set[tuple[str, str]] | None = None
|
|
849
|
+
if pairs is not None:
|
|
850
|
+
wanted = set()
|
|
851
|
+
for a, b in pairs:
|
|
852
|
+
wanted.update({(a, b), (b, a)})
|
|
853
|
+
|
|
854
|
+
citations: list[dict] = []
|
|
855
|
+
for source in sorted(skills, key=lambda s: s["name"]):
|
|
856
|
+
if source["text"] is None:
|
|
857
|
+
continue
|
|
858
|
+
lines = _split_lines(source["text"])
|
|
859
|
+
for target in names:
|
|
860
|
+
if target == source["name"]:
|
|
861
|
+
continue
|
|
862
|
+
if wanted is not None and (source["name"], target) not in wanted:
|
|
863
|
+
continue
|
|
864
|
+
pattern = patterns[target]
|
|
865
|
+
hits: list[dict] = []
|
|
866
|
+
for lineno, line in enumerate(lines, start=1):
|
|
867
|
+
matches = list(pattern.finditer(line))
|
|
868
|
+
if not matches:
|
|
869
|
+
continue
|
|
870
|
+
hits.append({
|
|
871
|
+
"line": lineno,
|
|
872
|
+
"substring": matches[0].group(0),
|
|
873
|
+
"excerpt": _excerpt(
|
|
874
|
+
line, matches[0].start(), matches[-1].end()
|
|
875
|
+
),
|
|
876
|
+
})
|
|
877
|
+
if not hits:
|
|
878
|
+
continue
|
|
879
|
+
citations.append({
|
|
880
|
+
"from": source["name"],
|
|
881
|
+
"to": target,
|
|
882
|
+
"substring": hits[0]["substring"],
|
|
883
|
+
"line": hits[0]["line"],
|
|
884
|
+
"count": len(hits),
|
|
885
|
+
"excerpt": hits[0]["excerpt"],
|
|
886
|
+
"hits": hits,
|
|
887
|
+
})
|
|
888
|
+
|
|
889
|
+
result: dict = {
|
|
890
|
+
"citations": citations,
|
|
891
|
+
"edges": [[c["from"], c["to"]] for c in citations],
|
|
892
|
+
}
|
|
893
|
+
warnings: list[str] = []
|
|
894
|
+
if pairs is not None:
|
|
895
|
+
cited = {(c["from"], c["to"]) for c in citations}
|
|
896
|
+
rows: list[dict] = []
|
|
897
|
+
for a, b in sorted(pairs):
|
|
898
|
+
for name in (a, b):
|
|
899
|
+
if name not in patterns:
|
|
900
|
+
warnings.append(
|
|
901
|
+
f"{name}: named in --pairs but not in --skills"
|
|
902
|
+
)
|
|
903
|
+
rows.append({
|
|
904
|
+
"library_a": a,
|
|
905
|
+
"library_b": b,
|
|
906
|
+
"a_cites_b": (a, b) in cited,
|
|
907
|
+
"b_cites_a": (b, a) in cited,
|
|
908
|
+
"cited": (a, b) in cited or (b, a) in cited,
|
|
909
|
+
})
|
|
910
|
+
result["pairs"] = rows
|
|
911
|
+
# dict.fromkeys keeps the first of repeated warnings, in order
|
|
912
|
+
result["warnings"] = list(dict.fromkeys(warnings))
|
|
913
|
+
return result
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
# --------------------------------------------------------------------------
|
|
917
|
+
# Reference check: file, script and asset references
|
|
918
|
+
# --------------------------------------------------------------------------
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
# A link title is "...", '...' or (...); a reference definition must end
|
|
922
|
+
# after its title, so a prose line such as `[Note]: see below` is not one.
|
|
923
|
+
_LINK_TITLE = r"(?:\s+(?:\"[^\"\n]*\"|'[^'\n]*'|\([^()\n]*\)))?"
|
|
924
|
+
# A link destination is `<...>`, or a run with no whitespace whose
|
|
925
|
+
# parentheses are backslash-escaped or balanced one level deep
|
|
926
|
+
# (`references/a_(1).md`). A backslash always takes the next character.
|
|
927
|
+
_DEST_CHAR = r"(?:\\\S|[^\s()\\])"
|
|
928
|
+
_LINK_RE = re.compile(
|
|
929
|
+
rf"!?\[[^\]\n]*\]\(\s*(<[^>\n]*>|(?:{_DEST_CHAR}|\({_DEST_CHAR}*\))+)"
|
|
930
|
+
+ _LINK_TITLE
|
|
931
|
+
+ r"\s*\)"
|
|
932
|
+
)
|
|
933
|
+
_REF_DEFINITION_RE = re.compile(
|
|
934
|
+
r"^\s{0,3}\[([^\]\n]+)\]:\s*(<[^>\n]*>|\S+)" + _LINK_TITLE + r"\s*$"
|
|
935
|
+
)
|
|
936
|
+
_MENTION_RE = re.compile(
|
|
937
|
+
r"(?<![\w.:/@-])(?:\./)?(references|scripts|assets)/[^\s`'\"()|,;]+"
|
|
938
|
+
)
|
|
939
|
+
# Backslash-escaped ASCII punctuation, or an entity reference: CommonMark
|
|
940
|
+
# resolves both inside a link destination.
|
|
941
|
+
_ESCAPE_RE = re.compile(
|
|
942
|
+
r"\\([!-/:-@\[-`{-~])"
|
|
943
|
+
r"|&(?:#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[A-Za-z][A-Za-z0-9]*);"
|
|
944
|
+
)
|
|
945
|
+
_URL_SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]+:")
|
|
946
|
+
_LINE_SUFFIX_RE = re.compile(r":L?\d+(?:-L?\d+)?$")
|
|
947
|
+
_PLACEHOLDER_CHARS = frozenset("{}<>*?$")
|
|
948
|
+
_MENTION_TRAILING = ".,:;!?*_~>]}"
|
|
949
|
+
_PACKAGE_FOLDERS = ("scripts", "assets")
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _inline_code_spans(line: str) -> list[tuple[int, int]]:
|
|
953
|
+
"""[start, end) of each inline code span: a backtick run closed by an
|
|
954
|
+
equal run. An unmatched run is literal text."""
|
|
955
|
+
spans: list[tuple[int, int]] = []
|
|
956
|
+
i, n = 0, len(line)
|
|
957
|
+
while i < n:
|
|
958
|
+
if line[i] != "`":
|
|
959
|
+
i += 1
|
|
960
|
+
continue
|
|
961
|
+
j = i
|
|
962
|
+
while j < n and line[j] == "`":
|
|
963
|
+
j += 1
|
|
964
|
+
run = j - i
|
|
965
|
+
k = j
|
|
966
|
+
close = -1
|
|
967
|
+
while k < n:
|
|
968
|
+
if line[k] != "`":
|
|
969
|
+
k += 1
|
|
970
|
+
continue
|
|
971
|
+
m = k
|
|
972
|
+
while m < n and line[m] == "`":
|
|
973
|
+
m += 1
|
|
974
|
+
if m - k == run:
|
|
975
|
+
close = k
|
|
976
|
+
break
|
|
977
|
+
k = m
|
|
978
|
+
if close == -1:
|
|
979
|
+
i = j
|
|
980
|
+
continue
|
|
981
|
+
spans.append((i, close + run))
|
|
982
|
+
i = close + run
|
|
983
|
+
return spans
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _mask(line: str, spans: list[tuple[int, int]]) -> str:
|
|
987
|
+
"""Blank out each span, keeping every offset in place."""
|
|
988
|
+
chars = list(line)
|
|
989
|
+
for start, end in spans:
|
|
990
|
+
chars[start:end] = " " * (end - start)
|
|
991
|
+
return "".join(chars)
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _unescape(text: str) -> str:
|
|
995
|
+
"""Resolve backslash escapes and entity references in one pass, so an
|
|
996
|
+
escaped `\\&` stays `&`."""
|
|
997
|
+
return _ESCAPE_RE.sub(
|
|
998
|
+
lambda m: m.group(1) or html.unescape(m.group(0)), text
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _clean_link(dest: str) -> str | None:
|
|
1003
|
+
"""The local path a link destination names, or None when it names none."""
|
|
1004
|
+
if dest.startswith("<") and dest.endswith(">"):
|
|
1005
|
+
dest = dest[1:-1].strip()
|
|
1006
|
+
dest = _unescape(dest)
|
|
1007
|
+
if (
|
|
1008
|
+
not dest
|
|
1009
|
+
or dest.startswith(("#", "//"))
|
|
1010
|
+
or _URL_SCHEME_RE.match(dest)
|
|
1011
|
+
):
|
|
1012
|
+
return None
|
|
1013
|
+
dest = unquote(dest.split("#", 1)[0].split("?", 1)[0])
|
|
1014
|
+
if not dest or any(c in _PLACEHOLDER_CHARS for c in dest):
|
|
1015
|
+
return None
|
|
1016
|
+
return dest
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _clean_mention(token: str) -> str | None:
|
|
1020
|
+
"""The path a bare mention names, or None for a folder or placeholder."""
|
|
1021
|
+
token = _LINE_SUFFIX_RE.sub("", token.rstrip(_MENTION_TRAILING))
|
|
1022
|
+
token = token.split("#", 1)[0].rstrip(_MENTION_TRAILING)
|
|
1023
|
+
if token.endswith("/") or any(c in _PLACEHOLDER_CHARS for c in token):
|
|
1024
|
+
return None
|
|
1025
|
+
return token
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _reference_type(target: str) -> str:
|
|
1029
|
+
rel = target
|
|
1030
|
+
while rel.startswith("./"):
|
|
1031
|
+
rel = rel[2:]
|
|
1032
|
+
if rel.startswith(tuple(f"{folder}/" for folder in _PACKAGE_FOLDERS)):
|
|
1033
|
+
return "script-asset"
|
|
1034
|
+
return "file-path"
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def extract_references(
|
|
1038
|
+
text: str, package_folders: frozenset[str] = frozenset()
|
|
1039
|
+
) -> list[dict]:
|
|
1040
|
+
"""List the file, script and asset references in a SKILL.md body.
|
|
1041
|
+
|
|
1042
|
+
`package_folders` names the folders among `scripts` and `assets` that
|
|
1043
|
+
the skill package has; a bare mention of another one counts only
|
|
1044
|
+
inside the Scripts & Assets section. Returns [{"line", "form",
|
|
1045
|
+
"target"}] in document order (before any existence check).
|
|
1046
|
+
"""
|
|
1047
|
+
lines = _split_lines(text)
|
|
1048
|
+
start = _frontmatter_end(lines)
|
|
1049
|
+
code = _code_lines(lines, start)
|
|
1050
|
+
scripts_section = {
|
|
1051
|
+
i
|
|
1052
|
+
for s, e, _ in _section_spans(
|
|
1053
|
+
_body_headings(lines, start, code),
|
|
1054
|
+
len(lines),
|
|
1055
|
+
lambda heading: heading.lower().startswith("scripts"),
|
|
1056
|
+
)
|
|
1057
|
+
for i in range(s, e)
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
refs: list[dict] = []
|
|
1061
|
+
for idx in range(start, len(lines)):
|
|
1062
|
+
if code[idx]:
|
|
1063
|
+
continue
|
|
1064
|
+
line = lines[idx]
|
|
1065
|
+
# links never sit inside inline code; mentions may
|
|
1066
|
+
plain = _mask(line, _inline_code_spans(line))
|
|
1067
|
+
found: list[tuple[int, str, str | None]] = []
|
|
1068
|
+
link_spans: list[tuple[int, int]] = []
|
|
1069
|
+
|
|
1070
|
+
definition = _REF_DEFINITION_RE.match(plain)
|
|
1071
|
+
if definition and not definition.group(1).startswith("^"):
|
|
1072
|
+
target = _clean_link(definition.group(2))
|
|
1073
|
+
found.append((definition.start(2), "link", target))
|
|
1074
|
+
link_spans.append((0, definition.end()))
|
|
1075
|
+
for m in _LINK_RE.finditer(plain):
|
|
1076
|
+
found.append((m.start(1), "link", _clean_link(m.group(1))))
|
|
1077
|
+
link_spans.append(m.span())
|
|
1078
|
+
|
|
1079
|
+
for m in _MENTION_RE.finditer(_mask(line, link_spans)):
|
|
1080
|
+
folder = m.group(1)
|
|
1081
|
+
if (
|
|
1082
|
+
folder in _PACKAGE_FOLDERS
|
|
1083
|
+
and folder not in package_folders
|
|
1084
|
+
and idx not in scripts_section
|
|
1085
|
+
):
|
|
1086
|
+
continue
|
|
1087
|
+
found.append((m.start(), "mention", _clean_mention(m.group(0))))
|
|
1088
|
+
|
|
1089
|
+
for _, form, target in sorted(found, key=lambda f: f[0]):
|
|
1090
|
+
if target is not None:
|
|
1091
|
+
refs.append({"line": idx + 1, "form": form, "target": target})
|
|
1092
|
+
return refs
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def scripts_assets_section(text: str, skill_dir: Path) -> dict:
|
|
1096
|
+
"""The package's scripts/ and assets/ folders and its Scripts & Assets heading."""
|
|
1097
|
+
lines = _split_lines(text)
|
|
1098
|
+
start = _frontmatter_end(lines)
|
|
1099
|
+
heading = next(
|
|
1100
|
+
(
|
|
1101
|
+
{"heading": t, "line": idx + 1}
|
|
1102
|
+
for idx, _, t in _body_headings(lines, start, _code_lines(lines, start))
|
|
1103
|
+
if t.lower().startswith("scripts")
|
|
1104
|
+
),
|
|
1105
|
+
None,
|
|
1106
|
+
)
|
|
1107
|
+
folders = sorted(f for f in _PACKAGE_FOLDERS if (skill_dir / f).is_dir())
|
|
1108
|
+
return {
|
|
1109
|
+
"folders": folders,
|
|
1110
|
+
"section": heading,
|
|
1111
|
+
"missing": bool(folders) and heading is None,
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def _realpath(path: str) -> str:
|
|
1116
|
+
try:
|
|
1117
|
+
return os.path.realpath(path)
|
|
1118
|
+
except (OSError, ValueError):
|
|
1119
|
+
# a name the OS rejects (an embedded NUL, say) resolves nowhere
|
|
1120
|
+
return os.path.abspath(path)
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def _within(path: str, root: str) -> bool:
|
|
1124
|
+
path, root = os.path.normcase(path), os.path.normcase(root)
|
|
1125
|
+
return path == root or path.startswith(root.rstrip(os.sep) + os.sep)
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def check_references(
|
|
1129
|
+
refs: list[dict],
|
|
1130
|
+
skill_dir: str,
|
|
1131
|
+
source_root: str | None = None,
|
|
1132
|
+
skills_root: str | None = None,
|
|
1133
|
+
) -> dict:
|
|
1134
|
+
"""Resolve each reference and check it exists inside an allowed root.
|
|
1135
|
+
|
|
1136
|
+
A source or skills root that is not a directory becomes a warning.
|
|
1137
|
+
"""
|
|
1138
|
+
warnings = [
|
|
1139
|
+
f"--{flag} is not a directory: {path}"
|
|
1140
|
+
for flag, path in (
|
|
1141
|
+
("source-root", source_root),
|
|
1142
|
+
("skills-root", skills_root),
|
|
1143
|
+
)
|
|
1144
|
+
if path and not os.path.isdir(path)
|
|
1145
|
+
]
|
|
1146
|
+
roots = [("skill", _realpath(skill_dir))]
|
|
1147
|
+
if source_root:
|
|
1148
|
+
roots.append(("source", _realpath(source_root)))
|
|
1149
|
+
if skills_root:
|
|
1150
|
+
roots.append(("skills", _realpath(skills_root)))
|
|
1151
|
+
base = roots[0][1]
|
|
1152
|
+
|
|
1153
|
+
references: list[dict] = []
|
|
1154
|
+
counts = {"total": 0, "ok": 0, "missing": 0, "escapes": 0}
|
|
1155
|
+
seen: set[tuple[int, str]] = set()
|
|
1156
|
+
for ref in refs:
|
|
1157
|
+
canonical = _realpath(os.path.join(base, ref["target"]))
|
|
1158
|
+
key = (ref["line"], os.path.normcase(canonical))
|
|
1159
|
+
if key in seen:
|
|
1160
|
+
continue
|
|
1161
|
+
seen.add(key)
|
|
1162
|
+
root = next(
|
|
1163
|
+
(label for label, path in roots if _within(canonical, path)), None
|
|
1164
|
+
)
|
|
1165
|
+
if root is None:
|
|
1166
|
+
status = "escapes"
|
|
1167
|
+
elif os.path.exists(canonical):
|
|
1168
|
+
status = "ok"
|
|
1169
|
+
else:
|
|
1170
|
+
status = "missing"
|
|
1171
|
+
counts["total"] += 1
|
|
1172
|
+
counts[status] += 1
|
|
1173
|
+
references.append({
|
|
1174
|
+
"line": ref["line"],
|
|
1175
|
+
"type": _reference_type(ref["target"]),
|
|
1176
|
+
"form": ref["form"],
|
|
1177
|
+
"target": ref["target"],
|
|
1178
|
+
"canonical": canonical,
|
|
1179
|
+
"status": status,
|
|
1180
|
+
"root": root,
|
|
1181
|
+
})
|
|
1182
|
+
|
|
1183
|
+
labels = dict(roots)
|
|
1184
|
+
return {
|
|
1185
|
+
"roots": {
|
|
1186
|
+
"skill": labels["skill"],
|
|
1187
|
+
"source": labels.get("source"),
|
|
1188
|
+
"skills": labels.get("skills"),
|
|
1189
|
+
},
|
|
1190
|
+
"references": references,
|
|
1191
|
+
"counts": counts,
|
|
1192
|
+
"warnings": warnings,
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
|
|
319
1196
|
# --------------------------------------------------------------------------
|
|
320
1197
|
# CLI
|
|
321
1198
|
# --------------------------------------------------------------------------
|
|
@@ -325,14 +1202,22 @@ def _read_text(path: Path) -> str:
|
|
|
325
1202
|
return path.read_text(encoding="utf-8")
|
|
326
1203
|
|
|
327
1204
|
|
|
1205
|
+
def _read_markdown(path: Path, errors: str = "strict") -> str:
|
|
1206
|
+
"""The file as UTF-8 for the line model: a leading byte order mark is
|
|
1207
|
+
dropped, and every `\\r` is kept, since `read_text` would turn a lone
|
|
1208
|
+
`\\r` into a line break that `grep -n` does not count."""
|
|
1209
|
+
return path.read_bytes().decode("utf-8-sig", errors=errors)
|
|
1210
|
+
|
|
1211
|
+
|
|
328
1212
|
def _cmd_scan(args: argparse.Namespace) -> int:
|
|
329
1213
|
skill_md = Path(args.skill_md)
|
|
330
1214
|
if not skill_md.is_file():
|
|
331
1215
|
print(f"error: file not found: {skill_md}", file=sys.stderr)
|
|
332
1216
|
return 1
|
|
333
1217
|
try:
|
|
334
|
-
|
|
335
|
-
|
|
1218
|
+
# the Line model's read, so an `outline` line matches `grep -n`
|
|
1219
|
+
text = _read_markdown(skill_md)
|
|
1220
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
336
1221
|
print(f"error: cannot read {skill_md}: {exc}", file=sys.stderr)
|
|
337
1222
|
return 1
|
|
338
1223
|
|
|
@@ -345,6 +1230,7 @@ def _cmd_scan(args: argparse.Namespace) -> int:
|
|
|
345
1230
|
"fence_count": fence_count,
|
|
346
1231
|
"bare_opening_fences": bare,
|
|
347
1232
|
"table_drift": find_table_drift(text),
|
|
1233
|
+
"scripts_assets": scripts_assets_section(text, skill_md.parent),
|
|
348
1234
|
}
|
|
349
1235
|
|
|
350
1236
|
json.dump(payload, sys.stdout, indent=2)
|
|
@@ -352,13 +1238,332 @@ def _cmd_scan(args: argparse.Namespace) -> int:
|
|
|
352
1238
|
return 0
|
|
353
1239
|
|
|
354
1240
|
|
|
1241
|
+
class UserError(Exception):
|
|
1242
|
+
"""A caller-facing input error: exit code 1."""
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
def _load_skill_md(raw: str) -> tuple[Path, str]:
|
|
1246
|
+
skill_md = Path(raw)
|
|
1247
|
+
if not skill_md.is_file():
|
|
1248
|
+
raise UserError(f"file not found: {skill_md}")
|
|
1249
|
+
try:
|
|
1250
|
+
return skill_md, _read_markdown(skill_md)
|
|
1251
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
1252
|
+
raise UserError(f"cannot read {skill_md}: {exc}") from exc
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def _read_json(source: str, label: str):
|
|
1256
|
+
"""Parse JSON from a file path, or from stdin when `source` is '-'."""
|
|
1257
|
+
if source == "-":
|
|
1258
|
+
try:
|
|
1259
|
+
text = sys.stdin.read()
|
|
1260
|
+
except OSError as exc:
|
|
1261
|
+
raise UserError(
|
|
1262
|
+
f"failed to read {label} from stdin: {exc}"
|
|
1263
|
+
) from exc
|
|
1264
|
+
else:
|
|
1265
|
+
path = Path(source)
|
|
1266
|
+
if not path.is_file():
|
|
1267
|
+
raise UserError(f"{label} file not found: {path}")
|
|
1268
|
+
try:
|
|
1269
|
+
text = _read_text(path)
|
|
1270
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
1271
|
+
raise UserError(
|
|
1272
|
+
f"failed to read {label} file {path}: {exc}"
|
|
1273
|
+
) from exc
|
|
1274
|
+
try:
|
|
1275
|
+
return json.loads(text)
|
|
1276
|
+
except json.JSONDecodeError as exc:
|
|
1277
|
+
raise UserError(f"malformed JSON in {label} input: {exc}") from exc
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def parse_exports(payload) -> list[dict]:
|
|
1281
|
+
"""Normalize the --exports JSON into [{"name", "kind"}] dicts.
|
|
1282
|
+
|
|
1283
|
+
Accepts a list of names or {"name", "kind"} objects, an object with an
|
|
1284
|
+
`exports` list, or the validate-inventory.py result (`inventory`).
|
|
1285
|
+
"""
|
|
1286
|
+
items = payload
|
|
1287
|
+
if isinstance(payload, dict):
|
|
1288
|
+
if "exports" in payload:
|
|
1289
|
+
items = payload["exports"]
|
|
1290
|
+
elif isinstance(payload.get("inventory"), dict):
|
|
1291
|
+
items = payload["inventory"].get("exports")
|
|
1292
|
+
if not isinstance(items, list):
|
|
1293
|
+
raise UserError(
|
|
1294
|
+
"--exports must be a JSON array, an object with an `exports` "
|
|
1295
|
+
"array, or a validate-inventory.py result"
|
|
1296
|
+
)
|
|
1297
|
+
exports: list[dict] = []
|
|
1298
|
+
for idx, item in enumerate(items):
|
|
1299
|
+
if isinstance(item, str):
|
|
1300
|
+
name, kind = item, None
|
|
1301
|
+
elif isinstance(item, dict):
|
|
1302
|
+
name, kind = item.get("name"), item.get("kind")
|
|
1303
|
+
else:
|
|
1304
|
+
raise UserError(
|
|
1305
|
+
f"--exports[{idx}] must be a name or an object; got {item!r}"
|
|
1306
|
+
)
|
|
1307
|
+
# surrounding whitespace is never part of a name, and a stray space
|
|
1308
|
+
# would make every line miss
|
|
1309
|
+
if not isinstance(name, str) or not name.strip():
|
|
1310
|
+
raise UserError(f"--exports[{idx}] needs a non-empty `name`")
|
|
1311
|
+
if kind is not None and not isinstance(kind, str):
|
|
1312
|
+
raise UserError(f"--exports[{idx}] `kind` must be a string")
|
|
1313
|
+
kind = (kind or "").strip() or None
|
|
1314
|
+
exports.append({"name": name.strip(), "kind": kind})
|
|
1315
|
+
return exports
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def parse_kinds(raw: str) -> set[str]:
|
|
1319
|
+
kinds = {k.strip() for k in raw.split(",") if k.strip()}
|
|
1320
|
+
if not kinds:
|
|
1321
|
+
raise UserError("--kinds needs at least one kind, e.g. function,method")
|
|
1322
|
+
return kinds
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
# The names an skf-enumerate-stack-skills.py inventory entry gives the
|
|
1326
|
+
# repository and the folder its skill was built from.
|
|
1327
|
+
SOURCE_ALIAS_KEYS = ("source_repo_basename", "source_root_basename")
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def _source_aliases(item: dict) -> list[str]:
|
|
1331
|
+
"""The entry's SOURCE_ALIAS_KEYS values that are non-empty strings."""
|
|
1332
|
+
found: list[str] = []
|
|
1333
|
+
for key in SOURCE_ALIAS_KEYS:
|
|
1334
|
+
value = item.get(key)
|
|
1335
|
+
if isinstance(value, str) and value.strip():
|
|
1336
|
+
found.append(value.strip())
|
|
1337
|
+
return found
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def parse_skill_entries(payload) -> list[dict]:
|
|
1341
|
+
"""Normalize the --skills JSON into [{"name", "file", "terms"}] dicts.
|
|
1342
|
+
|
|
1343
|
+
`file` is the SKILL.md path as given (`skill_md`, else `path` joined
|
|
1344
|
+
with SKILL.md); `terms` is the name plus its aliases, case-insensitive
|
|
1345
|
+
duplicates dropped. An entry's source basenames (SOURCE_ALIAS_KEYS)
|
|
1346
|
+
are aliases too, unless another entry has the same term as its name,
|
|
1347
|
+
an alias or a source basename: one word would then cite two skills.
|
|
1348
|
+
"""
|
|
1349
|
+
items = payload.get("skills") if isinstance(payload, dict) else payload
|
|
1350
|
+
if not isinstance(items, list):
|
|
1351
|
+
raise UserError(
|
|
1352
|
+
"--skills must be a JSON array of skill entries or an object "
|
|
1353
|
+
"with a `skills` array"
|
|
1354
|
+
)
|
|
1355
|
+
parsed: list[tuple[str, str, list[str], list[str]]] = []
|
|
1356
|
+
seen: set[str] = set()
|
|
1357
|
+
for idx, item in enumerate(items):
|
|
1358
|
+
if not isinstance(item, dict):
|
|
1359
|
+
raise UserError(f"--skills[{idx}] must be an object; got {item!r}")
|
|
1360
|
+
name = item.get("name")
|
|
1361
|
+
if not isinstance(name, str) or not name.strip():
|
|
1362
|
+
raise UserError(f"--skills[{idx}] needs a non-empty `name`")
|
|
1363
|
+
name = name.strip()
|
|
1364
|
+
if name in seen:
|
|
1365
|
+
raise UserError(f"--skills names `{name}` twice")
|
|
1366
|
+
seen.add(name)
|
|
1367
|
+
skill_md, folder = item.get("skill_md"), item.get("path")
|
|
1368
|
+
if isinstance(skill_md, str) and skill_md:
|
|
1369
|
+
file = skill_md
|
|
1370
|
+
elif isinstance(folder, str) and folder:
|
|
1371
|
+
file = os.path.join(folder, "SKILL.md")
|
|
1372
|
+
else:
|
|
1373
|
+
raise UserError(
|
|
1374
|
+
f"--skills[{idx}] ({name}) needs `skill_md` or `path`"
|
|
1375
|
+
)
|
|
1376
|
+
aliases = item.get("aliases") or []
|
|
1377
|
+
if not isinstance(aliases, list) or not all(
|
|
1378
|
+
isinstance(a, str) for a in aliases
|
|
1379
|
+
):
|
|
1380
|
+
raise UserError(
|
|
1381
|
+
f"--skills[{idx}] ({name}) `aliases` must be strings"
|
|
1382
|
+
)
|
|
1383
|
+
parsed.append((name, file, aliases, _source_aliases(item)))
|
|
1384
|
+
|
|
1385
|
+
# How many entries hold each term, case-insensitively.
|
|
1386
|
+
owners: dict[str, int] = {}
|
|
1387
|
+
for name, _file, aliases, source in parsed:
|
|
1388
|
+
held = {t.strip().lower() for t in (name, *aliases, *source) if t.strip()}
|
|
1389
|
+
for term in held:
|
|
1390
|
+
owners[term] = owners.get(term, 0) + 1
|
|
1391
|
+
|
|
1392
|
+
entries: list[dict] = []
|
|
1393
|
+
for name, file, aliases, source in parsed:
|
|
1394
|
+
unique = [term for term in source if owners[term.lower()] == 1]
|
|
1395
|
+
terms: dict[str, str] = {}
|
|
1396
|
+
for term in (name, *aliases, *unique):
|
|
1397
|
+
term = term.strip()
|
|
1398
|
+
if term:
|
|
1399
|
+
terms.setdefault(term.lower(), term)
|
|
1400
|
+
entries.append(
|
|
1401
|
+
{"name": name, "file": file, "terms": list(terms.values())}
|
|
1402
|
+
)
|
|
1403
|
+
return entries
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
def parse_pairs(payload) -> list[tuple[str, str]]:
|
|
1407
|
+
"""Normalize the --pairs JSON into unique (a, b) tuples."""
|
|
1408
|
+
items = payload.get("pairs") if isinstance(payload, dict) else payload
|
|
1409
|
+
if not isinstance(items, list):
|
|
1410
|
+
raise UserError(
|
|
1411
|
+
"--pairs must be a JSON array of pairs or an object with a "
|
|
1412
|
+
"`pairs` array"
|
|
1413
|
+
)
|
|
1414
|
+
pairs: list[tuple[str, str]] = []
|
|
1415
|
+
seen: set[frozenset[str]] = set()
|
|
1416
|
+
for idx, item in enumerate(items):
|
|
1417
|
+
if isinstance(item, list) and len(item) == 2:
|
|
1418
|
+
a, b = item
|
|
1419
|
+
elif isinstance(item, dict):
|
|
1420
|
+
a, b = item.get("library_a"), item.get("library_b")
|
|
1421
|
+
else:
|
|
1422
|
+
raise UserError(
|
|
1423
|
+
f"--pairs[{idx}] must be [a, b] or "
|
|
1424
|
+
f"{{\"library_a\", \"library_b\"}}; got {item!r}"
|
|
1425
|
+
)
|
|
1426
|
+
if not (isinstance(a, str) and a and isinstance(b, str) and b):
|
|
1427
|
+
raise UserError(f"--pairs[{idx}] needs two non-empty skill names")
|
|
1428
|
+
if a == b:
|
|
1429
|
+
raise UserError(f"--pairs[{idx}] pairs `{a}` with itself")
|
|
1430
|
+
key = frozenset((a, b))
|
|
1431
|
+
if key not in seen:
|
|
1432
|
+
seen.add(key)
|
|
1433
|
+
pairs.append((a, b))
|
|
1434
|
+
return pairs
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
def _reference_paths(skill_dir: Path) -> list[Path]:
|
|
1438
|
+
"""Every `references/**/*.md` under the skill folder, in path order."""
|
|
1439
|
+
ref_dir = skill_dir / "references"
|
|
1440
|
+
if not ref_dir.is_dir():
|
|
1441
|
+
return []
|
|
1442
|
+
paths = [
|
|
1443
|
+
Path(root) / name
|
|
1444
|
+
for root, _dirs, files in os.walk(ref_dir)
|
|
1445
|
+
for name in files
|
|
1446
|
+
if name.lower().endswith(".md")
|
|
1447
|
+
]
|
|
1448
|
+
return sorted(paths, key=lambda p: p.relative_to(skill_dir).as_posix())
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def _read_reference_files(
|
|
1452
|
+
skill_dir: Path, paths: list[Path]
|
|
1453
|
+
) -> tuple[list[tuple[str, str]], list[str]]:
|
|
1454
|
+
"""(relative path, text) of each reference file, in the order given.
|
|
1455
|
+
|
|
1456
|
+
A file that cannot be read becomes a warning; undecodable bytes are
|
|
1457
|
+
replaced, so one bad byte never hides a whole file from the count.
|
|
1458
|
+
"""
|
|
1459
|
+
texts: list[tuple[str, str]] = []
|
|
1460
|
+
warnings: list[str] = []
|
|
1461
|
+
for path in paths:
|
|
1462
|
+
rel = path.relative_to(skill_dir).as_posix()
|
|
1463
|
+
try:
|
|
1464
|
+
texts.append((rel, _read_markdown(path, errors="replace")))
|
|
1465
|
+
except OSError as exc:
|
|
1466
|
+
warnings.append(f"{rel}: cannot read: {exc}")
|
|
1467
|
+
return texts, warnings
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
def _emit(payload: dict) -> int:
|
|
1471
|
+
json.dump(payload, sys.stdout, indent=2)
|
|
1472
|
+
sys.stdout.write("\n")
|
|
1473
|
+
return 0
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
def _cmd_usage_scope(args: argparse.Namespace) -> int:
|
|
1477
|
+
skill_md, text = _load_skill_md(args.skill_md)
|
|
1478
|
+
exports = parse_exports(_read_json(args.exports, "--exports"))
|
|
1479
|
+
kinds = parse_kinds(args.kinds) if args.kinds is not None else None
|
|
1480
|
+
paths = _reference_paths(skill_md.parent)
|
|
1481
|
+
body = args.body
|
|
1482
|
+
if body == "auto":
|
|
1483
|
+
# a reference file that cannot be read still makes the body split
|
|
1484
|
+
body = "split" if paths else "single"
|
|
1485
|
+
references: list[tuple[str, str]] = []
|
|
1486
|
+
warnings: list[str] = []
|
|
1487
|
+
if body == "split":
|
|
1488
|
+
references, warnings = _read_reference_files(skill_md.parent, paths)
|
|
1489
|
+
payload = usage_scope(
|
|
1490
|
+
text,
|
|
1491
|
+
references,
|
|
1492
|
+
exports,
|
|
1493
|
+
kinds=kinds,
|
|
1494
|
+
skill_label=skill_md.name,
|
|
1495
|
+
body=body,
|
|
1496
|
+
)
|
|
1497
|
+
payload["warnings"] = warnings
|
|
1498
|
+
return _emit(payload)
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
def _cmd_cross_reference(args: argparse.Namespace) -> int:
|
|
1502
|
+
if args.skills == "-" and args.pairs == "-":
|
|
1503
|
+
raise UserError(
|
|
1504
|
+
"--skills and --pairs cannot both read from stdin ('-')"
|
|
1505
|
+
)
|
|
1506
|
+
# a wrong root would turn every skill into a not-found warning and
|
|
1507
|
+
# leave nothing to cite, which reads like a clean result
|
|
1508
|
+
if args.skills_root is not None and not Path(args.skills_root).is_dir():
|
|
1509
|
+
raise UserError(
|
|
1510
|
+
f"--skills-root is not a directory: {args.skills_root}"
|
|
1511
|
+
)
|
|
1512
|
+
entries = parse_skill_entries(_read_json(args.skills, "--skills"))
|
|
1513
|
+
pairs = None
|
|
1514
|
+
if args.pairs is not None:
|
|
1515
|
+
pairs = parse_pairs(_read_json(args.pairs, "--pairs"))
|
|
1516
|
+
|
|
1517
|
+
skills: list[dict] = []
|
|
1518
|
+
warnings: list[str] = []
|
|
1519
|
+
for entry in entries:
|
|
1520
|
+
skill_md = Path(entry["file"])
|
|
1521
|
+
if args.skills_root and not skill_md.is_absolute():
|
|
1522
|
+
skill_md = Path(args.skills_root) / skill_md
|
|
1523
|
+
text = None
|
|
1524
|
+
if not skill_md.is_file():
|
|
1525
|
+
warnings.append(f"{entry['name']}: SKILL.md not found: {skill_md}")
|
|
1526
|
+
else:
|
|
1527
|
+
try:
|
|
1528
|
+
text = _read_markdown(skill_md)
|
|
1529
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
1530
|
+
warnings.append(
|
|
1531
|
+
f"{entry['name']}: cannot read {skill_md}: {exc}"
|
|
1532
|
+
)
|
|
1533
|
+
skills.append(
|
|
1534
|
+
{"name": entry["name"], "terms": entry["terms"], "text": text}
|
|
1535
|
+
)
|
|
1536
|
+
|
|
1537
|
+
payload = cross_references(skills, pairs)
|
|
1538
|
+
payload["warnings"] = warnings + payload["warnings"]
|
|
1539
|
+
return _emit(payload)
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
def _cmd_reference_check(args: argparse.Namespace) -> int:
|
|
1543
|
+
skill_md, text = _load_skill_md(args.skill_md)
|
|
1544
|
+
skill_dir = skill_md.parent
|
|
1545
|
+
package_folders = frozenset(
|
|
1546
|
+
folder for folder in _PACKAGE_FOLDERS if (skill_dir / folder).is_dir()
|
|
1547
|
+
)
|
|
1548
|
+
refs = extract_references(text, package_folders)
|
|
1549
|
+
payload = check_references(
|
|
1550
|
+
refs, str(skill_dir), args.source_root, args.skills_root
|
|
1551
|
+
)
|
|
1552
|
+
warnings = payload.pop("warnings")
|
|
1553
|
+
payload["scripts_assets"] = scripts_assets_section(text, skill_dir)
|
|
1554
|
+
payload["warnings"] = warnings
|
|
1555
|
+
return _emit(payload)
|
|
1556
|
+
|
|
1557
|
+
|
|
355
1558
|
def _build_parser() -> argparse.ArgumentParser:
|
|
356
1559
|
parser = argparse.ArgumentParser(
|
|
357
1560
|
prog="skf-scan-skill-md-structure",
|
|
358
1561
|
description=(
|
|
359
1562
|
"Deterministic structural scans for SKILL.md: fence balance, "
|
|
360
|
-
"bare opening fences, table column drift,
|
|
361
|
-
"presence (case-insensitive synonym match)
|
|
1563
|
+
"bare opening fences, table column drift, required-section "
|
|
1564
|
+
"presence (case-insensitive synonym match), export usage counts "
|
|
1565
|
+
"over the usage sections, literal citations between skills, "
|
|
1566
|
+
"and file, script and asset reference checks."
|
|
362
1567
|
),
|
|
363
1568
|
)
|
|
364
1569
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
@@ -372,13 +1577,112 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
372
1577
|
)
|
|
373
1578
|
p_scan.set_defaults(func=_cmd_scan)
|
|
374
1579
|
|
|
1580
|
+
p_usage = sub.add_parser(
|
|
1581
|
+
"usage-scope",
|
|
1582
|
+
help=(
|
|
1583
|
+
"count the lines that use each export in the usage sections "
|
|
1584
|
+
"(and references/**/*.md for a split-body skill)"
|
|
1585
|
+
),
|
|
1586
|
+
)
|
|
1587
|
+
p_usage.add_argument("skill_md", help="path to a SKILL.md file")
|
|
1588
|
+
p_usage.add_argument(
|
|
1589
|
+
"--exports",
|
|
1590
|
+
required=True,
|
|
1591
|
+
help=(
|
|
1592
|
+
"path to the exports JSON, or '-' for stdin. Shape: "
|
|
1593
|
+
'["<name>", ...], [{"name": "...", "kind": "..."}, ...], '
|
|
1594
|
+
'{"exports": [...]} or {"inventory": {"exports": [...]}}'
|
|
1595
|
+
),
|
|
1596
|
+
)
|
|
1597
|
+
p_usage.add_argument(
|
|
1598
|
+
"--kinds",
|
|
1599
|
+
help=(
|
|
1600
|
+
"comma-separated kinds to count, e.g. function,method; an "
|
|
1601
|
+
"export given without a kind is always counted"
|
|
1602
|
+
),
|
|
1603
|
+
)
|
|
1604
|
+
p_usage.add_argument(
|
|
1605
|
+
"--body",
|
|
1606
|
+
choices=("auto", *_BODIES),
|
|
1607
|
+
default="auto",
|
|
1608
|
+
help=(
|
|
1609
|
+
"single: the first usage-synonym section; split: every "
|
|
1610
|
+
"usage-family section plus references/**/*.md; auto (the "
|
|
1611
|
+
"default): split when the package has a references/**/*.md "
|
|
1612
|
+
"file, else single"
|
|
1613
|
+
),
|
|
1614
|
+
)
|
|
1615
|
+
p_usage.set_defaults(func=_cmd_usage_scope)
|
|
1616
|
+
|
|
1617
|
+
p_xref = sub.add_parser(
|
|
1618
|
+
"cross-reference",
|
|
1619
|
+
help=(
|
|
1620
|
+
"list the literal citations of each skill's name or aliases "
|
|
1621
|
+
"in the other skills' SKILL.md"
|
|
1622
|
+
),
|
|
1623
|
+
)
|
|
1624
|
+
p_xref.add_argument(
|
|
1625
|
+
"--skills",
|
|
1626
|
+
required=True,
|
|
1627
|
+
help=(
|
|
1628
|
+
"path to the skills JSON, or '-' for stdin. Shape: "
|
|
1629
|
+
'[{"name": "...", "skill_md" | "path": "...", "aliases": [...]}] '
|
|
1630
|
+
'or {"skills": [...]} (the skf-enumerate-stack-skills.py output, '
|
|
1631
|
+
"whose source basenames a single skill holds count as aliases)"
|
|
1632
|
+
),
|
|
1633
|
+
)
|
|
1634
|
+
p_xref.add_argument(
|
|
1635
|
+
"--skills-root",
|
|
1636
|
+
help=(
|
|
1637
|
+
"folder that relative `path` and `skill_md` entries resolve "
|
|
1638
|
+
"against"
|
|
1639
|
+
),
|
|
1640
|
+
)
|
|
1641
|
+
p_xref.add_argument(
|
|
1642
|
+
"--pairs",
|
|
1643
|
+
help=(
|
|
1644
|
+
"path to a pairs JSON, or '-' for stdin, that limits the "
|
|
1645
|
+
'citations to those pairs. Shape: [["<a>", "<b>"], ...], '
|
|
1646
|
+
'[{"library_a": "...", "library_b": "..."}] or {"pairs": [...]}'
|
|
1647
|
+
),
|
|
1648
|
+
)
|
|
1649
|
+
p_xref.set_defaults(func=_cmd_cross_reference)
|
|
1650
|
+
|
|
1651
|
+
p_refs = sub.add_parser(
|
|
1652
|
+
"reference-check",
|
|
1653
|
+
help=(
|
|
1654
|
+
"check that each file, script and asset reference exists and "
|
|
1655
|
+
"resolves inside the allowed roots"
|
|
1656
|
+
),
|
|
1657
|
+
)
|
|
1658
|
+
p_refs.add_argument("skill_md", help="path to a SKILL.md file")
|
|
1659
|
+
p_refs.add_argument(
|
|
1660
|
+
"--source-root",
|
|
1661
|
+
help=(
|
|
1662
|
+
"the skill's extraction tree (metadata.json source_root), a "
|
|
1663
|
+
"second allowed root"
|
|
1664
|
+
),
|
|
1665
|
+
)
|
|
1666
|
+
p_refs.add_argument(
|
|
1667
|
+
"--skills-root",
|
|
1668
|
+
help=(
|
|
1669
|
+
"the skills output folder, an allowed root for a stack skill "
|
|
1670
|
+
"whose references reach its constituent skills"
|
|
1671
|
+
),
|
|
1672
|
+
)
|
|
1673
|
+
p_refs.set_defaults(func=_cmd_reference_check)
|
|
1674
|
+
|
|
375
1675
|
return parser
|
|
376
1676
|
|
|
377
1677
|
|
|
378
1678
|
def main(argv: list[str] | None = None) -> int:
|
|
379
1679
|
parser = _build_parser()
|
|
380
1680
|
args = parser.parse_args(argv)
|
|
381
|
-
|
|
1681
|
+
try:
|
|
1682
|
+
return args.func(args)
|
|
1683
|
+
except UserError as exc:
|
|
1684
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
1685
|
+
return 1
|
|
382
1686
|
|
|
383
1687
|
|
|
384
1688
|
if __name__ == "__main__":
|