bmad-module-skill-forge 1.4.0 → 1.5.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 +0 -8
- package/docs/_data/pinned.yaml +1 -1
- package/docs/_internal/STABILITY.md +1 -1
- package/docs/architecture.md +2 -2
- package/docs/examples.md +1 -1
- package/docs/workflows.md +5 -5
- package/package.json +2 -2
- package/src/README.md +1 -1
- package/src/knowledge/ccc-bridge.md +12 -12
- package/src/knowledge/qmd-registry.md +5 -5
- package/src/shared/health-check.md +5 -5
- package/src/shared/references/description-guard-protocol.md +100 -0
- package/src/shared/references/output-contract-schema.md +1 -1
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +6 -6
- package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +9 -3
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +149 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +22 -2
- package/src/shared/scripts/skf-build-change-manifest.py +420 -0
- package/src/shared/scripts/skf-check-workspace-drift.py +321 -0
- package/src/shared/scripts/skf-compare-file-hashes.py +357 -0
- package/src/shared/scripts/skf-description-guard.py +359 -0
- package/src/shared/scripts/skf-detect-language.py +2 -2
- package/src/shared/scripts/skf-detect-scripts-assets.py +613 -0
- package/src/shared/scripts/skf-detect-tools.py +97 -8
- package/src/shared/scripts/skf-detect-workspaces.py +1 -1
- package/src/shared/scripts/skf-disqualify-candidates.py +576 -0
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +3 -3
- package/src/shared/scripts/skf-emit-result-envelope.py +93 -9
- package/src/shared/scripts/skf-enumerate-stack-skills.py +514 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +11 -11
- package/src/shared/scripts/skf-hash-content.py +284 -0
- package/src/shared/scripts/skf-load-provenance.py +295 -0
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +45 -1
- package/src/shared/scripts/skf-pair-intersect.py +250 -0
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +433 -0
- package/src/shared/scripts/skf-qmd-classify-collections.py +42 -8
- package/src/shared/scripts/skf-recommend-scope-type.py +2 -2
- package/src/shared/scripts/skf-render-quick-metadata.py +2 -2
- package/src/shared/scripts/skf-resolve-authoritative-files.py +504 -0
- package/src/shared/scripts/skf-scan-manifests.py +738 -0
- package/src/shared/scripts/skf-scan-skill-md-structure.py +375 -0
- package/src/shared/scripts/skf-update-active-symlink.py +315 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +5 -5
- package/src/shared/scripts/skf-validate-brief-schema.py +348 -0
- package/src/shared/scripts/skf-write-skill-brief.py +61 -5
- package/src/skf-analyze-source/SKILL.md +69 -16
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/customize.toml +55 -0
- package/src/skf-analyze-source/{steps-c/step-01b-continue.md → references/continue.md} +12 -12
- package/src/skf-analyze-source/{steps-c/step-06-generate-briefs.md → references/generate-briefs.md} +29 -10
- package/src/skf-analyze-source/{steps-c/step-07-health-check.md → references/health-check.md} +5 -3
- package/src/skf-analyze-source/{steps-c/step-03-identify-units.md → references/identify-units.md} +44 -14
- package/src/skf-analyze-source/{steps-c/step-01-init.md → references/init.md} +27 -6
- package/src/skf-analyze-source/{steps-c/step-04-map-and-detect.md → references/map-and-detect.md} +52 -25
- package/src/skf-analyze-source/{steps-c/step-05-recommend.md → references/recommend.md} +15 -8
- package/src/skf-analyze-source/{steps-c/step-02-scan-project.md → references/scan-project.md} +12 -10
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -0
- package/src/skf-analyze-source/templates/analysis-report-template.md +6 -6
- package/src/skf-audit-skill/SKILL.md +66 -15
- package/src/skf-audit-skill/assets/drift-report-template.md +6 -5
- package/src/skf-audit-skill/customize.toml +49 -0
- package/src/skf-audit-skill/{steps-c/step-07-health-check.md → references/health-check.md} +5 -3
- package/src/skf-audit-skill/references/init.md +305 -0
- package/src/skf-audit-skill/{steps-c/step-02-re-index.md → references/re-index.md} +11 -11
- package/src/skf-audit-skill/{steps-c/step-06-report.md → references/report.md} +25 -7
- package/src/skf-audit-skill/{steps-c/step-04-semantic-diff.md → references/semantic-diff.md} +6 -6
- package/src/skf-audit-skill/{steps-c/step-05-severity-classify.md → references/severity-classify.md} +5 -5
- package/src/skf-audit-skill/references/severity-rules.md +2 -0
- package/src/skf-audit-skill/{steps-c/step-03-structural-diff.md → references/structural-diff.md} +29 -16
- package/src/skf-brief-skill/SKILL.md +48 -18
- package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
- package/src/skf-brief-skill/assets/skill-brief-schema.md +32 -2
- package/src/skf-brief-skill/customize.toml +44 -0
- package/src/skf-brief-skill/{steps-c/step-02-analyze-target.md → references/analyze-target.md} +5 -5
- package/src/skf-brief-skill/{steps-c/step-04-confirm-brief.md → references/confirm-brief.md} +13 -10
- package/src/skf-brief-skill/references/draft-checkpoint.md +4 -4
- package/src/skf-brief-skill/{steps-c/step-01-gather-intent.md → references/gather-intent.md} +24 -24
- package/src/skf-brief-skill/references/headless-args.md +9 -9
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +1 -1
- package/src/skf-brief-skill/{steps-c/step-06-health-check.md → references/health-check.md} +4 -2
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +2 -2
- package/src/skf-brief-skill/references/qmd-collection-registration.md +1 -1
- package/src/skf-brief-skill/{steps-c/step-03-scope-definition.md → references/scope-definition.md} +19 -16
- package/src/skf-brief-skill/references/version-resolution.md +2 -2
- package/src/skf-brief-skill/{steps-c/step-05-write-brief.md → references/write-brief.md} +11 -10
- package/src/skf-create-skill/SKILL.md +25 -19
- package/src/skf-create-skill/assets/compile-assembly-rules.md +3 -3
- package/src/skf-create-skill/assets/skill-sections.md +5 -5
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +11 -11
- package/src/skf-create-skill/customize.toml +35 -0
- package/src/skf-create-skill/references/authoritative-files-protocol.md +142 -0
- package/src/skf-create-skill/{steps-c/step-05-compile.md → references/compile.md} +16 -16
- package/src/skf-create-skill/{steps-c/step-03d-component-extraction.md → references/component-extraction.md} +16 -16
- package/src/skf-create-skill/{steps-c/step-02-ecosystem-check.md → references/ecosystem-check.md} +4 -4
- package/src/skf-create-skill/{steps-c/step-04-enrich.md → references/enrich.md} +3 -3
- package/src/skf-create-skill/{steps-c/step-03-extract.md → references/extract.md} +52 -120
- package/src/skf-create-skill/references/extraction-patterns.md +8 -8
- package/src/skf-create-skill/{steps-c/step-07-generate-artifacts.md → references/generate-artifacts.md} +17 -9
- package/src/skf-create-skill/{steps-c/step-09-health-check.md → references/health-check.md} +4 -2
- package/src/skf-create-skill/{steps-c/step-01-load-brief.md → references/load-brief.md} +40 -32
- package/src/skf-create-skill/{steps-c/step-08-report.md → references/report.md} +8 -8
- package/src/skf-create-skill/references/source-resolution-protocols.md +6 -6
- package/src/skf-create-skill/{steps-c/sub/step-02b-ccc-discover.md → references/sub/ccc-discover.md} +5 -5
- package/src/skf-create-skill/{steps-c/sub/step-03c-fetch-docs.md → references/sub/fetch-docs.md} +28 -12
- package/src/skf-create-skill/{steps-c/sub/step-03b-fetch-temporal.md → references/sub/fetch-temporal.md} +81 -50
- package/src/skf-create-skill/{steps-c/step-06-validate.md → references/validate.md} +46 -26
- package/src/skf-create-stack-skill/SKILL.md +76 -17
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +102 -0
- package/src/skf-create-stack-skill/assets/stack-skill-template.md +1 -1
- package/src/skf-create-stack-skill/customize.toml +46 -0
- package/src/skf-create-stack-skill/{steps-c/step-06-compile-stack.md → references/compile-stack.md} +6 -5
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +3 -1
- package/src/skf-create-stack-skill/{steps-c/step-05-detect-integrations.md → references/detect-integrations.md} +32 -11
- package/src/skf-create-stack-skill/{steps-c/step-02-detect-manifests.md → references/detect-manifests.md} +28 -28
- package/src/skf-create-stack-skill/{steps-c/step-07-generate-output.md → references/generate-output.md} +11 -85
- package/src/skf-create-stack-skill/{steps-c/step-10-health-check.md → references/health-check.md} +4 -2
- package/src/skf-create-stack-skill/{steps-c/step-01-init.md → references/init.md} +70 -4
- package/src/skf-create-stack-skill/references/integration-patterns.md +2 -0
- package/src/skf-create-stack-skill/references/manifest-patterns.md +2 -0
- package/src/skf-create-stack-skill/{steps-c/step-04-parallel-extract.md → references/parallel-extract.md} +40 -17
- package/src/skf-create-stack-skill/{steps-c/step-03-rank-and-confirm.md → references/rank-and-confirm.md} +5 -4
- package/src/skf-create-stack-skill/{steps-c/step-09-report.md → references/report.md} +3 -3
- package/src/skf-create-stack-skill/{steps-c/step-08-validate.md → references/validate.md} +6 -6
- package/src/skf-drop-skill/SKILL.md +84 -12
- package/src/skf-drop-skill/customize.toml +57 -0
- package/src/skf-drop-skill/{steps-c/step-02-execute.md → references/execute.md} +49 -34
- package/src/skf-drop-skill/{steps-c/step-04-health-check.md → references/health-check.md} +6 -4
- package/src/skf-drop-skill/{steps-c/step-03-report.md → references/report.md} +13 -5
- package/src/skf-drop-skill/{steps-c/step-01-select.md → references/select.md} +63 -15
- package/src/skf-export-skill/SKILL.md +85 -16
- package/src/skf-export-skill/assets/managed-section-format.md +3 -3
- package/src/skf-export-skill/assets/snippet-format.md +2 -2
- package/src/skf-export-skill/customize.toml +49 -0
- package/src/skf-export-skill/{steps-c/step-03-generate-snippet.md → references/generate-snippet.md} +18 -12
- package/src/skf-export-skill/{steps-c/step-07-health-check.md → references/health-check.md} +6 -4
- package/src/skf-export-skill/{steps-c/step-01-load-skill.md → references/load-skill.md} +21 -51
- package/src/skf-export-skill/references/manifest-rebuild.md +68 -0
- package/src/skf-export-skill/references/multi-skill-mode.md +38 -0
- package/src/skf-export-skill/references/orphan-context-detection.md +75 -0
- package/src/skf-export-skill/references/orphan-row-detection.md +102 -0
- package/src/skf-export-skill/{steps-c/step-02-package.md → references/package.md} +7 -7
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +74 -0
- package/src/skf-export-skill/{steps-c/step-06-summary.md → references/summary.md} +30 -6
- package/src/skf-export-skill/{steps-c/step-05-token-report.md → references/token-report.md} +5 -5
- package/src/skf-export-skill/{steps-c/step-04-update-context.md → references/update-context.md} +116 -91
- package/src/skf-quick-skill/SKILL.md +63 -123
- package/src/skf-quick-skill/customize.toml +44 -0
- package/src/skf-quick-skill/references/batch-mode.md +102 -0
- package/src/skf-quick-skill/{steps-c/step-04-compile.md → references/compile.md} +10 -10
- package/src/skf-quick-skill/{steps-c/step-02-ecosystem-check.md → references/ecosystem-check.md} +5 -5
- package/src/skf-quick-skill/{steps-c/step-06-finalize.md → references/finalize.md} +7 -7
- package/src/skf-quick-skill/{steps-c/step-07-health-check.md → references/health-check.md} +3 -3
- package/src/skf-quick-skill/{steps-c/step-03-quick-extract.md → references/quick-extract.md} +5 -5
- package/src/skf-quick-skill/references/registry-resolution.md +2 -0
- package/src/skf-quick-skill/{steps-c/step-01-resolve-target.md → references/resolve-target.md} +14 -10
- package/src/skf-quick-skill/{steps-c/step-05-write-and-validate.md → references/write-and-validate.md} +4 -4
- package/src/skf-refine-architecture/SKILL.md +86 -16
- package/src/skf-refine-architecture/customize.toml +49 -0
- package/src/skf-refine-architecture/{steps-c/step-05-compile.md → references/compile.md} +4 -4
- package/src/skf-refine-architecture/{steps-c/step-02-gap-analysis.md → references/gap-analysis.md} +5 -5
- package/src/skf-refine-architecture/{steps-c/step-07-health-check.md → references/health-check.md} +6 -4
- package/src/skf-refine-architecture/{steps-c/step-04-improvements.md → references/improvements.md} +5 -5
- package/src/skf-refine-architecture/references/init.md +144 -0
- package/src/skf-refine-architecture/{steps-c/step-03-issue-detection.md → references/issue-detection.md} +5 -5
- package/src/skf-refine-architecture/references/refinement-rules.md +2 -0
- package/src/skf-refine-architecture/{steps-c/step-06-report.md → references/report.md} +14 -5
- package/src/skf-rename-skill/SKILL.md +82 -12
- package/src/skf-rename-skill/customize.toml +52 -0
- package/src/skf-rename-skill/{steps-c/step-02-execute.md → references/execute.md} +94 -99
- package/src/skf-rename-skill/references/health-check.md +30 -0
- package/src/skf-rename-skill/references/rebuild-context.md +110 -0
- package/src/skf-rename-skill/{steps-c/step-03-report.md → references/report.md} +13 -5
- package/src/skf-rename-skill/{steps-c/step-01-select.md → references/select.md} +59 -20
- package/src/skf-setup/SKILL.md +35 -35
- package/src/skf-setup/customize.toml +33 -0
- package/src/skf-setup/{steps-c/step-03-auto-index.md → references/auto-index.md} +10 -18
- package/src/skf-setup/{steps-c/step-01b-ccc-index.md → references/ccc-index.md} +14 -22
- package/src/skf-setup/{steps-c/step-01-detect-and-tier.md → references/detect-and-tier.md} +23 -10
- package/src/skf-setup/{steps-c/step-05-health-check.md → references/health-check.md} +1 -1
- package/src/skf-setup/{steps-c/step-04-report.md → references/report.md} +21 -19
- package/src/skf-setup/references/tier-rules.md +1 -1
- package/src/skf-setup/{steps-c/step-02-write-config.md → references/write-config.md} +14 -13
- package/src/skf-test-skill/SKILL.md +77 -15
- package/src/skf-test-skill/customize.toml +54 -0
- package/src/skf-test-skill/{steps-c/step-04-coherence-check.md → references/coherence-check.md} +28 -71
- package/src/skf-test-skill/{steps-c/step-03-coverage-check.md → references/coverage-check.md} +16 -39
- package/src/skf-test-skill/{steps-c/step-02-detect-mode.md → references/detect-mode.md} +4 -26
- package/src/skf-test-skill/{steps-c/step-04b-external-validators.md → references/external-validators.md} +6 -28
- package/src/skf-test-skill/references/health-check.md +14 -0
- package/src/skf-test-skill/{steps-c/step-01-init.md → references/init.md} +21 -37
- package/src/skf-test-skill/references/migration-section-rules.md +4 -2
- package/src/skf-test-skill/{steps-c/step-06-report.md → references/report.md} +41 -51
- package/src/skf-test-skill/{steps-c/step-05-score.md → references/score.md} +26 -43
- package/src/skf-test-skill/references/scoring-rules.md +8 -6
- package/src/skf-test-skill/references/source-access-protocol.md +7 -5
- package/src/skf-test-skill/scripts/compute-score.py +88 -17
- package/src/skf-test-skill/templates/test-report-template.md +13 -13
- package/src/skf-update-skill/SKILL.md +23 -14
- package/src/skf-update-skill/customize.toml +44 -0
- package/src/skf-update-skill/{steps-c/step-02-detect-changes.md → references/detect-changes.md} +176 -68
- package/src/skf-update-skill/references/health-check.md +32 -0
- package/src/skf-update-skill/{steps-c/step-01-init.md → references/init.md} +56 -8
- package/src/skf-update-skill/references/manual-section-rules.md +4 -0
- package/src/skf-update-skill/references/merge-conflict-rules.md +4 -0
- package/src/skf-update-skill/{steps-c/step-04-merge.md → references/merge.md} +8 -8
- package/src/skf-update-skill/{steps-c/step-03-re-extract.md → references/re-extract.md} +53 -44
- package/src/skf-update-skill/references/remote-source-resolution.md +5 -1
- package/src/skf-update-skill/references/report.md +214 -0
- package/src/skf-update-skill/{steps-c/step-05-validate.md → references/validate.md} +5 -5
- package/src/skf-update-skill/{steps-c/step-06-write.md → references/write.md} +73 -51
- package/src/skf-verify-stack/SKILL.md +89 -17
- package/src/skf-verify-stack/assets/feasibility-report-template.md +4 -4
- package/src/skf-verify-stack/customize.toml +50 -0
- package/src/skf-verify-stack/references/coverage-patterns.md +2 -2
- package/src/skf-verify-stack/{steps-c/step-02-coverage.md → references/coverage.md} +7 -7
- package/src/skf-verify-stack/{steps-c/step-07-health-check.md → references/health-check.md} +7 -5
- package/src/skf-verify-stack/references/init.md +166 -0
- package/src/skf-verify-stack/references/integration-verification-rules.md +1 -1
- package/src/skf-verify-stack/{steps-c/step-03-integrations.md → references/integrations.md} +12 -12
- package/src/skf-verify-stack/{steps-c/step-06-report.md → references/report.md} +22 -41
- package/src/skf-verify-stack/{steps-c/step-04-requirements.md → references/requirements.md} +7 -7
- package/src/skf-verify-stack/{steps-c/step-05-synthesize.md → references/synthesize.md} +8 -8
- package/tools/validate-docs-drift.js +1 -1
- package/src/skf-audit-skill/steps-c/step-01-init.md +0 -221
- package/src/skf-refine-architecture/steps-c/step-01-init.md +0 -136
- package/src/skf-rename-skill/steps-c/step-04-health-check.md +0 -22
- package/src/skf-test-skill/steps-c/step-07-health-check.md +0 -25
- package/src/skf-update-skill/steps-c/step-07-report.md +0 -148
- package/src/skf-update-skill/steps-c/step-08-health-check.md +0 -22
- package/src/skf-verify-stack/steps-c/step-01-init.md +0 -178
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
# ///
|
|
5
5
|
"""SKF Forge Tier RW — Read/write primitives for forger-sidecar YAML files.
|
|
6
6
|
|
|
7
|
-
Replaces the prose-driven YAML emission in `src/skf-setup/
|
|
8
|
-
|
|
9
|
-
step
|
|
7
|
+
Replaces the prose-driven YAML emission in `src/skf-setup/references/
|
|
8
|
+
write-config.md` §1-§3 (and the prose-driven cleanup logic in
|
|
9
|
+
step 3 §5/§5b) with one Python invocation. The script is the source
|
|
10
10
|
of truth for the on-disk forge-tier.yaml schema (matching the
|
|
11
|
-
canonical template at
|
|
11
|
+
canonical template at write-config.md:24-58) and guarantees
|
|
12
12
|
that registry arrays are PRESERVED across rewrites — losing
|
|
13
13
|
`qmd_collections` or `ccc_index_registry` would break every
|
|
14
14
|
downstream skill (skf-create-skill, skf-audit-skill, skf-update-skill,
|
|
@@ -37,7 +37,7 @@ Subcommands:
|
|
|
37
37
|
the same `name` is replaced, otherwise appended). All
|
|
38
38
|
other forge-tier state (tools / tier / ccc_index /
|
|
39
39
|
ccc_index_registry / other qmd_collections entries) is
|
|
40
|
-
preserved verbatim. Used by skf-brief-skill step
|
|
40
|
+
preserved verbatim. Used by skf-brief-skill step 5 §5
|
|
41
41
|
and skf-create-skill to register Deep-tier QMD
|
|
42
42
|
collections without re-rendering the whole file in
|
|
43
43
|
prose.
|
|
@@ -61,7 +61,7 @@ exit non-zero (1 for user error, 2 for I/O failure).
|
|
|
61
61
|
|
|
62
62
|
Cross-platform: pure stdlib + PyYAML. Atomic writes via temp + rename
|
|
63
63
|
mirror skf-atomic-write.py's pattern. Concurrent access requires
|
|
64
|
-
external `flock` coordination — see step
|
|
64
|
+
external `flock` coordination — see step 7 of skf-create-skill for
|
|
65
65
|
the precedent.
|
|
66
66
|
|
|
67
67
|
CLI — invoke via `uv run` so the PEP 723 PyYAML dependency declared
|
|
@@ -177,7 +177,7 @@ def render_forge_tier_yaml(payload: dict) -> str:
|
|
|
177
177
|
"""Render the canonical forge-tier.yaml from a context payload.
|
|
178
178
|
|
|
179
179
|
Preserves the human-readable section comments from the template at
|
|
180
|
-
|
|
180
|
+
write-config.md:24-58. Sections are emitted in a fixed order
|
|
181
181
|
so re-runs against unchanged inputs produce byte-identical output.
|
|
182
182
|
"""
|
|
183
183
|
tools = payload["tools"]
|
|
@@ -187,7 +187,7 @@ def render_forge_tier_yaml(payload: dict) -> str:
|
|
|
187
187
|
ccc_index_registry = payload.get("ccc_index_registry", [])
|
|
188
188
|
qmd_collections = payload.get("qmd_collections", [])
|
|
189
189
|
|
|
190
|
-
# Render `tools` block in the canonical key order (matches step
|
|
190
|
+
# Render `tools` block in the canonical key order (matches step 2 template).
|
|
191
191
|
tools_ordered = {
|
|
192
192
|
"ast_grep": tools["ast_grep"],
|
|
193
193
|
"gh_cli": tools["gh_cli"],
|
|
@@ -219,7 +219,7 @@ def render_forge_tier_yaml(payload: dict) -> str:
|
|
|
219
219
|
f"tier: {tier}",
|
|
220
220
|
f"tier_detected_at: {_yaml_scalar(tier_detected_at)}",
|
|
221
221
|
"",
|
|
222
|
-
"# CCC semantic index state (managed by setup step
|
|
222
|
+
"# CCC semantic index state (managed by setup step 1b and extraction workflows)",
|
|
223
223
|
_yaml_block({"ccc_index": ccc_ordered}),
|
|
224
224
|
"",
|
|
225
225
|
"# CCC index registry (tracks which source paths have been indexed for skill workflows)",
|
|
@@ -242,13 +242,13 @@ def _yaml_scalar(value) -> str:
|
|
|
242
242
|
def _merge_preserved_fields(payload: dict, existing: dict | None) -> dict:
|
|
243
243
|
"""Inject preserved fields from the existing file into the new payload.
|
|
244
244
|
|
|
245
|
-
Three preservation rules (per step
|
|
245
|
+
Three preservation rules (per step 2 §1 "Note on re-runs"):
|
|
246
246
|
- `qmd_collections` array — preserved entirely from existing.
|
|
247
247
|
- `ccc_index_registry` array — preserved entirely from existing.
|
|
248
248
|
- `ccc_index.staleness_threshold_hours` scalar — preserved if user set
|
|
249
249
|
a non-default value; else uses payload value or DEFAULT.
|
|
250
250
|
Note: `ccc_index.exclude_patterns` is NOT preserved (rewritten fresh
|
|
251
|
-
by step
|
|
251
|
+
by step 1b on every run, per the explicit step 2 contract).
|
|
252
252
|
"""
|
|
253
253
|
if existing is None:
|
|
254
254
|
return payload
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Hash Content — SHA-256 hashing helpers for skill workflows.
|
|
6
|
+
|
|
7
|
+
Two patterns recur across the update-skill workflow's stage prose:
|
|
8
|
+
|
|
9
|
+
1. **Single-file hash** — detect-changes.md §1b, when a candidate file is
|
|
10
|
+
promoted into `brief.scope.include`, the LLM is asked to "compute SHA-256
|
|
11
|
+
content hash of candidate.path" and emit a record
|
|
12
|
+
`{path, heuristic, size_bytes, line_count, content_hash}` for
|
|
13
|
+
`promoted_docs_new[]`. Deterministic file-read + hashlib.sha256.
|
|
14
|
+
|
|
15
|
+
2. **Bulk-compare against provenance** — detect-changes.md §Category D,
|
|
16
|
+
where the LLM is asked to "for each file_entry: compute current SHA-256
|
|
17
|
+
content hash, compare against stored hash" and classify entries as
|
|
18
|
+
MODIFIED_FILE / DELETED_FILE / (UNCHANGED). Same hashing primitive,
|
|
19
|
+
iterated, plus a stat-and-compare. NEW_FILE detection is intentionally
|
|
20
|
+
out of scope here — that lives in `skf-detect-scripts-assets.py`.
|
|
21
|
+
|
|
22
|
+
Subcommands:
|
|
23
|
+
hash <path> [--include-path]
|
|
24
|
+
Emit JSON {"content_hash": "sha256:...", "size_bytes": N, "line_count": L}
|
|
25
|
+
for a single file. With --include-path, the record also includes
|
|
26
|
+
"path": <as given>, so batch callers can collect records by streaming
|
|
27
|
+
multiple invocations.
|
|
28
|
+
|
|
29
|
+
compare <source-root> --provenance-map <path>
|
|
30
|
+
Read `file_entries[]` from a provenance-map JSON and classify each row
|
|
31
|
+
against the current source tree. Emits
|
|
32
|
+
{
|
|
33
|
+
"comparisons": [
|
|
34
|
+
{"source_file": "...", "classification": "UNCHANGED|MODIFIED_FILE|DELETED_FILE",
|
|
35
|
+
"stored_hash": "sha256:...", "current_hash": "sha256:..."|null,
|
|
36
|
+
"current_size_bytes": N|null}, ...
|
|
37
|
+
],
|
|
38
|
+
"stats": {"total": N, "unchanged": U, "modified": M, "deleted": D}
|
|
39
|
+
}
|
|
40
|
+
The provenance file must contain `file_entries` as a top-level array
|
|
41
|
+
OR be the array itself (handles both schemas).
|
|
42
|
+
|
|
43
|
+
Hash format: `sha256:` prefix on a hex digest. Matches the convention used
|
|
44
|
+
by skf-detect-scripts-assets.py and the existing prose ("SHA-256 content
|
|
45
|
+
hash" — agnostic about prefix, but the prefix is the SKF convention to make
|
|
46
|
+
hashes self-describing for future algorithm migrations).
|
|
47
|
+
|
|
48
|
+
CLI examples:
|
|
49
|
+
uv run skf-hash-content.py hash docs/AGENTS.md
|
|
50
|
+
uv run skf-hash-content.py hash docs/AGENTS.md --include-path
|
|
51
|
+
uv run skf-hash-content.py compare /path/to/source \\
|
|
52
|
+
--provenance-map /path/to/provenance-map.json
|
|
53
|
+
|
|
54
|
+
Exit codes:
|
|
55
|
+
0 — operation succeeded (including: file in compare missing on disk;
|
|
56
|
+
its classification is DELETED_FILE, not an error)
|
|
57
|
+
1 — user error (bad path, malformed JSON, missing required field)
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
import argparse
|
|
63
|
+
import hashlib
|
|
64
|
+
import json
|
|
65
|
+
import sys
|
|
66
|
+
from pathlib import Path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# --------------------------------------------------------------------------
|
|
70
|
+
# Hashing primitives
|
|
71
|
+
# --------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def sha256_of_file(path: Path) -> str:
|
|
75
|
+
"""SHA-256 of file content, with sha256: prefix."""
|
|
76
|
+
h = hashlib.sha256()
|
|
77
|
+
with path.open("rb") as fh:
|
|
78
|
+
for chunk in iter(lambda: fh.read(65536), b""):
|
|
79
|
+
h.update(chunk)
|
|
80
|
+
return "sha256:" + h.hexdigest()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def count_lines(path: Path) -> int:
|
|
84
|
+
"""Count newlines in file content; 0 if unreadable."""
|
|
85
|
+
n = 0
|
|
86
|
+
try:
|
|
87
|
+
with path.open("rb") as fh:
|
|
88
|
+
for chunk in iter(lambda: fh.read(65536), b""):
|
|
89
|
+
n += chunk.count(b"\n")
|
|
90
|
+
except OSError:
|
|
91
|
+
return 0
|
|
92
|
+
return n
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def hash_record(path: Path, *, include_path: bool = False) -> dict:
|
|
96
|
+
"""Build a single-file record: content_hash + size_bytes + line_count."""
|
|
97
|
+
rec: dict = {
|
|
98
|
+
"content_hash": sha256_of_file(path),
|
|
99
|
+
"size_bytes": path.stat().st_size,
|
|
100
|
+
"line_count": count_lines(path),
|
|
101
|
+
}
|
|
102
|
+
if include_path:
|
|
103
|
+
rec = {"path": path.as_posix(), **rec}
|
|
104
|
+
return rec
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --------------------------------------------------------------------------
|
|
108
|
+
# Provenance comparison
|
|
109
|
+
# --------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
UNCHANGED = "UNCHANGED"
|
|
113
|
+
MODIFIED_FILE = "MODIFIED_FILE"
|
|
114
|
+
DELETED_FILE = "DELETED_FILE"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def load_file_entries(provenance_path: Path) -> list[dict]:
|
|
118
|
+
"""Extract the file_entries list from a provenance file.
|
|
119
|
+
|
|
120
|
+
Accepts two shapes:
|
|
121
|
+
- top-level object with a `file_entries` key (canonical)
|
|
122
|
+
- top-level array of entries (already-extracted)
|
|
123
|
+
|
|
124
|
+
Returns a copy of the list. Raises ValueError on malformed JSON or
|
|
125
|
+
missing key.
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
data = json.loads(provenance_path.read_text(encoding="utf-8"))
|
|
129
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
130
|
+
raise ValueError(f"failed to read provenance file {provenance_path}: {exc}") from exc
|
|
131
|
+
|
|
132
|
+
if isinstance(data, list):
|
|
133
|
+
return list(data)
|
|
134
|
+
if isinstance(data, dict):
|
|
135
|
+
entries = data.get("file_entries")
|
|
136
|
+
if entries is None:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"provenance file {provenance_path} has no `file_entries` field"
|
|
139
|
+
)
|
|
140
|
+
if not isinstance(entries, list):
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"`file_entries` in {provenance_path} is not an array"
|
|
143
|
+
)
|
|
144
|
+
return list(entries)
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f"provenance file {provenance_path} must be an object or array; "
|
|
147
|
+
f"got {type(data).__name__}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def classify_entry(source_root: Path, entry: dict) -> dict:
|
|
152
|
+
"""Classify a single file_entry against the current source tree."""
|
|
153
|
+
source_file = entry.get("source_file")
|
|
154
|
+
stored_hash = entry.get("content_hash")
|
|
155
|
+
if not isinstance(source_file, str):
|
|
156
|
+
raise ValueError(f"file_entry missing required `source_file`: {entry!r}")
|
|
157
|
+
|
|
158
|
+
current = (source_root / source_file).resolve()
|
|
159
|
+
# guard against ../.. escapes — resolved path must stay inside source_root
|
|
160
|
+
try:
|
|
161
|
+
current.relative_to(source_root.resolve())
|
|
162
|
+
except ValueError:
|
|
163
|
+
return {
|
|
164
|
+
"source_file": source_file,
|
|
165
|
+
"classification": DELETED_FILE,
|
|
166
|
+
"stored_hash": stored_hash,
|
|
167
|
+
"current_hash": None,
|
|
168
|
+
"current_size_bytes": None,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if not current.is_file():
|
|
172
|
+
return {
|
|
173
|
+
"source_file": source_file,
|
|
174
|
+
"classification": DELETED_FILE,
|
|
175
|
+
"stored_hash": stored_hash,
|
|
176
|
+
"current_hash": None,
|
|
177
|
+
"current_size_bytes": None,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
current_hash = sha256_of_file(current)
|
|
181
|
+
if current_hash == stored_hash:
|
|
182
|
+
classification = UNCHANGED
|
|
183
|
+
else:
|
|
184
|
+
classification = MODIFIED_FILE
|
|
185
|
+
return {
|
|
186
|
+
"source_file": source_file,
|
|
187
|
+
"classification": classification,
|
|
188
|
+
"stored_hash": stored_hash,
|
|
189
|
+
"current_hash": current_hash,
|
|
190
|
+
"current_size_bytes": current.stat().st_size,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def compare(source_root: Path, provenance_path: Path) -> dict:
|
|
195
|
+
entries = load_file_entries(provenance_path)
|
|
196
|
+
comparisons = [classify_entry(source_root, entry) for entry in entries]
|
|
197
|
+
counts = {UNCHANGED: 0, MODIFIED_FILE: 0, DELETED_FILE: 0}
|
|
198
|
+
for c in comparisons:
|
|
199
|
+
counts[c["classification"]] += 1
|
|
200
|
+
return {
|
|
201
|
+
"comparisons": comparisons,
|
|
202
|
+
"stats": {
|
|
203
|
+
"total": len(comparisons),
|
|
204
|
+
"unchanged": counts[UNCHANGED],
|
|
205
|
+
"modified": counts[MODIFIED_FILE],
|
|
206
|
+
"deleted": counts[DELETED_FILE],
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# --------------------------------------------------------------------------
|
|
212
|
+
# CLI
|
|
213
|
+
# --------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _cmd_hash(args: argparse.Namespace) -> int:
|
|
217
|
+
path = Path(args.path)
|
|
218
|
+
if not path.is_file():
|
|
219
|
+
print(f"error: file not found: {path}", file=sys.stderr)
|
|
220
|
+
return 1
|
|
221
|
+
rec = hash_record(path, include_path=args.include_path)
|
|
222
|
+
json.dump(rec, sys.stdout)
|
|
223
|
+
sys.stdout.write("\n")
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _cmd_compare(args: argparse.Namespace) -> int:
|
|
228
|
+
source_root = Path(args.source_root)
|
|
229
|
+
provenance = Path(args.provenance_map)
|
|
230
|
+
if not source_root.is_dir():
|
|
231
|
+
print(f"error: source root not a directory: {source_root}", file=sys.stderr)
|
|
232
|
+
return 1
|
|
233
|
+
if not provenance.is_file():
|
|
234
|
+
print(f"error: provenance map not found: {provenance}", file=sys.stderr)
|
|
235
|
+
return 1
|
|
236
|
+
try:
|
|
237
|
+
result = compare(source_root, provenance)
|
|
238
|
+
except ValueError as exc:
|
|
239
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
240
|
+
return 1
|
|
241
|
+
json.dump(result, sys.stdout, indent=2)
|
|
242
|
+
sys.stdout.write("\n")
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
247
|
+
parser = argparse.ArgumentParser(
|
|
248
|
+
prog="skf-hash-content",
|
|
249
|
+
description="SHA-256 hashing helpers: single-file record or bulk-compare against provenance.",
|
|
250
|
+
)
|
|
251
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
252
|
+
|
|
253
|
+
p_hash = sub.add_parser("hash", help="emit hash record for a single file")
|
|
254
|
+
p_hash.add_argument("path", help="path to the file")
|
|
255
|
+
p_hash.add_argument(
|
|
256
|
+
"--include-path",
|
|
257
|
+
action="store_true",
|
|
258
|
+
help="include the path string in the emitted record",
|
|
259
|
+
)
|
|
260
|
+
p_hash.set_defaults(func=_cmd_hash)
|
|
261
|
+
|
|
262
|
+
p_cmp = sub.add_parser(
|
|
263
|
+
"compare",
|
|
264
|
+
help="classify provenance file_entries[] against current source tree",
|
|
265
|
+
)
|
|
266
|
+
p_cmp.add_argument("source_root", help="path to the source tree root")
|
|
267
|
+
p_cmp.add_argument(
|
|
268
|
+
"--provenance-map",
|
|
269
|
+
required=True,
|
|
270
|
+
help="path to provenance-map.json (object with file_entries[] or bare array)",
|
|
271
|
+
)
|
|
272
|
+
p_cmp.set_defaults(func=_cmd_compare)
|
|
273
|
+
|
|
274
|
+
return parser
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def main(argv: list[str] | None = None) -> int:
|
|
278
|
+
parser = _build_parser()
|
|
279
|
+
args = parser.parse_args(argv)
|
|
280
|
+
return args.func(args)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
if __name__ == "__main__":
|
|
284
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Load Provenance — normalize provenance-map.json into deterministic projections.
|
|
6
|
+
|
|
7
|
+
Two places in `skf-audit-skill` walk the same provenance map to extract
|
|
8
|
+
identical deterministic projections:
|
|
9
|
+
|
|
10
|
+
1. **init.md §4 Load Provenance Map + Stack Skill Detection** — extracts
|
|
11
|
+
`source_root`, `baseline_commit` (`source_commit`), `baseline_ref`
|
|
12
|
+
(`source_ref`), and detects stack-skill flags (`provenance_version` and
|
|
13
|
+
top-level `libraries` for legacy v1 stacks).
|
|
14
|
+
|
|
15
|
+
2. **structural-diff.md §1 Prepare Comparison Sets** — re-walks the map to
|
|
16
|
+
build a `reexport_map` from any `__init__.py` re-export mapping recorded
|
|
17
|
+
in the provenance map, used to canonicalize public-API renames before
|
|
18
|
+
diffing.
|
|
19
|
+
|
|
20
|
+
Both files also reference the **bounded scan list** — the union of
|
|
21
|
+
`entries[].source_file` and `file_entries[].source_file` — which `re-index.md`
|
|
22
|
+
§2 consumes to constrain its scan. Collecting that list once at provenance
|
|
23
|
+
load time and surfacing it as a normalized field removes ~80 lines of
|
|
24
|
+
LLM walk-and-extract prose from the audit-skill references.
|
|
25
|
+
|
|
26
|
+
The transforms here are pure projections — no I/O against the source tree,
|
|
27
|
+
no comparisons. The script reads one JSON, emits one JSON. The downstream
|
|
28
|
+
canonicalization transforms in structural-diff.md §1 (quote-style on string
|
|
29
|
+
defaults, stdlib module qualification on signature components) STAY in the
|
|
30
|
+
LLM prose because they require per-signature judgment; this script handles
|
|
31
|
+
only the deterministic projections.
|
|
32
|
+
|
|
33
|
+
Subcommand:
|
|
34
|
+
normalize <map.json>
|
|
35
|
+
Emit JSON:
|
|
36
|
+
{
|
|
37
|
+
"bounded_scan_files": ["<rel-path, forward-slash>", ...],
|
|
38
|
+
"is_stack_skill": <bool>,
|
|
39
|
+
"legacy_stack_provenance": <bool>,
|
|
40
|
+
"source_root": "<path or null>",
|
|
41
|
+
"baseline_commit": "<sha or null>",
|
|
42
|
+
"baseline_ref": "<ref or null>",
|
|
43
|
+
"reexport_map": {"<from-internal>": "<to-public>"}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
Bounded scan = union of `entries[].source_file` and
|
|
47
|
+
`file_entries[].source_file`, deduplicated, sorted, normalized to
|
|
48
|
+
POSIX forward-slash separators.
|
|
49
|
+
|
|
50
|
+
Stack-skill detection mirrors `init.md` §Stack Skill Detection:
|
|
51
|
+
- `is_stack_skill` is true if `provenance_version >= "2.0"` AND a
|
|
52
|
+
top-level `libraries` key is present.
|
|
53
|
+
- `legacy_stack_provenance` is true if `provenance_version` is "1"
|
|
54
|
+
(or "1.0", or absent treated as v1) AND `skill_type == "stack"`.
|
|
55
|
+
Note: detecting `is_stack_skill` for a v1 map by the presence of
|
|
56
|
+
`libraries` alone (the documented behavior in init.md) is also
|
|
57
|
+
honored — see condition handling in `detect_stack_flags`.
|
|
58
|
+
|
|
59
|
+
Re-export map: read `reexport_map` field directly from the provenance
|
|
60
|
+
map if it exists (writer-side captures __init__.py walk results there).
|
|
61
|
+
Otherwise, walk `entries[]` for any `reexported_as` field on individual
|
|
62
|
+
entries — older provenance writers may have used that shape. Empty
|
|
63
|
+
object when neither is present.
|
|
64
|
+
|
|
65
|
+
CLI examples:
|
|
66
|
+
uv run skf-load-provenance.py normalize /path/to/provenance-map.json
|
|
67
|
+
|
|
68
|
+
Exit codes:
|
|
69
|
+
0 — normalization succeeded (including empty / well-formed map with no
|
|
70
|
+
entries — emits all-defaults JSON)
|
|
71
|
+
1 — user error (file not found, malformed JSON, structurally invalid map)
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
from __future__ import annotations
|
|
75
|
+
|
|
76
|
+
import argparse
|
|
77
|
+
import json
|
|
78
|
+
import sys
|
|
79
|
+
from pathlib import Path
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --------------------------------------------------------------------------
|
|
83
|
+
# Provenance map I/O
|
|
84
|
+
# --------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_provenance(path: Path) -> dict:
|
|
88
|
+
"""Read a provenance map JSON. Returns the top-level object.
|
|
89
|
+
|
|
90
|
+
Raises ValueError on read failure or non-object top-level.
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
text = path.read_text(encoding="utf-8")
|
|
94
|
+
except OSError as exc:
|
|
95
|
+
raise ValueError(f"failed to read provenance file {path}: {exc}") from exc
|
|
96
|
+
try:
|
|
97
|
+
data = json.loads(text)
|
|
98
|
+
except json.JSONDecodeError as exc:
|
|
99
|
+
raise ValueError(f"malformed JSON in provenance file {path}: {exc}") from exc
|
|
100
|
+
if not isinstance(data, dict):
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"provenance file {path} must be a JSON object at top level; "
|
|
103
|
+
f"got {type(data).__name__}"
|
|
104
|
+
)
|
|
105
|
+
return data
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# --------------------------------------------------------------------------
|
|
109
|
+
# Projections
|
|
110
|
+
# --------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _posix(p: str) -> str:
|
|
114
|
+
"""Normalize a path string to forward-slash form."""
|
|
115
|
+
return p.replace("\\", "/")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def bounded_scan_files(data: dict) -> list[str]:
|
|
119
|
+
"""Union of entries[].source_file + file_entries[].source_file.
|
|
120
|
+
|
|
121
|
+
Deduplicated; sorted; forward-slash. Non-string source_file values are
|
|
122
|
+
silently skipped (consistent with downstream code that expects strings).
|
|
123
|
+
"""
|
|
124
|
+
paths: set[str] = set()
|
|
125
|
+
entries = data.get("entries")
|
|
126
|
+
if isinstance(entries, list):
|
|
127
|
+
for entry in entries:
|
|
128
|
+
if not isinstance(entry, dict):
|
|
129
|
+
continue
|
|
130
|
+
sf = entry.get("source_file")
|
|
131
|
+
if isinstance(sf, str) and sf:
|
|
132
|
+
paths.add(_posix(sf))
|
|
133
|
+
file_entries = data.get("file_entries")
|
|
134
|
+
if isinstance(file_entries, list):
|
|
135
|
+
for entry in file_entries:
|
|
136
|
+
if not isinstance(entry, dict):
|
|
137
|
+
continue
|
|
138
|
+
sf = entry.get("source_file")
|
|
139
|
+
if isinstance(sf, str) and sf:
|
|
140
|
+
paths.add(_posix(sf))
|
|
141
|
+
return sorted(paths)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _normalize_version(v: object) -> tuple[int, ...] | None:
|
|
145
|
+
"""Parse a provenance_version string like "2.0" / "1.5" into a tuple.
|
|
146
|
+
|
|
147
|
+
Returns None on absence or unparseable input (treat as legacy v1).
|
|
148
|
+
"""
|
|
149
|
+
if v is None:
|
|
150
|
+
return None
|
|
151
|
+
if isinstance(v, (int, float)):
|
|
152
|
+
return (int(v),)
|
|
153
|
+
if not isinstance(v, str):
|
|
154
|
+
return None
|
|
155
|
+
parts: list[int] = []
|
|
156
|
+
for chunk in v.split("."):
|
|
157
|
+
try:
|
|
158
|
+
parts.append(int(chunk))
|
|
159
|
+
except ValueError:
|
|
160
|
+
return None
|
|
161
|
+
return tuple(parts) if parts else None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def detect_stack_flags(data: dict) -> tuple[bool, bool]:
|
|
165
|
+
"""Return (is_stack_skill, legacy_stack_provenance).
|
|
166
|
+
|
|
167
|
+
Mirrors init.md §Stack Skill Detection:
|
|
168
|
+
- v2+ with libraries → is_stack_skill=true
|
|
169
|
+
- v2+ with skill_type=="stack" → is_stack_skill=true
|
|
170
|
+
- v1 / no version with top-level `libraries` → is_stack_skill=true,
|
|
171
|
+
legacy_stack_provenance=true
|
|
172
|
+
- v1 / no version with skill_type=="stack" → is_stack_skill=true,
|
|
173
|
+
legacy_stack_provenance=true
|
|
174
|
+
- otherwise → both false
|
|
175
|
+
"""
|
|
176
|
+
version = _normalize_version(data.get("provenance_version"))
|
|
177
|
+
has_libraries = isinstance(data.get("libraries"), (dict, list))
|
|
178
|
+
skill_type = data.get("skill_type") if isinstance(data.get("skill_type"), str) else None
|
|
179
|
+
is_v2 = version is not None and version[0] >= 2
|
|
180
|
+
|
|
181
|
+
if is_v2:
|
|
182
|
+
# v2 stack signaled by either skill_type or libraries presence
|
|
183
|
+
is_stack = (skill_type == "stack") or has_libraries
|
|
184
|
+
legacy = False
|
|
185
|
+
return is_stack, legacy
|
|
186
|
+
|
|
187
|
+
# v1 / unversioned — legacy detection by `libraries` key OR skill_type
|
|
188
|
+
if has_libraries or skill_type == "stack":
|
|
189
|
+
return True, True
|
|
190
|
+
return False, False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def extract_reexport_map(data: dict) -> dict[str, str]:
|
|
194
|
+
"""Build the public-API re-export map.
|
|
195
|
+
|
|
196
|
+
Two writer schemas are supported:
|
|
197
|
+
|
|
198
|
+
1. **Top-level `reexport_map`** — modern writers persist the
|
|
199
|
+
__init__.py walk results here directly, as `{"<internal>": "<public>"}`.
|
|
200
|
+
|
|
201
|
+
2. **Per-entry `reexported_as`** — legacy writers (or hand-edited maps)
|
|
202
|
+
may carry the re-export on each `entries[]` item as
|
|
203
|
+
`{"export_name": "_Impl", "reexported_as": "Public"}`. We collect
|
|
204
|
+
all such pairs into the same map shape.
|
|
205
|
+
|
|
206
|
+
Non-string keys/values are silently skipped.
|
|
207
|
+
"""
|
|
208
|
+
out: dict[str, str] = {}
|
|
209
|
+
top = data.get("reexport_map")
|
|
210
|
+
if isinstance(top, dict):
|
|
211
|
+
for k, v in top.items():
|
|
212
|
+
if isinstance(k, str) and isinstance(v, str):
|
|
213
|
+
out[k] = v
|
|
214
|
+
entries = data.get("entries")
|
|
215
|
+
if isinstance(entries, list):
|
|
216
|
+
for entry in entries:
|
|
217
|
+
if not isinstance(entry, dict):
|
|
218
|
+
continue
|
|
219
|
+
internal = entry.get("export_name")
|
|
220
|
+
public = entry.get("reexported_as")
|
|
221
|
+
if isinstance(internal, str) and isinstance(public, str):
|
|
222
|
+
# per-entry value wins only if the top-level didn't already
|
|
223
|
+
# carry a binding for the internal name (top-level is the
|
|
224
|
+
# canonical aggregated form)
|
|
225
|
+
out.setdefault(internal, public)
|
|
226
|
+
return out
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def normalize(data: dict) -> dict:
|
|
230
|
+
"""Build the full normalized projection record."""
|
|
231
|
+
is_stack, legacy = detect_stack_flags(data)
|
|
232
|
+
source_root = data.get("source_root") if isinstance(data.get("source_root"), str) else None
|
|
233
|
+
baseline_commit = (
|
|
234
|
+
data.get("source_commit") if isinstance(data.get("source_commit"), str) else None
|
|
235
|
+
)
|
|
236
|
+
baseline_ref = (
|
|
237
|
+
data.get("source_ref") if isinstance(data.get("source_ref"), str) else None
|
|
238
|
+
)
|
|
239
|
+
return {
|
|
240
|
+
"bounded_scan_files": bounded_scan_files(data),
|
|
241
|
+
"is_stack_skill": is_stack,
|
|
242
|
+
"legacy_stack_provenance": legacy,
|
|
243
|
+
"source_root": source_root,
|
|
244
|
+
"baseline_commit": baseline_commit,
|
|
245
|
+
"baseline_ref": baseline_ref,
|
|
246
|
+
"reexport_map": extract_reexport_map(data),
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# --------------------------------------------------------------------------
|
|
251
|
+
# CLI
|
|
252
|
+
# --------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _cmd_normalize(args: argparse.Namespace) -> int:
|
|
256
|
+
path = Path(args.provenance_map)
|
|
257
|
+
if not path.is_file():
|
|
258
|
+
print(f"error: provenance map not found: {path}", file=sys.stderr)
|
|
259
|
+
return 1
|
|
260
|
+
try:
|
|
261
|
+
data = load_provenance(path)
|
|
262
|
+
except ValueError as exc:
|
|
263
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
264
|
+
return 1
|
|
265
|
+
result = normalize(data)
|
|
266
|
+
json.dump(result, sys.stdout, indent=2)
|
|
267
|
+
sys.stdout.write("\n")
|
|
268
|
+
return 0
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
272
|
+
parser = argparse.ArgumentParser(
|
|
273
|
+
prog="skf-load-provenance",
|
|
274
|
+
description=(
|
|
275
|
+
"Normalize provenance-map.json into deterministic projections "
|
|
276
|
+
"(bounded scan, stack flags, source_root/commit/ref, reexport map)."
|
|
277
|
+
),
|
|
278
|
+
)
|
|
279
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
280
|
+
p_norm = sub.add_parser(
|
|
281
|
+
"normalize", help="emit normalized projection JSON from a provenance map"
|
|
282
|
+
)
|
|
283
|
+
p_norm.add_argument("provenance_map", help="path to provenance-map.json")
|
|
284
|
+
p_norm.set_defaults(func=_cmd_normalize)
|
|
285
|
+
return parser
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def main(argv: list[str] | None = None) -> int:
|
|
289
|
+
parser = _build_parser()
|
|
290
|
+
args = parser.parse_args(argv)
|
|
291
|
+
return args.func(args)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__":
|
|
295
|
+
raise SystemExit(main())
|