bmad-module-skill-forge 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +51 -49
- package/docs/404.md +10 -8
- package/docs/_data/pinned.yaml +22 -13
- package/docs/_internal/STABILITY.md +58 -35
- package/docs/agents.md +39 -53
- package/docs/architecture.md +65 -51
- package/docs/bmad-synergy.md +53 -51
- package/docs/campaign.md +127 -75
- package/docs/concepts.md +34 -28
- package/docs/examples.md +70 -68
- package/docs/forge-auto.md +34 -31
- package/docs/getting-started.md +118 -57
- package/docs/how-it-works.md +25 -21
- package/docs/index.md +15 -15
- package/docs/skill-model.md +70 -59
- package/docs/troubleshooting.md +135 -18
- package/docs/verifying-a-skill.md +83 -57
- package/docs/why-skf.md +24 -22
- package/docs/workflows.md +274 -110
- package/package.json +18 -10
- package/src/forger/preferences.yaml +8 -1
- package/src/knowledge/agentskills-spec.md +2 -0
- package/src/knowledge/ccc-bridge.md +40 -24
- package/src/knowledge/confidence-tiers.md +9 -5
- package/src/knowledge/overview.md +3 -3
- package/src/knowledge/progressive-capability.md +8 -8
- package/src/knowledge/provenance-tracking.md +9 -5
- package/src/knowledge/qmd-registry.md +2 -2
- package/src/knowledge/skf-knowledge-index.csv +2 -2
- package/src/knowledge/skill-lifecycle.md +18 -11
- package/src/knowledge/split-body-strategy.md +0 -3
- package/src/knowledge/tool-resolution.md +4 -2
- package/src/knowledge/version-paths.md +101 -15
- package/src/module-help.csv +3 -3
- package/src/module.yaml +10 -0
- package/src/shared/data/ast-grep-recipes.yaml +608 -0
- package/src/shared/data/ide-context-files.json +30 -0
- package/src/shared/health-check.md +32 -12
- package/src/shared/references/description-guard-protocol.md +14 -9
- package/src/shared/references/feasibility-report-schema.md +19 -13
- package/src/shared/references/headless-gate-convention.md +55 -3
- package/src/shared/references/output-contract-schema.md +19 -8
- package/src/shared/references/pipeline-contracts.md +94 -29
- package/src/shared/references/tessl-review.md +77 -0
- package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
- package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
- package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
- package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
- package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
- package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
- package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
- package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
- package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
- package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
- package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
- package/src/shared/scripts/skf-atomic-write.py +30 -14
- package/src/shared/scripts/skf-build-change-manifest.py +2203 -24
- package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
- package/src/shared/scripts/skf-check-unit-records.py +184 -0
- package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
- package/src/shared/scripts/skf-classify-changed-files.py +694 -0
- package/src/shared/scripts/skf-comention-pairs.py +1333 -123
- package/src/shared/scripts/skf-compare-file-hashes.py +1 -1
- package/src/shared/scripts/skf-count-imports.py +1867 -0
- package/src/shared/scripts/skf-count-tokens.py +1 -1
- package/src/shared/scripts/skf-derive-assembly-shape.py +3 -2
- package/src/shared/scripts/skf-description-guard.py +117 -9
- package/src/shared/scripts/skf-detect-docs.py +892 -4
- package/src/shared/scripts/skf-detect-language.py +486 -107
- package/src/shared/scripts/skf-detect-registry.py +887 -0
- package/src/shared/scripts/skf-detect-scripts-assets.py +1 -1
- package/src/shared/scripts/skf-detect-tools.py +215 -27
- package/src/shared/scripts/skf-detect-workspaces.py +506 -30
- package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
- package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
- package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
- package/src/shared/scripts/skf-extract-public-api.py +4084 -85
- package/src/shared/scripts/skf-extraction-inventory.py +746 -0
- package/src/shared/scripts/skf-extraction-snapshot.py +539 -0
- package/src/shared/scripts/skf-fetch-temporal.py +674 -0
- package/src/shared/scripts/skf-find-cycles.py +97 -29
- package/src/shared/scripts/skf-find-test-report.py +476 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +444 -203
- package/src/shared/scripts/skf-github-fetch.py +365 -0
- package/src/shared/scripts/skf-github-probe.py +661 -0
- package/src/shared/scripts/skf-hash-content.py +166 -12
- package/src/shared/scripts/skf-language-corpora.py +1 -1
- package/src/shared/scripts/skf-load-provenance.py +159 -27
- package/src/shared/scripts/skf-manifest-ops.py +1 -1
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
- package/src/shared/scripts/skf-merge-doc-urls.py +1 -1
- package/src/shared/scripts/skf-names-present.py +221 -0
- package/src/shared/scripts/skf-pair-intersect.py +119 -28
- package/src/shared/scripts/skf-parse-gaps.py +1384 -0
- package/src/shared/scripts/skf-preapply.py +1 -1
- package/src/shared/scripts/skf-preflight.py +161 -27
- package/src/shared/scripts/skf-promote-staged.py +430 -0
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +39 -7
- package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
- package/src/shared/scripts/skf-quick-batch.py +774 -0
- package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
- package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
- package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
- package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
- package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
- package/src/shared/scripts/skf-resolve-authoritative-files.py +128 -13
- package/src/shared/scripts/skf-resolve-package.py +523 -106
- package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
- package/src/shared/scripts/skf-run-lock.py +577 -0
- package/src/shared/scripts/skf-scan-manifests.py +998 -57
- package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
- package/src/shared/scripts/skf-severity-classify.py +712 -108
- package/src/shared/scripts/skf-shape-detect.py +298 -204
- package/src/shared/scripts/skf-shard-body.py +9 -8
- package/src/shared/scripts/skf-skill-inventory.py +2412 -89
- package/src/shared/scripts/skf-skills-module.py +472 -0
- package/src/shared/scripts/skf-source-tree.py +1796 -0
- package/src/shared/scripts/skf-structural-diff.py +670 -119
- package/src/shared/scripts/skf-tessl-review.py +706 -0
- package/src/shared/scripts/skf-update-active-symlink.py +5 -5
- package/src/shared/scripts/skf-update-run-state.py +624 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
- package/src/shared/scripts/skf-validate-brief-schema.py +4 -1
- package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
- package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
- package/src/shared/scripts/skf-validate-output.py +712 -64
- package/src/shared/scripts/skf-validate-pins.py +1 -1
- package/src/shared/scripts/skf-verify-no-trace.py +116 -10
- package/src/shared/scripts/skf-verify-provenance-completeness.py +2919 -54
- package/src/shared/scripts/skf-write-skill-brief.py +367 -19
- package/src/shared/tool-requirements.yaml +201 -0
- package/src/skf-analyze-source/SKILL.md +41 -36
- package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
- package/src/skf-analyze-source/customize.toml +36 -24
- package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
- package/src/skf-analyze-source/references/continue.md +2 -13
- package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
- package/src/skf-analyze-source/references/generate-briefs.md +121 -55
- package/src/skf-analyze-source/references/headless-contract.md +42 -15
- package/src/skf-analyze-source/references/identify-units.md +96 -110
- package/src/skf-analyze-source/references/init.md +73 -71
- package/src/skf-analyze-source/references/map-and-detect.md +123 -105
- package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
- package/src/skf-analyze-source/references/recommend.md +51 -70
- package/src/skf-analyze-source/references/scan-project.md +60 -84
- package/src/skf-analyze-source/references/scan-root.md +39 -0
- package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
- package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
- package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
- package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
- package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
- package/src/skf-audit-skill/SKILL.md +43 -51
- package/src/skf-audit-skill/assets/drift-report-template.md +27 -3
- package/src/skf-audit-skill/customize.toml +41 -20
- package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
- package/src/skf-audit-skill/references/doc-drift.md +141 -0
- package/src/skf-audit-skill/references/headless-contract.md +29 -0
- package/src/skf-audit-skill/references/init.md +148 -210
- package/src/skf-audit-skill/references/re-index.md +96 -108
- package/src/skf-audit-skill/references/report.md +96 -33
- package/src/skf-audit-skill/references/semantic-diff.md +27 -18
- package/src/skf-audit-skill/references/severity-classify.md +79 -65
- package/src/skf-audit-skill/references/severity-rules.md +15 -6
- package/src/skf-audit-skill/references/structural-diff.md +75 -90
- package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
- package/src/skf-audit-skill/scripts/render-drift-tables.py +685 -0
- package/src/skf-brief-skill/SKILL.md +36 -25
- package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +17 -24
- package/src/skf-brief-skill/assets/skill-brief-schema.md +29 -15
- package/src/skf-brief-skill/customize.toml +48 -20
- package/src/skf-brief-skill/references/analyze-target.md +142 -125
- package/src/skf-brief-skill/references/confirm-brief.md +10 -8
- package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
- package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
- package/src/skf-brief-skill/references/gather-intent.md +87 -182
- package/src/skf-brief-skill/references/headless-args.md +84 -16
- package/src/skf-brief-skill/references/health-check.md +1 -1
- package/src/skf-brief-skill/references/invocation-contract.md +18 -13
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
- package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
- package/src/skf-brief-skill/references/scope-definition.md +123 -47
- package/src/skf-brief-skill/references/step-auto-brief.md +24 -80
- package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
- package/src/skf-brief-skill/references/version-resolution.md +3 -2
- package/src/skf-brief-skill/references/write-brief.md +66 -57
- package/src/skf-campaign/SKILL.md +31 -34
- package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
- package/src/skf-campaign/customize.toml +47 -16
- package/src/skf-campaign/references/campaign-contracts.md +39 -29
- package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
- package/src/skf-campaign/references/health-check.md +7 -16
- package/src/skf-campaign/references/step-01-setup.md +46 -84
- package/src/skf-campaign/references/step-02-strategy.md +31 -30
- package/src/skf-campaign/references/step-03-pins.md +26 -18
- package/src/skf-campaign/references/step-04-provenance.md +24 -23
- package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
- package/src/skf-campaign/references/step-06-batch.md +41 -34
- package/src/skf-campaign/references/step-07-capstone.md +40 -25
- package/src/skf-campaign/references/step-08-verify.md +28 -29
- package/src/skf-campaign/references/step-09-refine.md +29 -30
- package/src/skf-campaign/references/step-10-export.md +48 -36
- package/src/skf-campaign/references/step-11-maintenance.md +31 -34
- package/src/skf-campaign/references/step-resume.md +37 -68
- package/src/skf-campaign/scripts/campaign-deps.py +57 -18
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
- package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
- package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
- package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
- package/src/skf-campaign/scripts/campaign-report.py +204 -37
- package/src/skf-campaign/scripts/campaign-state.py +1128 -0
- package/src/skf-campaign/scripts/campaign-status.py +10 -74
- package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
- package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
- package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
- package/src/skf-campaign/templates/campaign-report-template.md +5 -1
- package/src/skf-campaign/templates/kickoff-template.md +0 -14
- package/src/skf-create-skill/SKILL.md +27 -20
- package/src/skf-create-skill/assets/compile-assembly-rules.md +6 -5
- package/src/skf-create-skill/assets/skill-sections.md +17 -6
- package/src/skf-create-skill/customize.toml +20 -8
- package/src/skf-create-skill/references/authoritative-files-protocol.md +41 -42
- package/src/skf-create-skill/references/batch-mode.md +81 -0
- package/src/skf-create-skill/references/ccc-index-check.md +27 -0
- package/src/skf-create-skill/references/compile.md +107 -29
- package/src/skf-create-skill/references/component-extraction.md +113 -91
- package/src/skf-create-skill/references/enrich.md +41 -23
- package/src/skf-create-skill/references/entry-points-by-hand.md +20 -0
- package/src/skf-create-skill/references/extract.md +200 -161
- package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +2 -2
- package/src/skf-create-skill/references/extraction-patterns.md +584 -196
- package/src/skf-create-skill/references/generate-artifacts.md +145 -93
- package/src/skf-create-skill/references/health-check.md +1 -1
- package/src/skf-create-skill/references/load-brief.md +53 -36
- package/src/skf-create-skill/references/relabel-rule.md +12 -0
- package/src/skf-create-skill/references/report.md +28 -59
- package/src/skf-create-skill/references/source-resolution-protocols.md +49 -155
- package/src/skf-create-skill/references/step-auto-shard.md +20 -93
- package/src/skf-create-skill/references/step-doc-rot.md +44 -60
- package/src/skf-create-skill/references/step-doc-sources.md +55 -18
- package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
- package/src/skf-create-skill/references/sub/fetch-docs.md +106 -57
- package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
- package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
- package/src/skf-create-skill/references/validate.md +173 -99
- package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
- package/src/skf-create-stack-skill/SKILL.md +29 -56
- package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
- package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
- package/src/skf-create-stack-skill/customize.toml +23 -12
- package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
- package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
- package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
- package/src/skf-create-stack-skill/references/generate-output.md +138 -128
- package/src/skf-create-stack-skill/references/init.md +90 -32
- package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
- package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
- package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
- package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
- package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
- package/src/skf-create-stack-skill/references/report.md +39 -33
- package/src/skf-create-stack-skill/references/validate.md +20 -39
- package/src/skf-drop-skill/SKILL.md +28 -51
- package/src/skf-drop-skill/customize.toml +44 -30
- package/src/skf-drop-skill/references/execute.md +79 -146
- package/src/skf-drop-skill/references/invocation-contract.md +64 -0
- package/src/skf-drop-skill/references/report.md +46 -30
- package/src/skf-drop-skill/references/select.md +118 -124
- package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
- package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
- package/src/skf-export-skill/SKILL.md +40 -62
- package/src/skf-export-skill/assets/managed-section-format.md +14 -50
- package/src/skf-export-skill/assets/snippet-format.md +3 -1
- package/src/skf-export-skill/customize.toml +42 -13
- package/src/skf-export-skill/references/generate-snippet.md +54 -46
- package/src/skf-export-skill/references/health-check.md +2 -0
- package/src/skf-export-skill/references/invocation-contract.md +15 -0
- package/src/skf-export-skill/references/load-skill.md +88 -62
- package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
- package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
- package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
- package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
- package/src/skf-export-skill/references/package.md +15 -73
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
- package/src/skf-export-skill/references/result-envelope.md +31 -16
- package/src/skf-export-skill/references/summary.md +41 -17
- package/src/skf-export-skill/references/token-report.md +4 -12
- package/src/skf-export-skill/references/update-context.md +118 -237
- package/src/skf-forger/SKILL.md +61 -37
- package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
- package/src/skf-forger/customize.toml +43 -0
- package/src/skf-forger/references/pipeline-mode.md +73 -18
- package/src/skf-forger/scripts/forge-status.py +299 -0
- package/src/skf-forger/scripts/parse-pipeline.py +322 -54
- package/src/skf-forger/scripts/pipeline-gate.py +428 -0
- package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
- package/src/skf-quick-skill/SKILL.md +54 -53
- package/src/skf-quick-skill/assets/skill-template.md +4 -2
- package/src/skf-quick-skill/customize.toml +51 -20
- package/src/skf-quick-skill/references/batch-mode.md +74 -21
- package/src/skf-quick-skill/references/compile.md +30 -31
- package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
- package/src/skf-quick-skill/references/finalize.md +69 -18
- package/src/skf-quick-skill/references/halt-contract.md +41 -29
- package/src/skf-quick-skill/references/health-check.md +5 -3
- package/src/skf-quick-skill/references/quick-extract.md +114 -45
- package/src/skf-quick-skill/references/registry-resolution.md +9 -64
- package/src/skf-quick-skill/references/resolve-target.md +116 -66
- package/src/skf-quick-skill/references/write-and-validate.md +79 -40
- package/src/skf-refine-architecture/SKILL.md +43 -53
- package/src/skf-refine-architecture/customize.toml +49 -20
- package/src/skf-refine-architecture/references/compile.md +97 -54
- package/src/skf-refine-architecture/references/exit-codes.md +30 -0
- package/src/skf-refine-architecture/references/finding-storage.md +21 -0
- package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
- package/src/skf-refine-architecture/references/improvements.md +14 -20
- package/src/skf-refine-architecture/references/init.md +98 -57
- package/src/skf-refine-architecture/references/issue-detection.md +32 -39
- package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
- package/src/skf-refine-architecture/references/report.md +65 -54
- package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
- package/src/skf-rename-skill/SKILL.md +19 -54
- package/src/skf-rename-skill/customize.toml +32 -26
- package/src/skf-rename-skill/references/execute.md +230 -184
- package/src/skf-rename-skill/references/exit-codes.md +7 -7
- package/src/skf-rename-skill/references/health-check.md +1 -7
- package/src/skf-rename-skill/references/invocation-contract.md +25 -0
- package/src/skf-rename-skill/references/report.md +63 -52
- package/src/skf-rename-skill/references/select.md +197 -115
- package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
- package/src/skf-setup/SKILL.md +26 -26
- package/src/skf-setup/customize.toml +14 -10
- package/src/skf-setup/references/auto-index.md +50 -63
- package/src/skf-setup/references/ccc-index.md +29 -89
- package/src/skf-setup/references/detect-and-tier.md +58 -66
- package/src/skf-setup/references/health-check.md +2 -0
- package/src/skf-setup/references/invocation-contract.md +14 -0
- package/src/skf-setup/references/report.md +43 -166
- package/src/skf-setup/references/tier-rules.md +5 -5
- package/src/skf-setup/references/write-config.md +34 -57
- package/src/skf-test-skill/SKILL.md +17 -57
- package/src/skf-test-skill/assets/output-section-formats.md +33 -34
- package/src/skf-test-skill/customize.toml +27 -13
- package/src/skf-test-skill/references/coherence-check.md +134 -62
- package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
- package/src/skf-test-skill/references/coverage-check.md +217 -254
- package/src/skf-test-skill/references/detect-mode.md +5 -5
- package/src/skf-test-skill/references/external-validators.md +94 -82
- package/src/skf-test-skill/references/health-check.md +6 -7
- package/src/skf-test-skill/references/init.md +163 -88
- package/src/skf-test-skill/references/invocation-contract.md +66 -0
- package/src/skf-test-skill/references/migration-section-rules.md +5 -2
- package/src/skf-test-skill/references/report.md +162 -142
- package/src/skf-test-skill/references/score.md +80 -107
- package/src/skf-test-skill/references/scoring-rules.md +41 -155
- package/src/skf-test-skill/references/source-access-protocol.md +19 -92
- package/src/skf-test-skill/references/step-hard-gate.md +51 -33
- package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
- package/src/skf-test-skill/scripts/build-result-context.py +287 -0
- package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
- package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
- package/src/skf-test-skill/scripts/compute-score.py +223 -45
- package/src/skf-test-skill/scripts/gap-ledger.py +1381 -0
- package/src/skf-test-skill/scripts/hard-gate.py +203 -0
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +760 -0
- package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
- package/src/skf-test-skill/scripts/reconcile-coverage.py +273 -79
- package/src/skf-test-skill/scripts/score-signatures.py +459 -0
- package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
- package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +127 -52
- package/src/skf-test-skill/templates/test-report-template.md +21 -15
- package/src/skf-update-skill/SKILL.md +38 -25
- package/src/skf-update-skill/customize.toml +27 -12
- package/src/skf-update-skill/references/detect-changes.md +233 -222
- package/src/skf-update-skill/references/gap-driven.md +336 -0
- package/src/skf-update-skill/references/health-check.md +31 -5
- package/src/skf-update-skill/references/init.md +250 -84
- package/src/skf-update-skill/references/invocation-contract.md +12 -0
- package/src/skf-update-skill/references/manual-section-rules.md +6 -23
- package/src/skf-update-skill/references/merge.md +180 -47
- package/src/skf-update-skill/references/re-extract.md +53 -262
- package/src/skf-update-skill/references/report.md +100 -35
- package/src/skf-update-skill/references/write.md +331 -149
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +29 -7
- package/src/skf-verify-stack/SKILL.md +23 -42
- package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
- package/src/skf-verify-stack/customize.toml +55 -22
- package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
- package/src/skf-verify-stack/references/coverage.md +67 -64
- package/src/skf-verify-stack/references/exit-codes.md +21 -8
- package/src/skf-verify-stack/references/init.md +107 -62
- package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
- package/src/skf-verify-stack/references/integrations.md +106 -65
- package/src/skf-verify-stack/references/report.md +59 -42
- package/src/skf-verify-stack/references/requirements.md +32 -9
- package/src/skf-verify-stack/references/synthesize.md +53 -39
- package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
- package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
- package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
- package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
- package/tools/cli/commands/install.js +11 -4
- package/tools/cli/commands/status.js +15 -3
- package/tools/cli/commands/update.js +11 -4
- package/tools/cli/lib/tool-check.js +320 -0
- package/tools/cli/lib/version-check.js +22 -9
- package/tools/cli/skf-cli.js +4 -1
- package/.gitattributes +0 -16
- package/.nvmrc +0 -1
- package/docs/_internal/RELEASING.md +0 -645
- package/release-audits/v1.0.0-launch-audit.md +0 -728
- package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
- package/src/skf-campaign/manifest.yaml +0 -15
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
- package/src/skf-create-skill/references/ecosystem-check.md +0 -74
- package/src/skf-drop-skill/references/headless-contract.md +0 -18
- package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
- package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
- package/src/skf-update-skill/references/validate.md +0 -124
- package/tools/validate-docs-drift.js +0 -233
- package/tools/validate-docs-links.js +0 -665
|
@@ -1,40 +1,101 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# /// script
|
|
3
|
-
# requires-python = ">=3.
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
4
|
# dependencies = []
|
|
5
5
|
# ///
|
|
6
|
-
"""SKF Co-mention Pairs
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
6
|
+
"""SKF Co-mention Pairs: which loaded skills a document names, and which
|
|
7
|
+
pairs of them it names together.
|
|
8
|
+
|
|
9
|
+
The script reports evidence and the calling step judges it: every pair it
|
|
10
|
+
emits is a candidate, never a confirmed integration. Its three subcommands
|
|
11
|
+
share one skills input, one matcher and one Markdown reader:
|
|
12
|
+
|
|
13
|
+
comention compose-mode co-mention pairs from an architecture document
|
|
14
|
+
(create-stack-skill detect-integrations.md §2, compose branch)
|
|
15
|
+
mentions the skills a document names and the pairs it names together
|
|
16
|
+
(refine-architecture gap-analysis.md scope sets and documented
|
|
17
|
+
pairs)
|
|
18
|
+
infer candidate pairs when there is no architecture document: one
|
|
19
|
+
skill's own docs naming the other, or shared domain keywords,
|
|
20
|
+
never a shared language alone
|
|
21
|
+
|
|
22
|
+
Skills input (--skills):
|
|
23
|
+
A JSON array. Each entry is a skill name (`"react"`) or an object:
|
|
24
|
+
{"name": "<skill>", required, non-empty
|
|
25
|
+
"aliases": ["<term>", ...], other names the skill goes by
|
|
26
|
+
"keywords": ["<kw>", ...], domain keywords (read by infer only)
|
|
27
|
+
"language": "<language>", the skill's language, an array of them
|
|
28
|
+
(a stack lists its libraries'
|
|
29
|
+
languages) or null (infer only)
|
|
30
|
+
"docs": ["<path>", ...]} the skill's own docs (infer only)
|
|
31
|
+
Other keys are ignored, so an inventory entry, such as a `skills[]` entry
|
|
32
|
+
of skf-enumerate-stack-skills.py, can be passed as it is. Entries with
|
|
33
|
+
the same name merge, languages included. Output names a skill by its
|
|
34
|
+
`name`, never an alias.
|
|
35
|
+
|
|
36
|
+
Matching:
|
|
37
|
+
A skill is named where one of its terms (its name and each alias)
|
|
38
|
+
appears, case-insensitively, with no word character right before or
|
|
39
|
+
after it: `react` never matches inside `reactive`. For a term that starts
|
|
40
|
+
and ends with a word character, as every skill name does, this is the
|
|
41
|
+
`\\b{term}\\b` rule; a term such as `C++` or `.NET` matches too. The
|
|
42
|
+
words of a multi-word alias may be split by any whitespace, a line break
|
|
43
|
+
included. comention matches each skill on its own, as it always has, so
|
|
44
|
+
`react` also matches the `react` in `react-dom`. mentions and infer read
|
|
45
|
+
each occurrence once, as the longest term that matches there: when the
|
|
46
|
+
skills include both `react` and `react-dom`, the text `react-dom` names
|
|
47
|
+
`react-dom` only. Two skills that share a term are both named by it.
|
|
48
|
+
|
|
49
|
+
Paragraphs, units and evidence kinds:
|
|
50
|
+
A body paragraph is a run of consecutive non-blank lines that are not
|
|
51
|
+
ATX headers (1-6 `#` then a space or tab); headers of any level end it,
|
|
52
|
+
and the nearest H1/H2 above it governs it. A paragraph splits into
|
|
53
|
+
units: a run of prose, one list item with its continuation lines, one
|
|
54
|
+
table row, or one line of fenced code. A list item or table row inside a
|
|
55
|
+
blockquote (`> - item`) is a unit the same way. Each evidence entry is
|
|
56
|
+
one body paragraph that names both skills of a pair, and its `kind` says
|
|
57
|
+
how:
|
|
58
|
+
co-mention one unit of the paragraph names both skills
|
|
59
|
+
lead-in no unit does, but a run of prose names one skill and a list
|
|
60
|
+
item, table row or code line under it (before the next run
|
|
61
|
+
of prose) names the other: "The express server mounts:"
|
|
62
|
+
then "- passport"
|
|
63
|
+
list-only neither: the two are named only in separate units, such as
|
|
64
|
+
separate list items, table rows or code lines
|
|
65
|
+
A "Tech Stack" table and a "Components" list that each name every
|
|
66
|
+
library give every pair of them two list-only paragraphs. A lead-in
|
|
67
|
+
counts only inside its paragraph: with a blank line before the list, the
|
|
68
|
+
lead-in and the items are two paragraphs. The units are a heuristic, not
|
|
69
|
+
a Markdown parser: consecutive "Label: value" lines with no list marker
|
|
70
|
+
are one run of prose, so they give co-mentions, and a line that starts
|
|
71
|
+
with a number and a period (`2024.`) right after a bullet item is a new
|
|
72
|
+
item, where CommonMark reads a continuation.
|
|
73
|
+
|
|
74
|
+
Each entry also shows where the paragraph names the pair: `unit_excerpt`
|
|
75
|
+
is the collapsed text of the first unit that names both (a co-mention),
|
|
76
|
+
or of the lead-in and the item (a lead-in, with `...` for any items
|
|
77
|
+
between them), cut to 240 characters around the two names; when the
|
|
78
|
+
words between the names do not fit, the middle is cut instead, so both
|
|
79
|
+
names stay. `...` marks every cut. `unit_line` is the line (1-based) of
|
|
80
|
+
the first of the two names. Both are null for a list-only entry, while
|
|
81
|
+
`excerpt` and `line` are always the paragraph's start and first line.
|
|
82
|
+
|
|
83
|
+
Subcommands:
|
|
33
84
|
comention --doc <md-file-or-'-'> --skills <json-file-or-'-'>
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
85
|
+
Emits every pair that one body paragraph or more names in one unit
|
|
86
|
+
or through a lead-in, by the matching above; heading text is never a
|
|
87
|
+
source. A pair only ever listed together is left out and counted, as
|
|
88
|
+
mentions does: that is a structural kind of evidence, and nothing is
|
|
89
|
+
filtered on meaning. Whether a passage describes an integration is the
|
|
90
|
+
calling step's judgment, from its excerpt, and two fields carry what
|
|
91
|
+
it weighs. `excluded_section` is true on an evidence entry whose
|
|
92
|
+
paragraph an H1/H2 header governs that normalises to one of
|
|
93
|
+
{introduction, overview, glossary, table of contents, references,
|
|
94
|
+
appendix, index}, sections that often only list the libraries;
|
|
95
|
+
`paragraph_count` counts the body paragraphs that name the pair,
|
|
96
|
+
one being enough. Fenced code is read as body text here, as this
|
|
97
|
+
mode always read it, so a diagram line names a pair like any other
|
|
98
|
+
line, and a `#` line inside a fence still counts as a header.
|
|
38
99
|
|
|
39
100
|
Emit JSON:
|
|
40
101
|
{
|
|
@@ -42,45 +103,198 @@ Subcommand:
|
|
|
42
103
|
{
|
|
43
104
|
"a": "<skill>", "b": "<skill>",
|
|
44
105
|
"paragraph_count": N,
|
|
106
|
+
"comention_count": C,
|
|
107
|
+
"lead_in_count": I,
|
|
45
108
|
"evidence": [{"header": "<governing-header-or-null>",
|
|
46
|
-
"excerpt": "<collapsed paragraph text>"
|
|
109
|
+
"excerpt": "<collapsed paragraph text>",
|
|
110
|
+
"line": L,
|
|
111
|
+
"kind": "co-mention|lead-in|list-only",
|
|
112
|
+
"unit_line": U-or-null,
|
|
113
|
+
"unit_excerpt": "<text-naming-both>-or-null",
|
|
114
|
+
"excluded_section": true|false},
|
|
115
|
+
...]
|
|
47
116
|
},
|
|
48
117
|
...
|
|
49
118
|
],
|
|
119
|
+
"list_only_pair_count": P,
|
|
50
120
|
"excluded_section_count": M
|
|
51
121
|
}
|
|
52
122
|
|
|
53
|
-
Only unordered pairs (a < b lexicographically)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
123
|
+
Only unordered pairs (a < b lexicographically) are emitted, each a
|
|
124
|
+
candidate with comention_count + lead_in_count >= 1 (its co-mention
|
|
125
|
+
and lead-in entries; a list-only entry from another paragraph stays
|
|
126
|
+
in its evidence). `list_only_pair_count` counts the pairs only ever
|
|
127
|
+
listed together, which are not emitted. `pairs[]` is sorted by
|
|
128
|
+
paragraph_count DESC, then (a, b) ASC, mirroring
|
|
129
|
+
skf-pair-intersect.py. `evidence[]` is in document order.
|
|
130
|
+
`excluded_section_count` is the number of DISTINCT such introductory
|
|
131
|
+
H1/H2 sections (by normalised header) that governed at least one
|
|
132
|
+
body paragraph: a diagnostic for the caller.
|
|
133
|
+
|
|
134
|
+
mentions --doc <md-file-or-'-'> --skills <json-file-or-'-'>
|
|
135
|
+
[--technologies <json-file-or-'-'>]
|
|
136
|
+
Fenced code blocks are skipped: their lines are neither paragraphs
|
|
137
|
+
nor headings. No section is excluded, and one paragraph is enough.
|
|
138
|
+
|
|
139
|
+
Emit JSON:
|
|
140
|
+
{
|
|
141
|
+
"skills": [
|
|
142
|
+
{"name": "<skill>",
|
|
143
|
+
"paragraph_count": N, "heading_count": H, "fenced_count": F,
|
|
144
|
+
"terms": [{"term": "<the skill's term>",
|
|
145
|
+
"kind": "name|alias",
|
|
146
|
+
"paragraph_count": N, "heading_count": H,
|
|
147
|
+
"fenced_count": F}, ...],
|
|
148
|
+
"paragraphs": [{"header": "<governing-header-or-null>",
|
|
149
|
+
"excerpt": "<collapsed paragraph text>",
|
|
150
|
+
"line": L}, ...]},
|
|
151
|
+
...
|
|
152
|
+
],
|
|
153
|
+
"mentioned": ["<skill>", ...],
|
|
154
|
+
"fenced_only": ["<skill>", ...],
|
|
155
|
+
"unmentioned": ["<skill>", ...],
|
|
156
|
+
"candidates": [<a pair shaped as a comention pair, its
|
|
157
|
+
evidence without `excluded_section`>, ...],
|
|
158
|
+
"list_only_pair_count": P,
|
|
159
|
+
"fenced_block_count": B,
|
|
160
|
+
"fenced_blocks": [{"line": L, "info": "<info string>"}, ...]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
`skills[]` has one entry per skill, sorted by name: how many body
|
|
164
|
+
paragraphs, headings (any level) and fenced blocks name it, and each
|
|
165
|
+
such paragraph in document order. `terms[]` lists each of the
|
|
166
|
+
skill's terms that named it, spelled as the skills input gives it,
|
|
167
|
+
its name first and then its aliases in input order, with `kind`
|
|
168
|
+
saying which, and how many paragraphs, headings and fenced blocks
|
|
169
|
+
that term named the skill in: the term a scope gate shows for each
|
|
170
|
+
skill, so a skill named only through an alias reads as such.
|
|
171
|
+
`mentioned` lists the skills a paragraph or a heading names,
|
|
172
|
+
`fenced_only` the skills named only inside fenced code (a diagram,
|
|
173
|
+
for example) and `unmentioned` the rest; the three are sorted and
|
|
174
|
+
hold every skill once.
|
|
175
|
+
|
|
176
|
+
`candidates[]` holds each pair that a body paragraph names in one
|
|
177
|
+
unit or through a lead-in (comention_count + lead_in_count >= 1),
|
|
178
|
+
with every paragraph that names both as evidence, sorted by
|
|
179
|
+
comention_count DESC, lead_in_count DESC, paragraph_count DESC, then
|
|
180
|
+
(a, b) ASC. `list_only_pair_count` counts the pairs only ever listed
|
|
181
|
+
together, which are not emitted. `fenced_blocks[]` lists the fenced
|
|
182
|
+
blocks skipped, in document order: the line of each opening fence and
|
|
183
|
+
its info string, trimmed (`mermaid` for a Mermaid diagram, "" for
|
|
184
|
+
none).
|
|
185
|
+
|
|
186
|
+
--technologies takes a JSON array of technology names as a document
|
|
187
|
+
writes them (the caller judges which strings are technologies) and
|
|
188
|
+
adds two keys:
|
|
189
|
+
"technologies": [{"name": "<as given>",
|
|
190
|
+
"covered_by": [{"skill": "<skill>",
|
|
191
|
+
"term": "<the skill's term>",
|
|
192
|
+
"kind": "name|alias|name-contained|alias-contained"},
|
|
193
|
+
...]}, ...],
|
|
194
|
+
"unverified_technologies": ["<name>", ...]
|
|
195
|
+
in the order given, a repeat (ignoring case and runs of whitespace)
|
|
196
|
+
dropped. A term covers only by squashed equality: a skill covers a
|
|
197
|
+
technology when one of its terms, its name or an alias, equals the
|
|
198
|
+
name once case, whitespace, hyphens, dots and underscores are
|
|
199
|
+
dropped (a `react-router` skill covers `React Router`, a
|
|
200
|
+
`tailwindcss` skill `Tailwind CSS`, a skill with the alias `cognee`
|
|
201
|
+
`Cognee`). A name or alias found only inside a longer name, by the
|
|
202
|
+
matching above with each occurrence read as its longest term, is
|
|
203
|
+
listed as name-contained or alias-contained and covers nothing:
|
|
204
|
+
whether `next` is `Next.js`, `react` is `React Native`, or the
|
|
205
|
+
common-word alias `ai` is `Azure AI Search`, is a question of meaning,
|
|
206
|
+
so the name stays unverified for the caller to judge. The match runs
|
|
207
|
+
one way: a `storybook-react-vite` skill does not cover `React`.
|
|
208
|
+
`covered_by` is sorted by skill, each with the first of its terms
|
|
209
|
+
that covers the name (or, for a contained entry, the term found) and
|
|
210
|
+
its kind, and `unverified_technologies` lists the names no skill
|
|
211
|
+
covers.
|
|
212
|
+
|
|
213
|
+
infer --skills <json-file-or-'-'> [--top-k N]
|
|
214
|
+
A pair is a candidate on either kind of evidence:
|
|
215
|
+
docs-mention one skill's docs name the other. Every line of
|
|
216
|
+
each `docs` file is read, front matter and code
|
|
217
|
+
included; each occurrence names the skill of the
|
|
218
|
+
longest term there (see Matching), and an
|
|
219
|
+
occurrence of the documenting skill's own name or
|
|
220
|
+
alias names no other skill. A mention is where a
|
|
221
|
+
documented contract would show, not proof of one:
|
|
222
|
+
the calling step reads the excerpt and counts a
|
|
223
|
+
documented contract only when the line says how
|
|
224
|
+
the two work together, never on a "see also" or
|
|
225
|
+
"unlike" line.
|
|
226
|
+
shared-keywords the two skills share a domain keyword. Keywords
|
|
227
|
+
compare trimmed and lowercased, with runs of
|
|
228
|
+
whitespace collapsed; a keyword equal to any
|
|
229
|
+
skill's language does not count.
|
|
230
|
+
A shared language alone never makes a pair: `language` is read only
|
|
231
|
+
to set such keywords aside and to count language_only_pair_count,
|
|
232
|
+
the pairs of skills that share a language and have no evidence.
|
|
233
|
+
|
|
234
|
+
Emit JSON:
|
|
235
|
+
{
|
|
236
|
+
"pairs": [
|
|
237
|
+
{
|
|
238
|
+
"a": "<skill>", "b": "<skill>",
|
|
239
|
+
"docs_mention_count": 0-2,
|
|
240
|
+
"shared_keyword_count": K,
|
|
241
|
+
"evidence": [
|
|
242
|
+
{"kind": "docs-mention",
|
|
243
|
+
"documented_by": "<skill>", "doc": "<path>",
|
|
244
|
+
"line": L, "line_count": N,
|
|
245
|
+
"excerpt": "<collapsed line text>"},
|
|
246
|
+
{"kind": "shared-keywords", "keywords": ["<kw>", ...]}
|
|
247
|
+
]
|
|
248
|
+
},
|
|
249
|
+
...
|
|
250
|
+
],
|
|
251
|
+
"truncated": <bool>,
|
|
252
|
+
"total_pairs": T,
|
|
253
|
+
"language_only_pair_count": P
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
A docs-mention entry is the first line (docs in the order given)
|
|
257
|
+
where `documented_by` names the other skill, and line_count counts
|
|
258
|
+
every such line. docs_mention_count is how many of the two skills'
|
|
259
|
+
docs name the other. `pairs[]` is sorted by docs_mention_count DESC,
|
|
260
|
+
shared_keyword_count DESC, then (a, b) ASC, and capped at --top-k
|
|
261
|
+
(default 20, the Top-K of detect-integrations.md §1); `truncated` and
|
|
262
|
+
`total_pairs` report the cap as skf-pair-intersect.py does.
|
|
59
263
|
|
|
60
264
|
CLI examples:
|
|
61
265
|
uv run skf-comention-pairs.py comention --doc arch.md --skills skills.json
|
|
62
266
|
echo '["react","express"]' | uv run skf-comention-pairs.py comention \\
|
|
63
267
|
--doc arch.md --skills -
|
|
268
|
+
uv run skf-comention-pairs.py mentions --doc arch.md --skills skills.json
|
|
269
|
+
uv run skf-comention-pairs.py mentions --doc arch.md --skills skills.json \\
|
|
270
|
+
--technologies technologies.json
|
|
271
|
+
uv run skf-comention-pairs.py infer --skills skills.json --top-k 20
|
|
64
272
|
|
|
65
273
|
Exit codes:
|
|
66
|
-
0
|
|
67
|
-
1
|
|
68
|
-
|
|
69
|
-
|
|
274
|
+
0 operation succeeded (including: nothing qualifies, so empty lists)
|
|
275
|
+
1 user error (bad JSON, a missing, unreadable or non-UTF-8 file, two
|
|
276
|
+
inputs on stdin, malformed skills or technologies array, negative
|
|
277
|
+
--top-k)
|
|
278
|
+
2 unexpected internal error
|
|
70
279
|
"""
|
|
71
280
|
|
|
72
281
|
from __future__ import annotations
|
|
73
282
|
|
|
74
283
|
import argparse
|
|
284
|
+
import bisect
|
|
285
|
+
import functools
|
|
286
|
+
import itertools
|
|
75
287
|
import json
|
|
76
288
|
import re
|
|
77
289
|
import sys
|
|
78
290
|
from pathlib import Path
|
|
291
|
+
from typing import NamedTuple
|
|
79
292
|
|
|
80
293
|
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
294
|
+
# The introductory sections: comention marks `excluded_section` on the
|
|
295
|
+
# evidence of a paragraph whose governing H1/H2 header normalises to one of
|
|
296
|
+
# these, since such sections often list every library without describing an
|
|
297
|
+
# integration. The calling step weighs the mark; nothing is dropped.
|
|
84
298
|
EXCLUDED_HEADERS = frozenset({
|
|
85
299
|
"introduction",
|
|
86
300
|
"overview",
|
|
@@ -96,21 +310,112 @@ EXCLUDED_HEADERS = frozenset({
|
|
|
96
310
|
# as body text. Capture group 1 = hashes (level), group 2 = header text.
|
|
97
311
|
_HEADER_RE = re.compile(r"^(#{1,6})[ \t]+(.*)$")
|
|
98
312
|
|
|
313
|
+
# A fence line: three or more backticks or tildes after any indentation
|
|
314
|
+
# (the rule the other SKF Markdown scanners use), then the info string.
|
|
315
|
+
_FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})(.*)$")
|
|
316
|
+
|
|
317
|
+
# The indentation and blockquote markers (`>`) that can precede a list
|
|
318
|
+
# marker or a table row.
|
|
319
|
+
_QUOTE_RE = re.compile(r"\s*(?:>\s*)*")
|
|
320
|
+
|
|
321
|
+
# List item markers, at any indentation so that a nested item is a unit of
|
|
322
|
+
# its own, and inside a blockquote: a bullet, or a number with `.` or `)`,
|
|
323
|
+
# then whitespace and text.
|
|
324
|
+
_BULLET_RE = re.compile(r"^\s*(?:>\s*)*[-*+]\s+\S")
|
|
325
|
+
_ORDERED_RE = re.compile(r"^\s*(?:>\s*)*(\d{1,9})[.)]\s+\S")
|
|
326
|
+
|
|
327
|
+
# A GFM table delimiter row, such as `| --- | :-: |` or `--- | ---`, matched
|
|
328
|
+
# whole against the row with its blockquote markers and outer whitespace
|
|
329
|
+
# removed: leading and trailing `\s*` here would backtrack quadratically on
|
|
330
|
+
# a long run of spaces.
|
|
331
|
+
_TABLE_DELIM_RE = re.compile(r"\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)*\|?")
|
|
332
|
+
|
|
333
|
+
_WORD_RE = re.compile(r"\S+")
|
|
334
|
+
|
|
99
335
|
_EXCERPT_LIMIT = 240
|
|
100
336
|
|
|
337
|
+
# infer's Top-K cap: the cap detect-integrations.md §1 applies to pairs.
|
|
338
|
+
DEFAULT_TOP_K = 20
|
|
339
|
+
|
|
340
|
+
CO_MENTION = "co-mention"
|
|
341
|
+
LEAD_IN = "lead-in"
|
|
342
|
+
LIST_ONLY = "list-only"
|
|
343
|
+
DOCS_MENTION = "docs-mention"
|
|
344
|
+
SHARED_KEYWORDS = "shared-keywords"
|
|
345
|
+
# The covered_by kinds of a skill name or an alias found only inside a longer
|
|
346
|
+
# technology name: listed, but they cover nothing.
|
|
347
|
+
NAME_CONTAINED = "name-contained"
|
|
348
|
+
ALIAS_CONTAINED = "alias-contained"
|
|
349
|
+
|
|
101
350
|
|
|
102
351
|
class UserError(Exception):
|
|
103
352
|
"""A caller-facing input error → exit code 1."""
|
|
104
353
|
|
|
105
354
|
|
|
355
|
+
class Skill(NamedTuple):
|
|
356
|
+
"""One loaded skill: the terms that name it (its name first, then its
|
|
357
|
+
aliases) and the inputs only infer reads."""
|
|
358
|
+
|
|
359
|
+
name: str
|
|
360
|
+
terms: tuple[str, ...]
|
|
361
|
+
keywords: tuple[str, ...] = ()
|
|
362
|
+
languages: tuple[str, ...] = ()
|
|
363
|
+
docs: tuple[str, ...] = ()
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class Unit(NamedTuple):
|
|
367
|
+
"""A part of a paragraph that a pair can share: its first line
|
|
368
|
+
(1-based), its text, and whether it is a run of prose (the others are
|
|
369
|
+
list items, table rows and code lines)."""
|
|
370
|
+
|
|
371
|
+
line: int
|
|
372
|
+
text: str
|
|
373
|
+
prose: bool
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class Paragraph(NamedTuple):
|
|
377
|
+
"""A body paragraph: its governing H1/H2 header (or None), its first
|
|
378
|
+
line (1-based), its text and its units."""
|
|
379
|
+
|
|
380
|
+
header: str | None
|
|
381
|
+
line: int
|
|
382
|
+
text: str
|
|
383
|
+
units: tuple[Unit, ...]
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class Fence(NamedTuple):
|
|
387
|
+
"""A fenced code block: the line of its opening fence (1-based), its
|
|
388
|
+
info string, trimmed, and its content without the fence lines."""
|
|
389
|
+
|
|
390
|
+
line: int
|
|
391
|
+
info: str
|
|
392
|
+
text: str
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class MarkdownDoc(NamedTuple):
|
|
396
|
+
"""What read_markdown finds: body paragraphs, the text of every header
|
|
397
|
+
line, and each fenced block it set apart."""
|
|
398
|
+
|
|
399
|
+
paragraphs: list[Paragraph]
|
|
400
|
+
headings: list[str]
|
|
401
|
+
fenced: list[Fence]
|
|
402
|
+
|
|
403
|
+
|
|
106
404
|
# --------------------------------------------------------------------------
|
|
107
405
|
# Markdown parsing
|
|
108
406
|
# --------------------------------------------------------------------------
|
|
109
407
|
|
|
110
408
|
|
|
111
409
|
def _strip_closing_hashes(text: str) -> str:
|
|
112
|
-
"""Remove a closed-ATX trailing run of hashes (e.g. `Overview ##`).
|
|
113
|
-
|
|
410
|
+
"""Remove a closed-ATX trailing run of hashes (e.g. `Overview ##`).
|
|
411
|
+
|
|
412
|
+
String methods rather than a `[ \\t]*#+[ \\t]*$` search, which
|
|
413
|
+
backtracks quadratically on a long run of spaces.
|
|
414
|
+
"""
|
|
415
|
+
body = text.rstrip(" \t")
|
|
416
|
+
if not body.endswith("#"):
|
|
417
|
+
return text
|
|
418
|
+
return body.rstrip("#").rstrip(" \t")
|
|
114
419
|
|
|
115
420
|
|
|
116
421
|
def normalize_header(text: str) -> str:
|
|
@@ -122,27 +427,163 @@ def normalize_header(text: str) -> str:
|
|
|
122
427
|
return t
|
|
123
428
|
|
|
124
429
|
|
|
125
|
-
def
|
|
126
|
-
"""
|
|
127
|
-
|
|
430
|
+
def _fence_roles(lines: list[str]) -> list[str | None]:
|
|
431
|
+
"""Mark each line of a fenced code block `open`, `body` or `close`, and
|
|
432
|
+
every other line None. A fence closes on a line of the same character,
|
|
433
|
+
at least as long, with nothing after it; an unclosed fence runs to the
|
|
434
|
+
end of the document."""
|
|
435
|
+
roles: list[str | None] = []
|
|
436
|
+
fence: str | None = None
|
|
437
|
+
for line in lines:
|
|
438
|
+
m = _FENCE_RE.match(line)
|
|
439
|
+
if fence is None:
|
|
440
|
+
# A backtick fence's info string cannot hold a backtick.
|
|
441
|
+
if m is not None and not (m.group(1)[0] == "`" and "`" in m.group(2)):
|
|
442
|
+
fence = m.group(1)
|
|
443
|
+
roles.append("open")
|
|
444
|
+
else:
|
|
445
|
+
roles.append(None)
|
|
446
|
+
elif (
|
|
447
|
+
m is not None
|
|
448
|
+
and m.group(1)[0] == fence[0]
|
|
449
|
+
and len(m.group(1)) >= len(fence)
|
|
450
|
+
and not m.group(2).strip()
|
|
451
|
+
):
|
|
452
|
+
fence = None
|
|
453
|
+
roles.append("close")
|
|
454
|
+
else:
|
|
455
|
+
roles.append("body")
|
|
456
|
+
return roles
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _fenced_blocks(lines: list[str], roles: list[str | None]) -> list[Fence]:
|
|
460
|
+
"""Each fenced code block, with its opening line and info string."""
|
|
461
|
+
blocks: list[tuple[int, str, list[str]]] = []
|
|
462
|
+
for idx, (line, role) in enumerate(zip(lines, roles)):
|
|
463
|
+
if role == "open":
|
|
464
|
+
info = _FENCE_RE.match(line).group(2).strip()
|
|
465
|
+
blocks.append((idx + 1, info, []))
|
|
466
|
+
elif role == "body":
|
|
467
|
+
blocks[-1][2].append(line)
|
|
468
|
+
return [Fence(line, info, "\n".join(body)) for line, info, body in blocks]
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _unquote(line: str) -> str:
|
|
472
|
+
"""The line without its indentation and blockquote markers."""
|
|
473
|
+
return line[_QUOTE_RE.match(line).end():]
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _is_list_item(line: str) -> bool:
|
|
477
|
+
return bool(_BULLET_RE.match(line) or _ORDERED_RE.match(line))
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _table_rows(lines: list[str], code: list[bool]) -> set[int]:
|
|
481
|
+
"""Indexes of a paragraph's table rows: every line that starts with `|`,
|
|
482
|
+
and each GFM table (a header row, its delimiter row, then each line up
|
|
483
|
+
to a list item or code) even when its rows have no leading pipe."""
|
|
484
|
+
rows = {
|
|
485
|
+
i for i, line in enumerate(lines)
|
|
486
|
+
if not code[i] and _unquote(line).startswith("|")
|
|
487
|
+
}
|
|
488
|
+
for i in range(1, len(lines)):
|
|
489
|
+
if (
|
|
490
|
+
code[i] or code[i - 1]
|
|
491
|
+
or "|" not in lines[i] or "|" not in lines[i - 1]
|
|
492
|
+
or not _TABLE_DELIM_RE.fullmatch(_unquote(lines[i]).rstrip())
|
|
493
|
+
):
|
|
494
|
+
continue
|
|
495
|
+
rows.update((i - 1, i))
|
|
496
|
+
j = i + 1
|
|
497
|
+
while j < len(lines) and not code[j] and not _is_list_item(lines[j]):
|
|
498
|
+
rows.add(j)
|
|
499
|
+
j += 1
|
|
500
|
+
return rows
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _units(lines: list[str], code: list[bool], first: int) -> tuple[Unit, ...]:
|
|
504
|
+
"""Split a paragraph's lines, the first of which is line `first` of the
|
|
505
|
+
document, into the units a pair can share: a run of prose, one list item
|
|
506
|
+
with its continuation lines, one table row, or one line of fenced
|
|
507
|
+
code."""
|
|
508
|
+
rows = _table_rows(lines, code)
|
|
509
|
+
units: list[Unit] = []
|
|
510
|
+
start = 0 # index of the open unit's first line
|
|
511
|
+
# What the open unit holds: "prose", "item" (a list item) or None.
|
|
512
|
+
current: str | None = None
|
|
513
|
+
|
|
514
|
+
def close(end: int) -> None:
|
|
515
|
+
if current is not None:
|
|
516
|
+
units.append(Unit(
|
|
517
|
+
first + start, "\n".join(lines[start:end]), current == "prose"
|
|
518
|
+
))
|
|
519
|
+
|
|
520
|
+
for i, line in enumerate(lines):
|
|
521
|
+
if code[i] or i in rows:
|
|
522
|
+
close(i)
|
|
523
|
+
current = None
|
|
524
|
+
units.append(Unit(first + i, line, False))
|
|
525
|
+
continue
|
|
526
|
+
ordered = _ORDERED_RE.match(line)
|
|
527
|
+
# A bullet can interrupt running prose; an ordered marker only when
|
|
528
|
+
# it is 1 (CommonMark), so `2024. Then ...` inside prose stays text.
|
|
529
|
+
if _BULLET_RE.match(line) or (
|
|
530
|
+
ordered is not None
|
|
531
|
+
and (current != "prose" or int(ordered.group(1)) == 1)
|
|
532
|
+
):
|
|
533
|
+
close(i)
|
|
534
|
+
start, current = i, "item"
|
|
535
|
+
elif current is None:
|
|
536
|
+
# Any other line opens a run of prose, or continues the open
|
|
537
|
+
# run or list item.
|
|
538
|
+
start, current = i, "prose"
|
|
539
|
+
close(len(lines))
|
|
540
|
+
return tuple(units)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def read_markdown(doc_text: str, *, skip_fenced: bool) -> MarkdownDoc:
|
|
544
|
+
"""Split the document into body paragraphs, headings and fenced blocks.
|
|
128
545
|
|
|
129
546
|
Paragraphs are maximal runs of consecutive non-blank, non-header lines,
|
|
130
|
-
separated by blank lines. Header lines of ANY level are never emitted
|
|
131
|
-
paragraphs (headings are not a co-mention source). Only H1/H2
|
|
132
|
-
update the governing header; H3-H6 leave it unchanged.
|
|
547
|
+
separated by blank lines. Header lines of ANY level are never emitted
|
|
548
|
+
as paragraphs (headings are not a co-mention source). Only H1/H2
|
|
549
|
+
headers update the governing header; H3-H6 leave it unchanged.
|
|
550
|
+
|
|
551
|
+
With skip_fenced (mentions), fenced code blocks are set apart first:
|
|
552
|
+
their lines end a paragraph, are never headers, and each block lands in
|
|
553
|
+
`fenced`. Without it (comention), fence lines are body text and a `#`
|
|
554
|
+
line inside a fence is a header, as that mode has always read them; the
|
|
555
|
+
units still know which lines are code.
|
|
133
556
|
"""
|
|
134
|
-
|
|
557
|
+
lines = doc_text.splitlines()
|
|
558
|
+
roles = _fence_roles(lines)
|
|
559
|
+
doc = MarkdownDoc(
|
|
560
|
+
paragraphs=[],
|
|
561
|
+
headings=[],
|
|
562
|
+
fenced=_fenced_blocks(lines, roles) if skip_fenced else [],
|
|
563
|
+
)
|
|
135
564
|
governing: str | None = None
|
|
136
|
-
buf: list[
|
|
565
|
+
buf: list[int] = [] # line indexes of the open paragraph
|
|
137
566
|
|
|
138
567
|
def _flush() -> None:
|
|
139
568
|
if buf:
|
|
140
|
-
text = "\n".join(buf).strip()
|
|
569
|
+
text = "\n".join(lines[i] for i in buf).strip()
|
|
141
570
|
if text:
|
|
142
|
-
paragraphs.append((
|
|
571
|
+
doc.paragraphs.append(Paragraph(
|
|
572
|
+
header=governing,
|
|
573
|
+
line=buf[0] + 1,
|
|
574
|
+
text=text,
|
|
575
|
+
units=_units(
|
|
576
|
+
[lines[i] for i in buf],
|
|
577
|
+
[roles[i] is not None for i in buf],
|
|
578
|
+
buf[0] + 1,
|
|
579
|
+
),
|
|
580
|
+
))
|
|
143
581
|
buf.clear()
|
|
144
582
|
|
|
145
|
-
for raw_line in
|
|
583
|
+
for idx, raw_line in enumerate(lines):
|
|
584
|
+
if skip_fenced and roles[idx] is not None:
|
|
585
|
+
_flush()
|
|
586
|
+
continue
|
|
146
587
|
m = _HEADER_RE.match(raw_line)
|
|
147
588
|
if m is not None:
|
|
148
589
|
# A header terminates the current paragraph; the header line
|
|
@@ -150,19 +591,202 @@ def parse_body_paragraphs(doc_text: str) -> list[tuple[str | None, str]]:
|
|
|
150
591
|
_flush()
|
|
151
592
|
level = len(m.group(1))
|
|
152
593
|
header_text = _strip_closing_hashes(m.group(2)).strip()
|
|
594
|
+
doc.headings.append(header_text)
|
|
153
595
|
if level <= 2:
|
|
154
596
|
governing = header_text if header_text else None
|
|
155
597
|
continue
|
|
156
598
|
if raw_line.strip() == "":
|
|
157
599
|
_flush()
|
|
158
600
|
continue
|
|
159
|
-
buf.append(
|
|
601
|
+
buf.append(idx)
|
|
160
602
|
_flush()
|
|
161
|
-
return
|
|
603
|
+
return doc
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def parse_body_paragraphs(doc_text: str) -> list[tuple[str | None, str]]:
|
|
607
|
+
"""Body paragraphs as (governing header text or None, paragraph text),
|
|
608
|
+
read the way comention reads them (fenced code is body text)."""
|
|
609
|
+
return [
|
|
610
|
+
(p.header, p.text)
|
|
611
|
+
for p in read_markdown(doc_text, skip_fenced=False).paragraphs
|
|
612
|
+
]
|
|
162
613
|
|
|
163
614
|
|
|
164
615
|
# --------------------------------------------------------------------------
|
|
165
|
-
#
|
|
616
|
+
# Skills and matching
|
|
617
|
+
# --------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _norm(text: str) -> str:
|
|
621
|
+
"""Lowercase, trimmed, with each run of whitespace collapsed."""
|
|
622
|
+
return " ".join(text.lower().split())
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _dedupe(items: tuple[str, ...], key=lambda s: s) -> tuple[str, ...]:
|
|
626
|
+
"""Drop repeats (by key), keeping the first of each in order."""
|
|
627
|
+
seen: set[str] = set()
|
|
628
|
+
out: list[str] = []
|
|
629
|
+
for item in items:
|
|
630
|
+
k = key(item)
|
|
631
|
+
if k not in seen:
|
|
632
|
+
seen.add(k)
|
|
633
|
+
out.append(item)
|
|
634
|
+
return tuple(out)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _term_body(term: str) -> str:
|
|
638
|
+
"""Regex source for a term's words, with any whitespace between them."""
|
|
639
|
+
return r"\s+".join(re.escape(word) for word in term.split())
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _skill_pattern(skill: Skill) -> re.Pattern[str]:
|
|
643
|
+
"""One case-insensitive pattern that matches any of the skill's terms
|
|
644
|
+
with no word character on either side."""
|
|
645
|
+
terms = sorted(skill.terms, key=lambda t: (-len(t), t))
|
|
646
|
+
return re.compile(
|
|
647
|
+
"|".join(rf"(?:(?<!\w){_term_body(t)}(?!\w))" for t in terms),
|
|
648
|
+
re.IGNORECASE,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
class _Occurrence(NamedTuple):
|
|
653
|
+
"""A match of a term in a text, and the skills it names there. `term`
|
|
654
|
+
is the matched term, normalised (mentions and infer only)."""
|
|
655
|
+
|
|
656
|
+
start: int
|
|
657
|
+
end: int
|
|
658
|
+
names: frozenset[str]
|
|
659
|
+
term: str = ""
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
class _EachSkill:
|
|
663
|
+
"""comention's matcher: each skill's terms on their own, so `react`
|
|
664
|
+
also matches the `react` in `react-dom`."""
|
|
665
|
+
|
|
666
|
+
def __init__(self, specs: dict[str, Skill]) -> None:
|
|
667
|
+
self.patterns = {n: _skill_pattern(specs[n]) for n in sorted(specs)}
|
|
668
|
+
|
|
669
|
+
def names(self, text: str) -> set[str]:
|
|
670
|
+
return {n for n, pattern in self.patterns.items() if pattern.search(text)}
|
|
671
|
+
|
|
672
|
+
def find(self, text: str, among: frozenset[str]) -> list[_Occurrence]:
|
|
673
|
+
"""Every match in the text of a term of a skill in `among`."""
|
|
674
|
+
return [
|
|
675
|
+
_Occurrence(m.start(), m.end(), frozenset((n,)))
|
|
676
|
+
for n in sorted(among)
|
|
677
|
+
for m in self.patterns[n].finditer(text)
|
|
678
|
+
]
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
class _LongestTerm:
|
|
682
|
+
"""The matcher of mentions and infer: one pattern over every skill's
|
|
683
|
+
terms, longest first, so each occurrence is the longest term that
|
|
684
|
+
matches there, and names the skills of that term."""
|
|
685
|
+
|
|
686
|
+
def __init__(self, specs: dict[str, Skill]) -> None:
|
|
687
|
+
owners: dict[str, set[str]] = {}
|
|
688
|
+
for skill in specs.values():
|
|
689
|
+
for term in skill.terms:
|
|
690
|
+
owners.setdefault(_norm(term), set()).add(skill.name)
|
|
691
|
+
self.owners = {t: frozenset(n) for t, n in owners.items()}
|
|
692
|
+
self.terms = sorted(self.owners, key=lambda t: (-len(t), t))
|
|
693
|
+
# Plain alternatives (no groups) keep the regex engine's fast path.
|
|
694
|
+
alternatives = "|".join(_term_body(t) for t in self.terms)
|
|
695
|
+
self.pattern = re.compile(
|
|
696
|
+
rf"(?<!\w)(?:{alternatives})(?!\w)", re.IGNORECASE
|
|
697
|
+
) if self.terms else None
|
|
698
|
+
|
|
699
|
+
def names(self, text: str) -> set[str]:
|
|
700
|
+
return set().union(*(o.names for o in self.find(text)))
|
|
701
|
+
|
|
702
|
+
def find(
|
|
703
|
+
self, text: str, among: frozenset[str] | None = None
|
|
704
|
+
) -> list[_Occurrence]:
|
|
705
|
+
"""Each occurrence in the text, in order, with the skills it names
|
|
706
|
+
(only those in `among`, when given; an occurrence naming none of
|
|
707
|
+
them is left out)."""
|
|
708
|
+
found: list[_Occurrence] = []
|
|
709
|
+
for m in self.pattern.finditer(text) if self.pattern else ():
|
|
710
|
+
term = self._term(m.group(0))
|
|
711
|
+
names = self.owners.get(term, frozenset())
|
|
712
|
+
if among is not None:
|
|
713
|
+
names &= among
|
|
714
|
+
if names:
|
|
715
|
+
found.append(_Occurrence(m.start(), m.end(), names, term))
|
|
716
|
+
return found
|
|
717
|
+
|
|
718
|
+
def _term(self, text: str) -> str:
|
|
719
|
+
"""The normalised term the pattern matched in `text`."""
|
|
720
|
+
term = _norm(text)
|
|
721
|
+
if term in self.owners:
|
|
722
|
+
return term
|
|
723
|
+
# A case-insensitive match that lowercasing does not map back to its
|
|
724
|
+
# term (the long s matches `s`): the first term, longest first, that
|
|
725
|
+
# matches the text whole is the one the pattern chose.
|
|
726
|
+
for term in self.terms:
|
|
727
|
+
if re.fullmatch(_term_body(term), text, re.IGNORECASE):
|
|
728
|
+
return term
|
|
729
|
+
return ""
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _coerce(skills: list[Skill | str]) -> dict[str, Skill]:
|
|
733
|
+
"""Index skills by name, merging entries that share a name. A plain
|
|
734
|
+
string is a skill with no alias."""
|
|
735
|
+
merged: dict[str, Skill] = {}
|
|
736
|
+
for item in skills:
|
|
737
|
+
skill = item if isinstance(item, Skill) else Skill(item, (item,))
|
|
738
|
+
prev = merged.get(skill.name)
|
|
739
|
+
if prev is not None:
|
|
740
|
+
skill = Skill(
|
|
741
|
+
name=skill.name,
|
|
742
|
+
terms=_dedupe(prev.terms + skill.terms, key=_norm),
|
|
743
|
+
keywords=tuple(sorted(set(prev.keywords + skill.keywords))),
|
|
744
|
+
languages=tuple(sorted(set(prev.languages + skill.languages))),
|
|
745
|
+
docs=_dedupe(prev.docs + skill.docs),
|
|
746
|
+
)
|
|
747
|
+
merged[skill.name] = skill
|
|
748
|
+
return merged
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _string_list(entry: dict, key: str, where: str) -> tuple[str, ...]:
|
|
752
|
+
"""The entry's optional `key`: an array of non-empty strings."""
|
|
753
|
+
value = entry.get(key)
|
|
754
|
+
if value is None:
|
|
755
|
+
return ()
|
|
756
|
+
if not isinstance(value, list):
|
|
757
|
+
raise UserError(
|
|
758
|
+
f"{where}.{key} must be an array of non-empty strings; "
|
|
759
|
+
f"got {type(value).__name__}"
|
|
760
|
+
)
|
|
761
|
+
out: list[str] = []
|
|
762
|
+
for j, item in enumerate(value):
|
|
763
|
+
if not isinstance(item, str) or not item.strip():
|
|
764
|
+
raise UserError(
|
|
765
|
+
f"{where}.{key}[{j}] must be a non-empty string; got {item!r}"
|
|
766
|
+
)
|
|
767
|
+
out.append(item.strip())
|
|
768
|
+
return tuple(out)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _languages(entry: dict, where: str) -> tuple[str, ...]:
|
|
772
|
+
"""The entry's optional `language`: a string, or an array of strings (a
|
|
773
|
+
stack lists its libraries' languages), normalised and sorted."""
|
|
774
|
+
value = entry.get("language")
|
|
775
|
+
if value is None:
|
|
776
|
+
return ()
|
|
777
|
+
items = [value] if isinstance(value, str) else value
|
|
778
|
+
if not isinstance(items, list) or not all(
|
|
779
|
+
isinstance(item, str) and item.strip() for item in items
|
|
780
|
+
):
|
|
781
|
+
raise UserError(
|
|
782
|
+
f"{where}.language must be a non-empty string or an array of "
|
|
783
|
+
f"non-empty strings; got {value!r}"
|
|
784
|
+
)
|
|
785
|
+
return tuple(sorted({_norm(item) for item in items}))
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
# --------------------------------------------------------------------------
|
|
789
|
+
# Evidence
|
|
166
790
|
# --------------------------------------------------------------------------
|
|
167
791
|
|
|
168
792
|
|
|
@@ -174,53 +798,488 @@ def _make_excerpt(text: str, limit: int = _EXCERPT_LIMIT) -> str:
|
|
|
174
798
|
return collapsed
|
|
175
799
|
|
|
176
800
|
|
|
177
|
-
|
|
178
|
-
|
|
801
|
+
@functools.lru_cache(maxsize=256)
|
|
802
|
+
def _words(text: str) -> tuple[tuple[int, ...], tuple[int, ...], tuple[int, ...]]:
|
|
803
|
+
"""The text's words, found once per text: where each starts and ends,
|
|
804
|
+
and the running total of their lengths."""
|
|
805
|
+
spans = [m.span() for m in _WORD_RE.finditer(text)]
|
|
806
|
+
return (
|
|
807
|
+
tuple(s for s, _ in spans),
|
|
808
|
+
tuple(e for _, e in spans),
|
|
809
|
+
tuple(itertools.accumulate((e - s for s, e in spans), initial=0)),
|
|
810
|
+
)
|
|
179
811
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
812
|
+
|
|
813
|
+
def _excerpt_around(
|
|
814
|
+
text: str,
|
|
815
|
+
first: tuple[int, int],
|
|
816
|
+
second: tuple[int, int],
|
|
817
|
+
limit: int = _EXCERPT_LIMIT,
|
|
818
|
+
) -> str:
|
|
819
|
+
"""Collapse `text` like an excerpt and cut it to `limit` characters
|
|
820
|
+
around two names, at the spans `first` and `second` (first starting
|
|
821
|
+
first): the words around both, or, when the words between them do not
|
|
822
|
+
fit, the words around each with the middle cut. `...` marks each cut."""
|
|
823
|
+
starts, ends, pre = _words(text)
|
|
824
|
+
last = len(starts) - 1
|
|
825
|
+
|
|
826
|
+
def join(i: int, j: int) -> str:
|
|
827
|
+
return " ".join(text[starts[k]:ends[k]] for k in range(i, j + 1))
|
|
828
|
+
|
|
829
|
+
if pre[-1] + last <= limit: # the whole text fits
|
|
830
|
+
return join(0, last)
|
|
831
|
+
|
|
832
|
+
def word(pos: int) -> int:
|
|
833
|
+
"""The index of the word that holds text[pos]."""
|
|
834
|
+
return bisect.bisect_right(ends, pos)
|
|
835
|
+
|
|
836
|
+
def width(i: int, j: int) -> int:
|
|
837
|
+
"""Words i..j joined, with a `...` for each side cut."""
|
|
838
|
+
return pre[j + 1] - pre[i] + (j - i) + 3 * (i > 0) + 3 * (j < last)
|
|
839
|
+
|
|
840
|
+
def grow(i: int, j: int, room: int) -> tuple[int, int]:
|
|
841
|
+
"""Add words on both sides of i..j, in turn, while they fit."""
|
|
842
|
+
grown = True
|
|
843
|
+
while grown:
|
|
844
|
+
grown = False
|
|
845
|
+
if i > 0 and width(i - 1, j) <= room:
|
|
846
|
+
i, grown = i - 1, True
|
|
847
|
+
if j < last and width(i, j + 1) <= room:
|
|
848
|
+
j, grown = j + 1, True
|
|
849
|
+
return i, j
|
|
850
|
+
|
|
851
|
+
i, j = word(first[0]), word(max(first[1], second[1]) - 1)
|
|
852
|
+
first_end, second_start = word(first[1] - 1), word(second[0])
|
|
853
|
+
if width(i, j) <= limit or second_start <= first_end:
|
|
854
|
+
windows = [grow(i, j, limit)]
|
|
855
|
+
else:
|
|
856
|
+
# The words between the names do not fit: keep each name with the
|
|
857
|
+
# words around it, and cut the middle.
|
|
858
|
+
windows = [
|
|
859
|
+
grow(i, first_end, limit // 2), grow(second_start, j, limit // 2),
|
|
860
|
+
]
|
|
861
|
+
quote = " ... ".join(join(a, b) for a, b in windows)
|
|
862
|
+
if windows[0][0] > 0:
|
|
863
|
+
quote = "..." + quote
|
|
864
|
+
if windows[-1][1] < last:
|
|
865
|
+
quote += "..."
|
|
866
|
+
# Only a name inside a word too long for its window overflows the limit.
|
|
867
|
+
return _make_excerpt(quote, limit)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _pair_quote(
|
|
871
|
+
text: str,
|
|
872
|
+
line: int,
|
|
873
|
+
found_a: list[_Occurrence],
|
|
874
|
+
found_b: list[_Occurrence],
|
|
875
|
+
) -> tuple[int, str]:
|
|
876
|
+
"""Where `text`, whose first line is `line`, names two skills nearest
|
|
877
|
+
each other, given where it names each: the line of the first of the
|
|
878
|
+
two names, and the excerpt around them."""
|
|
879
|
+
# One pass in text order: each occurrence pairs with the latest one
|
|
880
|
+
# before it of the other skill (an occurrence of both, with itself).
|
|
881
|
+
marked = sorted(
|
|
882
|
+
[(o.start, o.end, 0, o) for o in found_a]
|
|
883
|
+
+ [(o.start, o.end, 1, o) for o in found_b],
|
|
884
|
+
key=lambda m: m[:3],
|
|
885
|
+
)
|
|
886
|
+
latest: list[_Occurrence | None] = [None, None]
|
|
887
|
+
best: tuple[tuple[int, int], _Occurrence, _Occurrence] | None = None
|
|
888
|
+
for _, _, side, o in marked:
|
|
889
|
+
p = latest[1 - side]
|
|
890
|
+
if p is not None:
|
|
891
|
+
key = (max(p.end, o.end) - p.start, p.start)
|
|
892
|
+
if best is None or key < best[0]:
|
|
893
|
+
best = (key, p, o)
|
|
894
|
+
latest[side] = o
|
|
895
|
+
_, first, second = best # the text names both skills
|
|
896
|
+
return (
|
|
897
|
+
line + text.count("\n", 0, first.start),
|
|
898
|
+
_excerpt_around(
|
|
899
|
+
text, (first.start, first.end), (second.start, second.end)
|
|
900
|
+
),
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _paragraph_evidence(
|
|
905
|
+
para: Paragraph, present: list[str], matcher: _EachSkill | _LongestTerm
|
|
906
|
+
) -> list[tuple[tuple[str, str], dict]]:
|
|
907
|
+
"""One evidence entry per pair of the skills a paragraph names, with
|
|
908
|
+
its kind and where the paragraph names the pair. `present` is sorted,
|
|
909
|
+
so every pair is (a, b) with a < b."""
|
|
910
|
+
if len(present) < 2:
|
|
911
|
+
return []
|
|
912
|
+
among = frozenset(present)
|
|
913
|
+
# Each unit, with where it names each skill.
|
|
914
|
+
units: list[tuple[Unit, dict[str, list[_Occurrence]]]] = []
|
|
915
|
+
for unit in para.units:
|
|
916
|
+
named: dict[str, list[_Occurrence]] = {}
|
|
917
|
+
for o in matcher.find(unit.text, among):
|
|
918
|
+
for n in o.names:
|
|
919
|
+
named.setdefault(n, []).append(o)
|
|
920
|
+
units.append((unit, named))
|
|
921
|
+
# pair -> (kind, unit_line, unit_excerpt); a co-mention wins over a
|
|
922
|
+
# lead-in, and the first unit (or lead-in and item) over later ones.
|
|
923
|
+
where: dict[tuple[str, str], tuple[str, int, str]] = {}
|
|
924
|
+
for unit, named in units:
|
|
925
|
+
for a, b in itertools.combinations(sorted(named), 2):
|
|
926
|
+
if (a, b) not in where:
|
|
927
|
+
where[a, b] = (CO_MENTION, *_pair_quote(
|
|
928
|
+
unit.text, unit.line, named[a], named[b]
|
|
929
|
+
))
|
|
930
|
+
lead: int | None = None # the run of prose the next units sit under
|
|
931
|
+
for k, (unit, named) in enumerate(units):
|
|
932
|
+
if unit.prose:
|
|
933
|
+
lead = k
|
|
934
|
+
continue
|
|
935
|
+
if lead is None:
|
|
936
|
+
continue
|
|
937
|
+
lead_unit, lead_named = units[lead]
|
|
938
|
+
# Each skill of such a pair is named in one of the two units only:
|
|
939
|
+
# a unit that named both would have made it a co-mention.
|
|
940
|
+
pairs = [
|
|
941
|
+
(min(x, y), max(x, y))
|
|
942
|
+
for x, y in itertools.product(sorted(lead_named), sorted(named))
|
|
943
|
+
if (min(x, y), max(x, y)) not in where and x != y
|
|
944
|
+
]
|
|
945
|
+
if not pairs:
|
|
946
|
+
continue
|
|
947
|
+
# The lead-in, then the item, with `...` for any items between.
|
|
948
|
+
gap = "\n" if k == lead + 1 else "\n...\n"
|
|
949
|
+
text = lead_unit.text + gap + unit.text
|
|
950
|
+
shift = len(lead_unit.text) + len(gap)
|
|
951
|
+
for a, b in pairs:
|
|
952
|
+
found = [
|
|
953
|
+
lead_named[n] if n in lead_named else [
|
|
954
|
+
o._replace(start=o.start + shift, end=o.end + shift)
|
|
955
|
+
for o in named[n]
|
|
956
|
+
]
|
|
957
|
+
for n in (a, b)
|
|
958
|
+
]
|
|
959
|
+
where[a, b] = (LEAD_IN, *_pair_quote(text, lead_unit.line, *found))
|
|
960
|
+
excerpt = _make_excerpt(para.text)
|
|
961
|
+
evidence = []
|
|
962
|
+
for pair in itertools.combinations(present, 2):
|
|
963
|
+
kind, unit_line, unit_excerpt = where.get(pair, (LIST_ONLY, None, None))
|
|
964
|
+
evidence.append((pair, {
|
|
965
|
+
"header": para.header,
|
|
966
|
+
"excerpt": excerpt,
|
|
967
|
+
"line": para.line,
|
|
968
|
+
"kind": kind,
|
|
969
|
+
"unit_line": unit_line,
|
|
970
|
+
"unit_excerpt": unit_excerpt,
|
|
971
|
+
}))
|
|
972
|
+
return evidence
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _pair_entry(a: str, b: str, evidence: list[dict]) -> dict:
|
|
976
|
+
return {
|
|
977
|
+
"a": a,
|
|
978
|
+
"b": b,
|
|
979
|
+
"paragraph_count": len(evidence),
|
|
980
|
+
"comention_count": sum(1 for e in evidence if e["kind"] == CO_MENTION),
|
|
981
|
+
"lead_in_count": sum(1 for e in evidence if e["kind"] == LEAD_IN),
|
|
982
|
+
"evidence": evidence,
|
|
186
983
|
}
|
|
187
|
-
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
# --------------------------------------------------------------------------
|
|
987
|
+
# Subcommand logic
|
|
988
|
+
# --------------------------------------------------------------------------
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def analyze(doc_text: str, skills: list[Skill | str]) -> dict:
|
|
992
|
+
"""comention: every candidate pair a body paragraph names in one unit
|
|
993
|
+
or through a lead-in, with its evidence, each entry marking whether an
|
|
994
|
+
introductory section holds it, and the count of pairs only listed
|
|
995
|
+
together.
|
|
996
|
+
|
|
997
|
+
Deterministic: same (doc_text, skills) → identical output.
|
|
998
|
+
"""
|
|
999
|
+
matcher = _EachSkill(_coerce(skills))
|
|
188
1000
|
|
|
189
1001
|
excluded_sections: set[str] = set()
|
|
190
|
-
# (a, b) -> list of evidence dicts, one per
|
|
1002
|
+
# (a, b) -> list of evidence dicts, one per paragraph naming both
|
|
191
1003
|
pair_evidence: dict[tuple[str, str], list[dict]] = {}
|
|
192
1004
|
|
|
193
|
-
for
|
|
194
|
-
|
|
195
|
-
|
|
1005
|
+
for para in read_markdown(doc_text, skip_fenced=False).paragraphs:
|
|
1006
|
+
excluded = False
|
|
1007
|
+
if para.header is not None:
|
|
1008
|
+
norm = normalize_header(para.header)
|
|
196
1009
|
if norm in EXCLUDED_HEADERS:
|
|
197
1010
|
excluded_sections.add(norm)
|
|
198
|
-
|
|
199
|
-
#
|
|
200
|
-
present =
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
evidence_entry = {"header": governing, "excerpt": excerpt}
|
|
205
|
-
for i in range(len(present)):
|
|
206
|
-
for j in range(i + 1, len(present)):
|
|
207
|
-
key = (present[i], present[j])
|
|
208
|
-
pair_evidence.setdefault(key, []).append(evidence_entry)
|
|
1011
|
+
excluded = True
|
|
1012
|
+
# `present` is sorted → pairs are (a < b)
|
|
1013
|
+
present = sorted(matcher.names(para.text))
|
|
1014
|
+
for pair, entry in _paragraph_evidence(para, present, matcher):
|
|
1015
|
+
entry["excluded_section"] = excluded
|
|
1016
|
+
pair_evidence.setdefault(pair, []).append(entry)
|
|
209
1017
|
|
|
210
1018
|
pairs: list[dict] = []
|
|
1019
|
+
list_only_pairs = 0
|
|
211
1020
|
for (a, b), evidence in pair_evidence.items():
|
|
212
|
-
|
|
1021
|
+
entry = _pair_entry(a, b, evidence)
|
|
1022
|
+
if entry["comention_count"] or entry["lead_in_count"]:
|
|
1023
|
+
pairs.append(entry)
|
|
1024
|
+
else:
|
|
1025
|
+
list_only_pairs += 1
|
|
1026
|
+
pairs.sort(key=lambda p: (-p["paragraph_count"], p["a"], p["b"]))
|
|
1027
|
+
|
|
1028
|
+
return {
|
|
1029
|
+
"pairs": pairs,
|
|
1030
|
+
"list_only_pair_count": list_only_pairs,
|
|
1031
|
+
"excluded_section_count": len(excluded_sections),
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _named(occurrences: list[_Occurrence]) -> set[str]:
|
|
1036
|
+
return set().union(*(o.names for o in occurrences))
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _spelling(skill: Skill, term: str) -> tuple[str, str]:
|
|
1040
|
+
"""The skill's own spelling of a normalised term, and its kind."""
|
|
1041
|
+
for i, t in enumerate(skill.terms):
|
|
1042
|
+
if _norm(t) == term:
|
|
1043
|
+
return t, "name" if i == 0 else "alias"
|
|
1044
|
+
return term, "alias"
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _squash(text: str) -> str:
|
|
1048
|
+
"""Lowercased, with whitespace, hyphens, dots and underscores dropped."""
|
|
1049
|
+
return re.sub(r"[\s\-._]+", "", text.lower())
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def coverage(
|
|
1053
|
+
technologies: list[str], specs: dict[str, Skill], matcher: _LongestTerm
|
|
1054
|
+
) -> tuple[list[dict], list[str]]:
|
|
1055
|
+
"""Which skills cover each technology name, and the names none covers.
|
|
1056
|
+
Names are trimmed, and a repeat (ignoring case and runs of whitespace)
|
|
1057
|
+
is dropped. A term covers only by squashed equality: a name or an
|
|
1058
|
+
alias found only inside the name is listed as `name-contained` or
|
|
1059
|
+
`alias-contained` and covers nothing."""
|
|
1060
|
+
technologies = list(_dedupe(tuple(t.strip() for t in technologies), key=_norm))
|
|
1061
|
+
squashed = [
|
|
1062
|
+
(n, t, "name" if i == 0 else "alias", _squash(t))
|
|
1063
|
+
for n in sorted(specs) for i, t in enumerate(specs[n].terms)
|
|
1064
|
+
]
|
|
1065
|
+
entries: list[dict] = []
|
|
1066
|
+
unverified: list[str] = []
|
|
1067
|
+
for tech in technologies:
|
|
1068
|
+
# skill -> (term, kind): the skills that cover the name.
|
|
1069
|
+
found: dict[str, tuple[str, str]] = {}
|
|
1070
|
+
# skill -> (term, kind): the skills one of whose terms sits inside
|
|
1071
|
+
# the name.
|
|
1072
|
+
contained: dict[str, tuple[str, str]] = {}
|
|
1073
|
+
for occurrence in matcher.find(tech):
|
|
1074
|
+
for n in sorted(occurrence.names):
|
|
1075
|
+
term, kind = _spelling(specs[n], occurrence.term)
|
|
1076
|
+
kind = NAME_CONTAINED if kind == "name" else ALIAS_CONTAINED
|
|
1077
|
+
contained.setdefault(n, (term, kind))
|
|
1078
|
+
target = _squash(tech)
|
|
1079
|
+
for n, term, kind, flat in squashed:
|
|
1080
|
+
if flat and flat == target:
|
|
1081
|
+
found.setdefault(n, (term, kind))
|
|
1082
|
+
covered_by = {
|
|
1083
|
+
n: {"skill": n, "term": term, "kind": kind}
|
|
1084
|
+
for n, (term, kind) in found.items()
|
|
1085
|
+
}
|
|
1086
|
+
for n, (term, kind) in contained.items():
|
|
1087
|
+
covered_by.setdefault(n, {"skill": n, "term": term, "kind": kind})
|
|
1088
|
+
entries.append({
|
|
1089
|
+
"name": tech,
|
|
1090
|
+
"covered_by": [covered_by[n] for n in sorted(covered_by)],
|
|
1091
|
+
})
|
|
1092
|
+
if not found:
|
|
1093
|
+
unverified.append(tech)
|
|
1094
|
+
return entries, unverified
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def mentions(
|
|
1098
|
+
doc_text: str,
|
|
1099
|
+
skills: list[Skill | str],
|
|
1100
|
+
technologies: list[str] | None = None,
|
|
1101
|
+
) -> dict:
|
|
1102
|
+
"""mentions: which skills the document names outside fenced code, the
|
|
1103
|
+
term that named each, the candidate pairs a body paragraph names in one
|
|
1104
|
+
unit or through a lead-in, and, given technology names, which skills
|
|
1105
|
+
cover each."""
|
|
1106
|
+
specs = _coerce(skills)
|
|
1107
|
+
names = sorted(specs)
|
|
1108
|
+
matcher = _LongestTerm(specs)
|
|
1109
|
+
doc = read_markdown(doc_text, skip_fenced=True)
|
|
1110
|
+
|
|
1111
|
+
# (skill, normalised term) -> [paragraphs, headings, fenced blocks]
|
|
1112
|
+
term_counts: dict[tuple[str, str], list[int]] = {}
|
|
1113
|
+
|
|
1114
|
+
def count_terms(occurrences: list[_Occurrence], slot: int) -> None:
|
|
1115
|
+
for name, term in {(n, o.term) for o in occurrences for n in o.names}:
|
|
1116
|
+
term_counts.setdefault((name, term), [0, 0, 0])[slot] += 1
|
|
1117
|
+
|
|
1118
|
+
hits: dict[str, list[dict]] = {n: [] for n in names}
|
|
1119
|
+
pair_evidence: dict[tuple[str, str], list[dict]] = {}
|
|
1120
|
+
for para in doc.paragraphs:
|
|
1121
|
+
occurrences = matcher.find(para.text)
|
|
1122
|
+
count_terms(occurrences, 0)
|
|
1123
|
+
present = sorted(_named(occurrences))
|
|
1124
|
+
if not present:
|
|
1125
|
+
continue
|
|
1126
|
+
excerpt = _make_excerpt(para.text)
|
|
1127
|
+
for n in present:
|
|
1128
|
+
hits[n].append(
|
|
1129
|
+
{"header": para.header, "excerpt": excerpt, "line": para.line}
|
|
1130
|
+
)
|
|
1131
|
+
for pair, entry in _paragraph_evidence(para, present, matcher):
|
|
1132
|
+
pair_evidence.setdefault(pair, []).append(entry)
|
|
1133
|
+
|
|
1134
|
+
in_headings: list[set[str]] = []
|
|
1135
|
+
for heading in doc.headings:
|
|
1136
|
+
occurrences = matcher.find(heading)
|
|
1137
|
+
count_terms(occurrences, 1)
|
|
1138
|
+
in_headings.append(_named(occurrences))
|
|
1139
|
+
in_fenced: list[set[str]] = []
|
|
1140
|
+
for fence in doc.fenced:
|
|
1141
|
+
occurrences = matcher.find(fence.text)
|
|
1142
|
+
count_terms(occurrences, 2)
|
|
1143
|
+
in_fenced.append(_named(occurrences))
|
|
1144
|
+
skills_out: list[dict] = []
|
|
1145
|
+
mentioned: list[str] = []
|
|
1146
|
+
fenced_only: list[str] = []
|
|
1147
|
+
unmentioned: list[str] = []
|
|
1148
|
+
for n in names:
|
|
1149
|
+
heading_count = sum(1 for found in in_headings if n in found)
|
|
1150
|
+
fenced_count = sum(1 for found in in_fenced if n in found)
|
|
1151
|
+
terms = []
|
|
1152
|
+
for term in specs[n].terms:
|
|
1153
|
+
counts = term_counts.get((n, _norm(term)))
|
|
1154
|
+
if counts:
|
|
1155
|
+
terms.append({
|
|
1156
|
+
"term": term,
|
|
1157
|
+
"kind": _spelling(specs[n], _norm(term))[1],
|
|
1158
|
+
"paragraph_count": counts[0],
|
|
1159
|
+
"heading_count": counts[1],
|
|
1160
|
+
"fenced_count": counts[2],
|
|
1161
|
+
})
|
|
1162
|
+
skills_out.append({
|
|
1163
|
+
"name": n,
|
|
1164
|
+
"paragraph_count": len(hits[n]),
|
|
1165
|
+
"heading_count": heading_count,
|
|
1166
|
+
"fenced_count": fenced_count,
|
|
1167
|
+
"terms": terms,
|
|
1168
|
+
"paragraphs": hits[n],
|
|
1169
|
+
})
|
|
1170
|
+
if hits[n] or heading_count:
|
|
1171
|
+
mentioned.append(n)
|
|
1172
|
+
elif fenced_count:
|
|
1173
|
+
fenced_only.append(n)
|
|
1174
|
+
else:
|
|
1175
|
+
unmentioned.append(n)
|
|
1176
|
+
|
|
1177
|
+
candidates: list[dict] = []
|
|
1178
|
+
list_only_pairs = 0
|
|
1179
|
+
for (a, b), evidence in pair_evidence.items():
|
|
1180
|
+
entry = _pair_entry(a, b, evidence)
|
|
1181
|
+
if entry["comention_count"] or entry["lead_in_count"]:
|
|
1182
|
+
candidates.append(entry)
|
|
1183
|
+
else:
|
|
1184
|
+
list_only_pairs += 1
|
|
1185
|
+
candidates.sort(key=lambda p: (
|
|
1186
|
+
-p["comention_count"], -p["lead_in_count"], -p["paragraph_count"],
|
|
1187
|
+
p["a"], p["b"],
|
|
1188
|
+
))
|
|
1189
|
+
|
|
1190
|
+
result = {
|
|
1191
|
+
"skills": skills_out,
|
|
1192
|
+
"mentioned": mentioned,
|
|
1193
|
+
"fenced_only": fenced_only,
|
|
1194
|
+
"unmentioned": unmentioned,
|
|
1195
|
+
"candidates": candidates,
|
|
1196
|
+
"list_only_pair_count": list_only_pairs,
|
|
1197
|
+
"fenced_block_count": len(doc.fenced),
|
|
1198
|
+
"fenced_blocks": [{"line": f.line, "info": f.info} for f in doc.fenced],
|
|
1199
|
+
}
|
|
1200
|
+
if technologies is not None:
|
|
1201
|
+
entries, unverified = coverage(technologies, specs, matcher)
|
|
1202
|
+
result["technologies"] = entries
|
|
1203
|
+
result["unverified_technologies"] = unverified
|
|
1204
|
+
return result
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def _read_doc(path_text: str, owner: str) -> str:
|
|
1208
|
+
"""Read one of a skill's `docs` files (infer)."""
|
|
1209
|
+
path = Path(path_text)
|
|
1210
|
+
if not path.is_file():
|
|
1211
|
+
raise UserError(f"docs file of skill {owner!r} not found: {path}")
|
|
1212
|
+
try:
|
|
1213
|
+
return path.read_text(encoding="utf-8")
|
|
1214
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
1215
|
+
raise UserError(
|
|
1216
|
+
f"failed to read docs file of skill {owner!r} ({path}): {exc}"
|
|
1217
|
+
) from exc
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def infer(skills: list[Skill | str], top_k: int = DEFAULT_TOP_K) -> dict:
|
|
1221
|
+
"""infer: candidate pairs without an architecture document, from one
|
|
1222
|
+
skill's docs naming the other or shared domain keywords, never a shared
|
|
1223
|
+
language alone. Top-K capped."""
|
|
1224
|
+
specs = _coerce(skills)
|
|
1225
|
+
names = sorted(specs)
|
|
1226
|
+
languages = {lang for s in specs.values() for lang in s.languages}
|
|
1227
|
+
matcher = _LongestTerm(specs)
|
|
1228
|
+
|
|
1229
|
+
# (documenting skill, named skill) -> its docs-mention entry
|
|
1230
|
+
mentioned: dict[tuple[str, str], dict] = {}
|
|
1231
|
+
for a in names:
|
|
1232
|
+
for doc in specs[a].docs:
|
|
1233
|
+
for lineno, line in enumerate(_read_doc(doc, a).splitlines(), start=1):
|
|
1234
|
+
named: set[str] = set()
|
|
1235
|
+
for occurrence in matcher.find(line):
|
|
1236
|
+
# An occurrence of the documenting skill's own term
|
|
1237
|
+
# names that skill, never another one.
|
|
1238
|
+
if a not in occurrence.names:
|
|
1239
|
+
named |= occurrence.names
|
|
1240
|
+
for b in sorted(named):
|
|
1241
|
+
found = mentioned.get((a, b))
|
|
1242
|
+
if found is None:
|
|
1243
|
+
mentioned[(a, b)] = {
|
|
1244
|
+
"kind": DOCS_MENTION,
|
|
1245
|
+
"documented_by": a,
|
|
1246
|
+
"doc": doc.replace("\\", "/"),
|
|
1247
|
+
"line": lineno,
|
|
1248
|
+
"line_count": 1,
|
|
1249
|
+
"excerpt": _make_excerpt(line),
|
|
1250
|
+
}
|
|
1251
|
+
else:
|
|
1252
|
+
found["line_count"] += 1
|
|
1253
|
+
|
|
1254
|
+
pairs: list[dict] = []
|
|
1255
|
+
language_only_pairs = 0
|
|
1256
|
+
for a, b in itertools.combinations(names, 2):
|
|
1257
|
+
evidence = [mentioned[k] for k in ((a, b), (b, a)) if k in mentioned]
|
|
1258
|
+
docs_mention_count = len(evidence)
|
|
1259
|
+
shared = sorted(
|
|
1260
|
+
(set(specs[a].keywords) & set(specs[b].keywords)) - languages
|
|
1261
|
+
)
|
|
1262
|
+
if shared:
|
|
1263
|
+
evidence.append({"kind": SHARED_KEYWORDS, "keywords": shared})
|
|
1264
|
+
if evidence:
|
|
213
1265
|
pairs.append({
|
|
214
1266
|
"a": a,
|
|
215
1267
|
"b": b,
|
|
216
|
-
"
|
|
1268
|
+
"docs_mention_count": docs_mention_count,
|
|
1269
|
+
"shared_keyword_count": len(shared),
|
|
217
1270
|
"evidence": evidence,
|
|
218
1271
|
})
|
|
219
|
-
|
|
1272
|
+
elif set(specs[a].languages) & set(specs[b].languages):
|
|
1273
|
+
language_only_pairs += 1
|
|
1274
|
+
pairs.sort(key=lambda p: (
|
|
1275
|
+
-p["docs_mention_count"], -p["shared_keyword_count"], p["a"], p["b"],
|
|
1276
|
+
))
|
|
220
1277
|
|
|
221
1278
|
return {
|
|
222
|
-
"pairs": pairs,
|
|
223
|
-
"
|
|
1279
|
+
"pairs": pairs[:top_k],
|
|
1280
|
+
"truncated": len(pairs) > top_k,
|
|
1281
|
+
"total_pairs": len(pairs),
|
|
1282
|
+
"language_only_pair_count": language_only_pairs,
|
|
224
1283
|
}
|
|
225
1284
|
|
|
226
1285
|
|
|
@@ -234,39 +1293,76 @@ def _read_source(source: str, label: str) -> str:
|
|
|
234
1293
|
if source == "-":
|
|
235
1294
|
try:
|
|
236
1295
|
return sys.stdin.read()
|
|
237
|
-
except OSError as exc:
|
|
1296
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
238
1297
|
raise UserError(f"failed to read {label} from stdin: {exc}") from exc
|
|
239
1298
|
path = Path(source)
|
|
240
1299
|
if not path.is_file():
|
|
241
1300
|
raise UserError(f"{label} file not found: {path}")
|
|
242
1301
|
try:
|
|
243
1302
|
return path.read_text(encoding="utf-8")
|
|
244
|
-
except OSError as exc:
|
|
1303
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
245
1304
|
raise UserError(f"failed to read {label} file {path}: {exc}") from exc
|
|
246
1305
|
|
|
247
1306
|
|
|
248
|
-
def parse_skills(raw_text: str) -> list[
|
|
249
|
-
"""Parse and validate the --skills JSON array of
|
|
1307
|
+
def parse_skills(raw_text: str) -> list[Skill]:
|
|
1308
|
+
"""Parse and validate the --skills JSON array of skill names or objects."""
|
|
250
1309
|
try:
|
|
251
1310
|
data = json.loads(raw_text)
|
|
252
1311
|
except json.JSONDecodeError as exc:
|
|
253
1312
|
raise UserError(f"malformed JSON in --skills input: {exc}") from exc
|
|
254
1313
|
if not isinstance(data, list):
|
|
255
1314
|
raise UserError(
|
|
256
|
-
"--skills input must be a JSON array of
|
|
1315
|
+
"--skills input must be a JSON array of skill names or objects; "
|
|
1316
|
+
f"got {type(data).__name__}"
|
|
1317
|
+
)
|
|
1318
|
+
skills: list[Skill] = []
|
|
1319
|
+
for idx, item in enumerate(data):
|
|
1320
|
+
where = f"--skills[{idx}]"
|
|
1321
|
+
if isinstance(item, str) and item.strip():
|
|
1322
|
+
skills.append(Skill(item, (item,)))
|
|
1323
|
+
continue
|
|
1324
|
+
if not isinstance(item, dict):
|
|
1325
|
+
raise UserError(
|
|
1326
|
+
f"{where} must be a non-empty string or an object with a "
|
|
1327
|
+
f"non-empty `name`; got {item!r}"
|
|
1328
|
+
)
|
|
1329
|
+
name = item.get("name")
|
|
1330
|
+
if not isinstance(name, str) or not name.strip():
|
|
1331
|
+
raise UserError(
|
|
1332
|
+
f"{where}.name must be a non-empty string; got {name!r}"
|
|
1333
|
+
)
|
|
1334
|
+
skills.append(Skill(
|
|
1335
|
+
name=name,
|
|
1336
|
+
terms=_dedupe((name, *_string_list(item, "aliases", where)), key=_norm),
|
|
1337
|
+
keywords=tuple(sorted({
|
|
1338
|
+
_norm(k) for k in _string_list(item, "keywords", where)
|
|
1339
|
+
})),
|
|
1340
|
+
languages=_languages(item, where),
|
|
1341
|
+
docs=_dedupe(_string_list(item, "docs", where)),
|
|
1342
|
+
))
|
|
1343
|
+
return skills
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def parse_technologies(raw_text: str) -> list[str]:
|
|
1347
|
+
"""Parse and validate the --technologies JSON array of names."""
|
|
1348
|
+
try:
|
|
1349
|
+
data = json.loads(raw_text)
|
|
1350
|
+
except json.JSONDecodeError as exc:
|
|
1351
|
+
raise UserError(f"malformed JSON in --technologies input: {exc}") from exc
|
|
1352
|
+
if not isinstance(data, list):
|
|
1353
|
+
raise UserError(
|
|
1354
|
+
"--technologies input must be a JSON array of names; "
|
|
257
1355
|
f"got {type(data).__name__}"
|
|
258
1356
|
)
|
|
259
|
-
names: list[str] = []
|
|
260
1357
|
for idx, item in enumerate(data):
|
|
261
|
-
if not isinstance(item, str) or not item:
|
|
1358
|
+
if not isinstance(item, str) or not item.strip():
|
|
262
1359
|
raise UserError(
|
|
263
|
-
f"--
|
|
1360
|
+
f"--technologies[{idx}] must be a non-empty string; got {item!r}"
|
|
264
1361
|
)
|
|
265
|
-
|
|
266
|
-
return names
|
|
1362
|
+
return data
|
|
267
1363
|
|
|
268
1364
|
|
|
269
|
-
def
|
|
1365
|
+
def _read_doc_and_skills(args: argparse.Namespace) -> tuple[str, list[Skill]]:
|
|
270
1366
|
if args.doc == "-" and args.skills == "-":
|
|
271
1367
|
raise UserError(
|
|
272
1368
|
"--doc and --skills cannot both read from stdin ('-')"
|
|
@@ -278,27 +1374,102 @@ def _cmd_comention(args: argparse.Namespace) -> int:
|
|
|
278
1374
|
else:
|
|
279
1375
|
doc_text = _read_source(args.doc, "--doc")
|
|
280
1376
|
skills_text = _read_source(args.skills, "--skills")
|
|
281
|
-
|
|
1377
|
+
return doc_text, parse_skills(skills_text)
|
|
1378
|
+
|
|
282
1379
|
|
|
283
|
-
|
|
1380
|
+
def _emit(result: dict) -> int:
|
|
1381
|
+
json.dump(result, sys.stdout, indent=2)
|
|
1382
|
+
sys.stdout.write("\n")
|
|
1383
|
+
return 0
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def _cmd_comention(args: argparse.Namespace) -> int:
|
|
1387
|
+
doc_text, skills = _read_doc_and_skills(args)
|
|
1388
|
+
result = analyze(doc_text, skills)
|
|
284
1389
|
if args.verbose:
|
|
285
1390
|
print(
|
|
286
|
-
f"analyzed {len(
|
|
287
|
-
f"{len(result['pairs'])}
|
|
288
|
-
f"{result['
|
|
1391
|
+
f"analyzed {len({s.name for s in skills})} distinct skill names; "
|
|
1392
|
+
f"{len(result['pairs'])} candidate pairs; "
|
|
1393
|
+
f"{result['list_only_pair_count']} list-only pairs; "
|
|
1394
|
+
f"{result['excluded_section_count']} introductory sections",
|
|
289
1395
|
file=sys.stderr,
|
|
290
1396
|
)
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
1397
|
+
return _emit(result)
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
def _cmd_mentions(args: argparse.Namespace) -> int:
|
|
1401
|
+
if args.technologies == "-" and "-" in (args.doc, args.skills):
|
|
1402
|
+
raise UserError(
|
|
1403
|
+
"only one of --doc, --skills and --technologies can read from "
|
|
1404
|
+
"stdin ('-')"
|
|
1405
|
+
)
|
|
1406
|
+
doc_text, skills = _read_doc_and_skills(args)
|
|
1407
|
+
technologies = None
|
|
1408
|
+
if args.technologies is not None:
|
|
1409
|
+
technologies = parse_technologies(
|
|
1410
|
+
_read_source(args.technologies, "--technologies")
|
|
1411
|
+
)
|
|
1412
|
+
result = mentions(doc_text, skills, technologies)
|
|
1413
|
+
if args.verbose:
|
|
1414
|
+
summary = (
|
|
1415
|
+
f"analyzed {len(result['skills'])} distinct skill names; "
|
|
1416
|
+
f"{len(result['mentioned'])} mentioned, "
|
|
1417
|
+
f"{len(result['fenced_only'])} only in fenced code, "
|
|
1418
|
+
f"{len(result['unmentioned'])} unmentioned; "
|
|
1419
|
+
f"{len(result['candidates'])} candidate pairs; "
|
|
1420
|
+
f"{result['list_only_pair_count']} list-only pairs"
|
|
1421
|
+
)
|
|
1422
|
+
if technologies is not None:
|
|
1423
|
+
summary += (
|
|
1424
|
+
f"; {len(result['technologies'])} technologies, "
|
|
1425
|
+
f"{len(result['unverified_technologies'])} not covered"
|
|
1426
|
+
)
|
|
1427
|
+
print(summary, file=sys.stderr)
|
|
1428
|
+
return _emit(result)
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def _cmd_infer(args: argparse.Namespace) -> int:
|
|
1432
|
+
if args.top_k < 0:
|
|
1433
|
+
raise UserError(f"--top-k must be >= 0; got {args.top_k}")
|
|
1434
|
+
skills = parse_skills(_read_source(args.skills, "--skills"))
|
|
1435
|
+
result = infer(skills, top_k=args.top_k)
|
|
1436
|
+
if args.verbose:
|
|
1437
|
+
print(
|
|
1438
|
+
f"analyzed {len({s.name for s in skills})} distinct skill names; "
|
|
1439
|
+
f"{result['total_pairs']} candidate pairs, "
|
|
1440
|
+
f"{len(result['pairs'])} emitted; "
|
|
1441
|
+
f"{result['language_only_pair_count']} language-only pairs",
|
|
1442
|
+
file=sys.stderr,
|
|
1443
|
+
)
|
|
1444
|
+
return _emit(result)
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def _add_doc_and_skills(p: argparse.ArgumentParser, doc_help: str) -> None:
|
|
1448
|
+
p.add_argument("--doc", required=True, help=doc_help)
|
|
1449
|
+
p.add_argument(
|
|
1450
|
+
"--skills",
|
|
1451
|
+
required=True,
|
|
1452
|
+
help=(
|
|
1453
|
+
"path to a JSON array of loaded skills, or '-' for stdin. "
|
|
1454
|
+
"Shape: [\"<skill>\" | {\"name\": \"<skill>\", "
|
|
1455
|
+
"\"aliases\": [\"<term>\", ...]}, ...]"
|
|
1456
|
+
),
|
|
1457
|
+
)
|
|
1458
|
+
p.add_argument(
|
|
1459
|
+
"--verbose",
|
|
1460
|
+
action="store_true",
|
|
1461
|
+
help="print a one-line summary to stderr",
|
|
1462
|
+
)
|
|
294
1463
|
|
|
295
1464
|
|
|
296
1465
|
def _build_parser() -> argparse.ArgumentParser:
|
|
297
1466
|
parser = argparse.ArgumentParser(
|
|
298
1467
|
prog="skf-comention-pairs",
|
|
299
1468
|
description=(
|
|
300
|
-
"
|
|
301
|
-
"
|
|
1469
|
+
"Report which loaded skills a document names and the pairs it "
|
|
1470
|
+
"names together (comention: detect-integrations.md §2 compose "
|
|
1471
|
+
"branch; mentions: refine-architecture scope sets), or candidate "
|
|
1472
|
+
"pairs from the skills' own docs and keywords (infer)."
|
|
302
1473
|
),
|
|
303
1474
|
)
|
|
304
1475
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
@@ -306,29 +1477,68 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
306
1477
|
p_cm = sub.add_parser(
|
|
307
1478
|
"comention",
|
|
308
1479
|
help=(
|
|
309
|
-
"emit
|
|
310
|
-
"word-boundary,
|
|
1480
|
+
"emit every candidate pair a body paragraph names in one unit "
|
|
1481
|
+
"or through a lead-in (word-boundary), with evidence kinds and "
|
|
1482
|
+
"introductory sections marked, and count the pairs only listed "
|
|
1483
|
+
"together, as JSON"
|
|
311
1484
|
),
|
|
312
1485
|
)
|
|
313
|
-
|
|
314
|
-
"
|
|
315
|
-
|
|
316
|
-
|
|
1486
|
+
_add_doc_and_skills(
|
|
1487
|
+
p_cm, "path to the architecture markdown document, or '-' for stdin"
|
|
1488
|
+
)
|
|
1489
|
+
p_cm.set_defaults(func=_cmd_comention)
|
|
1490
|
+
|
|
1491
|
+
p_mn = sub.add_parser(
|
|
1492
|
+
"mentions",
|
|
1493
|
+
help=(
|
|
1494
|
+
"emit which skills a document names (fenced code skipped), the "
|
|
1495
|
+
"term that named each, the pairs a paragraph names together "
|
|
1496
|
+
"and, with --technologies, which skills cover each name, as JSON"
|
|
1497
|
+
),
|
|
1498
|
+
)
|
|
1499
|
+
_add_doc_and_skills(
|
|
1500
|
+
p_mn, "path to the markdown document, or '-' for stdin"
|
|
1501
|
+
)
|
|
1502
|
+
p_mn.add_argument(
|
|
1503
|
+
"--technologies",
|
|
1504
|
+
help=(
|
|
1505
|
+
"path to a JSON array of technology names as the document "
|
|
1506
|
+
"writes them, or '-' for stdin: adds which skills cover each "
|
|
1507
|
+
"name and the names none covers"
|
|
1508
|
+
),
|
|
1509
|
+
)
|
|
1510
|
+
p_mn.set_defaults(func=_cmd_mentions)
|
|
1511
|
+
|
|
1512
|
+
p_in = sub.add_parser(
|
|
1513
|
+
"infer",
|
|
1514
|
+
help=(
|
|
1515
|
+
"emit candidate pairs with no architecture document: one "
|
|
1516
|
+
"skill's docs naming the other or shared domain keywords, never "
|
|
1517
|
+
"a shared language alone, Top-K capped, as JSON"
|
|
1518
|
+
),
|
|
317
1519
|
)
|
|
318
|
-
|
|
1520
|
+
p_in.add_argument(
|
|
319
1521
|
"--skills",
|
|
320
1522
|
required=True,
|
|
321
1523
|
help=(
|
|
322
|
-
"path to a JSON array of loaded
|
|
323
|
-
"Shape: [\"<skill>\", ...]"
|
|
1524
|
+
"path to a JSON array of loaded skills, or '-' for stdin. "
|
|
1525
|
+
"Shape: [{\"name\": \"<skill>\", \"aliases\": [...], "
|
|
1526
|
+
"\"keywords\": [...], \"language\": \"<language>\" | [...], "
|
|
1527
|
+
"\"docs\": [\"<path>\", ...]}, ...]"
|
|
324
1528
|
),
|
|
325
1529
|
)
|
|
326
|
-
|
|
1530
|
+
p_in.add_argument(
|
|
1531
|
+
"--top-k",
|
|
1532
|
+
type=int,
|
|
1533
|
+
default=DEFAULT_TOP_K,
|
|
1534
|
+
help=f"cap the output at the top N pairs (default {DEFAULT_TOP_K})",
|
|
1535
|
+
)
|
|
1536
|
+
p_in.add_argument(
|
|
327
1537
|
"--verbose",
|
|
328
1538
|
action="store_true",
|
|
329
1539
|
help="print a one-line summary to stderr",
|
|
330
1540
|
)
|
|
331
|
-
|
|
1541
|
+
p_in.set_defaults(func=_cmd_infer)
|
|
332
1542
|
|
|
333
1543
|
return parser
|
|
334
1544
|
|