bmad-module-skill-forge 2.2.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +49 -49
- package/docs/404.md +10 -8
- package/docs/_data/pinned.yaml +22 -13
- package/docs/_internal/STABILITY.md +58 -35
- package/docs/agents.md +39 -53
- package/docs/architecture.md +65 -51
- package/docs/bmad-synergy.md +53 -51
- package/docs/campaign.md +127 -75
- package/docs/concepts.md +34 -28
- package/docs/examples.md +70 -68
- package/docs/forge-auto.md +34 -31
- package/docs/getting-started.md +118 -57
- package/docs/how-it-works.md +25 -21
- package/docs/index.md +15 -15
- package/docs/skill-model.md +70 -59
- package/docs/troubleshooting.md +143 -18
- package/docs/verifying-a-skill.md +87 -57
- package/docs/why-skf.md +24 -22
- package/docs/workflows.md +274 -110
- package/package.json +18 -10
- package/src/forger/preferences.yaml +8 -1
- package/src/knowledge/agentskills-spec.md +2 -0
- package/src/knowledge/ccc-bridge.md +40 -24
- package/src/knowledge/confidence-tiers.md +9 -5
- package/src/knowledge/overview.md +3 -3
- package/src/knowledge/progressive-capability.md +8 -8
- package/src/knowledge/provenance-tracking.md +9 -5
- package/src/knowledge/qmd-registry.md +3 -3
- package/src/knowledge/skf-knowledge-index.csv +2 -2
- package/src/knowledge/skill-lifecycle.md +18 -11
- package/src/knowledge/split-body-strategy.md +0 -3
- package/src/knowledge/tool-resolution.md +4 -2
- package/src/knowledge/version-paths.md +101 -15
- package/src/module-help.csv +3 -3
- package/src/module.yaml +10 -0
- package/src/shared/data/ast-grep-recipes.yaml +608 -0
- package/src/shared/data/ide-context-files.json +30 -0
- package/src/shared/health-check.md +31 -59
- package/src/shared/references/description-guard-protocol.md +12 -9
- package/src/shared/references/feasibility-report-schema.md +19 -13
- package/src/shared/references/headless-gate-convention.md +55 -3
- package/src/shared/references/output-contract-schema.md +19 -8
- package/src/shared/references/pipeline-contracts.md +94 -29
- package/src/shared/references/tessl-review.md +77 -0
- package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
- package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
- package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
- package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
- package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
- package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
- package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
- package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
- package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
- package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
- package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
- package/src/shared/scripts/skf-atomic-write.py +233 -64
- package/src/shared/scripts/skf-build-change-manifest.py +2753 -28
- package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
- package/src/shared/scripts/skf-check-unit-records.py +184 -0
- package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
- package/src/shared/scripts/skf-classify-changed-files.py +772 -0
- package/src/shared/scripts/skf-comention-pairs.py +1333 -123
- package/src/shared/scripts/skf-compare-file-hashes.py +267 -105
- package/src/shared/scripts/skf-count-imports.py +1867 -0
- package/src/shared/scripts/skf-count-tokens.py +1 -1
- package/src/shared/scripts/skf-derive-assembly-shape.py +21 -3
- package/src/shared/scripts/skf-description-guard.py +99 -11
- package/src/shared/scripts/skf-detect-docs.py +694 -2
- package/src/shared/scripts/skf-detect-language.py +486 -107
- package/src/shared/scripts/skf-detect-registry.py +887 -0
- package/src/shared/scripts/skf-detect-scripts-assets.py +197 -55
- package/src/shared/scripts/skf-detect-tools.py +209 -27
- package/src/shared/scripts/skf-detect-workspaces.py +506 -30
- package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
- package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
- package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
- package/src/shared/scripts/skf-extract-public-api.py +5247 -96
- package/src/shared/scripts/skf-extraction-inventory.py +1280 -0
- package/src/shared/scripts/skf-extraction-snapshot.py +664 -0
- package/src/shared/scripts/skf-fetch-temporal.py +674 -0
- package/src/shared/scripts/skf-find-cycles.py +97 -29
- package/src/shared/scripts/skf-find-test-report.py +476 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +469 -205
- package/src/shared/scripts/skf-github-fetch.py +365 -0
- package/src/shared/scripts/skf-github-probe.py +700 -0
- package/src/shared/scripts/skf-hash-content.py +180 -20
- package/src/shared/scripts/skf-language-corpora.py +1 -1
- package/src/shared/scripts/skf-load-provenance.py +161 -27
- package/src/shared/scripts/skf-manifest-ops.py +1 -1
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
- package/src/shared/scripts/skf-merge-doc-urls.py +143 -20
- package/src/shared/scripts/skf-names-present.py +221 -0
- package/src/shared/scripts/skf-pair-intersect.py +119 -28
- package/src/shared/scripts/skf-parse-gaps.py +1384 -0
- package/src/shared/scripts/skf-preapply.py +1 -1
- package/src/shared/scripts/skf-preflight.py +176 -28
- package/src/shared/scripts/skf-promote-staged.py +430 -0
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +64 -8
- package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
- package/src/shared/scripts/skf-quick-batch.py +774 -0
- package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
- package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
- package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
- package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
- package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
- package/src/shared/scripts/skf-resolve-authoritative-files.py +155 -14
- package/src/shared/scripts/skf-resolve-package.py +523 -106
- package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
- package/src/shared/scripts/skf-run-lock.py +577 -0
- package/src/shared/scripts/skf-scan-manifests.py +998 -57
- package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
- package/src/shared/scripts/skf-severity-classify.py +712 -108
- package/src/shared/scripts/skf-shape-detect.py +298 -204
- package/src/shared/scripts/skf-shard-body.py +9 -8
- package/src/shared/scripts/skf-skill-inventory.py +2412 -89
- package/src/shared/scripts/skf-skills-module.py +472 -0
- package/src/shared/scripts/skf-source-tree.py +1796 -0
- package/src/shared/scripts/skf-structural-diff.py +787 -121
- package/src/shared/scripts/skf-tessl-review.py +706 -0
- package/src/shared/scripts/skf-update-active-symlink.py +120 -67
- package/src/shared/scripts/skf-update-run-state.py +624 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
- package/src/shared/scripts/skf-validate-brief-schema.py +23 -2
- package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
- package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
- package/src/shared/scripts/skf-validate-output.py +712 -64
- package/src/shared/scripts/skf-validate-pins.py +1 -1
- package/src/shared/scripts/skf-verify-no-trace.py +116 -10
- package/src/shared/scripts/skf-verify-provenance-completeness.py +3945 -152
- package/src/shared/scripts/skf-write-skill-brief.py +406 -25
- package/src/shared/tool-requirements.yaml +201 -0
- package/src/skf-analyze-source/SKILL.md +41 -36
- package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
- package/src/skf-analyze-source/customize.toml +36 -24
- package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
- package/src/skf-analyze-source/references/continue.md +2 -13
- package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
- package/src/skf-analyze-source/references/generate-briefs.md +121 -55
- package/src/skf-analyze-source/references/headless-contract.md +42 -15
- package/src/skf-analyze-source/references/identify-units.md +96 -110
- package/src/skf-analyze-source/references/init.md +73 -71
- package/src/skf-analyze-source/references/map-and-detect.md +123 -105
- package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
- package/src/skf-analyze-source/references/recommend.md +51 -70
- package/src/skf-analyze-source/references/scan-project.md +60 -84
- package/src/skf-analyze-source/references/scan-root.md +39 -0
- package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
- package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
- package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
- package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
- package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
- package/src/skf-audit-skill/SKILL.md +43 -51
- package/src/skf-audit-skill/assets/drift-report-template.md +28 -3
- package/src/skf-audit-skill/customize.toml +41 -20
- package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
- package/src/skf-audit-skill/references/doc-drift.md +141 -0
- package/src/skf-audit-skill/references/headless-contract.md +29 -0
- package/src/skf-audit-skill/references/health-check.md +2 -0
- package/src/skf-audit-skill/references/init.md +149 -210
- package/src/skf-audit-skill/references/re-index.md +103 -110
- package/src/skf-audit-skill/references/report.md +96 -33
- package/src/skf-audit-skill/references/semantic-diff.md +34 -25
- package/src/skf-audit-skill/references/severity-classify.md +79 -65
- package/src/skf-audit-skill/references/severity-rules.md +15 -6
- package/src/skf-audit-skill/references/structural-diff.md +80 -90
- package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
- package/src/skf-audit-skill/scripts/render-drift-tables.py +712 -0
- package/src/skf-brief-skill/SKILL.md +37 -26
- package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +18 -25
- package/src/skf-brief-skill/assets/skill-brief-schema.md +30 -23
- package/src/skf-brief-skill/customize.toml +48 -20
- package/src/skf-brief-skill/references/analyze-target.md +144 -127
- package/src/skf-brief-skill/references/confirm-brief.md +11 -9
- package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
- package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
- package/src/skf-brief-skill/references/gather-intent.md +87 -182
- package/src/skf-brief-skill/references/headless-args.md +84 -16
- package/src/skf-brief-skill/references/health-check.md +1 -1
- package/src/skf-brief-skill/references/invocation-contract.md +18 -13
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
- package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
- package/src/skf-brief-skill/references/scope-definition.md +123 -47
- package/src/skf-brief-skill/references/step-auto-brief.md +26 -100
- package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
- package/src/skf-brief-skill/references/version-resolution.md +7 -10
- package/src/skf-brief-skill/references/write-brief.md +66 -57
- package/src/skf-campaign/SKILL.md +31 -34
- package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
- package/src/skf-campaign/customize.toml +47 -16
- package/src/skf-campaign/references/campaign-contracts.md +39 -29
- package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
- package/src/skf-campaign/references/health-check.md +7 -16
- package/src/skf-campaign/references/step-01-setup.md +46 -84
- package/src/skf-campaign/references/step-02-strategy.md +31 -30
- package/src/skf-campaign/references/step-03-pins.md +26 -18
- package/src/skf-campaign/references/step-04-provenance.md +24 -23
- package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
- package/src/skf-campaign/references/step-06-batch.md +41 -34
- package/src/skf-campaign/references/step-07-capstone.md +40 -25
- package/src/skf-campaign/references/step-08-verify.md +28 -29
- package/src/skf-campaign/references/step-09-refine.md +29 -30
- package/src/skf-campaign/references/step-10-export.md +48 -36
- package/src/skf-campaign/references/step-11-maintenance.md +31 -34
- package/src/skf-campaign/references/step-resume.md +37 -68
- package/src/skf-campaign/scripts/campaign-deps.py +57 -18
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
- package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
- package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
- package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
- package/src/skf-campaign/scripts/campaign-report.py +204 -37
- package/src/skf-campaign/scripts/campaign-state.py +1128 -0
- package/src/skf-campaign/scripts/campaign-status.py +10 -74
- package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
- package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
- package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
- package/src/skf-campaign/templates/campaign-report-template.md +5 -1
- package/src/skf-campaign/templates/kickoff-template.md +0 -14
- package/src/skf-create-skill/SKILL.md +27 -20
- package/src/skf-create-skill/assets/compile-assembly-rules.md +11 -8
- package/src/skf-create-skill/assets/skill-sections.md +21 -11
- package/src/skf-create-skill/customize.toml +20 -8
- package/src/skf-create-skill/references/authoritative-files-protocol.md +36 -58
- package/src/skf-create-skill/references/batch-mode.md +81 -0
- package/src/skf-create-skill/references/ccc-index-check.md +27 -0
- package/src/skf-create-skill/references/compile.md +119 -70
- package/src/skf-create-skill/references/component-extraction.md +113 -91
- package/src/skf-create-skill/references/enrich.md +41 -23
- package/src/skf-create-skill/references/entry-points-by-hand.md +37 -0
- package/src/skf-create-skill/references/extract.md +204 -162
- package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +11 -11
- package/src/skf-create-skill/references/extraction-patterns.md +588 -201
- package/src/skf-create-skill/references/generate-artifacts.md +145 -93
- package/src/skf-create-skill/references/health-check.md +1 -1
- package/src/skf-create-skill/references/load-brief.md +53 -36
- package/src/skf-create-skill/references/relabel-rule.md +12 -0
- package/src/skf-create-skill/references/report.md +37 -57
- package/src/skf-create-skill/references/source-resolution-protocols.md +58 -169
- package/src/skf-create-skill/references/step-auto-shard.md +20 -93
- package/src/skf-create-skill/references/step-doc-rot.md +44 -60
- package/src/skf-create-skill/references/step-doc-sources.md +28 -19
- package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
- package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
- package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
- package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
- package/src/skf-create-skill/references/validate.md +173 -99
- package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
- package/src/skf-create-stack-skill/SKILL.md +29 -56
- package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
- package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
- package/src/skf-create-stack-skill/customize.toml +23 -12
- package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
- package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
- package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
- package/src/skf-create-stack-skill/references/generate-output.md +138 -128
- package/src/skf-create-stack-skill/references/init.md +90 -32
- package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
- package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
- package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
- package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
- package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
- package/src/skf-create-stack-skill/references/report.md +39 -33
- package/src/skf-create-stack-skill/references/validate.md +20 -39
- package/src/skf-drop-skill/SKILL.md +28 -51
- package/src/skf-drop-skill/customize.toml +44 -30
- package/src/skf-drop-skill/references/execute.md +83 -146
- package/src/skf-drop-skill/references/health-check.md +2 -0
- package/src/skf-drop-skill/references/invocation-contract.md +64 -0
- package/src/skf-drop-skill/references/report.md +46 -30
- package/src/skf-drop-skill/references/select.md +122 -124
- package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
- package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
- package/src/skf-export-skill/SKILL.md +40 -62
- package/src/skf-export-skill/assets/managed-section-format.md +14 -50
- package/src/skf-export-skill/assets/snippet-format.md +3 -1
- package/src/skf-export-skill/customize.toml +42 -13
- package/src/skf-export-skill/references/generate-snippet.md +54 -46
- package/src/skf-export-skill/references/health-check.md +2 -0
- package/src/skf-export-skill/references/invocation-contract.md +15 -0
- package/src/skf-export-skill/references/load-skill.md +88 -62
- package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
- package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
- package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
- package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
- package/src/skf-export-skill/references/package.md +15 -73
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
- package/src/skf-export-skill/references/result-envelope.md +31 -16
- package/src/skf-export-skill/references/summary.md +41 -17
- package/src/skf-export-skill/references/token-report.md +4 -12
- package/src/skf-export-skill/references/update-context.md +118 -237
- package/src/skf-forger/SKILL.md +61 -37
- package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
- package/src/skf-forger/customize.toml +43 -0
- package/src/skf-forger/references/pipeline-mode.md +73 -18
- package/src/skf-forger/scripts/forge-status.py +299 -0
- package/src/skf-forger/scripts/parse-pipeline.py +322 -54
- package/src/skf-forger/scripts/pipeline-gate.py +428 -0
- package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
- package/src/skf-quick-skill/SKILL.md +54 -53
- package/src/skf-quick-skill/assets/skill-template.md +4 -2
- package/src/skf-quick-skill/customize.toml +51 -20
- package/src/skf-quick-skill/references/batch-mode.md +74 -21
- package/src/skf-quick-skill/references/compile.md +30 -31
- package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
- package/src/skf-quick-skill/references/finalize.md +69 -18
- package/src/skf-quick-skill/references/halt-contract.md +41 -29
- package/src/skf-quick-skill/references/health-check.md +5 -3
- package/src/skf-quick-skill/references/quick-extract.md +114 -45
- package/src/skf-quick-skill/references/registry-resolution.md +9 -64
- package/src/skf-quick-skill/references/resolve-target.md +116 -66
- package/src/skf-quick-skill/references/write-and-validate.md +79 -40
- package/src/skf-refine-architecture/SKILL.md +43 -53
- package/src/skf-refine-architecture/customize.toml +49 -20
- package/src/skf-refine-architecture/references/compile.md +97 -54
- package/src/skf-refine-architecture/references/exit-codes.md +30 -0
- package/src/skf-refine-architecture/references/finding-storage.md +21 -0
- package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
- package/src/skf-refine-architecture/references/improvements.md +14 -20
- package/src/skf-refine-architecture/references/init.md +98 -57
- package/src/skf-refine-architecture/references/issue-detection.md +32 -39
- package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
- package/src/skf-refine-architecture/references/report.md +65 -54
- package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
- package/src/skf-rename-skill/SKILL.md +19 -54
- package/src/skf-rename-skill/customize.toml +32 -26
- package/src/skf-rename-skill/references/execute.md +230 -184
- package/src/skf-rename-skill/references/exit-codes.md +7 -7
- package/src/skf-rename-skill/references/health-check.md +1 -7
- package/src/skf-rename-skill/references/invocation-contract.md +25 -0
- package/src/skf-rename-skill/references/report.md +63 -52
- package/src/skf-rename-skill/references/select.md +197 -115
- package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
- package/src/skf-setup/SKILL.md +26 -26
- package/src/skf-setup/customize.toml +14 -10
- package/src/skf-setup/references/auto-index.md +50 -63
- package/src/skf-setup/references/ccc-index.md +29 -90
- package/src/skf-setup/references/detect-and-tier.md +58 -67
- package/src/skf-setup/references/health-check.md +2 -0
- package/src/skf-setup/references/invocation-contract.md +14 -0
- package/src/skf-setup/references/report.md +43 -166
- package/src/skf-setup/references/tier-rules.md +5 -5
- package/src/skf-setup/references/write-config.md +34 -57
- package/src/skf-test-skill/SKILL.md +17 -57
- package/src/skf-test-skill/assets/output-section-formats.md +33 -34
- package/src/skf-test-skill/customize.toml +27 -13
- package/src/skf-test-skill/references/coherence-check.md +134 -62
- package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
- package/src/skf-test-skill/references/coverage-check.md +209 -264
- package/src/skf-test-skill/references/detect-mode.md +5 -5
- package/src/skf-test-skill/references/external-validators.md +94 -82
- package/src/skf-test-skill/references/health-check.md +6 -7
- package/src/skf-test-skill/references/init.md +164 -91
- package/src/skf-test-skill/references/invocation-contract.md +66 -0
- package/src/skf-test-skill/references/migration-section-rules.md +5 -2
- package/src/skf-test-skill/references/report.md +164 -142
- package/src/skf-test-skill/references/score.md +80 -107
- package/src/skf-test-skill/references/scoring-rules.md +42 -155
- package/src/skf-test-skill/references/source-access-protocol.md +18 -94
- package/src/skf-test-skill/references/step-hard-gate.md +51 -33
- package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
- package/src/skf-test-skill/scripts/build-result-context.py +380 -0
- package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
- package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
- package/src/skf-test-skill/scripts/compute-score.py +223 -45
- package/src/skf-test-skill/scripts/gap-ledger.py +1666 -0
- package/src/skf-test-skill/scripts/hard-gate.py +203 -0
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +1198 -0
- package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
- package/src/skf-test-skill/scripts/reconcile-coverage.py +547 -79
- package/src/skf-test-skill/scripts/score-signatures.py +479 -0
- package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
- package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +133 -56
- package/src/skf-test-skill/templates/test-report-template.md +21 -15
- package/src/skf-update-skill/SKILL.md +38 -25
- package/src/skf-update-skill/customize.toml +27 -12
- package/src/skf-update-skill/references/detect-changes.md +253 -223
- package/src/skf-update-skill/references/gap-driven.md +336 -0
- package/src/skf-update-skill/references/health-check.md +31 -5
- package/src/skf-update-skill/references/init.md +254 -84
- package/src/skf-update-skill/references/invocation-contract.md +12 -0
- package/src/skf-update-skill/references/manual-section-rules.md +6 -23
- package/src/skf-update-skill/references/merge.md +182 -49
- package/src/skf-update-skill/references/re-extract.md +53 -262
- package/src/skf-update-skill/references/report.md +102 -35
- package/src/skf-update-skill/references/write.md +357 -155
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +183 -46
- package/src/skf-verify-stack/SKILL.md +23 -42
- package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
- package/src/skf-verify-stack/customize.toml +55 -22
- package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
- package/src/skf-verify-stack/references/coverage.md +67 -64
- package/src/skf-verify-stack/references/exit-codes.md +21 -8
- package/src/skf-verify-stack/references/init.md +107 -62
- package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
- package/src/skf-verify-stack/references/integrations.md +106 -65
- package/src/skf-verify-stack/references/report.md +59 -42
- package/src/skf-verify-stack/references/requirements.md +32 -9
- package/src/skf-verify-stack/references/synthesize.md +53 -39
- package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
- package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
- package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
- package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
- package/tools/cli/commands/install.js +11 -4
- package/tools/cli/commands/status.js +15 -3
- package/tools/cli/commands/update.js +11 -4
- package/tools/cli/lib/tool-check.js +320 -0
- package/tools/cli/lib/version-check.js +22 -9
- package/tools/cli/skf-cli.js +4 -1
- package/.gitattributes +0 -16
- package/.nvmrc +0 -1
- package/docs/_internal/RELEASING.md +0 -679
- package/release-audits/v1.0.0-launch-audit.md +0 -728
- package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
- package/src/skf-campaign/manifest.yaml +0 -15
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
- package/src/skf-create-skill/references/ecosystem-check.md +0 -74
- package/src/skf-drop-skill/references/headless-contract.md +0 -18
- package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
- package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
- package/src/skf-update-skill/references/validate.md +0 -124
- package/tools/validate-docs-drift.js +0 -233
- package/tools/validate-docs-links.js +0 -665
|
@@ -0,0 +1,1280 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
|
+
# dependencies = ["pyyaml"]
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Extraction Inventory: write, extend and read create-skill's extraction inventory on disk.
|
|
6
|
+
|
|
7
|
+
skf-create-skill step 3 keeps its extraction inventory as JSON beside the
|
|
8
|
+
staging folder (`_bmad-output/.skf-stage/{skill-name}.inventory.json`), so
|
|
9
|
+
the exports, their citations and the extraction rules survive a context
|
|
10
|
+
compaction between extraction and compile. `init` seeds it from the recipe
|
|
11
|
+
runner's JSON and the script/asset detector's JSON, so the model never
|
|
12
|
+
types a record those tools already wrote; the model then sends only what it
|
|
13
|
+
produced itself (`patch`, `add`, `set`). Step 3c adds the T3 items it
|
|
14
|
+
extracts from fetched documentation, step 4 the T2 annotations it finds in
|
|
15
|
+
QMD, compile writes the provenance map (`provenance`) and metadata.json
|
|
16
|
+
(`metadata`) from it, and step 7 writes `extraction-rules.yaml` from it
|
|
17
|
+
(`rules`). Every write goes through a temporary file and one rename.
|
|
18
|
+
|
|
19
|
+
Subcommands:
|
|
20
|
+
|
|
21
|
+
init --inventory <file> --skill <name>
|
|
22
|
+
--mode source|docs-only|component-library
|
|
23
|
+
--tier Quick|Forge|Forge+|Deep
|
|
24
|
+
[--extraction <runner JSON>] [--detected <detector JSON>]
|
|
25
|
+
Write a new inventory, replacing any file at --inventory. From
|
|
26
|
+
--extraction (skf-extract-public-api.py --mode full): every
|
|
27
|
+
export as the runner recorded it (confidence T1, extraction_method
|
|
28
|
+
ast-grep), with `internal: true` on each one its
|
|
29
|
+
entry_point_diff.internal lists; files_scanned (its
|
|
30
|
+
files_in_scope); aggregates, counts and arms; extraction_rules
|
|
31
|
+
(recipe_set, the recipes ids, scope, ast_grep_version); and the
|
|
32
|
+
runner warnings as lines (the head cap, each errors[] item, each
|
|
33
|
+
file_issues[] file, each files_without_recipes extension, each
|
|
34
|
+
warnings[] entry). From --detected (skf-detect-scripts-assets.py
|
|
35
|
+
detect): scripts_inventory and assets_inventory.
|
|
36
|
+
Public surface. When the runner's scope.type is public-api (in
|
|
37
|
+
any case) and its entry-point diff can be read, each export of a
|
|
38
|
+
language family whose entry_points.by_language status is
|
|
39
|
+
`barrel` is marked `public: true` when its name and file are a
|
|
40
|
+
pair of the diff's `public` (its name, or the name `local` gives
|
|
41
|
+
it in that file) or `extraction_gaps`, or when a `public` item of
|
|
42
|
+
its name and family has no file; `public: false` otherwise. A
|
|
43
|
+
`public` item with `via` namespace and a `members` list (what
|
|
44
|
+
the submodule passes on, which users reach as `pkg.sub.name`)
|
|
45
|
+
gives its members' pairs (each member's name, or its `local`
|
|
46
|
+
one, and its file; a member with no file its name) in place of
|
|
47
|
+
its own; one whose `members` is null (a module the runner could
|
|
48
|
+
not read), or that has no `members` key (an older runner's),
|
|
49
|
+
gives its own pair, as any item does. It is the rule
|
|
50
|
+
skf-build-change-manifest.py's `records` marks an update's
|
|
51
|
+
exports by (that script loads it from here). The rule's inputs
|
|
52
|
+
are kept as `public_surface`, so `add` marks an export read by
|
|
53
|
+
eye the same way. Any other scope type, and a family with no
|
|
54
|
+
barrel, mark nothing. A public-api run with no entry-point diff,
|
|
55
|
+
an incomplete one, one with errors or one whose
|
|
56
|
+
entry_points.unresolved is not empty marks nothing either
|
|
57
|
+
(`public_surface` null), with one warning that says why: every
|
|
58
|
+
export is then documented. One warning names the exports marked
|
|
59
|
+
`public: false`, each as `name (file)` (the first ten, then how
|
|
60
|
+
many more).
|
|
61
|
+
|
|
62
|
+
add --inventory <file> --field exports|t2_annotations|t3_items|warnings
|
|
63
|
+
Read a JSON list on stdin (objects; strings for warnings) and
|
|
64
|
+
append it to that list. An entry already in the list (equal
|
|
65
|
+
JSON; for exports, the same export_name and source_file) is not
|
|
66
|
+
added twice and counts in `duplicates`. An exports entry needs an
|
|
67
|
+
export_name; a missing confidence, extraction_method,
|
|
68
|
+
ast_node_type or ast_recipe defaults to T1-low, source-read,
|
|
69
|
+
null and null (an export read by eye). Its source_file is
|
|
70
|
+
relative to the source root, written and compared with `/`
|
|
71
|
+
separators and no leading `./`, as the runner writes its files
|
|
72
|
+
(`./pkg/api.py` and `pkg\\api.py` are `pkg/api.py`); an absolute
|
|
73
|
+
one (a leading `/` or a drive letter) is refused, and the call
|
|
74
|
+
adds nothing. An exports entry is
|
|
75
|
+
marked `public` by the inventory's `public_surface`, as `init`
|
|
76
|
+
marks the runner's (a `public` key the payload sends is not
|
|
77
|
+
kept), and the warning naming the exports marked `public: false`
|
|
78
|
+
is written again, in its place. A t3_items entry whose
|
|
79
|
+
export_name names an export the inventory already holds is
|
|
80
|
+
dropped, not added: T3 content never overrides a T1, T1-low or
|
|
81
|
+
T2 entry for the same export.
|
|
82
|
+
|
|
83
|
+
patch --inventory <file>
|
|
84
|
+
Read a JSON list of objects on stdin, each an export_name, an
|
|
85
|
+
optional source_file, and any of signature, params and
|
|
86
|
+
return_type, and set those fields on the export it names (by
|
|
87
|
+
export_name and source_file; by export_name alone when no
|
|
88
|
+
source_file is given). A name that matches no export is listed
|
|
89
|
+
in `unmatched`, one that matches several in `ambiguous`; neither
|
|
90
|
+
changes anything. params lists each parameter as the runner
|
|
91
|
+
records one, {name, type, default, optional}, or as a typed
|
|
92
|
+
string ('userId: string'). The source_file is compared as `add`
|
|
93
|
+
compares it: `./pkg/api.py` matches the export at `pkg/api.py`.
|
|
94
|
+
|
|
95
|
+
set --inventory <file>
|
|
96
|
+
Read a JSON object on stdin and set each of its keys: top_exports,
|
|
97
|
+
co_imports, promoted_docs, component_catalog (lists),
|
|
98
|
+
authoritative_files_scan (an object or null), files_scanned (an
|
|
99
|
+
integer), counts and arms (objects, merged into what is there)
|
|
100
|
+
and intent_mapping. intent_mapping maps `scripts` and `assets`
|
|
101
|
+
to {"intent": "<free text>", "kept": [source_file, ...]}: the
|
|
102
|
+
inventory keeps only the kept entries of that list and records
|
|
103
|
+
the others in the mapping's `left_out`. A top_exports name every
|
|
104
|
+
export of which is marked `public: false` is not kept and is
|
|
105
|
+
listed in `dropped`.
|
|
106
|
+
|
|
107
|
+
provenance --inventory <file> --target <provenance-map.json> [--add-entries]
|
|
108
|
+
Write the provenance map to --target, so compile never types an
|
|
109
|
+
entry the inventory holds. stdin holds the map's header, a JSON
|
|
110
|
+
object: source_repo and source_commit (required, null allowed),
|
|
111
|
+
and source_ref (default null), provenance_version (default
|
|
112
|
+
"2.0"), skill_name (default the inventory's), skill_type
|
|
113
|
+
(default "single") and generated_at (default now, ISO-8601
|
|
114
|
+
UTC); any other key is refused. entries[] holds the entry of
|
|
115
|
+
every export of the inventory, in its order, but one marked
|
|
116
|
+
`public: false` (off a public-api skill's public surface, which
|
|
117
|
+
neither entries[] nor `unsigned` lists): export_name,
|
|
118
|
+
export_type, params, return_type, source_file, source_line,
|
|
119
|
+
confidence, extraction_method, ast_node_type and
|
|
120
|
+
signature_source, labeled by the tool that found it. An export
|
|
121
|
+
an ast-grep rule matched is T1 (extraction_method ast-grep, the
|
|
122
|
+
ast_node_type its recipe declares, signature_source T1); any
|
|
123
|
+
other was read by eye and is T1-low (extraction_method
|
|
124
|
+
source-read, ast_node_type null, signature_source T1-low).
|
|
125
|
+
params holds the map's typed strings, one per parameter the
|
|
126
|
+
runner (or a patch) recorded: `name: type`, `name?: type` for a
|
|
127
|
+
JS/TS parameter that is optional and has no default,
|
|
128
|
+
` = default` after a default, and the type alone for a
|
|
129
|
+
parameter with no name; a string a patch or an add sent is kept
|
|
130
|
+
as it is. params and return_type are null on an export whose
|
|
131
|
+
params is null: any export but a function, or a function nobody
|
|
132
|
+
read the signature of, which `unsigned` lists (patch them and
|
|
133
|
+
run provenance again). The inventory is not changed.
|
|
134
|
+
With --add-entries, stdin holds instead a JSON object with an
|
|
135
|
+
`entries` list, a `file_entries` list or both, appended to the
|
|
136
|
+
map at --target: the entries the inventory does not hold (T2 and
|
|
137
|
+
T3) and the file entries. An entry whose export_name and
|
|
138
|
+
source_file the map already holds, or a file entry whose
|
|
139
|
+
file_name it holds, is not added and counts in `duplicates`, so
|
|
140
|
+
an inventory export's entry is never replaced. An entry whose
|
|
141
|
+
export_name and source_file are an inventory export's marked
|
|
142
|
+
`public: false` is not added either and is listed in
|
|
143
|
+
`not_public`, so the map never holds an export off the surface.
|
|
144
|
+
|
|
145
|
+
metadata --inventory <file> --brief <skill-brief.yaml> --stats <file>
|
|
146
|
+
--skf-root <dir> --target <metadata.json>
|
|
147
|
+
Write the skill's metadata.json to --target, so compile types
|
|
148
|
+
only the fields it decides. stdin holds them, a JSON object:
|
|
149
|
+
version and language (non-empty strings), source_root,
|
|
150
|
+
source_commit, source_ref and qmd_version (strings or null),
|
|
151
|
+
each required, and ast_grep_version (a string or null, default
|
|
152
|
+
null) and stats_extra (an object, default {}); any other key is
|
|
153
|
+
refused. The rest comes from the files:
|
|
154
|
+
name, scope_type (the brief's scope.type, verbatim), source_repo
|
|
155
|
+
and source_authority (default "community") from --brief;
|
|
156
|
+
description from the frontmatter of the SKILL.md beside
|
|
157
|
+
--target (compile's staged one); confidence_tier (the
|
|
158
|
+
inventory's tier); exports[], the names of the inventory's
|
|
159
|
+
exports but one marked `internal: true` or `public: false`,
|
|
160
|
+
each once, in its order (a docs-only inventory's t3_items
|
|
161
|
+
names; none for a reference-app brief); ast_node_count and
|
|
162
|
+
tool_versions.ast_grep, only when ast-grep ran: when the
|
|
163
|
+
runner ran it (extraction_rules.ast_grep_version is set), the
|
|
164
|
+
runner's aggregates.exports and that version; otherwise, when
|
|
165
|
+
exports carry extraction_method ast-grep (the recipes run
|
|
166
|
+
without the runner), their number and stdin's
|
|
167
|
+
ast_grep_version, else "unknown" with a warning (stdin's
|
|
168
|
+
ast_grep_version is read in that case only);
|
|
169
|
+
tool_versions.ast_grep is null when ast-grep did not run, and
|
|
170
|
+
ast_node_count left out; scripts[] and assets[], {file:
|
|
171
|
+
scripts/<name> or assets/<name>, purpose, source_file,
|
|
172
|
+
confidence} per scripts_inventory or assets_inventory entry,
|
|
173
|
+
left out when it is empty; and stats and
|
|
174
|
+
confidence_distribution verbatim from --stats (the JSON
|
|
175
|
+
skf-render-metadata-stats.py wrote), with stats_extra's keys
|
|
176
|
+
added: a component-library brief's own stats, or a
|
|
177
|
+
reference-app brief's notes alone (another scope type takes
|
|
178
|
+
none, and a key the stats helper wrote is refused). It stamps
|
|
179
|
+
generation_date (UTC, to the second), reads tool_versions.skf
|
|
180
|
+
under --skf-root (the installed module) as
|
|
181
|
+
skf-render-quick-metadata.py does (package.json's version, else
|
|
182
|
+
the VERSION file's first line, else "unknown", with a warning),
|
|
183
|
+
and writes skill_type "single", spec_version "1.3",
|
|
184
|
+
generated_by "create-skill", dependencies [] and compatibility
|
|
185
|
+
"". The inventory is not changed.
|
|
186
|
+
|
|
187
|
+
rules --inventory <file> --language <language> --target <file>
|
|
188
|
+
Write extraction-rules.yaml (the skill, its language, tier and
|
|
189
|
+
extraction mode, and the inventory's extraction_rules) to
|
|
190
|
+
--target. The inventory is not changed.
|
|
191
|
+
|
|
192
|
+
summary --inventory <file>
|
|
193
|
+
Print the inventory's counts without changing it, and in
|
|
194
|
+
`not_public` each export marked `public: false` ({name, file}),
|
|
195
|
+
which the evidence report lists.
|
|
196
|
+
|
|
197
|
+
Inventory shape (the keys this helper reads or writes):
|
|
198
|
+
|
|
199
|
+
{
|
|
200
|
+
"skill_name": "...", "extraction_mode": "...", "tier": "...",
|
|
201
|
+
"files_scanned": N | null,
|
|
202
|
+
"exports": [{"export_name": "...", "source_file": "...", ...}, ...],
|
|
203
|
+
"top_exports": ["name", ...],
|
|
204
|
+
"aggregates": {...}, "counts": {...}, "arms": {...},
|
|
205
|
+
"scripts_inventory": [...], "assets_inventory": [...],
|
|
206
|
+
"intent_mapping": {"scripts": {"intent", "kept", "left_out"}, ...},
|
|
207
|
+
"co_imports": [...], "promoted_docs": [...],
|
|
208
|
+
"authoritative_files_scan": {...} | null,
|
|
209
|
+
"extraction_rules": {"recipe_set", "recipes", "scope", "ast_grep_version"},
|
|
210
|
+
"public_surface": {"by_language": {...}, "public": [...],
|
|
211
|
+
"extraction_gaps": [...]} | null,
|
|
212
|
+
"t2_annotations": [...], "t3_items": [...], "warnings": ["..."]
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
An export may carry `internal: true` (the runner's entry_point_diff.internal
|
|
216
|
+
lists it) and `public: true` or `public: false` (see init's Public surface).
|
|
217
|
+
|
|
218
|
+
Output (stdout, exit 0), for every subcommand but rules, provenance and
|
|
219
|
+
metadata:
|
|
220
|
+
|
|
221
|
+
{
|
|
222
|
+
"status": "ok",
|
|
223
|
+
"inventory": "<path, / separators>",
|
|
224
|
+
"extraction_mode": "...",
|
|
225
|
+
"counts": {"files_scanned": N | null, "exports": N, "not_public": N,
|
|
226
|
+
"t1": N, "t1_low": N, "by_type": {"<export_type>": N, ...},
|
|
227
|
+
"top_exports": N, "co_imports": N, "scripts": N,
|
|
228
|
+
"assets": N, "t2_annotations": N, "t2_past": N,
|
|
229
|
+
"t2_future": N, "functions_enriched": N, "t3_items": N,
|
|
230
|
+
"items": N},
|
|
231
|
+
"added": N, "duplicates": N, "dropped": ["name"], # add
|
|
232
|
+
"patched": N, "unmatched": [...], "ambiguous": [...], # patch
|
|
233
|
+
"set": ["key", ...], "dropped": ["name"], # set
|
|
234
|
+
"not_public": [{"name", "file"}, ...] # summary
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
not_public counts the exports marked `public: false`, which the provenance
|
|
238
|
+
map leaves out: when it equals `exports`, the map holds no export. `items`
|
|
239
|
+
is exports plus t3_items, less not_public: what compile can document. t1
|
|
240
|
+
and t1_low count the exports by their `confidence`, by_type by their
|
|
241
|
+
`export_type`.
|
|
242
|
+
t2_past and t2_future count the T2 annotations by their `temporal`, and
|
|
243
|
+
functions_enriched the distinct export_name values among them; an
|
|
244
|
+
annotation of a name every export of which is marked `public: false`
|
|
245
|
+
counts in none of t2_annotations, t2_past, t2_future and
|
|
246
|
+
functions_enriched. rules
|
|
247
|
+
prints {"status": "ok", "target": "<path>", "bytes": N}, provenance
|
|
248
|
+
{"status": "ok", "target": "<path>", "entries": N, "unsigned": ["name
|
|
249
|
+
(file)", ...]}, and provenance --add-entries {"status": "ok", "target":
|
|
250
|
+
"<path>", "entries": N, "file_entries": N, "added": N, "duplicates": N,
|
|
251
|
+
"not_public": [{"name", "file"}, ...]}, the counts of the map after the
|
|
252
|
+
merge, and metadata {"status": "ok", "target": "<path>", "exports": N,
|
|
253
|
+
"commit_short": "<source_commit's first 8 characters, or unknown>",
|
|
254
|
+
"tool_versions": {...}, "warnings": [...]}.
|
|
255
|
+
|
|
256
|
+
Exit codes:
|
|
257
|
+
0 success
|
|
258
|
+
1 input error, nothing changed: an inventory that is missing, not JSON
|
|
259
|
+
or has no exports list, an --extraction, --detected, --brief or
|
|
260
|
+
--stats file that cannot be read, a provenance map at --target that
|
|
261
|
+
--add-entries cannot read, a staged SKILL.md with no frontmatter
|
|
262
|
+
description, a stdin payload of the wrong shape, an exports entry
|
|
263
|
+
whose source_file is absolute, an unknown key or a kept file the
|
|
264
|
+
inventory does not list, or a write that failed
|
|
265
|
+
2 usage error (argparse)
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
from __future__ import annotations
|
|
269
|
+
|
|
270
|
+
import argparse
|
|
271
|
+
import importlib.util
|
|
272
|
+
import json
|
|
273
|
+
import os
|
|
274
|
+
import re
|
|
275
|
+
import sys
|
|
276
|
+
import time
|
|
277
|
+
from pathlib import Path
|
|
278
|
+
|
|
279
|
+
FIELDS = ("exports", "t2_annotations", "t3_items", "warnings")
|
|
280
|
+
MODES = ("source", "docs-only", "component-library")
|
|
281
|
+
TIERS = ("Quick", "Forge", "Forge+", "Deep")
|
|
282
|
+
LISTS = ("top_exports", "co_imports", "promoted_docs", "component_catalog", "scripts_inventory",
|
|
283
|
+
"assets_inventory", "t2_annotations", "t3_items", "warnings")
|
|
284
|
+
PATCHABLE = ("signature", "params", "return_type")
|
|
285
|
+
# set: the key, the JSON types it takes, and whether an object merges into the one there.
|
|
286
|
+
SETTABLE = {
|
|
287
|
+
"top_exports": ((list,), False),
|
|
288
|
+
"co_imports": ((list,), False),
|
|
289
|
+
"promoted_docs": ((list,), False),
|
|
290
|
+
"component_catalog": ((list,), False),
|
|
291
|
+
"authoritative_files_scan": ((dict, type(None)), False),
|
|
292
|
+
"files_scanned": ((int,), False),
|
|
293
|
+
"counts": ((dict,), True),
|
|
294
|
+
"arms": ((dict,), True),
|
|
295
|
+
"intent_mapping": ((dict,), False),
|
|
296
|
+
}
|
|
297
|
+
# intent_mapping kinds and the inventory list each one filters.
|
|
298
|
+
INTENT_LISTS = {"scripts": "scripts_inventory", "assets": "assets_inventory"}
|
|
299
|
+
BY_EYE_DEFAULTS = {"confidence": "T1-low", "extraction_method": "source-read", "ast_node_type": None,
|
|
300
|
+
"ast_recipe": None}
|
|
301
|
+
YAML_KEY_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class InventoryError(Exception):
|
|
305
|
+
"""Exit 1."""
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def load(path: Path) -> dict:
|
|
309
|
+
try:
|
|
310
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
311
|
+
except (OSError, UnicodeDecodeError, ValueError) as exc:
|
|
312
|
+
raise InventoryError(f"cannot read the inventory {path.as_posix()}: {exc}") from exc
|
|
313
|
+
if not isinstance(data, dict) or not isinstance(data.get("exports"), list):
|
|
314
|
+
raise InventoryError(f"the inventory {path.as_posix()} is not an object with an exports list")
|
|
315
|
+
for key in LISTS:
|
|
316
|
+
if key in data and not isinstance(data[key], list):
|
|
317
|
+
raise InventoryError(f"the inventory's {key} is not a list")
|
|
318
|
+
return data
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _read_json(path: Path, label: str):
|
|
322
|
+
try:
|
|
323
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
324
|
+
except (OSError, UnicodeDecodeError, ValueError) as exc:
|
|
325
|
+
raise InventoryError(f"cannot read the {label} {path.as_posix()}: {exc}") from exc
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _off_surface_names(data: dict) -> set[str]:
|
|
329
|
+
"""The export names every inventory export of which is marked `public: false`."""
|
|
330
|
+
marks: dict[str, bool] = {}
|
|
331
|
+
for export in data.get("exports") or []:
|
|
332
|
+
if isinstance(export, dict) and isinstance(export.get("export_name"), str):
|
|
333
|
+
name = export["export_name"]
|
|
334
|
+
marks[name] = marks.get(name, True) and export.get("public") is False
|
|
335
|
+
return {name for name, off in marks.items() if off}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _not_public(data: dict) -> list[dict]:
|
|
339
|
+
"""{name, file} of each export marked `public: false`, in the inventory's order."""
|
|
340
|
+
return [{"name": e.get("export_name"), "file": e.get("source_file")} for e in data.get("exports") or []
|
|
341
|
+
if isinstance(e, dict) and e.get("public") is False]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def counts(data: dict) -> dict:
|
|
345
|
+
exports = [e for e in data["exports"] if isinstance(e, dict)]
|
|
346
|
+
off = _off_surface_names(data)
|
|
347
|
+
raw_notes = [n for n in data.get("t2_annotations") or [] if isinstance(n, dict)]
|
|
348
|
+
notes = [n for n in raw_notes if not (isinstance(n.get("export_name"), str) and n["export_name"] in off)]
|
|
349
|
+
|
|
350
|
+
def _length(key: str) -> int:
|
|
351
|
+
value = data.get(key)
|
|
352
|
+
return len(value) if isinstance(value, list) else 0
|
|
353
|
+
|
|
354
|
+
by_type: dict[str, int] = {}
|
|
355
|
+
for export in exports:
|
|
356
|
+
kind = export.get("export_type")
|
|
357
|
+
if isinstance(kind, str) and kind:
|
|
358
|
+
by_type[kind] = by_type.get(kind, 0) + 1
|
|
359
|
+
files = data.get("files_scanned")
|
|
360
|
+
not_public = sum(1 for e in exports if e.get("public") is False)
|
|
361
|
+
return {
|
|
362
|
+
"files_scanned": files if isinstance(files, int) else None,
|
|
363
|
+
"exports": len(data["exports"]),
|
|
364
|
+
"not_public": not_public,
|
|
365
|
+
"t1": sum(1 for e in exports if e.get("confidence") == "T1"),
|
|
366
|
+
"t1_low": sum(1 for e in exports if e.get("confidence") == "T1-low"),
|
|
367
|
+
"by_type": dict(sorted(by_type.items())),
|
|
368
|
+
"top_exports": _length("top_exports"),
|
|
369
|
+
"co_imports": _length("co_imports"),
|
|
370
|
+
"scripts": _length("scripts_inventory"),
|
|
371
|
+
"assets": _length("assets_inventory"),
|
|
372
|
+
"t2_annotations": _length("t2_annotations") - (len(raw_notes) - len(notes)),
|
|
373
|
+
"t2_past": sum(1 for n in notes if n.get("temporal") == "T2-past"),
|
|
374
|
+
"t2_future": sum(1 for n in notes if n.get("temporal") == "T2-future"),
|
|
375
|
+
"functions_enriched": len({n["export_name"] for n in notes if isinstance(n.get("export_name"), str)}),
|
|
376
|
+
"t3_items": _length("t3_items"),
|
|
377
|
+
"items": len(data["exports"]) - not_public + _length("t3_items"),
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def runner_warnings(extraction: dict) -> list[str]:
|
|
382
|
+
"""The recipe runner's anomalies, one line each, for Gate 2 and the evidence report."""
|
|
383
|
+
lines: list[str] = []
|
|
384
|
+
recipes = [r for r in extraction.get("recipes") or [] if isinstance(r, dict)]
|
|
385
|
+
if extraction.get("truncated"):
|
|
386
|
+
capped = ", ".join(f"`{r.get('id')}`" for r in recipes if r.get("truncated")) or "a recipe"
|
|
387
|
+
lines.append(f"Extraction hit the head cap: {capped} matched more than {extraction.get('head_cap')} "
|
|
388
|
+
"exports, so some exports were read by eye or left out. Narrow `scope.include` in the "
|
|
389
|
+
"brief until no recipe reaches the cap.")
|
|
390
|
+
for error in extraction.get("errors") or []:
|
|
391
|
+
if isinstance(error, dict):
|
|
392
|
+
lines.append(f"ast-grep did not finish on {error.get('files')} files from `{error.get('first_file')}` "
|
|
393
|
+
f"({error.get('detail')}): their exports are read by eye")
|
|
394
|
+
for issue in extraction.get("file_issues") or []:
|
|
395
|
+
if isinstance(issue, dict):
|
|
396
|
+
lines.append(f"`{issue.get('file')}` ({issue.get('issue')}): its exports are read by eye")
|
|
397
|
+
without = extraction.get("files_without_recipes")
|
|
398
|
+
if isinstance(without, dict):
|
|
399
|
+
for ext, number in sorted(without.items()):
|
|
400
|
+
lines.append(f"{number} `{ext}` files in scope are in a language no recipe reads")
|
|
401
|
+
lines.extend(w for w in extraction.get("warnings") or [] if isinstance(w, str) and w)
|
|
402
|
+
return lines
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# --------------------------------------------------------------------------
|
|
406
|
+
# The public surface of a public-api skill (see init's "Public surface"):
|
|
407
|
+
# one rule, which skf-build-change-manifest.py loads from here for update-skill
|
|
408
|
+
# --------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _norm_path(path: object) -> str | None:
|
|
412
|
+
"""A file path compared as structural-diff compares it: `/` separators, no leading `./`."""
|
|
413
|
+
if not isinstance(path, str) or not path.strip():
|
|
414
|
+
return None
|
|
415
|
+
text = path.strip().replace("\\", "/")
|
|
416
|
+
while text.startswith("./"):
|
|
417
|
+
text = text[2:]
|
|
418
|
+
return text
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _relative_source(entry: dict) -> bool:
|
|
422
|
+
"""Write an exports entry's source_file in _norm_path's form, the runner's; False for an absolute one."""
|
|
423
|
+
path = _norm_path(entry.get("source_file"))
|
|
424
|
+
if path is None:
|
|
425
|
+
return True
|
|
426
|
+
if path.startswith("/") or re.match(r"[A-Za-z]:/", path):
|
|
427
|
+
return False
|
|
428
|
+
entry["source_file"] = path
|
|
429
|
+
return True
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
PUBLIC_API = "public-api"
|
|
433
|
+
# A record's language family, the one whose entry points declare its names: skf-extract-public-api.py's FAMILY_OF,
|
|
434
|
+
# by the record's `language`, else by its file's extension (that script's EXTENSION_LANGUAGES).
|
|
435
|
+
_FAMILY_OF_LANGUAGE = {"python": "python", "typescript": "javascript", "tsx": "javascript",
|
|
436
|
+
"javascript": "javascript", "vue": "javascript", "rust": "rust", "go": "go"}
|
|
437
|
+
_FAMILY_OF_EXTENSION = {".py": "python", ".pyi": "python", ".rs": "rust", ".go": "go",
|
|
438
|
+
**dict.fromkeys((".ts", ".mts", ".cts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue"),
|
|
439
|
+
"javascript")}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
class _PublicSurface:
|
|
443
|
+
"""The public surface of a public-api skill's extraction (see init's "Public surface" in the module
|
|
444
|
+
docstring)."""
|
|
445
|
+
|
|
446
|
+
def __init__(self, diff: dict, statuses: dict):
|
|
447
|
+
self.barrels = {family for family, status in statuses.items() if status == "barrel"}
|
|
448
|
+
self.pairs: set[tuple[str, str]] = set()
|
|
449
|
+
self.unfiled: set[tuple[object, str]] = set() # (family, name) of a public name no file defines
|
|
450
|
+
for key in ("public", "extraction_gaps"):
|
|
451
|
+
for item in diff.get(key) or []:
|
|
452
|
+
if not isinstance(item, dict) or not isinstance(item.get("name"), str) or not item["name"]:
|
|
453
|
+
continue
|
|
454
|
+
family = item.get("language")
|
|
455
|
+
members = item.get("members") if key == "public" and item.get("via") == "namespace" else None
|
|
456
|
+
if isinstance(members, list):
|
|
457
|
+
# a namespace whose module the runner read: its members, in place of the module itself
|
|
458
|
+
for member in members:
|
|
459
|
+
if isinstance(member, dict) and isinstance(member.get("name"), str) and member["name"]:
|
|
460
|
+
self._add(family, member["name"], member.get("local"), member.get("file"), True)
|
|
461
|
+
continue
|
|
462
|
+
self._add(family, item["name"], item.get("local") if key == "public" else None, item.get("file"),
|
|
463
|
+
key == "public")
|
|
464
|
+
|
|
465
|
+
def _add(self, family: object, name: str, local: object, path: object, listed_public: bool) -> None:
|
|
466
|
+
"""The pairs one public name (or gap) gives: (name, file) and (local, file), or, with no file, its
|
|
467
|
+
(family, name) when `public` lists it."""
|
|
468
|
+
file = _norm_path(path)
|
|
469
|
+
if not file:
|
|
470
|
+
if listed_public:
|
|
471
|
+
self.unfiled.add((family, name))
|
|
472
|
+
return
|
|
473
|
+
self.pairs.update((n, file) for n in (name, local) if isinstance(n, str) and n)
|
|
474
|
+
|
|
475
|
+
def public(self, name: str, path: str, language: object = None) -> bool | None:
|
|
476
|
+
"""Whether the export is on the public surface; None (no mark) when its family's entry points are no
|
|
477
|
+
barrel."""
|
|
478
|
+
family = _FAMILY_OF_LANGUAGE.get(language) if isinstance(language, str) else None
|
|
479
|
+
if family is None:
|
|
480
|
+
family = _FAMILY_OF_EXTENSION.get(os.path.splitext(path)[1].lower())
|
|
481
|
+
if family not in self.barrels:
|
|
482
|
+
return None
|
|
483
|
+
return (name, path) in self.pairs or (family, name) in self.unfiled
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def public_surface(extraction: object) -> tuple[_PublicSurface | None, str | None]:
|
|
487
|
+
"""(the public surface, None) of the recipe runner's output when its scope type is public-api; (None, why the
|
|
488
|
+
surface cannot be read from it) for a public-api run with no entry-point diff, an incomplete one, one with
|
|
489
|
+
errors or one whose entry points name a module the trace could not follow; (None, None) for any other scope
|
|
490
|
+
type. With no surface every export is kept, as before."""
|
|
491
|
+
if not isinstance(extraction, dict):
|
|
492
|
+
return None, None
|
|
493
|
+
scope, diff, points = extraction.get("scope"), extraction.get("entry_point_diff"), extraction.get("entry_points")
|
|
494
|
+
kind = scope.get("type") if isinstance(scope, dict) else None
|
|
495
|
+
if not (isinstance(kind, str) and kind.strip().lower() == PUBLIC_API):
|
|
496
|
+
return None, None
|
|
497
|
+
statuses = points.get("by_language") if isinstance(points, dict) else None
|
|
498
|
+
if not isinstance(diff, dict) or not isinstance(statuses, dict):
|
|
499
|
+
return None, "the recipe runner gave no entry-point diff (Quick tier, or a run that could not read the tree)"
|
|
500
|
+
if extraction.get("status") == "incomplete" or extraction.get("errors"):
|
|
501
|
+
return None, "the recipe runner's run is incomplete"
|
|
502
|
+
if points.get("unresolved"):
|
|
503
|
+
return None, "an entry point names a module the runner could not trace (entry_points.unresolved)"
|
|
504
|
+
return _PublicSurface(diff, statuses), None
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# The fields of an entry_point_diff item the rule reads, which the inventory's public_surface keeps for `add`, and
|
|
508
|
+
# those of each member of a namespace item.
|
|
509
|
+
SURFACE_FIELDS = {"public": ("name", "language", "file", "local", "via", "members"),
|
|
510
|
+
"extraction_gaps": ("name", "language", "file")}
|
|
511
|
+
MEMBER_FIELDS = ("name", "local", "file")
|
|
512
|
+
NOT_PUBLIC_NAMED = 10 # the names the not-public warning spells out, as update-skill's apply does
|
|
513
|
+
NOT_PUBLIC_LEAD = "Off the public surface: "
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _surface_state(extraction: dict) -> dict:
|
|
517
|
+
"""The parts of the runner's JSON the rule reads (public_surface found a surface in it)."""
|
|
518
|
+
diff = extraction["entry_point_diff"]
|
|
519
|
+
state: dict = {"by_language": dict(extraction["entry_points"]["by_language"])}
|
|
520
|
+
for key, fields in SURFACE_FIELDS.items():
|
|
521
|
+
state[key] = [{field: item[field] for field in fields if field in item}
|
|
522
|
+
for item in diff.get(key) or [] if isinstance(item, dict)]
|
|
523
|
+
for item in state["public"]:
|
|
524
|
+
if isinstance(item.get("members"), list):
|
|
525
|
+
item["members"] = [{field: m[field] for field in MEMBER_FIELDS if field in m}
|
|
526
|
+
for m in item["members"] if isinstance(m, dict)]
|
|
527
|
+
return state
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _stored_surface(data: dict) -> _PublicSurface | None:
|
|
531
|
+
"""The public surface the inventory's public_surface keeps, or None when init kept none."""
|
|
532
|
+
state = data.get("public_surface")
|
|
533
|
+
if not isinstance(state, dict) or not isinstance(state.get("by_language"), dict):
|
|
534
|
+
return None
|
|
535
|
+
return _PublicSurface(state, state["by_language"])
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _mark(surface: _PublicSurface | None, export: dict) -> None:
|
|
539
|
+
"""Set the export's `public` mark by the rule; no other `public` value is kept."""
|
|
540
|
+
export.pop("public", None)
|
|
541
|
+
name, path = export.get("export_name"), _norm_path(export.get("source_file"))
|
|
542
|
+
if surface is None or not isinstance(name, str) or not name or path is None:
|
|
543
|
+
return
|
|
544
|
+
mark = surface.public(name, path, export.get("language"))
|
|
545
|
+
if mark is not None:
|
|
546
|
+
export["public"] = mark
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _not_public_warning(names: list[str]) -> str:
|
|
550
|
+
"""The one warning naming the exports marked `public: false` (`name (file)`), for Gate 2 and the evidence
|
|
551
|
+
report."""
|
|
552
|
+
shown = ", ".join(names[:NOT_PUBLIC_NAMED])
|
|
553
|
+
more = f" and {len(names) - NOT_PUBLIC_NAMED} more" if len(names) > NOT_PUBLIC_NAMED else ""
|
|
554
|
+
return (f"{NOT_PUBLIC_LEAD}{len(names)} export(s) of this public-api skill are left out of the provenance map, "
|
|
555
|
+
f"SKILL.md, references/, the context snippet and metadata.json's exports[] (each is marked "
|
|
556
|
+
f"`public: false` in the inventory, and `summary` lists them all): {shown}{more}")
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _write_not_public_warning(data: dict) -> None:
|
|
560
|
+
"""Write the not-public warning in place of the one an earlier call wrote (else at the end), or drop it when
|
|
561
|
+
no export is marked `public: false`."""
|
|
562
|
+
names = [f"{item['name']} ({item['file']})" for item in _not_public(data)]
|
|
563
|
+
warnings = data.setdefault("warnings", [])
|
|
564
|
+
at = next((i for i, w in enumerate(warnings) if isinstance(w, str) and w.startswith(NOT_PUBLIC_LEAD)), None)
|
|
565
|
+
if at is not None:
|
|
566
|
+
del warnings[at]
|
|
567
|
+
if names:
|
|
568
|
+
warnings.insert(len(warnings) if at is None else at, _not_public_warning(names))
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def init(skill: str, mode: str, tier: str, extraction: dict | None = None, detected: dict | None = None) -> dict:
|
|
572
|
+
"""A new inventory, seeded from the runner's JSON and the detector's JSON."""
|
|
573
|
+
data: dict = {
|
|
574
|
+
"skill_name": skill, "extraction_mode": mode, "tier": tier, "files_scanned": None,
|
|
575
|
+
"exports": [], "top_exports": [], "aggregates": {}, "counts": {}, "arms": {},
|
|
576
|
+
"scripts_inventory": [], "assets_inventory": [], "intent_mapping": {}, "co_imports": [],
|
|
577
|
+
"promoted_docs": [], "authoritative_files_scan": None, "extraction_rules": {}, "public_surface": None,
|
|
578
|
+
"t2_annotations": [], "t3_items": [], "warnings": [],
|
|
579
|
+
}
|
|
580
|
+
if extraction is not None:
|
|
581
|
+
if not isinstance(extraction, dict):
|
|
582
|
+
raise InventoryError("the --extraction file is not a JSON object")
|
|
583
|
+
diff = extraction.get("entry_point_diff")
|
|
584
|
+
internal = {(i.get("name"), i.get("source_file"))
|
|
585
|
+
for i in (diff.get("internal") or [] if isinstance(diff, dict) else []) if isinstance(i, dict)}
|
|
586
|
+
surface, problem = public_surface(extraction)
|
|
587
|
+
for record in extraction.get("exports") or []:
|
|
588
|
+
if not isinstance(record, dict):
|
|
589
|
+
continue
|
|
590
|
+
export = dict(record)
|
|
591
|
+
export.setdefault("confidence", "T1")
|
|
592
|
+
export.setdefault("extraction_method", "ast-grep")
|
|
593
|
+
if (export.get("export_name"), export.get("source_file")) in internal:
|
|
594
|
+
export["internal"] = True
|
|
595
|
+
_mark(surface, export)
|
|
596
|
+
data["exports"].append(export)
|
|
597
|
+
files = extraction.get("files_in_scope")
|
|
598
|
+
data["files_scanned"] = files if isinstance(files, int) else None
|
|
599
|
+
for key in ("aggregates", "counts", "arms"):
|
|
600
|
+
value = extraction.get(key)
|
|
601
|
+
data[key] = dict(value) if isinstance(value, dict) else {}
|
|
602
|
+
ast_grep = extraction.get("ast_grep")
|
|
603
|
+
data["extraction_rules"] = {
|
|
604
|
+
"recipe_set": extraction.get("recipe_set"),
|
|
605
|
+
"recipes": [r.get("id") for r in extraction.get("recipes") or [] if isinstance(r, dict)],
|
|
606
|
+
"scope": extraction.get("scope") if isinstance(extraction.get("scope"), dict) else {},
|
|
607
|
+
"ast_grep_version": ast_grep.get("version") if isinstance(ast_grep, dict) else None,
|
|
608
|
+
}
|
|
609
|
+
data["warnings"] = runner_warnings(extraction)
|
|
610
|
+
if problem:
|
|
611
|
+
data["warnings"].append(f"The public surface of this public-api skill could not be applied ({problem}): "
|
|
612
|
+
"no export is marked `public: false`, so every export found is documented")
|
|
613
|
+
if surface is not None:
|
|
614
|
+
data["public_surface"] = _surface_state(extraction)
|
|
615
|
+
_write_not_public_warning(data)
|
|
616
|
+
if detected is not None:
|
|
617
|
+
if not isinstance(detected, dict):
|
|
618
|
+
raise InventoryError("the --detected file is not a JSON object")
|
|
619
|
+
for key in ("scripts_inventory", "assets_inventory"):
|
|
620
|
+
value = detected.get(key) or []
|
|
621
|
+
if not isinstance(value, list) or not all(isinstance(e, dict) for e in value):
|
|
622
|
+
raise InventoryError(f"the --detected file's {key} is not a list of objects")
|
|
623
|
+
data[key] = [dict(e) for e in value]
|
|
624
|
+
return data
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _same(a, b) -> bool:
|
|
628
|
+
return json.dumps(a, sort_keys=True) == json.dumps(b, sort_keys=True)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def add(data: dict, field: str, entries: list) -> tuple[int, int, list[str]]:
|
|
632
|
+
"""Append `entries` to data[field]; return (added, duplicates, dropped export names)."""
|
|
633
|
+
if field not in FIELDS:
|
|
634
|
+
raise InventoryError(f"--field must be one of {', '.join(FIELDS)}")
|
|
635
|
+
kind = str if field == "warnings" else dict
|
|
636
|
+
if not isinstance(entries, list) or not all(isinstance(e, kind) for e in entries):
|
|
637
|
+
raise InventoryError(f"stdin must hold a JSON list of {'strings' if kind is str else 'objects'}")
|
|
638
|
+
surface = _stored_surface(data) if field == "exports" else None
|
|
639
|
+
if field == "exports":
|
|
640
|
+
if not all(isinstance(e.get("export_name"), str) and e["export_name"] for e in entries):
|
|
641
|
+
raise InventoryError("every exports entry needs an export_name")
|
|
642
|
+
entries = [{**BY_EYE_DEFAULTS, **e} for e in entries]
|
|
643
|
+
absolute = [e for e in entries if not _relative_source(e)]
|
|
644
|
+
if absolute:
|
|
645
|
+
sent = ", ".join(f"{e['export_name']}: source_file {e['source_file']}" for e in absolute[:10])
|
|
646
|
+
sent += f" and {len(absolute) - 10} more" if len(absolute) > 10 else ""
|
|
647
|
+
verb, them = ("is", "it") if len(absolute) == 1 else ("are", "them")
|
|
648
|
+
raise InventoryError(f"{sent} {verb} absolute; send {them} relative to the source root, with / "
|
|
649
|
+
"separators, as the runner writes its files")
|
|
650
|
+
for entry in entries:
|
|
651
|
+
_mark(surface, entry)
|
|
652
|
+
target = data.setdefault(field, [])
|
|
653
|
+
known = {e.get("export_name") for e in data["exports"] if isinstance(e, dict)} - {None}
|
|
654
|
+
added = duplicates = 0
|
|
655
|
+
dropped: list[str] = []
|
|
656
|
+
for entry in entries:
|
|
657
|
+
if field == "t3_items" and entry.get("export_name") in known:
|
|
658
|
+
dropped.append(entry["export_name"])
|
|
659
|
+
continue
|
|
660
|
+
if field == "exports":
|
|
661
|
+
key = (entry["export_name"], _norm_path(entry.get("source_file")))
|
|
662
|
+
repeat = any(isinstance(e, dict) and (e.get("export_name"), _norm_path(e.get("source_file"))) == key
|
|
663
|
+
for e in target)
|
|
664
|
+
else:
|
|
665
|
+
repeat = any(_same(entry, e) for e in target)
|
|
666
|
+
if repeat:
|
|
667
|
+
duplicates += 1
|
|
668
|
+
continue
|
|
669
|
+
target.append(entry)
|
|
670
|
+
added += 1
|
|
671
|
+
if surface is not None:
|
|
672
|
+
_write_not_public_warning(data)
|
|
673
|
+
return added, duplicates, dropped
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def patch(data: dict, entries: list) -> tuple[int, list[str], list[str]]:
|
|
677
|
+
"""Set signature, params and return_type on the exports named; return (patched, unmatched, ambiguous)."""
|
|
678
|
+
if not isinstance(entries, list) or not all(isinstance(e, dict) for e in entries):
|
|
679
|
+
raise InventoryError("stdin must hold a JSON list of objects")
|
|
680
|
+
for entry in entries:
|
|
681
|
+
if not isinstance(entry.get("export_name"), str) or not entry["export_name"]:
|
|
682
|
+
raise InventoryError("every patch entry needs an export_name")
|
|
683
|
+
extra = sorted(set(entry) - {"export_name", "source_file", *PATCHABLE})
|
|
684
|
+
if extra:
|
|
685
|
+
raise InventoryError(f"patch sets only {', '.join(PATCHABLE)}; {entry['export_name']} also has "
|
|
686
|
+
f"{', '.join(extra)}")
|
|
687
|
+
patched = 0
|
|
688
|
+
unmatched: list[str] = []
|
|
689
|
+
ambiguous: list[str] = []
|
|
690
|
+
for entry in entries:
|
|
691
|
+
name, source = entry["export_name"], entry.get("source_file")
|
|
692
|
+
found = [e for e in data["exports"] if isinstance(e, dict) and e.get("export_name") == name
|
|
693
|
+
and (source is None or _norm_path(e.get("source_file")) == _norm_path(source))]
|
|
694
|
+
label = name if source is None else f"{name} ({source})"
|
|
695
|
+
if not found:
|
|
696
|
+
unmatched.append(label)
|
|
697
|
+
elif len(found) > 1:
|
|
698
|
+
ambiguous.append(label)
|
|
699
|
+
else:
|
|
700
|
+
found[0].update({k: entry[k] for k in PATCHABLE if k in entry})
|
|
701
|
+
patched += 1
|
|
702
|
+
return patched, unmatched, ambiguous
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def set_fields(data: dict, values: dict) -> tuple[list[str], list[str]]:
|
|
706
|
+
"""Set the keys of `values` on the inventory (see the module docstring); return (the keys set, the top_exports
|
|
707
|
+
names dropped as off the public surface)."""
|
|
708
|
+
if not isinstance(values, dict):
|
|
709
|
+
raise InventoryError("stdin must hold a JSON object")
|
|
710
|
+
unknown = sorted(set(values) - set(SETTABLE))
|
|
711
|
+
if unknown:
|
|
712
|
+
raise InventoryError(f"set takes only {', '.join(SETTABLE)}; not {', '.join(unknown)}")
|
|
713
|
+
for key, value in values.items():
|
|
714
|
+
types, _ = SETTABLE[key]
|
|
715
|
+
if not isinstance(value, types) or (key == "files_scanned" and isinstance(value, bool)):
|
|
716
|
+
names = " or ".join("null" if t is type(None) else t.__name__ for t in types)
|
|
717
|
+
raise InventoryError(f"{key} must be {names}")
|
|
718
|
+
mapping = _intent_mapping(data, values["intent_mapping"]) if "intent_mapping" in values else {}
|
|
719
|
+
dropped: list[str] = []
|
|
720
|
+
if "top_exports" in values:
|
|
721
|
+
off = _off_surface_names(data)
|
|
722
|
+
dropped = [n for n in values["top_exports"] if isinstance(n, str) and n in off]
|
|
723
|
+
values = {**values, "top_exports": [n for n in values["top_exports"] if not (isinstance(n, str) and n in off)]}
|
|
724
|
+
for key, value in values.items():
|
|
725
|
+
if key == "intent_mapping":
|
|
726
|
+
data["intent_mapping"] = {**(data.get("intent_mapping") or {}), **mapping}
|
|
727
|
+
elif SETTABLE[key][1]:
|
|
728
|
+
data[key] = {**(data.get(key) if isinstance(data.get(key), dict) else {}), **value}
|
|
729
|
+
else:
|
|
730
|
+
data[key] = value
|
|
731
|
+
return list(values), dropped
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _intent_mapping(data: dict, mapping: dict) -> dict:
|
|
735
|
+
"""Keep only the kept entries of each list a free-text intent names; return the recorded mapping."""
|
|
736
|
+
unknown = sorted(set(mapping) - set(INTENT_LISTS))
|
|
737
|
+
if unknown:
|
|
738
|
+
raise InventoryError(f"intent_mapping takes only {', '.join(INTENT_LISTS)}; not {', '.join(unknown)}")
|
|
739
|
+
recorded: dict = {}
|
|
740
|
+
filtered: dict[str, list] = {}
|
|
741
|
+
for kind, spec in mapping.items():
|
|
742
|
+
kept_files = spec.get("kept") if isinstance(spec, dict) else None
|
|
743
|
+
if not isinstance(spec, dict) or not isinstance(spec.get("intent"), str) or not isinstance(kept_files, list) \
|
|
744
|
+
or not all(isinstance(f, str) for f in kept_files):
|
|
745
|
+
raise InventoryError(f'intent_mapping.{kind} must be {{"intent": "<text>", "kept": [source_file, ...]}}')
|
|
746
|
+
entries = [e for e in data.get(INTENT_LISTS[kind]) or [] if isinstance(e, dict)]
|
|
747
|
+
listed = {e.get("source_file") for e in entries}
|
|
748
|
+
missing = [f for f in spec["kept"] if f not in listed]
|
|
749
|
+
if missing:
|
|
750
|
+
raise InventoryError(f"{INTENT_LISTS[kind]} lists no {', '.join(missing)}")
|
|
751
|
+
kept = set(spec["kept"])
|
|
752
|
+
filtered[kind] = [e for e in entries if e.get("source_file") in kept]
|
|
753
|
+
earlier = (data.get("intent_mapping") or {}).get(kind)
|
|
754
|
+
left_out = {e.get("source_file") for e in entries if e.get("source_file") not in kept}
|
|
755
|
+
if isinstance(earlier, dict) and earlier.get("intent") == spec["intent"]:
|
|
756
|
+
left_out |= set(earlier.get("left_out") or [])
|
|
757
|
+
recorded[kind] = {"intent": spec["intent"], "kept": sorted(kept), "left_out": sorted(left_out - kept)}
|
|
758
|
+
for kind, entries in filtered.items():
|
|
759
|
+
data[INTENT_LISTS[kind]] = entries
|
|
760
|
+
return recorded
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
JS_LANGUAGES = frozenset({"typescript", "tsx", "javascript"})
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def typed_param(param, language: str | None) -> str | None:
|
|
767
|
+
"""One parameter as the provenance map writes it ('userId: string',
|
|
768
|
+
'options?: TokenOptions', 'retries: int = 3'); a string is kept."""
|
|
769
|
+
if isinstance(param, str):
|
|
770
|
+
return param
|
|
771
|
+
if not isinstance(param, dict):
|
|
772
|
+
return None
|
|
773
|
+
name, type_, default = param.get("name"), param.get("type"), param.get("default")
|
|
774
|
+
if not name:
|
|
775
|
+
return type_ or None
|
|
776
|
+
text = str(name)
|
|
777
|
+
if param.get("optional") and default is None and language in JS_LANGUAGES and not text.startswith("..."):
|
|
778
|
+
text += "?"
|
|
779
|
+
if type_:
|
|
780
|
+
text += f": {type_}"
|
|
781
|
+
if default is not None:
|
|
782
|
+
text += f" = {default}"
|
|
783
|
+
return text
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
# provenance: the header keys stdin may give, and the default of each (REQUIRED: no default).
|
|
787
|
+
REQUIRED = object()
|
|
788
|
+
MAP_HEADER = {
|
|
789
|
+
"provenance_version": "2.0",
|
|
790
|
+
"skill_name": None, # the inventory's
|
|
791
|
+
"skill_type": "single",
|
|
792
|
+
"source_repo": REQUIRED,
|
|
793
|
+
"source_commit": REQUIRED,
|
|
794
|
+
"source_ref": None,
|
|
795
|
+
"generated_at": None, # now
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _labels(export: dict) -> dict:
|
|
800
|
+
"""The labels the tool that found `export` gives its entry (compile.md §6)."""
|
|
801
|
+
if export.get("extraction_method") == "ast-grep":
|
|
802
|
+
return {"confidence": "T1", "extraction_method": "ast-grep", "ast_node_type": export.get("ast_node_type"),
|
|
803
|
+
"signature_source": "T1"}
|
|
804
|
+
return {"confidence": "T1-low", "extraction_method": "source-read", "ast_node_type": None,
|
|
805
|
+
"signature_source": "T1-low"}
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def provenance_entries(data: dict) -> tuple[list[dict], list[str]]:
|
|
809
|
+
"""(the provenance map entry of every export but one marked `public: false`, the functions among them with no
|
|
810
|
+
params)."""
|
|
811
|
+
entries: list[dict] = []
|
|
812
|
+
unsigned: list[str] = []
|
|
813
|
+
for export in data.get("exports") or []:
|
|
814
|
+
if not isinstance(export, dict) or export.get("public") is False:
|
|
815
|
+
continue
|
|
816
|
+
raw = export.get("params")
|
|
817
|
+
params = None
|
|
818
|
+
if isinstance(raw, list):
|
|
819
|
+
params = [s for s in (typed_param(p, export.get("language")) for p in raw) if s is not None]
|
|
820
|
+
elif export.get("export_type") == "function":
|
|
821
|
+
unsigned.append(f"{export.get('export_name')} ({export.get('source_file')})")
|
|
822
|
+
entries.append({
|
|
823
|
+
"export_name": export.get("export_name"),
|
|
824
|
+
"export_type": export.get("export_type"),
|
|
825
|
+
"params": params,
|
|
826
|
+
"return_type": export.get("return_type") if params is not None else None,
|
|
827
|
+
"source_file": export.get("source_file"),
|
|
828
|
+
"source_line": export.get("source_line"),
|
|
829
|
+
**_labels(export),
|
|
830
|
+
})
|
|
831
|
+
return entries, unsigned
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _utc_now() -> str:
|
|
835
|
+
"""Now, in UTC, to the second (ISO-8601)."""
|
|
836
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def map_header(data: dict, given) -> dict:
|
|
840
|
+
"""The provenance map's header: stdin's keys over the defaults."""
|
|
841
|
+
if not isinstance(given, dict):
|
|
842
|
+
raise InventoryError("stdin must hold the map's header, a JSON object with source_repo and source_commit")
|
|
843
|
+
unknown = sorted(set(given) - set(MAP_HEADER))
|
|
844
|
+
if unknown:
|
|
845
|
+
raise InventoryError(f"the map's header takes only {', '.join(MAP_HEADER)}; not {', '.join(unknown)}")
|
|
846
|
+
missing = [key for key, default in MAP_HEADER.items() if default is REQUIRED and key not in given]
|
|
847
|
+
if missing:
|
|
848
|
+
raise InventoryError(f"the map's header needs {', '.join(missing)} (null when unknown)")
|
|
849
|
+
defaults = {"skill_name": data.get("skill_name"), "generated_at": _utc_now()}
|
|
850
|
+
return {key: given[key] if key in given else defaults.get(key, default) for key, default in MAP_HEADER.items()}
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def provenance_map(data: dict, header) -> tuple[dict, list[str]]:
|
|
854
|
+
"""(the provenance map of the inventory, the functions with no params)."""
|
|
855
|
+
entries, unsigned = provenance_entries(data)
|
|
856
|
+
return {**map_header(data, header), "entries": entries}, unsigned
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def add_map_entries(provenance: dict, payload, off: frozenset = frozenset()) -> tuple[int, int, list[dict]]:
|
|
860
|
+
"""Append stdin's `entries` and `file_entries` to the map; return (added, duplicates, {name, file} of each entry
|
|
861
|
+
not added because `off`, the (export_name, source_file) of the inventory's exports marked `public: false`,
|
|
862
|
+
holds it)."""
|
|
863
|
+
if not isinstance(payload, dict) or not payload or set(payload) - {"entries", "file_entries"}:
|
|
864
|
+
raise InventoryError('stdin must hold {"entries": [...], "file_entries": [...]} (either list, or both)')
|
|
865
|
+
keys = {"entries": ("export_name", "source_file"), "file_entries": ("file_name",)}
|
|
866
|
+
added = duplicates = 0
|
|
867
|
+
not_public: list[dict] = []
|
|
868
|
+
for field, items in payload.items():
|
|
869
|
+
if not isinstance(items, list) or not all(isinstance(e, dict) for e in items):
|
|
870
|
+
raise InventoryError(f"{field} must be a list of objects")
|
|
871
|
+
need = keys[field][0]
|
|
872
|
+
if not all(isinstance(e.get(need), str) and e[need] for e in items):
|
|
873
|
+
raise InventoryError(f"every {field} item needs a {need}")
|
|
874
|
+
target = provenance.setdefault(field, [])
|
|
875
|
+
if not isinstance(target, list):
|
|
876
|
+
raise InventoryError(f"the map's {field} is not a list")
|
|
877
|
+
held = {tuple(e.get(k) for k in keys[field]) for e in target if isinstance(e, dict)}
|
|
878
|
+
for item in items:
|
|
879
|
+
key = tuple(item.get(k) for k in keys[field])
|
|
880
|
+
if field == "entries" and (item.get("export_name"), _norm_path(item.get("source_file"))) in off:
|
|
881
|
+
not_public.append({"name": item.get("export_name"), "file": item.get("source_file")})
|
|
882
|
+
continue
|
|
883
|
+
if key in held:
|
|
884
|
+
duplicates += 1
|
|
885
|
+
continue
|
|
886
|
+
held.add(key)
|
|
887
|
+
target.append(item)
|
|
888
|
+
added += 1
|
|
889
|
+
return added, duplicates, not_public
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
_SIBLINGS: dict = {}
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _sibling(filename: str):
|
|
896
|
+
"""A helper installed beside this script, loaded once as a module: skf-resolve-authoritative-files.py (the
|
|
897
|
+
brief's YAML), skf-render-quick-metadata.py (the installed SKF's version) and skf-description-guard.py (the
|
|
898
|
+
staged SKILL.md's description)."""
|
|
899
|
+
if filename not in _SIBLINGS:
|
|
900
|
+
path = Path(__file__).resolve().parent / filename
|
|
901
|
+
spec = importlib.util.spec_from_file_location("skf_" + filename[4:-3].replace("-", "_"), path)
|
|
902
|
+
if spec is None or spec.loader is None:
|
|
903
|
+
raise InventoryError(f"cannot load {filename} beside {Path(__file__).name}")
|
|
904
|
+
module = importlib.util.module_from_spec(spec)
|
|
905
|
+
try:
|
|
906
|
+
spec.loader.exec_module(module)
|
|
907
|
+
except ImportError as exc:
|
|
908
|
+
raise InventoryError(f"cannot load {filename} ({exc}): run this helper with `uv run`") from exc
|
|
909
|
+
except (OSError, SyntaxError) as exc:
|
|
910
|
+
raise InventoryError(f"cannot load {filename} beside {Path(__file__).name}: {exc}") from exc
|
|
911
|
+
_SIBLINGS[filename] = module
|
|
912
|
+
return _SIBLINGS[filename]
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def read_brief(path: Path) -> dict:
|
|
916
|
+
"""The skill brief, a YAML mapping."""
|
|
917
|
+
module = _sibling("skf-resolve-authoritative-files.py")
|
|
918
|
+
try:
|
|
919
|
+
brief = module.parse_brief_yaml(path.read_text(encoding="utf-8"))
|
|
920
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
921
|
+
raise InventoryError(f"cannot read the brief {path.as_posix()}: {exc}") from exc
|
|
922
|
+
except module.yaml.YAMLError as exc:
|
|
923
|
+
raise InventoryError(f"the brief {path.as_posix()} is not valid YAML: {exc}") from exc
|
|
924
|
+
if not isinstance(brief, dict):
|
|
925
|
+
raise InventoryError(f"the brief {path.as_posix()} is not a YAML mapping")
|
|
926
|
+
return brief
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
# metadata: the stdin keys, each with the JSON types it takes; METADATA_REQUIRED have no default.
|
|
930
|
+
METADATA_INPUT = {
|
|
931
|
+
"version": (str,), "language": (str,),
|
|
932
|
+
"source_root": (str, type(None)), "source_commit": (str, type(None)), "source_ref": (str, type(None)),
|
|
933
|
+
"qmd_version": (str, type(None)), "ast_grep_version": (str, type(None)), "stats_extra": (dict,),
|
|
934
|
+
}
|
|
935
|
+
METADATA_REQUIRED = ("version", "language", "source_root", "source_commit", "source_ref", "qmd_version")
|
|
936
|
+
NON_EMPTY = ("version", "language")
|
|
937
|
+
REFERENCE_APP = "reference-app"
|
|
938
|
+
COMPONENT_LIBRARY = "component-library"
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _metadata_input(given) -> dict:
|
|
942
|
+
"""stdin's fields, checked, with the defaults of the optional ones."""
|
|
943
|
+
if not isinstance(given, dict):
|
|
944
|
+
raise InventoryError("stdin must hold a JSON object with " + ", ".join(METADATA_REQUIRED))
|
|
945
|
+
unknown = sorted(set(given) - set(METADATA_INPUT))
|
|
946
|
+
if unknown:
|
|
947
|
+
raise InventoryError(f"metadata takes only {', '.join(METADATA_INPUT)}; not {', '.join(unknown)}")
|
|
948
|
+
missing = [key for key in METADATA_REQUIRED if key not in given]
|
|
949
|
+
if missing:
|
|
950
|
+
raise InventoryError(f"metadata needs {', '.join(missing)} (null when unknown, but for "
|
|
951
|
+
f"{', '.join(NON_EMPTY)})")
|
|
952
|
+
for key, value in given.items():
|
|
953
|
+
if not isinstance(value, METADATA_INPUT[key]) or (key in NON_EMPTY and not value.strip()):
|
|
954
|
+
names = " or ".join("null" if t is type(None) else t.__name__ for t in METADATA_INPUT[key])
|
|
955
|
+
raise InventoryError(f"{key} must be {'a non-empty ' if key in NON_EMPTY else ''}{names}")
|
|
956
|
+
return {"ast_grep_version": None, "stats_extra": {}, **given}
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def read_description(skill_md: Path) -> str:
|
|
960
|
+
"""The `description` of the staged SKILL.md's frontmatter, which compile §2a wrote."""
|
|
961
|
+
module = _sibling("skf-description-guard.py")
|
|
962
|
+
try:
|
|
963
|
+
description, _hash = module.read_description(skill_md)
|
|
964
|
+
except (OSError, UnicodeDecodeError, ValueError) as exc:
|
|
965
|
+
raise InventoryError(f"cannot read the description of the staged SKILL.md: {exc} (write SKILL.md, "
|
|
966
|
+
"compile §2, before metadata.json)") from exc
|
|
967
|
+
if not description.strip():
|
|
968
|
+
raise InventoryError(f"the description of {skill_md.as_posix()} is empty")
|
|
969
|
+
return description
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def metadata_exports(data: dict, scope_type: object) -> list[str]:
|
|
973
|
+
"""metadata.json's exports[]: the names of the inventory's exports but one marked internal or not public, each
|
|
974
|
+
once (a docs-only inventory's t3_items names; none for a reference app)."""
|
|
975
|
+
if scope_type == REFERENCE_APP:
|
|
976
|
+
return []
|
|
977
|
+
if data.get("extraction_mode") == "docs-only":
|
|
978
|
+
items = [i for i in data.get("t3_items") or [] if isinstance(i, dict)]
|
|
979
|
+
else:
|
|
980
|
+
items = [e for e in data["exports"] if isinstance(e, dict) and e.get("internal") is not True
|
|
981
|
+
and e.get("public") is not False]
|
|
982
|
+
names = (i.get("export_name") for i in items)
|
|
983
|
+
return list(dict.fromkeys(n for n in names if isinstance(n, str) and n))
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _file_rows(entries: object, folder: str) -> list[dict]:
|
|
987
|
+
"""The scripts[] or assets[] rows of a scripts_inventory or assets_inventory."""
|
|
988
|
+
rows = []
|
|
989
|
+
for entry in entries if isinstance(entries, list) else []:
|
|
990
|
+
if not isinstance(entry, dict):
|
|
991
|
+
continue
|
|
992
|
+
source = entry.get("source_file")
|
|
993
|
+
name = entry.get("name") or (Path(str(source)).name if source else None)
|
|
994
|
+
rows.append({"file": f"{folder}/{name}", "purpose": entry.get("purpose") or "", "source_file": source,
|
|
995
|
+
"confidence": entry.get("confidence") or "T1-low"})
|
|
996
|
+
return rows
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _ast_grep_run(data: dict, given_version: str | None) -> tuple[str | None, int | None, list[str]]:
|
|
1000
|
+
"""(tool_versions.ast_grep, ast_node_count, warnings): the runner's version and match count when it ran
|
|
1001
|
+
ast-grep, else the number of exports labeled ast-grep (the recipes run without the runner) and the version
|
|
1002
|
+
stdin gave, else (None, None) when ast-grep did not run."""
|
|
1003
|
+
rules = data.get("extraction_rules") if isinstance(data.get("extraction_rules"), dict) else {}
|
|
1004
|
+
version = rules.get("ast_grep_version")
|
|
1005
|
+
if isinstance(version, str) and version:
|
|
1006
|
+
aggregates = data.get("aggregates") if isinstance(data.get("aggregates"), dict) else {}
|
|
1007
|
+
matched = aggregates.get("exports")
|
|
1008
|
+
return version, matched if isinstance(matched, int) and not isinstance(matched, bool) else None, []
|
|
1009
|
+
matched = sum(1 for e in data["exports"] if isinstance(e, dict) and e.get("extraction_method") == "ast-grep")
|
|
1010
|
+
if not matched:
|
|
1011
|
+
return None, None, []
|
|
1012
|
+
if given_version and given_version.strip():
|
|
1013
|
+
return given_version.strip(), matched, []
|
|
1014
|
+
return "unknown", matched, [f"tool_versions.ast_grep is unknown: ast-grep matched {matched} exports without "
|
|
1015
|
+
"the recipe runner, and stdin gave no ast_grep_version"]
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def build_metadata(data: dict, brief: dict, stats: object, given, skf_version: str,
|
|
1019
|
+
description: str) -> tuple[dict, list[str]]:
|
|
1020
|
+
"""(the skill's metadata.json, its warnings) (see `metadata` in the module docstring)."""
|
|
1021
|
+
fields = _metadata_input(given)
|
|
1022
|
+
if not isinstance(stats, dict) or not isinstance(stats.get("stats"), dict) \
|
|
1023
|
+
or not isinstance(stats.get("confidence_distribution"), dict):
|
|
1024
|
+
raise InventoryError("the --stats file must hold the stats helper's JSON: a stats and a "
|
|
1025
|
+
"confidence_distribution object")
|
|
1026
|
+
clash = sorted(set(fields["stats_extra"]) & set(stats["stats"]))
|
|
1027
|
+
if clash:
|
|
1028
|
+
raise InventoryError(f"stats_extra may not set {', '.join(clash)}: the stats helper writes it")
|
|
1029
|
+
scope = brief.get("scope") if isinstance(brief.get("scope"), dict) else {}
|
|
1030
|
+
scope_type = scope.get("type")
|
|
1031
|
+
if not isinstance(scope_type, str) or not scope_type:
|
|
1032
|
+
raise InventoryError("the brief has no scope.type, which metadata.json records as scope_type")
|
|
1033
|
+
extra = sorted(fields["stats_extra"])
|
|
1034
|
+
if scope_type == REFERENCE_APP and set(extra) - {"notes"}:
|
|
1035
|
+
raise InventoryError(f"a reference app's stats_extra holds only notes, not {', '.join(extra)}: the stats "
|
|
1036
|
+
"helper writes pattern_surfaces_documented")
|
|
1037
|
+
if extra and scope_type not in (REFERENCE_APP, COMPONENT_LIBRARY):
|
|
1038
|
+
raise InventoryError(f"a {scope_type} skill takes no stats_extra (only a component library or a "
|
|
1039
|
+
"reference app adds stats)")
|
|
1040
|
+
ast_grep, node_count, warnings = _ast_grep_run(data, fields["ast_grep_version"])
|
|
1041
|
+
metadata = {
|
|
1042
|
+
"name": brief.get("name") or data.get("skill_name"),
|
|
1043
|
+
"version": fields["version"],
|
|
1044
|
+
"skill_type": "single",
|
|
1045
|
+
"scope_type": scope_type,
|
|
1046
|
+
"source_authority": brief.get("source_authority") or "community",
|
|
1047
|
+
"source_repo": brief.get("source_repo"),
|
|
1048
|
+
"source_root": fields["source_root"],
|
|
1049
|
+
"source_commit": fields["source_commit"],
|
|
1050
|
+
"source_ref": fields["source_ref"],
|
|
1051
|
+
"confidence_tier": data.get("tier"),
|
|
1052
|
+
"spec_version": "1.3",
|
|
1053
|
+
"generation_date": _utc_now(),
|
|
1054
|
+
"description": description,
|
|
1055
|
+
"language": fields["language"],
|
|
1056
|
+
}
|
|
1057
|
+
if node_count is not None:
|
|
1058
|
+
metadata["ast_node_count"] = node_count
|
|
1059
|
+
metadata["exports"] = metadata_exports(data, scope_type)
|
|
1060
|
+
metadata["tool_versions"] = {"ast_grep": ast_grep, "qmd": fields["qmd_version"], "skf": skf_version}
|
|
1061
|
+
metadata["confidence_distribution"] = stats["confidence_distribution"]
|
|
1062
|
+
metadata["stats"] = {**stats["stats"], **fields["stats_extra"]}
|
|
1063
|
+
for key, folder in (("scripts_inventory", "scripts"), ("assets_inventory", "assets")):
|
|
1064
|
+
rows = _file_rows(data.get(key), folder)
|
|
1065
|
+
if rows:
|
|
1066
|
+
metadata[folder] = rows
|
|
1067
|
+
metadata["generated_by"] = "create-skill"
|
|
1068
|
+
metadata["dependencies"] = []
|
|
1069
|
+
metadata["compatibility"] = ""
|
|
1070
|
+
return metadata, warnings
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def rules_yaml(data: dict, language: str) -> str:
|
|
1074
|
+
"""extraction-rules.yaml: how the skill was extracted, for a later run to reproduce it."""
|
|
1075
|
+
head = {"skill_name": data.get("skill_name"), "language": language, "tier": data.get("tier"),
|
|
1076
|
+
"extraction_mode": data.get("extraction_mode")}
|
|
1077
|
+
rules = data.get("extraction_rules") if isinstance(data.get("extraction_rules"), dict) else {}
|
|
1078
|
+
lines = ["# Generated by skf-extraction-inventory.py rules from the extraction inventory."]
|
|
1079
|
+
lines += _yaml_lines({**head, **rules})
|
|
1080
|
+
return "\n".join(lines) + "\n"
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _yaml_scalar(value) -> str:
|
|
1084
|
+
if isinstance(value, dict):
|
|
1085
|
+
return "{}"
|
|
1086
|
+
if isinstance(value, list):
|
|
1087
|
+
return "[]"
|
|
1088
|
+
return json.dumps(value, ensure_ascii=False) # JSON scalars are YAML scalars
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _yaml_key(key) -> str:
|
|
1092
|
+
text = str(key)
|
|
1093
|
+
return text if YAML_KEY_RE.fullmatch(text) else json.dumps(text, ensure_ascii=False)
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _yaml_lines(value, indent: int = 0) -> list[str]:
|
|
1097
|
+
pad = " " * indent
|
|
1098
|
+
lines: list[str] = []
|
|
1099
|
+
items = value.items() if isinstance(value, dict) else ((None, v) for v in value)
|
|
1100
|
+
for key, item in items:
|
|
1101
|
+
lead = f"{pad}{_yaml_key(key)}:" if isinstance(value, dict) else f"{pad}-"
|
|
1102
|
+
if isinstance(item, (dict, list)) and item:
|
|
1103
|
+
nested = _yaml_lines(item, indent + 1)
|
|
1104
|
+
if isinstance(value, list):
|
|
1105
|
+
lines.append(f"{lead} {nested[0].lstrip()}")
|
|
1106
|
+
lines.extend(nested[1:])
|
|
1107
|
+
else:
|
|
1108
|
+
lines.append(lead)
|
|
1109
|
+
lines.extend(nested)
|
|
1110
|
+
else:
|
|
1111
|
+
lines.append(f"{lead} {_yaml_scalar(item)}")
|
|
1112
|
+
return lines
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def write(path: Path, data: dict | str) -> None:
|
|
1116
|
+
"""Write `data` (JSON, or text as it is) to `path` through a temporary file and one rename."""
|
|
1117
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
1118
|
+
try:
|
|
1119
|
+
with open(tmp, "w", encoding="utf-8", newline="\n") as fh:
|
|
1120
|
+
if isinstance(data, str):
|
|
1121
|
+
fh.write(data)
|
|
1122
|
+
else:
|
|
1123
|
+
json.dump(data, fh, indent=2, ensure_ascii=False)
|
|
1124
|
+
fh.write("\n")
|
|
1125
|
+
fh.flush()
|
|
1126
|
+
os.fsync(fh.fileno())
|
|
1127
|
+
os.replace(tmp, path)
|
|
1128
|
+
except OSError as exc:
|
|
1129
|
+
try:
|
|
1130
|
+
tmp.unlink()
|
|
1131
|
+
except OSError:
|
|
1132
|
+
pass
|
|
1133
|
+
raise InventoryError(f"cannot write {path.as_posix()}: {exc}") from exc
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def _result(path: Path, data: dict, **extra) -> dict:
|
|
1137
|
+
return {"status": "ok", "inventory": path.as_posix(), "extraction_mode": data.get("extraction_mode"),
|
|
1138
|
+
"counts": counts(data), **extra}
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def _stdin_json():
|
|
1142
|
+
raw = sys.stdin.read()
|
|
1143
|
+
try:
|
|
1144
|
+
return json.loads(raw) if raw.strip() else None
|
|
1145
|
+
except ValueError as exc:
|
|
1146
|
+
raise InventoryError(f"stdin is not JSON: {exc}") from exc
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
1150
|
+
parser = argparse.ArgumentParser(
|
|
1151
|
+
prog="skf-extraction-inventory.py",
|
|
1152
|
+
description=__doc__,
|
|
1153
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1154
|
+
)
|
|
1155
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1156
|
+
inventory_help = "the extraction inventory JSON"
|
|
1157
|
+
p_init = sub.add_parser("init", help="write a new inventory from the runner's and the detector's JSON")
|
|
1158
|
+
p_init.add_argument("--inventory", required=True, type=Path, help=inventory_help)
|
|
1159
|
+
p_init.add_argument("--skill", required=True, help="the skill name")
|
|
1160
|
+
p_init.add_argument("--mode", required=True, choices=MODES, help="the extraction mode")
|
|
1161
|
+
p_init.add_argument("--tier", required=True, choices=TIERS, help="the forge tier")
|
|
1162
|
+
p_init.add_argument("--extraction", type=Path, default=None,
|
|
1163
|
+
help="the recipe runner's JSON (skf-extract-public-api.py --mode full -o)")
|
|
1164
|
+
p_init.add_argument("--detected", type=Path, default=None,
|
|
1165
|
+
help="the script and asset detector's JSON (skf-detect-scripts-assets.py detect)")
|
|
1166
|
+
p_add = sub.add_parser("add", help="append a JSON list from stdin to one of the inventory's lists")
|
|
1167
|
+
p_add.add_argument("--inventory", required=True, type=Path, help=inventory_help)
|
|
1168
|
+
p_add.add_argument("--field", required=True, choices=FIELDS, help="the list to append to")
|
|
1169
|
+
p_patch = sub.add_parser("patch", help="set signature, params and return_type on exports from stdin")
|
|
1170
|
+
p_patch.add_argument("--inventory", required=True, type=Path, help=inventory_help)
|
|
1171
|
+
p_set = sub.add_parser("set", help="set the keys of a JSON object from stdin")
|
|
1172
|
+
p_set.add_argument("--inventory", required=True, type=Path, help=inventory_help)
|
|
1173
|
+
p_rules = sub.add_parser("rules", help="write extraction-rules.yaml from the inventory")
|
|
1174
|
+
p_rules.add_argument("--inventory", required=True, type=Path, help=inventory_help)
|
|
1175
|
+
p_rules.add_argument("--language", required=True, help="the brief's language")
|
|
1176
|
+
p_rules.add_argument("--target", required=True, type=Path, help="the extraction-rules.yaml to write")
|
|
1177
|
+
p_prov = sub.add_parser("provenance", help="write the provenance map from the inventory, the header on stdin")
|
|
1178
|
+
p_prov.add_argument("--inventory", required=True, type=Path, help=inventory_help)
|
|
1179
|
+
p_prov.add_argument("--target", required=True, type=Path, help="the provenance-map.json to write")
|
|
1180
|
+
p_prov.add_argument("--add-entries", action="store_true",
|
|
1181
|
+
help="append stdin's entries and file_entries to the map at --target instead")
|
|
1182
|
+
p_meta = sub.add_parser("metadata", help="write metadata.json from the inventory, the brief and the stats, "
|
|
1183
|
+
"the fields compile decides on stdin")
|
|
1184
|
+
p_meta.add_argument("--inventory", required=True, type=Path, help=inventory_help)
|
|
1185
|
+
p_meta.add_argument("--brief", required=True, type=Path, help="the skill-brief.yaml")
|
|
1186
|
+
p_meta.add_argument("--stats", required=True, type=Path,
|
|
1187
|
+
help="the JSON skf-render-metadata-stats.py wrote (its -o file)")
|
|
1188
|
+
p_meta.add_argument("--skf-root", required=True, type=Path,
|
|
1189
|
+
help="the installed SKF module folder ({project-root}/_bmad/skf), for tool_versions.skf")
|
|
1190
|
+
p_meta.add_argument("--target", required=True, type=Path,
|
|
1191
|
+
help="the metadata.json to write, beside the staged SKILL.md it reads the description of")
|
|
1192
|
+
p_summary = sub.add_parser("summary", help="print the inventory's counts")
|
|
1193
|
+
p_summary.add_argument("--inventory", required=True, type=Path, help=inventory_help)
|
|
1194
|
+
return parser
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1198
|
+
args = _build_parser().parse_args(argv)
|
|
1199
|
+
path = args.inventory
|
|
1200
|
+
try:
|
|
1201
|
+
if args.command == "init":
|
|
1202
|
+
extraction = _read_json(args.extraction, "runner JSON") if args.extraction else None
|
|
1203
|
+
detected = _read_json(args.detected, "detector JSON") if args.detected else None
|
|
1204
|
+
data = init(args.skill, args.mode, args.tier, extraction, detected)
|
|
1205
|
+
write(path, data)
|
|
1206
|
+
result = _result(path, data)
|
|
1207
|
+
elif args.command == "provenance" and args.add_entries:
|
|
1208
|
+
data = load(path)
|
|
1209
|
+
provenance = _read_json(args.target, "provenance map")
|
|
1210
|
+
if not isinstance(provenance, dict) or not isinstance(provenance.get("entries"), list):
|
|
1211
|
+
raise InventoryError(f"the provenance map {args.target.as_posix()} has no entries list: "
|
|
1212
|
+
"run provenance without --add-entries first")
|
|
1213
|
+
off = frozenset((item["name"], _norm_path(item["file"])) for item in _not_public(data))
|
|
1214
|
+
added, duplicates, not_public = add_map_entries(provenance, _stdin_json(), off)
|
|
1215
|
+
write(args.target, provenance)
|
|
1216
|
+
result = {"status": "ok", "target": args.target.as_posix(), "entries": len(provenance["entries"]),
|
|
1217
|
+
"file_entries": len(provenance.get("file_entries") or []), "added": added,
|
|
1218
|
+
"duplicates": duplicates, "not_public": not_public}
|
|
1219
|
+
elif args.command == "provenance":
|
|
1220
|
+
provenance, unsigned = provenance_map(load(path), _stdin_json())
|
|
1221
|
+
write(args.target, provenance)
|
|
1222
|
+
result = {"status": "ok", "target": args.target.as_posix(), "entries": len(provenance["entries"]),
|
|
1223
|
+
"unsigned": unsigned}
|
|
1224
|
+
elif args.command == "metadata":
|
|
1225
|
+
skf_version = _sibling("skf-render-quick-metadata.py").probe_skf_version(args.skf_root)
|
|
1226
|
+
metadata, warnings = build_metadata(load(path), read_brief(args.brief),
|
|
1227
|
+
_read_json(args.stats, "stats JSON"), _stdin_json(), skf_version,
|
|
1228
|
+
read_description(args.target.parent / "SKILL.md"))
|
|
1229
|
+
write(args.target, metadata)
|
|
1230
|
+
commit = metadata["source_commit"]
|
|
1231
|
+
if skf_version == "unknown":
|
|
1232
|
+
warnings.append(f"tool_versions.skf is unknown: {args.skf_root.as_posix()} holds no package.json "
|
|
1233
|
+
"or VERSION file with a version")
|
|
1234
|
+
result = {"status": "ok", "target": args.target.as_posix(), "exports": len(metadata["exports"]),
|
|
1235
|
+
"commit_short": commit[:8] if commit else "unknown",
|
|
1236
|
+
"tool_versions": metadata["tool_versions"], "warnings": warnings}
|
|
1237
|
+
elif args.command == "rules":
|
|
1238
|
+
text = rules_yaml(load(path), args.language)
|
|
1239
|
+
write(args.target, text)
|
|
1240
|
+
result = {"status": "ok", "target": args.target.as_posix(), "bytes": len(text.encode("utf-8"))}
|
|
1241
|
+
else:
|
|
1242
|
+
data = load(path)
|
|
1243
|
+
if args.command == "summary":
|
|
1244
|
+
result = _result(path, data, not_public=_not_public(data))
|
|
1245
|
+
elif args.command == "add":
|
|
1246
|
+
payload = _stdin_json()
|
|
1247
|
+
added, duplicates, dropped = add(data, args.field, [] if payload is None else payload)
|
|
1248
|
+
write(path, data)
|
|
1249
|
+
result = _result(path, data, added=added, duplicates=duplicates, dropped=dropped)
|
|
1250
|
+
elif args.command == "patch":
|
|
1251
|
+
payload = _stdin_json()
|
|
1252
|
+
patched, unmatched, ambiguous = patch(data, [] if payload is None else payload)
|
|
1253
|
+
write(path, data)
|
|
1254
|
+
result = _result(path, data, patched=patched, unmatched=unmatched, ambiguous=ambiguous)
|
|
1255
|
+
else:
|
|
1256
|
+
payload = _stdin_json()
|
|
1257
|
+
keys, dropped = set_fields(data, {} if payload is None else payload)
|
|
1258
|
+
write(path, data)
|
|
1259
|
+
result = _result(path, data, set=keys, dropped=dropped)
|
|
1260
|
+
except InventoryError as exc:
|
|
1261
|
+
print(json.dumps({"status": "error", "message": str(exc)}), file=sys.stderr)
|
|
1262
|
+
return 1
|
|
1263
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
1264
|
+
return 0
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def _force_utf8(*streams) -> None:
|
|
1268
|
+
"""Reconfigure stdin and the JSON streams to UTF-8 (a Windows console uses cp1252)."""
|
|
1269
|
+
for stream in streams:
|
|
1270
|
+
if hasattr(stream, "reconfigure"):
|
|
1271
|
+
errors = getattr(stream, "errors", None)
|
|
1272
|
+
if errors is None:
|
|
1273
|
+
stream.reconfigure(encoding="utf-8")
|
|
1274
|
+
else:
|
|
1275
|
+
stream.reconfigure(encoding="utf-8", errors=errors)
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
if __name__ == "__main__":
|
|
1279
|
+
_force_utf8(sys.stdin, sys.stdout, sys.stderr)
|
|
1280
|
+
sys.exit(main())
|