bmad-module-skill-forge 2.2.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +49 -49
- package/docs/404.md +10 -8
- package/docs/_data/pinned.yaml +22 -13
- package/docs/_internal/STABILITY.md +58 -35
- package/docs/agents.md +39 -53
- package/docs/architecture.md +65 -51
- package/docs/bmad-synergy.md +53 -51
- package/docs/campaign.md +127 -75
- package/docs/concepts.md +34 -28
- package/docs/examples.md +70 -68
- package/docs/forge-auto.md +34 -31
- package/docs/getting-started.md +118 -57
- package/docs/how-it-works.md +25 -21
- package/docs/index.md +15 -15
- package/docs/skill-model.md +70 -59
- package/docs/troubleshooting.md +135 -18
- package/docs/verifying-a-skill.md +83 -57
- package/docs/why-skf.md +24 -22
- package/docs/workflows.md +274 -110
- package/package.json +18 -10
- package/src/forger/preferences.yaml +8 -1
- package/src/knowledge/agentskills-spec.md +2 -0
- package/src/knowledge/ccc-bridge.md +40 -24
- package/src/knowledge/confidence-tiers.md +9 -5
- package/src/knowledge/overview.md +3 -3
- package/src/knowledge/progressive-capability.md +8 -8
- package/src/knowledge/provenance-tracking.md +9 -5
- package/src/knowledge/qmd-registry.md +2 -2
- package/src/knowledge/skf-knowledge-index.csv +2 -2
- package/src/knowledge/skill-lifecycle.md +18 -11
- package/src/knowledge/split-body-strategy.md +0 -3
- package/src/knowledge/tool-resolution.md +4 -2
- package/src/knowledge/version-paths.md +101 -15
- package/src/module-help.csv +3 -3
- package/src/module.yaml +10 -0
- package/src/shared/data/ast-grep-recipes.yaml +608 -0
- package/src/shared/data/ide-context-files.json +30 -0
- package/src/shared/health-check.md +32 -12
- package/src/shared/references/description-guard-protocol.md +12 -9
- package/src/shared/references/feasibility-report-schema.md +19 -13
- package/src/shared/references/headless-gate-convention.md +55 -3
- package/src/shared/references/output-contract-schema.md +19 -8
- package/src/shared/references/pipeline-contracts.md +94 -29
- package/src/shared/references/tessl-review.md +77 -0
- package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
- package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
- package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
- package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
- package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
- package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
- package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
- package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
- package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
- package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
- package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
- package/src/shared/scripts/skf-atomic-write.py +30 -14
- package/src/shared/scripts/skf-build-change-manifest.py +2203 -24
- package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
- package/src/shared/scripts/skf-check-unit-records.py +184 -0
- package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
- package/src/shared/scripts/skf-classify-changed-files.py +694 -0
- package/src/shared/scripts/skf-comention-pairs.py +1333 -123
- package/src/shared/scripts/skf-compare-file-hashes.py +1 -1
- package/src/shared/scripts/skf-count-imports.py +1867 -0
- package/src/shared/scripts/skf-count-tokens.py +1 -1
- package/src/shared/scripts/skf-derive-assembly-shape.py +3 -2
- package/src/shared/scripts/skf-description-guard.py +99 -11
- package/src/shared/scripts/skf-detect-docs.py +694 -2
- package/src/shared/scripts/skf-detect-language.py +486 -107
- package/src/shared/scripts/skf-detect-registry.py +887 -0
- package/src/shared/scripts/skf-detect-scripts-assets.py +1 -1
- package/src/shared/scripts/skf-detect-tools.py +209 -27
- package/src/shared/scripts/skf-detect-workspaces.py +506 -30
- package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
- package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
- package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
- package/src/shared/scripts/skf-extract-public-api.py +4084 -85
- package/src/shared/scripts/skf-extraction-inventory.py +746 -0
- package/src/shared/scripts/skf-extraction-snapshot.py +539 -0
- package/src/shared/scripts/skf-fetch-temporal.py +674 -0
- package/src/shared/scripts/skf-find-cycles.py +97 -29
- package/src/shared/scripts/skf-find-test-report.py +476 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +444 -203
- package/src/shared/scripts/skf-github-fetch.py +365 -0
- package/src/shared/scripts/skf-github-probe.py +661 -0
- package/src/shared/scripts/skf-hash-content.py +166 -12
- package/src/shared/scripts/skf-language-corpora.py +1 -1
- package/src/shared/scripts/skf-load-provenance.py +159 -27
- package/src/shared/scripts/skf-manifest-ops.py +1 -1
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
- package/src/shared/scripts/skf-merge-doc-urls.py +1 -1
- package/src/shared/scripts/skf-names-present.py +221 -0
- package/src/shared/scripts/skf-pair-intersect.py +119 -28
- package/src/shared/scripts/skf-parse-gaps.py +1384 -0
- package/src/shared/scripts/skf-preapply.py +1 -1
- package/src/shared/scripts/skf-preflight.py +161 -27
- package/src/shared/scripts/skf-promote-staged.py +430 -0
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +39 -7
- package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
- package/src/shared/scripts/skf-quick-batch.py +774 -0
- package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
- package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
- package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
- package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
- package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
- package/src/shared/scripts/skf-resolve-authoritative-files.py +128 -13
- package/src/shared/scripts/skf-resolve-package.py +523 -106
- package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
- package/src/shared/scripts/skf-run-lock.py +577 -0
- package/src/shared/scripts/skf-scan-manifests.py +998 -57
- package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
- package/src/shared/scripts/skf-severity-classify.py +712 -108
- package/src/shared/scripts/skf-shape-detect.py +298 -204
- package/src/shared/scripts/skf-shard-body.py +9 -8
- package/src/shared/scripts/skf-skill-inventory.py +2412 -89
- package/src/shared/scripts/skf-skills-module.py +472 -0
- package/src/shared/scripts/skf-source-tree.py +1796 -0
- package/src/shared/scripts/skf-structural-diff.py +670 -119
- package/src/shared/scripts/skf-tessl-review.py +706 -0
- package/src/shared/scripts/skf-update-active-symlink.py +5 -5
- package/src/shared/scripts/skf-update-run-state.py +624 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
- package/src/shared/scripts/skf-validate-brief-schema.py +4 -1
- package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
- package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
- package/src/shared/scripts/skf-validate-output.py +712 -64
- package/src/shared/scripts/skf-validate-pins.py +1 -1
- package/src/shared/scripts/skf-verify-no-trace.py +116 -10
- package/src/shared/scripts/skf-verify-provenance-completeness.py +2919 -54
- package/src/shared/scripts/skf-write-skill-brief.py +367 -19
- package/src/shared/tool-requirements.yaml +201 -0
- package/src/skf-analyze-source/SKILL.md +41 -36
- package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
- package/src/skf-analyze-source/customize.toml +36 -24
- package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
- package/src/skf-analyze-source/references/continue.md +2 -13
- package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
- package/src/skf-analyze-source/references/generate-briefs.md +121 -55
- package/src/skf-analyze-source/references/headless-contract.md +42 -15
- package/src/skf-analyze-source/references/identify-units.md +96 -110
- package/src/skf-analyze-source/references/init.md +73 -71
- package/src/skf-analyze-source/references/map-and-detect.md +123 -105
- package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
- package/src/skf-analyze-source/references/recommend.md +51 -70
- package/src/skf-analyze-source/references/scan-project.md +60 -84
- package/src/skf-analyze-source/references/scan-root.md +39 -0
- package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
- package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
- package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
- package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
- package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
- package/src/skf-audit-skill/SKILL.md +43 -51
- package/src/skf-audit-skill/assets/drift-report-template.md +27 -3
- package/src/skf-audit-skill/customize.toml +41 -20
- package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
- package/src/skf-audit-skill/references/doc-drift.md +141 -0
- package/src/skf-audit-skill/references/headless-contract.md +29 -0
- package/src/skf-audit-skill/references/init.md +148 -210
- package/src/skf-audit-skill/references/re-index.md +96 -108
- package/src/skf-audit-skill/references/report.md +96 -33
- package/src/skf-audit-skill/references/semantic-diff.md +27 -18
- package/src/skf-audit-skill/references/severity-classify.md +79 -65
- package/src/skf-audit-skill/references/severity-rules.md +15 -6
- package/src/skf-audit-skill/references/structural-diff.md +75 -90
- package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
- package/src/skf-audit-skill/scripts/render-drift-tables.py +685 -0
- package/src/skf-brief-skill/SKILL.md +36 -25
- package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +17 -24
- package/src/skf-brief-skill/assets/skill-brief-schema.md +29 -15
- package/src/skf-brief-skill/customize.toml +48 -20
- package/src/skf-brief-skill/references/analyze-target.md +142 -125
- package/src/skf-brief-skill/references/confirm-brief.md +10 -8
- package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
- package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
- package/src/skf-brief-skill/references/gather-intent.md +87 -182
- package/src/skf-brief-skill/references/headless-args.md +84 -16
- package/src/skf-brief-skill/references/health-check.md +1 -1
- package/src/skf-brief-skill/references/invocation-contract.md +18 -13
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
- package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
- package/src/skf-brief-skill/references/scope-definition.md +123 -47
- package/src/skf-brief-skill/references/step-auto-brief.md +24 -80
- package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
- package/src/skf-brief-skill/references/version-resolution.md +3 -2
- package/src/skf-brief-skill/references/write-brief.md +66 -57
- package/src/skf-campaign/SKILL.md +31 -34
- package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
- package/src/skf-campaign/customize.toml +47 -16
- package/src/skf-campaign/references/campaign-contracts.md +39 -29
- package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
- package/src/skf-campaign/references/health-check.md +7 -16
- package/src/skf-campaign/references/step-01-setup.md +46 -84
- package/src/skf-campaign/references/step-02-strategy.md +31 -30
- package/src/skf-campaign/references/step-03-pins.md +26 -18
- package/src/skf-campaign/references/step-04-provenance.md +24 -23
- package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
- package/src/skf-campaign/references/step-06-batch.md +41 -34
- package/src/skf-campaign/references/step-07-capstone.md +40 -25
- package/src/skf-campaign/references/step-08-verify.md +28 -29
- package/src/skf-campaign/references/step-09-refine.md +29 -30
- package/src/skf-campaign/references/step-10-export.md +48 -36
- package/src/skf-campaign/references/step-11-maintenance.md +31 -34
- package/src/skf-campaign/references/step-resume.md +37 -68
- package/src/skf-campaign/scripts/campaign-deps.py +57 -18
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
- package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
- package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
- package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
- package/src/skf-campaign/scripts/campaign-report.py +204 -37
- package/src/skf-campaign/scripts/campaign-state.py +1128 -0
- package/src/skf-campaign/scripts/campaign-status.py +10 -74
- package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
- package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
- package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
- package/src/skf-campaign/templates/campaign-report-template.md +5 -1
- package/src/skf-campaign/templates/kickoff-template.md +0 -14
- package/src/skf-create-skill/SKILL.md +27 -20
- package/src/skf-create-skill/assets/compile-assembly-rules.md +6 -5
- package/src/skf-create-skill/assets/skill-sections.md +15 -5
- package/src/skf-create-skill/customize.toml +20 -8
- package/src/skf-create-skill/references/authoritative-files-protocol.md +41 -42
- package/src/skf-create-skill/references/batch-mode.md +81 -0
- package/src/skf-create-skill/references/ccc-index-check.md +27 -0
- package/src/skf-create-skill/references/compile.md +107 -29
- package/src/skf-create-skill/references/component-extraction.md +113 -91
- package/src/skf-create-skill/references/enrich.md +41 -23
- package/src/skf-create-skill/references/entry-points-by-hand.md +20 -0
- package/src/skf-create-skill/references/extract.md +200 -161
- package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +2 -2
- package/src/skf-create-skill/references/extraction-patterns.md +584 -196
- package/src/skf-create-skill/references/generate-artifacts.md +145 -93
- package/src/skf-create-skill/references/health-check.md +1 -1
- package/src/skf-create-skill/references/load-brief.md +53 -36
- package/src/skf-create-skill/references/relabel-rule.md +12 -0
- package/src/skf-create-skill/references/report.md +28 -59
- package/src/skf-create-skill/references/source-resolution-protocols.md +49 -155
- package/src/skf-create-skill/references/step-auto-shard.md +20 -93
- package/src/skf-create-skill/references/step-doc-rot.md +44 -60
- package/src/skf-create-skill/references/step-doc-sources.md +28 -19
- package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
- package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
- package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
- package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
- package/src/skf-create-skill/references/validate.md +173 -99
- package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
- package/src/skf-create-stack-skill/SKILL.md +29 -56
- package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
- package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
- package/src/skf-create-stack-skill/customize.toml +23 -12
- package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
- package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
- package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
- package/src/skf-create-stack-skill/references/generate-output.md +138 -128
- package/src/skf-create-stack-skill/references/init.md +90 -32
- package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
- package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
- package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
- package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
- package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
- package/src/skf-create-stack-skill/references/report.md +39 -33
- package/src/skf-create-stack-skill/references/validate.md +20 -39
- package/src/skf-drop-skill/SKILL.md +28 -51
- package/src/skf-drop-skill/customize.toml +44 -30
- package/src/skf-drop-skill/references/execute.md +79 -146
- package/src/skf-drop-skill/references/invocation-contract.md +64 -0
- package/src/skf-drop-skill/references/report.md +46 -30
- package/src/skf-drop-skill/references/select.md +118 -124
- package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
- package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
- package/src/skf-export-skill/SKILL.md +40 -62
- package/src/skf-export-skill/assets/managed-section-format.md +14 -50
- package/src/skf-export-skill/assets/snippet-format.md +3 -1
- package/src/skf-export-skill/customize.toml +42 -13
- package/src/skf-export-skill/references/generate-snippet.md +54 -46
- package/src/skf-export-skill/references/health-check.md +2 -0
- package/src/skf-export-skill/references/invocation-contract.md +15 -0
- package/src/skf-export-skill/references/load-skill.md +88 -62
- package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
- package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
- package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
- package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
- package/src/skf-export-skill/references/package.md +15 -73
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
- package/src/skf-export-skill/references/result-envelope.md +31 -16
- package/src/skf-export-skill/references/summary.md +41 -17
- package/src/skf-export-skill/references/token-report.md +4 -12
- package/src/skf-export-skill/references/update-context.md +118 -237
- package/src/skf-forger/SKILL.md +61 -37
- package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
- package/src/skf-forger/customize.toml +43 -0
- package/src/skf-forger/references/pipeline-mode.md +73 -18
- package/src/skf-forger/scripts/forge-status.py +299 -0
- package/src/skf-forger/scripts/parse-pipeline.py +322 -54
- package/src/skf-forger/scripts/pipeline-gate.py +428 -0
- package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
- package/src/skf-quick-skill/SKILL.md +54 -53
- package/src/skf-quick-skill/assets/skill-template.md +4 -2
- package/src/skf-quick-skill/customize.toml +51 -20
- package/src/skf-quick-skill/references/batch-mode.md +74 -21
- package/src/skf-quick-skill/references/compile.md +30 -31
- package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
- package/src/skf-quick-skill/references/finalize.md +69 -18
- package/src/skf-quick-skill/references/halt-contract.md +41 -29
- package/src/skf-quick-skill/references/health-check.md +5 -3
- package/src/skf-quick-skill/references/quick-extract.md +114 -45
- package/src/skf-quick-skill/references/registry-resolution.md +9 -64
- package/src/skf-quick-skill/references/resolve-target.md +116 -66
- package/src/skf-quick-skill/references/write-and-validate.md +79 -40
- package/src/skf-refine-architecture/SKILL.md +43 -53
- package/src/skf-refine-architecture/customize.toml +49 -20
- package/src/skf-refine-architecture/references/compile.md +97 -54
- package/src/skf-refine-architecture/references/exit-codes.md +30 -0
- package/src/skf-refine-architecture/references/finding-storage.md +21 -0
- package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
- package/src/skf-refine-architecture/references/improvements.md +14 -20
- package/src/skf-refine-architecture/references/init.md +98 -57
- package/src/skf-refine-architecture/references/issue-detection.md +32 -39
- package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
- package/src/skf-refine-architecture/references/report.md +65 -54
- package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
- package/src/skf-rename-skill/SKILL.md +19 -54
- package/src/skf-rename-skill/customize.toml +32 -26
- package/src/skf-rename-skill/references/execute.md +230 -184
- package/src/skf-rename-skill/references/exit-codes.md +7 -7
- package/src/skf-rename-skill/references/health-check.md +1 -7
- package/src/skf-rename-skill/references/invocation-contract.md +25 -0
- package/src/skf-rename-skill/references/report.md +63 -52
- package/src/skf-rename-skill/references/select.md +197 -115
- package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
- package/src/skf-setup/SKILL.md +26 -26
- package/src/skf-setup/customize.toml +14 -10
- package/src/skf-setup/references/auto-index.md +50 -63
- package/src/skf-setup/references/ccc-index.md +29 -90
- package/src/skf-setup/references/detect-and-tier.md +58 -67
- package/src/skf-setup/references/health-check.md +2 -0
- package/src/skf-setup/references/invocation-contract.md +14 -0
- package/src/skf-setup/references/report.md +43 -166
- package/src/skf-setup/references/tier-rules.md +5 -5
- package/src/skf-setup/references/write-config.md +34 -57
- package/src/skf-test-skill/SKILL.md +17 -57
- package/src/skf-test-skill/assets/output-section-formats.md +33 -34
- package/src/skf-test-skill/customize.toml +27 -13
- package/src/skf-test-skill/references/coherence-check.md +134 -62
- package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
- package/src/skf-test-skill/references/coverage-check.md +217 -254
- package/src/skf-test-skill/references/detect-mode.md +5 -5
- package/src/skf-test-skill/references/external-validators.md +94 -82
- package/src/skf-test-skill/references/health-check.md +6 -7
- package/src/skf-test-skill/references/init.md +163 -88
- package/src/skf-test-skill/references/invocation-contract.md +66 -0
- package/src/skf-test-skill/references/migration-section-rules.md +5 -2
- package/src/skf-test-skill/references/report.md +162 -142
- package/src/skf-test-skill/references/score.md +80 -107
- package/src/skf-test-skill/references/scoring-rules.md +41 -155
- package/src/skf-test-skill/references/source-access-protocol.md +19 -92
- package/src/skf-test-skill/references/step-hard-gate.md +51 -33
- package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
- package/src/skf-test-skill/scripts/build-result-context.py +287 -0
- package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
- package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
- package/src/skf-test-skill/scripts/compute-score.py +223 -45
- package/src/skf-test-skill/scripts/gap-ledger.py +1381 -0
- package/src/skf-test-skill/scripts/hard-gate.py +203 -0
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +760 -0
- package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
- package/src/skf-test-skill/scripts/reconcile-coverage.py +273 -79
- package/src/skf-test-skill/scripts/score-signatures.py +459 -0
- package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
- package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +127 -52
- package/src/skf-test-skill/templates/test-report-template.md +21 -15
- package/src/skf-update-skill/SKILL.md +38 -25
- package/src/skf-update-skill/customize.toml +27 -12
- package/src/skf-update-skill/references/detect-changes.md +233 -222
- package/src/skf-update-skill/references/gap-driven.md +336 -0
- package/src/skf-update-skill/references/health-check.md +31 -5
- package/src/skf-update-skill/references/init.md +250 -84
- package/src/skf-update-skill/references/invocation-contract.md +12 -0
- package/src/skf-update-skill/references/manual-section-rules.md +6 -23
- package/src/skf-update-skill/references/merge.md +180 -47
- package/src/skf-update-skill/references/re-extract.md +53 -262
- package/src/skf-update-skill/references/report.md +100 -35
- package/src/skf-update-skill/references/write.md +331 -149
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +29 -7
- package/src/skf-verify-stack/SKILL.md +23 -42
- package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
- package/src/skf-verify-stack/customize.toml +55 -22
- package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
- package/src/skf-verify-stack/references/coverage.md +67 -64
- package/src/skf-verify-stack/references/exit-codes.md +21 -8
- package/src/skf-verify-stack/references/init.md +107 -62
- package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
- package/src/skf-verify-stack/references/integrations.md +106 -65
- package/src/skf-verify-stack/references/report.md +59 -42
- package/src/skf-verify-stack/references/requirements.md +32 -9
- package/src/skf-verify-stack/references/synthesize.md +53 -39
- package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
- package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
- package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
- package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
- package/tools/cli/commands/install.js +11 -4
- package/tools/cli/commands/status.js +15 -3
- package/tools/cli/commands/update.js +11 -4
- package/tools/cli/lib/tool-check.js +320 -0
- package/tools/cli/lib/version-check.js +22 -9
- package/tools/cli/skf-cli.js +4 -1
- package/.gitattributes +0 -16
- package/.nvmrc +0 -1
- package/docs/_internal/RELEASING.md +0 -679
- package/release-audits/v1.0.0-launch-audit.md +0 -728
- package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
- package/src/skf-campaign/manifest.yaml +0 -15
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
- package/src/skf-create-skill/references/ecosystem-check.md +0 -74
- package/src/skf-drop-skill/references/headless-contract.md +0 -18
- package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
- package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
- package/src/skf-update-skill/references/validate.md +0 -124
- package/tools/validate-docs-drift.js +0 -233
- package/tools/validate-docs-links.js +0 -665
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
|
+
# dependencies = ["pyyaml"]
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Classify Changed Files: update-skill's file-level changes (Category A) in one call.
|
|
6
|
+
|
|
7
|
+
skf-update-skill's detect-changes step sorts the source's files into
|
|
8
|
+
MODIFIED, ADDED and DELETED against the provenance map and the brief's
|
|
9
|
+
scope, before Category B compares the exports of the MODIFIED files. Done
|
|
10
|
+
by hand, that is glob matching (`**`, an exclude that overrides an include)
|
|
11
|
+
and set arithmetic in the prompt, where one missed match silently drops a
|
|
12
|
+
new in-scope file and its exports are never extracted. This helper does it
|
|
13
|
+
in one call, with the tested `glob_match` of
|
|
14
|
+
skf-resolve-authoritative-files.py.
|
|
15
|
+
|
|
16
|
+
Subcommand:
|
|
17
|
+
classify --source-root <path> [--provenance-map <provenance-map.json>]
|
|
18
|
+
[--brief <skill-brief.yaml>] [--language <language>]...
|
|
19
|
+
[--tree-status <status>] [--diff-status <status>]
|
|
20
|
+
[--changed-files <changed-files.json>] [--exclude <path>]...
|
|
21
|
+
[--lists-dir <dir>]
|
|
22
|
+
|
|
23
|
+
--tree-status, --diff-status and --changed-files take the values the
|
|
24
|
+
update-skill init step bound from skf-source-tree.py (`status`,
|
|
25
|
+
`diff_status`, `changed_files`); an empty, `null` or `none` value counts
|
|
26
|
+
as not given. --exclude (repeatable) names a path to leave out entirely:
|
|
27
|
+
the change-detection excludes, such as a document the authoritative-files
|
|
28
|
+
mirror just promoted. Without --brief (a skill built without one, such
|
|
29
|
+
as a quick skill) the scope is empty. --language (repeatable) is the
|
|
30
|
+
skill's language, metadata.json `language`. Without --provenance-map
|
|
31
|
+
(update-skill's degraded mode) the mode is `full`.
|
|
32
|
+
|
|
33
|
+
--lists-dir writes two JSON arrays of paths into that folder for the
|
|
34
|
+
steps that read the files next: modified-files.json (category_a's
|
|
35
|
+
modified files, the ones whose exports are diffed) and extract-files.json
|
|
36
|
+
(the modified and added files and each moved file's new path, whose
|
|
37
|
+
current exports are extracted).
|
|
38
|
+
|
|
39
|
+
The files it sorts:
|
|
40
|
+
tracked a file a provenance-map `entries[].source_file` names
|
|
41
|
+
named a tracked file, or one a `file_entries[].source_file` names
|
|
42
|
+
(a script, an asset or a promoted document: those are
|
|
43
|
+
Category D's, never ADDED here)
|
|
44
|
+
in scope a `scope.include` glob matches it and no `scope.exclude` glob
|
|
45
|
+
does. With an empty `scope.include`, a file whose extension one
|
|
46
|
+
of the tracked files has, or --language's language uses, and
|
|
47
|
+
that no `scope.exclude` glob matches.
|
|
48
|
+
promoted a file a promoted glob matches: the path of a
|
|
49
|
+
`scope.amendments[]` entry whose latest action is `promoted`
|
|
50
|
+
with category `scope-expansion`, when no tracked file matches
|
|
51
|
+
that glob yet (the scope reconciliation promoted it, and its
|
|
52
|
+
files were never extracted)
|
|
53
|
+
Globs follow the brief's semantics: `**` spans any number of folders,
|
|
54
|
+
`*` and `?` never cross a `/`. Walking the source skips symbolic links
|
|
55
|
+
and generated or vendored folders (node_modules, dist, build, .git, ...),
|
|
56
|
+
and an unnamed file in one of them is never ADDED. An --exclude path is
|
|
57
|
+
in no list.
|
|
58
|
+
|
|
59
|
+
Modes, from the statuses:
|
|
60
|
+
diff --diff-status ok (or --changed-files alone): the changed-files.json
|
|
61
|
+
skf-source-tree.py wrote, git's list of the files changed since the
|
|
62
|
+
skill's commit, {"base", "target", "files": [{"status": "A"|"M"|"D",
|
|
63
|
+
"path"}]}. A checkout rewrites every timestamp, so only this list
|
|
64
|
+
says what changed.
|
|
65
|
+
MODIFIED a tracked file with an M or A row
|
|
66
|
+
DELETED a tracked file with a D row, or one the source lacks
|
|
67
|
+
ADDED an unnamed, in-scope file with an A or M row (a file
|
|
68
|
+
that exported nothing when the map was built can gain
|
|
69
|
+
an export), or a promoted file, changed or not
|
|
70
|
+
MOVED a DELETED file with a D row and an ADDED file with an A
|
|
71
|
+
row holding the same contents (one git blob at base and
|
|
72
|
+
at target, each matched once): listed in moved_files,
|
|
73
|
+
and in neither added nor deleted
|
|
74
|
+
tree-without-diff --tree-status ready or offline, the diff unavailable:
|
|
75
|
+
the skill's commit could not be read, so
|
|
76
|
+
MODIFIED every tracked file the source holds
|
|
77
|
+
DELETED a tracked file the source lacks
|
|
78
|
+
ADDED every unnamed, in-scope file
|
|
79
|
+
with a `file-diff-unavailable` warning.
|
|
80
|
+
local anything else (a local source, read as it stands): a walk that
|
|
81
|
+
compares each file's modification time with the provenance map's
|
|
82
|
+
time, the later of its `generated_at` and, when its top-level
|
|
83
|
+
`update_type` is `incremental` or `full` (an update that read the
|
|
84
|
+
source), its top-level `last_update`. A time with a zone is that
|
|
85
|
+
instant. A date alone (update-skill wrote `last_update` as a date
|
|
86
|
+
before v3.0.0) or a time without a zone is the earliest instant it
|
|
87
|
+
can name, 14 hours before its UTC reading (the day starts first at
|
|
88
|
+
UTC+14): a baseline too early only re-checks a few files, while
|
|
89
|
+
one too late would miss a file edited after the update.
|
|
90
|
+
MODIFIED a tracked file modified at or after that time
|
|
91
|
+
DELETED a tracked file the source lacks
|
|
92
|
+
ADDED an unnamed, in-scope file modified at or after that
|
|
93
|
+
time, or a promoted file
|
|
94
|
+
With no usable time every tracked file the source holds is
|
|
95
|
+
MODIFIED and every unnamed, in-scope file ADDED, with a
|
|
96
|
+
`no-baseline-time` warning. No MOVED: a deleted file's contents
|
|
97
|
+
are gone.
|
|
98
|
+
full no --provenance-map: with no map to compare against, every
|
|
99
|
+
in-scope file the walk finds is MODIFIED, and none is ADDED,
|
|
100
|
+
DELETED or MOVED. A run with nothing to scope the walk by (no
|
|
101
|
+
`scope.include` glob and no --language this helper knows) is an
|
|
102
|
+
input error rather than an empty list.
|
|
103
|
+
|
|
104
|
+
An unknown --language value adds an `unknown-language` warning.
|
|
105
|
+
|
|
106
|
+
Output JSON (stdout):
|
|
107
|
+
|
|
108
|
+
{
|
|
109
|
+
"status": "ok",
|
|
110
|
+
"mode": "diff" | "tree-without-diff" | "local" | "full",
|
|
111
|
+
"category_a": {"modified": [...], "added": [...], "deleted": [...]},
|
|
112
|
+
"moved_files": [{"old_path": "...", "new_path": "..."}],
|
|
113
|
+
"summary": {"tracked": N, "modified": N, "added": N, "deleted": N,
|
|
114
|
+
"moved": N, "unchanged": N},
|
|
115
|
+
"baseline_time": "<YYYY-MM-DDTHH:MM:SSZ>" | null,
|
|
116
|
+
"baseline_source": "last_update" | "generated_at" | null,
|
|
117
|
+
"warnings": ["file-diff-unavailable: ..." | "no-baseline-time: ..." |
|
|
118
|
+
"moved-check-skipped: ..." | "unknown-language: ..."]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
category_a is the slice skf-build-change-manifest.py build reads, and each
|
|
122
|
+
moved_files item has the shape of its category_c.renamed_files items.
|
|
123
|
+
Paths are relative to the source root, with forward slashes, sorted.
|
|
124
|
+
baseline_time is the instant local mode compared the files with (a date
|
|
125
|
+
alone already moved to its earliest instant), and baseline_source the key
|
|
126
|
+
it came from; both are null outside local mode. summary
|
|
127
|
+
counts the tracked files the run read (an --exclude path is not one), and
|
|
128
|
+
unchanged is those neither MODIFIED, DELETED nor moved.
|
|
129
|
+
|
|
130
|
+
Exit codes:
|
|
131
|
+
0 classified
|
|
132
|
+
1 input error: a missing source root, provenance map, brief or
|
|
133
|
+
changed-files file, one that cannot be parsed, --diff-status ok
|
|
134
|
+
without --changed-files, a full mode with nothing to scope the walk
|
|
135
|
+
by, or a --lists-dir it cannot write (message on stderr)
|
|
136
|
+
2 usage error (argparse, usage on stderr)
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
from __future__ import annotations
|
|
140
|
+
|
|
141
|
+
import argparse
|
|
142
|
+
import importlib.util
|
|
143
|
+
import json
|
|
144
|
+
import os
|
|
145
|
+
import sys
|
|
146
|
+
from datetime import datetime, timedelta, timezone
|
|
147
|
+
from pathlib import Path, PurePosixPath
|
|
148
|
+
|
|
149
|
+
_TREE_STATUSES = ("ready", "offline")
|
|
150
|
+
# The update types that re-read the whole source (a normal update and a
|
|
151
|
+
# rebuild without a provenance map); a gap-driven repair reads no changes.
|
|
152
|
+
_SOURCE_READING_UPDATES = ("incremental", "full")
|
|
153
|
+
# The easternmost zone (UTC+14): a day there starts 14 hours before UTC's.
|
|
154
|
+
_EARLIEST_ZONE_OFFSET = timedelta(hours=14)
|
|
155
|
+
_GIT_TIMEOUT_SEC = 60.0
|
|
156
|
+
# --language (metadata.json `language`) -> the source file extensions a skill
|
|
157
|
+
# in that language reads: the recipe runner's language families
|
|
158
|
+
# (skf-extract-public-api.py EXTENSION_LANGUAGES) and the languages it has
|
|
159
|
+
# no recipe for, which are read by eye.
|
|
160
|
+
_JS_EXTENSIONS = (".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".vue")
|
|
161
|
+
_C_EXTENSIONS = (".c", ".h")
|
|
162
|
+
_CPP_EXTENSIONS = (".cpp", ".cc", ".cxx", ".hpp", ".hh", ".h")
|
|
163
|
+
LANGUAGE_EXTENSIONS = {
|
|
164
|
+
**dict.fromkeys(("javascript", "js", "jsx", "typescript", "ts", "tsx", "vue"), _JS_EXTENSIONS),
|
|
165
|
+
"python": (".py", ".pyi"),
|
|
166
|
+
"rust": (".rs",),
|
|
167
|
+
"go": (".go",),
|
|
168
|
+
"golang": (".go",),
|
|
169
|
+
"java": (".java",),
|
|
170
|
+
"kotlin": (".kt", ".kts"),
|
|
171
|
+
"swift": (".swift",),
|
|
172
|
+
"csharp": (".cs",),
|
|
173
|
+
"c#": (".cs",),
|
|
174
|
+
"php": (".php",),
|
|
175
|
+
"ruby": (".rb",),
|
|
176
|
+
"c": _C_EXTENSIONS,
|
|
177
|
+
"cpp": _CPP_EXTENSIONS,
|
|
178
|
+
"c++": _CPP_EXTENSIONS,
|
|
179
|
+
}
|
|
180
|
+
_RESOLVER = None
|
|
181
|
+
_SOURCE_TREE = None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _sibling(name: str, stem: str):
|
|
185
|
+
path = Path(__file__).resolve().parent / f"{stem}.py"
|
|
186
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
187
|
+
if spec is None or spec.loader is None:
|
|
188
|
+
raise ImportError(f"cannot load {path}")
|
|
189
|
+
module = importlib.util.module_from_spec(spec)
|
|
190
|
+
spec.loader.exec_module(module)
|
|
191
|
+
return module
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _resolver():
|
|
195
|
+
"""skf-resolve-authoritative-files.py from this folder, loaded once: the
|
|
196
|
+
brief is read and matched with its load_brief, glob_match, scope_match,
|
|
197
|
+
normalize_rel_path and EXCLUDED_DIR_NAMES."""
|
|
198
|
+
global _RESOLVER
|
|
199
|
+
if _RESOLVER is None:
|
|
200
|
+
_RESOLVER = _sibling("skf_resolve_authoritative_files", "skf-resolve-authoritative-files")
|
|
201
|
+
return _RESOLVER
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _source_tree():
|
|
205
|
+
"""skf-source-tree.py from this folder, loaded once: git runs through
|
|
206
|
+
its runner (_run stops a call at the time limit; _resolve_outside_cwd
|
|
207
|
+
never takes a git planted in the current folder)."""
|
|
208
|
+
global _SOURCE_TREE
|
|
209
|
+
if _SOURCE_TREE is None:
|
|
210
|
+
_SOURCE_TREE = _sibling("skf_source_tree", "skf-source-tree")
|
|
211
|
+
return _SOURCE_TREE
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _given(value: str | None) -> str:
|
|
215
|
+
"""value stripped; "" for an empty, `null` or `none` value."""
|
|
216
|
+
value = (value or "").strip()
|
|
217
|
+
return "" if value.lower() in ("null", "none") else value
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def language_extensions(languages: list[str]) -> tuple[set[str], list[str]]:
|
|
221
|
+
"""(the extensions the --language values use, the values none is known for).
|
|
222
|
+
|
|
223
|
+
An empty or `null` value is no language.
|
|
224
|
+
|
|
225
|
+
>>> sorted(language_extensions(["Python", "null"])[0]), language_extensions(["cobol"])[1]
|
|
226
|
+
(['.py', '.pyi'], ['cobol'])
|
|
227
|
+
"""
|
|
228
|
+
extensions: set[str] = set()
|
|
229
|
+
unknown = []
|
|
230
|
+
for value in languages:
|
|
231
|
+
if not _given(value):
|
|
232
|
+
continue
|
|
233
|
+
known = LANGUAGE_EXTENSIONS.get(_given(value).lower())
|
|
234
|
+
if known is None:
|
|
235
|
+
unknown.append(value)
|
|
236
|
+
else:
|
|
237
|
+
extensions.update(known)
|
|
238
|
+
return extensions, unknown
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# --------------------------------------------------------------------------
|
|
242
|
+
# Inputs
|
|
243
|
+
# --------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _read_json(path: Path, what: str) -> object:
|
|
247
|
+
try:
|
|
248
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
249
|
+
except OSError as exc:
|
|
250
|
+
raise ValueError(f"cannot read {what} at {path}: {exc}") from exc
|
|
251
|
+
except ValueError as exc:
|
|
252
|
+
raise ValueError(f"{what} at {path} is not valid JSON: {exc}") from exc
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _source_files(provenance: dict, key: str) -> set[str]:
|
|
256
|
+
norm = _resolver().normalize_rel_path
|
|
257
|
+
rows = provenance.get(key)
|
|
258
|
+
if not isinstance(rows, list):
|
|
259
|
+
return set()
|
|
260
|
+
return {
|
|
261
|
+
norm(row["source_file"])
|
|
262
|
+
for row in rows
|
|
263
|
+
if isinstance(row, dict) and isinstance(row.get("source_file"), str) and row["source_file"].strip()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _parse_time(value: object) -> datetime | None:
|
|
268
|
+
"""An ISO-8601 date or date-time as a UTC instant, or None.
|
|
269
|
+
|
|
270
|
+
A time with a zone is that instant. A date alone, or a time without a
|
|
271
|
+
zone, was written in some zone as far east as UTC+14, so it is read as
|
|
272
|
+
the earliest instant it can name: its UTC reading less 14 hours. A
|
|
273
|
+
baseline too early only re-checks a few files; one too late would miss
|
|
274
|
+
a file edited after the update.
|
|
275
|
+
"""
|
|
276
|
+
if not isinstance(value, str) or not value.strip():
|
|
277
|
+
return None
|
|
278
|
+
try:
|
|
279
|
+
moment = datetime.fromisoformat(value.strip())
|
|
280
|
+
if moment.tzinfo is None:
|
|
281
|
+
return moment.replace(tzinfo=timezone.utc) - _EARLIEST_ZONE_OFFSET
|
|
282
|
+
return moment.astimezone(timezone.utc)
|
|
283
|
+
except (ValueError, OverflowError):
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def baseline_time(provenance: dict) -> tuple[datetime | None, str | None]:
|
|
288
|
+
"""(time, source) a local source's files are compared with: the later of
|
|
289
|
+
generated_at and a source-reading update's last_update."""
|
|
290
|
+
found = []
|
|
291
|
+
generated = _parse_time(provenance.get("generated_at"))
|
|
292
|
+
if generated is not None:
|
|
293
|
+
found.append((generated, "generated_at"))
|
|
294
|
+
if provenance.get("update_type") in _SOURCE_READING_UPDATES:
|
|
295
|
+
updated = _parse_time(provenance.get("last_update"))
|
|
296
|
+
if updated is not None:
|
|
297
|
+
found.append((updated, "last_update"))
|
|
298
|
+
if not found:
|
|
299
|
+
return None, None
|
|
300
|
+
return max(found, key=lambda item: item[0])
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def promoted_globs(brief: dict, tracked: set[str]) -> list[str]:
|
|
304
|
+
"""The scope-expansion globs the latest scope-expansion amendment promoted
|
|
305
|
+
and no tracked file matches yet. An entry of another category on the same
|
|
306
|
+
path (an auth-doc or a demo-and-registry decision) never stands in for it."""
|
|
307
|
+
resolver = _resolver()
|
|
308
|
+
scope = brief.get("scope") if isinstance(brief.get("scope"), dict) else {}
|
|
309
|
+
amendments = scope.get("amendments") if isinstance(scope.get("amendments"), list) else []
|
|
310
|
+
latest: dict[str, dict] = {}
|
|
311
|
+
for amend in amendments:
|
|
312
|
+
if (isinstance(amend, dict) and amend.get("category") == "scope-expansion"
|
|
313
|
+
and isinstance(amend.get("path"), str) and amend["path"].strip()):
|
|
314
|
+
latest[resolver.normalize_rel_path(amend["path"])] = amend
|
|
315
|
+
return sorted(
|
|
316
|
+
glob
|
|
317
|
+
for glob, amend in latest.items()
|
|
318
|
+
if amend.get("action") == "promoted"
|
|
319
|
+
and not any(resolver.glob_match(path, glob) for path in tracked)
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# --------------------------------------------------------------------------
|
|
324
|
+
# Walk and scope
|
|
325
|
+
# --------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _vendored(rel: str) -> bool:
|
|
329
|
+
return any(part in _resolver().EXCLUDED_DIR_NAMES for part in rel.split("/")[:-1])
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def walk_source(root: Path) -> list[tuple[str, Path]]:
|
|
333
|
+
"""(relative path, path) of every file under root, sorted: no symbolic
|
|
334
|
+
link, and no generated or vendored folder."""
|
|
335
|
+
excluded = _resolver().EXCLUDED_DIR_NAMES
|
|
336
|
+
found = []
|
|
337
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
338
|
+
here = Path(dirpath)
|
|
339
|
+
dirnames[:] = [d for d in dirnames if d not in excluded and not (here / d).is_symlink()]
|
|
340
|
+
for name in filenames:
|
|
341
|
+
path = here / name
|
|
342
|
+
if path.is_symlink() or not path.is_file():
|
|
343
|
+
continue
|
|
344
|
+
found.append((path.relative_to(root).as_posix(), path))
|
|
345
|
+
return sorted(found)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _in_scope(rel: str, includes: list[str], excludes: list[str], extensions: set[str]) -> bool:
|
|
349
|
+
resolver = _resolver()
|
|
350
|
+
if includes:
|
|
351
|
+
return resolver.scope_match(rel, includes, excludes)[0]
|
|
352
|
+
if any(resolver.glob_match(rel, pattern) for pattern in excludes):
|
|
353
|
+
return False
|
|
354
|
+
return PurePosixPath(rel).suffix in extensions
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# --------------------------------------------------------------------------
|
|
358
|
+
# Same-content moves (diff mode)
|
|
359
|
+
# --------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _blobs(tree: Path, commit: str, paths: set[str]) -> dict[str, str]:
|
|
363
|
+
"""{path: blob id} at commit for the given paths. Raises RuntimeError
|
|
364
|
+
when git cannot list the commit."""
|
|
365
|
+
try:
|
|
366
|
+
runner = _source_tree()
|
|
367
|
+
except (ImportError, OSError) as exc:
|
|
368
|
+
raise RuntimeError(f"cannot load skf-source-tree.py: {exc}") from exc
|
|
369
|
+
exe = runner._resolve_outside_cwd("git")
|
|
370
|
+
if exe is None:
|
|
371
|
+
raise RuntimeError("git is not installed")
|
|
372
|
+
argv = [exe, "-c", f"core.hooksPath={os.devnull}", "-C", str(tree), "ls-tree", "-r", "-z", commit]
|
|
373
|
+
try:
|
|
374
|
+
rc, out, err, _killed = runner._run(argv, _GIT_TIMEOUT_SEC)
|
|
375
|
+
except (OSError, ValueError) as exc:
|
|
376
|
+
raise RuntimeError(f"git could not run: {exc}") from exc
|
|
377
|
+
if rc is None:
|
|
378
|
+
raise RuntimeError(f"git ls-tree timed out after {_GIT_TIMEOUT_SEC:g} seconds")
|
|
379
|
+
if rc != 0:
|
|
380
|
+
raise RuntimeError(runner._last_error(err.decode("utf-8", errors="replace")))
|
|
381
|
+
blobs = {}
|
|
382
|
+
for record in out.split(b"\0"):
|
|
383
|
+
meta, _, raw_path = record.partition(b"\t")
|
|
384
|
+
fields = meta.split()
|
|
385
|
+
if len(fields) != 3 or fields[1] != b"blob":
|
|
386
|
+
continue
|
|
387
|
+
path = raw_path.decode("utf-8", errors="surrogateescape")
|
|
388
|
+
if path in paths:
|
|
389
|
+
blobs[path] = fields[2].decode("ascii", errors="replace")
|
|
390
|
+
return blobs
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def same_content_moves(
|
|
394
|
+
tree: Path, base: str, target: str, deleted: set[str], added: set[str]
|
|
395
|
+
) -> list[dict]:
|
|
396
|
+
"""{old_path, new_path} for each deleted file whose blob at base is the
|
|
397
|
+
blob of exactly one added file at target, and no other deleted file's.
|
|
398
|
+
Raises RuntimeError when git cannot list either commit."""
|
|
399
|
+
if not deleted or not added:
|
|
400
|
+
return []
|
|
401
|
+
by_blob: dict[str, tuple[list[str], list[str]]] = {}
|
|
402
|
+
for path, blob in _blobs(tree, base, deleted).items():
|
|
403
|
+
by_blob.setdefault(blob, ([], []))[0].append(path)
|
|
404
|
+
for path, blob in _blobs(tree, target, added).items():
|
|
405
|
+
by_blob.setdefault(blob, ([], []))[1].append(path)
|
|
406
|
+
return sorted(
|
|
407
|
+
({"old_path": old[0], "new_path": new[0]} for old, new in by_blob.values() if len(old) == 1 and len(new) == 1),
|
|
408
|
+
key=lambda move: move["new_path"],
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# --------------------------------------------------------------------------
|
|
413
|
+
# Classify
|
|
414
|
+
# --------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _changed_rows(data: object, path: Path) -> tuple[dict[str, str], str, str]:
|
|
418
|
+
"""({path: status}, base, target) of a changed-files.json."""
|
|
419
|
+
norm = _resolver().normalize_rel_path
|
|
420
|
+
if not isinstance(data, dict) or not isinstance(data.get("files"), list):
|
|
421
|
+
raise ValueError(f"changed-files file at {path} has no files[] list")
|
|
422
|
+
rows = {}
|
|
423
|
+
for row in data["files"]:
|
|
424
|
+
if isinstance(row, dict) and row.get("status") in ("A", "M", "D") and isinstance(row.get("path"), str):
|
|
425
|
+
rows[norm(row["path"])] = row["status"]
|
|
426
|
+
base, target = data.get("base"), data.get("target")
|
|
427
|
+
return rows, base if isinstance(base, str) else "", target if isinstance(target, str) else ""
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def classify(
|
|
431
|
+
source_root: Path,
|
|
432
|
+
provenance: dict | None,
|
|
433
|
+
brief: dict,
|
|
434
|
+
*,
|
|
435
|
+
tree_status: str = "",
|
|
436
|
+
diff_status: str = "",
|
|
437
|
+
changed: object = None,
|
|
438
|
+
changed_path: Path | None = None,
|
|
439
|
+
excludes: list[str] | None = None,
|
|
440
|
+
languages: list[str] | None = None,
|
|
441
|
+
) -> dict:
|
|
442
|
+
"""Run Category A. See the module docstring for the output shape.
|
|
443
|
+
|
|
444
|
+
provenance is None in full mode, and brief {} for a skill without one.
|
|
445
|
+
changed is the parsed changed-files.json in diff mode (None otherwise).
|
|
446
|
+
Raises ValueError for a full mode with nothing to scope the walk by.
|
|
447
|
+
"""
|
|
448
|
+
resolver = _resolver()
|
|
449
|
+
norm = resolver.normalize_rel_path
|
|
450
|
+
left_out = {norm(p) for p in excludes or [] if isinstance(p, str) and p.strip()}
|
|
451
|
+
tracked = _source_files(provenance or {}, "entries") - left_out
|
|
452
|
+
named = tracked | _source_files(provenance or {}, "file_entries")
|
|
453
|
+
includes, scope_excludes, _ = resolver.extract_scope(brief)
|
|
454
|
+
includes = [norm(p) for p in includes]
|
|
455
|
+
scope_excludes = [norm(p) for p in scope_excludes]
|
|
456
|
+
spoken, unknown_languages = language_extensions(languages or [])
|
|
457
|
+
extensions = {PurePosixPath(p).suffix for p in tracked if PurePosixPath(p).suffix} | spoken
|
|
458
|
+
promoted = promoted_globs(brief, tracked)
|
|
459
|
+
|
|
460
|
+
def candidate(rel: str) -> bool:
|
|
461
|
+
"""An unnamed, in-scope file: ADDED when it changed, MODIFIED in full mode."""
|
|
462
|
+
return (
|
|
463
|
+
rel not in named
|
|
464
|
+
and rel not in left_out
|
|
465
|
+
and not _vendored(rel)
|
|
466
|
+
and _in_scope(rel, includes, scope_excludes, extensions)
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
def is_promoted(rel: str) -> bool:
|
|
470
|
+
return any(resolver.glob_match(rel, glob) for glob in promoted)
|
|
471
|
+
|
|
472
|
+
def present(rel: str) -> bool:
|
|
473
|
+
return (source_root / rel).is_file()
|
|
474
|
+
|
|
475
|
+
warnings: list[str] = [
|
|
476
|
+
f"unknown-language: no source file extension is known for {value!r}" for value in unknown_languages
|
|
477
|
+
]
|
|
478
|
+
moved: list[dict] = []
|
|
479
|
+
modified: set[str] = set()
|
|
480
|
+
added: set[str] = set()
|
|
481
|
+
deleted: set[str] = set()
|
|
482
|
+
baseline, baseline_source = None, None
|
|
483
|
+
|
|
484
|
+
if provenance is None:
|
|
485
|
+
mode = "full"
|
|
486
|
+
if not includes and not extensions:
|
|
487
|
+
raise ValueError(
|
|
488
|
+
"nothing scopes the walk without a provenance map: give a --brief whose scope.include "
|
|
489
|
+
"names the source files, or the skill's --language"
|
|
490
|
+
)
|
|
491
|
+
modified.update(rel for rel, _ in walk_source(source_root) if candidate(rel))
|
|
492
|
+
elif diff_status == "ok" or (not diff_status and changed is not None):
|
|
493
|
+
mode = "diff"
|
|
494
|
+
rows, base, target = _changed_rows(changed, changed_path or Path("changed-files.json"))
|
|
495
|
+
for rel in tracked:
|
|
496
|
+
status = rows.get(rel)
|
|
497
|
+
if status == "D" or not present(rel):
|
|
498
|
+
deleted.add(rel)
|
|
499
|
+
elif status in ("A", "M"):
|
|
500
|
+
modified.add(rel)
|
|
501
|
+
for rel, status in rows.items():
|
|
502
|
+
if status in ("A", "M") and candidate(rel) and present(rel):
|
|
503
|
+
added.add(rel)
|
|
504
|
+
if promoted:
|
|
505
|
+
added.update(rel for rel, _ in walk_source(source_root) if candidate(rel) and is_promoted(rel))
|
|
506
|
+
gone = {rel for rel in deleted if rows.get(rel) == "D"}
|
|
507
|
+
new = {rel for rel in added if rows.get(rel) == "A"}
|
|
508
|
+
if gone and new:
|
|
509
|
+
try:
|
|
510
|
+
if not (base and target):
|
|
511
|
+
raise RuntimeError("the changed-files list names no base or target commit")
|
|
512
|
+
moved = same_content_moves(source_root, base, target, gone, new)
|
|
513
|
+
except RuntimeError as exc:
|
|
514
|
+
warnings.append(f"moved-check-skipped: {exc}")
|
|
515
|
+
for move in moved:
|
|
516
|
+
deleted.discard(move["old_path"])
|
|
517
|
+
added.discard(move["new_path"])
|
|
518
|
+
elif tree_status in _TREE_STATUSES:
|
|
519
|
+
mode = "tree-without-diff"
|
|
520
|
+
for rel in tracked:
|
|
521
|
+
(modified if present(rel) else deleted).add(rel)
|
|
522
|
+
added.update(rel for rel, _ in walk_source(source_root) if candidate(rel))
|
|
523
|
+
commit = provenance.get("source_commit")
|
|
524
|
+
commit = commit.strip() if isinstance(commit, str) and commit.strip() else "the skill's commit"
|
|
525
|
+
warnings.append(
|
|
526
|
+
f"file-diff-unavailable: {commit} could not be read, so every tracked file was re-checked"
|
|
527
|
+
)
|
|
528
|
+
else:
|
|
529
|
+
mode = "local"
|
|
530
|
+
baseline, baseline_source = baseline_time(provenance)
|
|
531
|
+
since = baseline.timestamp() if baseline is not None else None
|
|
532
|
+
|
|
533
|
+
def touched(path: Path) -> bool:
|
|
534
|
+
if since is None:
|
|
535
|
+
return True
|
|
536
|
+
try:
|
|
537
|
+
return path.stat().st_mtime >= since
|
|
538
|
+
except OSError:
|
|
539
|
+
return True
|
|
540
|
+
|
|
541
|
+
for rel in tracked:
|
|
542
|
+
path = source_root / rel
|
|
543
|
+
if not path.is_file():
|
|
544
|
+
deleted.add(rel)
|
|
545
|
+
elif touched(path):
|
|
546
|
+
modified.add(rel)
|
|
547
|
+
added.update(
|
|
548
|
+
rel for rel, path in walk_source(source_root)
|
|
549
|
+
if candidate(rel) and (touched(path) or is_promoted(rel))
|
|
550
|
+
)
|
|
551
|
+
if baseline is None:
|
|
552
|
+
warnings.append(
|
|
553
|
+
"no-baseline-time: the provenance map records no usable generated_at or "
|
|
554
|
+
"last_update, so every tracked file was re-checked and every in-scope file "
|
|
555
|
+
"it does not name was listed as added"
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
unchanged = len(tracked - modified - deleted - {m["old_path"] for m in moved})
|
|
559
|
+
return {
|
|
560
|
+
"status": "ok",
|
|
561
|
+
"mode": mode,
|
|
562
|
+
"category_a": {
|
|
563
|
+
"modified": sorted(modified),
|
|
564
|
+
"added": sorted(added),
|
|
565
|
+
"deleted": sorted(deleted),
|
|
566
|
+
},
|
|
567
|
+
"moved_files": moved,
|
|
568
|
+
"summary": {
|
|
569
|
+
"tracked": len(tracked),
|
|
570
|
+
"modified": len(modified),
|
|
571
|
+
"added": len(added),
|
|
572
|
+
"deleted": len(deleted),
|
|
573
|
+
"moved": len(moved),
|
|
574
|
+
"unchanged": unchanged,
|
|
575
|
+
},
|
|
576
|
+
"baseline_time": baseline.strftime("%Y-%m-%dT%H:%M:%SZ") if baseline is not None else None,
|
|
577
|
+
"baseline_source": baseline_source,
|
|
578
|
+
"warnings": warnings,
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
# --------------------------------------------------------------------------
|
|
583
|
+
# CLI
|
|
584
|
+
# --------------------------------------------------------------------------
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def write_lists(result: dict, folder: Path) -> None:
|
|
588
|
+
"""--lists-dir: modified-files.json and extract-files.json. Raises
|
|
589
|
+
ValueError when a list cannot be written."""
|
|
590
|
+
category_a = result["category_a"]
|
|
591
|
+
extract = set(category_a["modified"]) | set(category_a["added"])
|
|
592
|
+
extract.update(move["new_path"] for move in result["moved_files"])
|
|
593
|
+
for name, paths in (("modified-files.json", category_a["modified"]), ("extract-files.json", sorted(extract))):
|
|
594
|
+
try:
|
|
595
|
+
(folder / name).write_text(json.dumps(paths, indent=2) + "\n", encoding="utf-8")
|
|
596
|
+
except OSError as exc:
|
|
597
|
+
raise ValueError(f"cannot write {name} in {folder}: {exc}") from exc
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _cmd_classify(args: argparse.Namespace) -> int:
|
|
601
|
+
source_root = Path(args.source_root)
|
|
602
|
+
if not source_root.is_dir():
|
|
603
|
+
print(f"error: source-root not a directory: {source_root}", file=sys.stderr)
|
|
604
|
+
return 1
|
|
605
|
+
for label, value in (("provenance map", args.provenance_map), ("brief", args.brief)):
|
|
606
|
+
if value is not None and not Path(value).is_file():
|
|
607
|
+
print(f"error: {label} not found: {value}", file=sys.stderr)
|
|
608
|
+
return 1
|
|
609
|
+
tree_status = _given(args.tree_status).lower()
|
|
610
|
+
diff_status = _given(args.diff_status).lower()
|
|
611
|
+
changed_files = _given(args.changed_files)
|
|
612
|
+
if diff_status == "ok" and not changed_files and args.provenance_map is not None:
|
|
613
|
+
print("error: --diff-status ok needs --changed-files", file=sys.stderr)
|
|
614
|
+
return 1
|
|
615
|
+
try:
|
|
616
|
+
provenance = None
|
|
617
|
+
if args.provenance_map is not None:
|
|
618
|
+
provenance = _read_json(Path(args.provenance_map), "provenance map")
|
|
619
|
+
if not isinstance(provenance, dict):
|
|
620
|
+
raise ValueError(f"provenance map at {args.provenance_map} must be a JSON object")
|
|
621
|
+
brief = _resolver().load_brief(Path(args.brief)) if args.brief is not None else {}
|
|
622
|
+
changed = None
|
|
623
|
+
if provenance is not None and changed_files and diff_status in ("ok", ""):
|
|
624
|
+
changed = _read_json(Path(changed_files), "changed-files list")
|
|
625
|
+
result = classify(
|
|
626
|
+
source_root,
|
|
627
|
+
provenance,
|
|
628
|
+
brief,
|
|
629
|
+
tree_status=tree_status,
|
|
630
|
+
diff_status=diff_status,
|
|
631
|
+
changed=changed,
|
|
632
|
+
changed_path=Path(changed_files) if changed_files else None,
|
|
633
|
+
excludes=args.exclude or [],
|
|
634
|
+
languages=args.language or [],
|
|
635
|
+
)
|
|
636
|
+
if args.lists_dir is not None:
|
|
637
|
+
write_lists(result, Path(args.lists_dir))
|
|
638
|
+
except ValueError as exc:
|
|
639
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
640
|
+
return 1
|
|
641
|
+
print(json.dumps(result, indent=2))
|
|
642
|
+
return 0
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
646
|
+
# --help (and classify --help) print the module docstring: the modes,
|
|
647
|
+
# their MODIFIED, ADDED and DELETED rules and the output a step cites.
|
|
648
|
+
parser = argparse.ArgumentParser(
|
|
649
|
+
prog="skf-classify-changed-files",
|
|
650
|
+
description=__doc__,
|
|
651
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
652
|
+
)
|
|
653
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
654
|
+
p = sub.add_parser(
|
|
655
|
+
"classify",
|
|
656
|
+
help="classify the file-level changes",
|
|
657
|
+
description=__doc__,
|
|
658
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
659
|
+
)
|
|
660
|
+
p.add_argument("--source-root", required=True, help="path to the source tree the run reads")
|
|
661
|
+
p.add_argument("--provenance-map", help="path to provenance-map.json (none: full mode)")
|
|
662
|
+
p.add_argument("--brief", help="path to skill-brief.yaml (none: an empty scope)")
|
|
663
|
+
p.add_argument(
|
|
664
|
+
"--language",
|
|
665
|
+
action="append",
|
|
666
|
+
metavar="LANG",
|
|
667
|
+
help="the skill's language, whose extensions an empty scope.include takes (repeatable)",
|
|
668
|
+
)
|
|
669
|
+
p.add_argument("--tree-status", help="skf-source-tree.py status (ready, offline, ...)")
|
|
670
|
+
p.add_argument("--diff-status", help="skf-source-tree.py diff_status (ok or unavailable)")
|
|
671
|
+
p.add_argument("--changed-files", help="path to skf-source-tree.py's changed-files.json")
|
|
672
|
+
p.add_argument(
|
|
673
|
+
"--exclude",
|
|
674
|
+
action="append",
|
|
675
|
+
metavar="PATH",
|
|
676
|
+
help="a path to leave out entirely (repeatable)",
|
|
677
|
+
)
|
|
678
|
+
p.add_argument(
|
|
679
|
+
"--lists-dir",
|
|
680
|
+
metavar="DIR",
|
|
681
|
+
help="write modified-files.json and extract-files.json into this folder",
|
|
682
|
+
)
|
|
683
|
+
p.set_defaults(func=_cmd_classify)
|
|
684
|
+
return parser
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def main(argv: list[str] | None = None) -> int:
|
|
688
|
+
parser = _build_parser()
|
|
689
|
+
args = parser.parse_args(argv)
|
|
690
|
+
return args.func(args)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
if __name__ == "__main__":
|
|
694
|
+
sys.exit(main())
|