bmad-module-skill-forge 1.4.1 → 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 +34 -4
- 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/{steps-c/step-01-init.md → references/init.md} +57 -19
- 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 -17
- 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} +11 -11
- 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} +11 -11
- 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} +11 -49
- 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} +20 -8
- 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} +109 -113
- 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} +16 -31
- 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-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
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Check Workspace Drift — pre-flight guard for gap-driven update-skill runs.
|
|
6
|
+
|
|
7
|
+
`skf-update-skill/references/re-extract.md §0.a` defines a four-state guard:
|
|
8
|
+
the workspace at `source_root` must point at the commit the skill was pinned
|
|
9
|
+
against (`metadata.source_commit`), otherwise gap-driven spot-checks read
|
|
10
|
+
bytes that differ from the pinned tree and silently produce wrong results
|
|
11
|
+
(symbols appear "verified" because the recorded line now points at different
|
|
12
|
+
code).
|
|
13
|
+
|
|
14
|
+
The guard's logic is deterministic — three git commands and a comparison —
|
|
15
|
+
but the prose form asked the LLM to chain them per run, with subtle short-
|
|
16
|
+
SHA prefix matching and skip-paths for non-git workspaces. This script bakes
|
|
17
|
+
the dispatch in.
|
|
18
|
+
|
|
19
|
+
CLI:
|
|
20
|
+
uv run skf-check-workspace-drift.py <source-root> \\
|
|
21
|
+
--pinned-commit <SHA or empty> \\
|
|
22
|
+
[--source-ref <ref>] \\
|
|
23
|
+
[--allow-drift]
|
|
24
|
+
|
|
25
|
+
Inputs:
|
|
26
|
+
source_root Filesystem path to the workspace under test.
|
|
27
|
+
--pinned-commit The pinned commit SHA (full or short). Pass an empty
|
|
28
|
+
string OR the literal "local" to declare that this
|
|
29
|
+
skill has no pinned commit — the guard skips with
|
|
30
|
+
skip_reason="no-pinned-commit".
|
|
31
|
+
--source-ref Optional. The ref name (tag, branch) the workspace
|
|
32
|
+
was pinned to. Included in the halt message for
|
|
33
|
+
user-facing context only.
|
|
34
|
+
--allow-drift Suppress the drift halt. Mismatches still return
|
|
35
|
+
status="overridden" so the caller can surface a
|
|
36
|
+
warning in the final report.
|
|
37
|
+
|
|
38
|
+
Output (JSON on stdout):
|
|
39
|
+
{
|
|
40
|
+
"status": "ok" | "skipped" | "mismatch" | "overridden",
|
|
41
|
+
"skip_reason": "no-pinned-commit" | "not-a-git-tree" | null,
|
|
42
|
+
"pinned_commit": "<as-given>",
|
|
43
|
+
"head_sha": "<full SHA>" | null,
|
|
44
|
+
"head_short_sha": "<7-12 chars>" | null,
|
|
45
|
+
"match_kind": "full" | "short-prefix" | null,
|
|
46
|
+
"log_message": "workspace_drift_check: ok (abc1234)",
|
|
47
|
+
"halt_message": "<multi-line user-facing message>" | null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
Exit codes:
|
|
51
|
+
0 — caller may continue (status is ok, skipped, or overridden)
|
|
52
|
+
1 — script error (bad args, source_root missing, git unavailable)
|
|
53
|
+
2 — drift detected and --allow-drift was NOT passed; caller MUST halt
|
|
54
|
+
with status="halted-for-workspace-drift". The halt_message field
|
|
55
|
+
is pre-formatted with all substitutions applied.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from __future__ import annotations
|
|
59
|
+
|
|
60
|
+
import argparse
|
|
61
|
+
import json
|
|
62
|
+
import shutil
|
|
63
|
+
import subprocess
|
|
64
|
+
import sys
|
|
65
|
+
from pathlib import Path
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
SKIP_NO_PINNED = "no-pinned-commit"
|
|
69
|
+
SKIP_NOT_GIT = "not-a-git-tree"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# --------------------------------------------------------------------------
|
|
73
|
+
# Git probes
|
|
74
|
+
# --------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _git(args: list[str], *, cwd: Path) -> tuple[int, str, str]:
|
|
78
|
+
"""Run a git command; return (rc, stdout, stderr). Stdout/stderr stripped."""
|
|
79
|
+
proc = subprocess.run(
|
|
80
|
+
["git", "-C", str(cwd), *args],
|
|
81
|
+
capture_output=True,
|
|
82
|
+
text=True,
|
|
83
|
+
check=False,
|
|
84
|
+
)
|
|
85
|
+
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def is_git_working_tree(source_root: Path) -> bool:
|
|
89
|
+
"""True if `git rev-parse --is-inside-work-tree` says yes."""
|
|
90
|
+
rc, out, _ = _git(["rev-parse", "--is-inside-work-tree"], cwd=source_root)
|
|
91
|
+
return rc == 0 and out == "true"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def head_sha(source_root: Path) -> str | None:
|
|
95
|
+
"""Return the workspace HEAD SHA, or None if it can't be read."""
|
|
96
|
+
rc, out, _ = _git(["rev-parse", "HEAD"], cwd=source_root)
|
|
97
|
+
if rc != 0 or not out:
|
|
98
|
+
return None
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --------------------------------------------------------------------------
|
|
103
|
+
# Match logic
|
|
104
|
+
# --------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def is_skippable_pinned(pinned_commit: str) -> bool:
|
|
108
|
+
"""Recognize the scalar values that mean 'no pinned commit'."""
|
|
109
|
+
if pinned_commit is None:
|
|
110
|
+
return True
|
|
111
|
+
stripped = pinned_commit.strip()
|
|
112
|
+
return stripped == "" or stripped.lower() == "local"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def classify_match(pinned: str, head: str) -> str | None:
|
|
116
|
+
"""Return 'full' if pinned == head, 'short-prefix' if pinned is a prefix
|
|
117
|
+
of head (≥ 7 chars to avoid coincidental collisions on short SHAs).
|
|
118
|
+
Returns None on no match."""
|
|
119
|
+
if pinned == head:
|
|
120
|
+
return "full"
|
|
121
|
+
if len(pinned) >= 7 and head.startswith(pinned):
|
|
122
|
+
return "short-prefix"
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def short(sha: str) -> str:
|
|
127
|
+
return sha[:7]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --------------------------------------------------------------------------
|
|
131
|
+
# Halt message
|
|
132
|
+
# --------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
HALT_MESSAGE_TEMPLATE = """Workspace HEAD does not match the commit this skill was pinned against.
|
|
136
|
+
|
|
137
|
+
pinned (metadata.source_commit): {pinned_commit}
|
|
138
|
+
pinned ref (metadata.source_ref): {source_ref}
|
|
139
|
+
workspace HEAD ({source_root}): {head_sha}
|
|
140
|
+
|
|
141
|
+
Gap-driven spot-checks read source at pinned line numbers — verifying
|
|
142
|
+
against a drifted tree silently produces wrong results (symbols appear at
|
|
143
|
+
unintended locations). Re-sync the workspace before re-running:
|
|
144
|
+
|
|
145
|
+
git -C "{source_root}" checkout {checkout_target}
|
|
146
|
+
|
|
147
|
+
Or, to intentionally proceed against the current workspace HEAD (accepting
|
|
148
|
+
that spot-checks will read bytes that differ from the pinned commit),
|
|
149
|
+
re-run update-skill with `--allow-workspace-drift`."""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def build_halt_message(
|
|
153
|
+
*, source_root: Path, pinned_commit: str, head_sha_full: str, source_ref: str | None
|
|
154
|
+
) -> str:
|
|
155
|
+
ref_display = source_ref if source_ref else "unset"
|
|
156
|
+
checkout_target = source_ref if source_ref else pinned_commit
|
|
157
|
+
return HALT_MESSAGE_TEMPLATE.format(
|
|
158
|
+
pinned_commit=pinned_commit,
|
|
159
|
+
source_ref=ref_display,
|
|
160
|
+
source_root=str(source_root),
|
|
161
|
+
head_sha=head_sha_full,
|
|
162
|
+
checkout_target=checkout_target,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# --------------------------------------------------------------------------
|
|
167
|
+
# Core check
|
|
168
|
+
# --------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def check(
|
|
172
|
+
source_root: Path,
|
|
173
|
+
*,
|
|
174
|
+
pinned_commit: str,
|
|
175
|
+
source_ref: str | None,
|
|
176
|
+
allow_drift: bool,
|
|
177
|
+
) -> dict:
|
|
178
|
+
"""Run the four-state guard and return a result envelope."""
|
|
179
|
+
if is_skippable_pinned(pinned_commit):
|
|
180
|
+
return {
|
|
181
|
+
"status": "skipped",
|
|
182
|
+
"skip_reason": SKIP_NO_PINNED,
|
|
183
|
+
"pinned_commit": pinned_commit,
|
|
184
|
+
"head_sha": None,
|
|
185
|
+
"head_short_sha": None,
|
|
186
|
+
"match_kind": None,
|
|
187
|
+
"log_message": "workspace_drift_check: skipped (no pinned commit)",
|
|
188
|
+
"halt_message": None,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if not is_git_working_tree(source_root):
|
|
192
|
+
return {
|
|
193
|
+
"status": "skipped",
|
|
194
|
+
"skip_reason": SKIP_NOT_GIT,
|
|
195
|
+
"pinned_commit": pinned_commit,
|
|
196
|
+
"head_sha": None,
|
|
197
|
+
"head_short_sha": None,
|
|
198
|
+
"match_kind": None,
|
|
199
|
+
"log_message": "workspace_drift_check: skipped (not a git working tree)",
|
|
200
|
+
"halt_message": None,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
head = head_sha(source_root)
|
|
204
|
+
if head is None:
|
|
205
|
+
# git tree exists but HEAD can't be resolved (orphan / empty repo).
|
|
206
|
+
# Treat as not-a-git-tree for guard purposes — we can't verify pinning.
|
|
207
|
+
return {
|
|
208
|
+
"status": "skipped",
|
|
209
|
+
"skip_reason": SKIP_NOT_GIT,
|
|
210
|
+
"pinned_commit": pinned_commit,
|
|
211
|
+
"head_sha": None,
|
|
212
|
+
"head_short_sha": None,
|
|
213
|
+
"match_kind": None,
|
|
214
|
+
"log_message": "workspace_drift_check: skipped (HEAD unreadable)",
|
|
215
|
+
"halt_message": None,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
match_kind = classify_match(pinned_commit, head)
|
|
219
|
+
if match_kind is not None:
|
|
220
|
+
return {
|
|
221
|
+
"status": "ok",
|
|
222
|
+
"skip_reason": None,
|
|
223
|
+
"pinned_commit": pinned_commit,
|
|
224
|
+
"head_sha": head,
|
|
225
|
+
"head_short_sha": short(head),
|
|
226
|
+
"match_kind": match_kind,
|
|
227
|
+
"log_message": f"workspace_drift_check: ok ({short(head)})",
|
|
228
|
+
"halt_message": None,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
# Mismatch path
|
|
232
|
+
halt_message = build_halt_message(
|
|
233
|
+
source_root=source_root,
|
|
234
|
+
pinned_commit=pinned_commit,
|
|
235
|
+
head_sha_full=head,
|
|
236
|
+
source_ref=source_ref,
|
|
237
|
+
)
|
|
238
|
+
if allow_drift:
|
|
239
|
+
return {
|
|
240
|
+
"status": "overridden",
|
|
241
|
+
"skip_reason": None,
|
|
242
|
+
"pinned_commit": pinned_commit,
|
|
243
|
+
"head_sha": head,
|
|
244
|
+
"head_short_sha": short(head),
|
|
245
|
+
"match_kind": None,
|
|
246
|
+
"log_message": (
|
|
247
|
+
f"workspace_drift_check: overridden "
|
|
248
|
+
f"(pinned={pinned_commit}, head={head})"
|
|
249
|
+
),
|
|
250
|
+
"halt_message": halt_message, # surfaced as warning by caller
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
"status": "mismatch",
|
|
255
|
+
"skip_reason": None,
|
|
256
|
+
"pinned_commit": pinned_commit,
|
|
257
|
+
"head_sha": head,
|
|
258
|
+
"head_short_sha": short(head),
|
|
259
|
+
"match_kind": None,
|
|
260
|
+
"log_message": (
|
|
261
|
+
f"workspace_drift_check: mismatch "
|
|
262
|
+
f"(pinned={pinned_commit}, head={head})"
|
|
263
|
+
),
|
|
264
|
+
"halt_message": halt_message,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# --------------------------------------------------------------------------
|
|
269
|
+
# CLI
|
|
270
|
+
# --------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def main(argv: list[str] | None = None) -> int:
|
|
274
|
+
parser = argparse.ArgumentParser(
|
|
275
|
+
prog="skf-check-workspace-drift",
|
|
276
|
+
description=(
|
|
277
|
+
"Verify that the source-root workspace HEAD matches the pinned "
|
|
278
|
+
"commit recorded in metadata.source_commit."
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
parser.add_argument("source_root", help="path to the workspace under test")
|
|
282
|
+
parser.add_argument(
|
|
283
|
+
"--pinned-commit",
|
|
284
|
+
required=True,
|
|
285
|
+
help='pinned commit SHA; pass "" or "local" to skip the guard',
|
|
286
|
+
)
|
|
287
|
+
parser.add_argument(
|
|
288
|
+
"--source-ref",
|
|
289
|
+
default=None,
|
|
290
|
+
help="optional pinned ref (tag/branch) for halt-message display",
|
|
291
|
+
)
|
|
292
|
+
parser.add_argument(
|
|
293
|
+
"--allow-drift",
|
|
294
|
+
action="store_true",
|
|
295
|
+
help="suppress the drift halt; mismatch becomes status=overridden",
|
|
296
|
+
)
|
|
297
|
+
args = parser.parse_args(argv)
|
|
298
|
+
|
|
299
|
+
source_root = Path(args.source_root)
|
|
300
|
+
if not source_root.is_dir():
|
|
301
|
+
print(f"error: source-root not a directory: {source_root}", file=sys.stderr)
|
|
302
|
+
return 1
|
|
303
|
+
if shutil.which("git") is None:
|
|
304
|
+
print("error: git binary not on PATH", file=sys.stderr)
|
|
305
|
+
return 1
|
|
306
|
+
|
|
307
|
+
result = check(
|
|
308
|
+
source_root,
|
|
309
|
+
pinned_commit=args.pinned_commit,
|
|
310
|
+
source_ref=args.source_ref,
|
|
311
|
+
allow_drift=args.allow_drift,
|
|
312
|
+
)
|
|
313
|
+
json.dump(result, sys.stdout, indent=2)
|
|
314
|
+
sys.stdout.write("\n")
|
|
315
|
+
if result["status"] == "mismatch":
|
|
316
|
+
return 2
|
|
317
|
+
return 0
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
if __name__ == "__main__":
|
|
321
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Compare File Hashes — script/asset/doc drift detection for audit-skill.
|
|
6
|
+
|
|
7
|
+
This script replaces the per-file hash-compute-and-classify prose at
|
|
8
|
+
`src/skf-audit-skill/references/structural-diff.md` §4b (Script/Asset Drift).
|
|
9
|
+
It compares the `file_entries[]` block of a provenance map against the
|
|
10
|
+
current state of the source tree and emits three drift categories:
|
|
11
|
+
|
|
12
|
+
- **added**: files present in the source tree but NOT recorded in
|
|
13
|
+
`file_entries[]`. Walk is restricted to standard script/
|
|
14
|
+
asset/doc directories (scripts/, bin/, tools/, cli/,
|
|
15
|
+
assets/, templates/, schemas/, configs/, examples/) and
|
|
16
|
+
excludes binary extensions and generated paths — same
|
|
17
|
+
path discipline as `skf-detect-scripts-assets.py`.
|
|
18
|
+
- **removed**: files in `file_entries[]` whose `source_file` is gone
|
|
19
|
+
from disk under `<source-root>`.
|
|
20
|
+
- **changed**: files in `file_entries[]` present on disk but with a
|
|
21
|
+
different content hash than the stored hash.
|
|
22
|
+
|
|
23
|
+
Distinct from `skf-hash-content.py compare`: that script classifies entries
|
|
24
|
+
that ARE in the provenance map (UNCHANGED / MODIFIED_FILE / DELETED_FILE).
|
|
25
|
+
audit-skill additionally needs the **inverse walk** — what new
|
|
26
|
+
script/asset/doc files have appeared in the source tree since the skill
|
|
27
|
+
was created? That's `added[]`. We delegate the path-resolution and
|
|
28
|
+
hash-comparison logic for entries IN the map by duplicating the small
|
|
29
|
+
sha256 helper here (15 lines) rather than importing across files — keeps
|
|
30
|
+
the runtime import surface flat and matches the existing canonical pattern
|
|
31
|
+
in `skf-detect-scripts-assets.py:216`.
|
|
32
|
+
|
|
33
|
+
Hash-prefix normalization (writer-vs-reader compatibility): stored hashes
|
|
34
|
+
in `file_entries[].content_hash` carry a `sha256:` prefix by SKF convention
|
|
35
|
+
(see `skf-create-skill/references/extraction-patterns-tracing.md` §Provenance).
|
|
36
|
+
The reader-side normalization here is unconditionally safe — strip any
|
|
37
|
+
lowercase-alphanumeric prefix terminated by `:` before comparing, and
|
|
38
|
+
re-emit the stored value as-given in the diff record so reviewers can see
|
|
39
|
+
the original form. A bare-hex hash from a future writer would pass through
|
|
40
|
+
unchanged.
|
|
41
|
+
|
|
42
|
+
Subcommand:
|
|
43
|
+
compare <provenance-map.json> <source-root>
|
|
44
|
+
Emit JSON:
|
|
45
|
+
{
|
|
46
|
+
"added": ["<rel-path, forward-slash>", ...],
|
|
47
|
+
"removed": ["<rel-path, forward-slash>", ...],
|
|
48
|
+
"changed": [
|
|
49
|
+
{"path": "<rel-path>", "stored_hash": "sha256:...",
|
|
50
|
+
"current_hash": "sha256:..."}, ...
|
|
51
|
+
],
|
|
52
|
+
"stats": {"added": N, "removed": N, "changed": N, "unchanged": N}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
All paths are forward-slash relative to `<source-root>`. Stable sort
|
|
56
|
+
on the lists for deterministic output (lexicographic on path).
|
|
57
|
+
|
|
58
|
+
CLI examples:
|
|
59
|
+
uv run skf-compare-file-hashes.py compare prov-map.json /path/to/src
|
|
60
|
+
|
|
61
|
+
Exit codes:
|
|
62
|
+
0 — comparison succeeded (including empty/well-formed input that yields
|
|
63
|
+
all-empty drift lists)
|
|
64
|
+
1 — user error (missing files, malformed JSON, unreadable paths)
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
import argparse
|
|
70
|
+
import hashlib
|
|
71
|
+
import json
|
|
72
|
+
import re
|
|
73
|
+
import sys
|
|
74
|
+
from pathlib import Path
|
|
75
|
+
from typing import Iterable
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --------------------------------------------------------------------------
|
|
79
|
+
# Constants — mirror skf-detect-scripts-assets.py so the inverse walk
|
|
80
|
+
# explores the same set of trees the writer side would have considered.
|
|
81
|
+
# --------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
SCRIPT_DIRS = {"scripts", "bin", "tools", "cli"}
|
|
85
|
+
ASSET_DIRS = {"assets", "templates", "schemas", "configs", "examples"}
|
|
86
|
+
DOC_DIR_PREFIXES = ("docs/authoritative/",) # synthetic namespace from create-skill §6
|
|
87
|
+
|
|
88
|
+
# Path-segment names that mark generated/vendored output trees — pruned
|
|
89
|
+
# from the walk so the inverse never reports build-tree artifacts as added.
|
|
90
|
+
EXCLUDED_DIR_NAMES = {
|
|
91
|
+
"node_modules", "__pycache__", "dist", "build", ".webpack",
|
|
92
|
+
"target", ".next", ".nuxt", "out", "coverage", ".git",
|
|
93
|
+
".venv", "venv", ".tox", ".mypy_cache", ".pytest_cache",
|
|
94
|
+
".ruff_cache", ".gradle", ".idea", ".vscode",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
BINARY_EXTS = {
|
|
98
|
+
".so", ".dll", ".jar", ".wasm", ".exe", ".dylib", ".a", ".o",
|
|
99
|
+
".pyc", ".class", ".png", ".jpg", ".jpeg", ".gif", ".ico",
|
|
100
|
+
".pdf", ".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z",
|
|
101
|
+
".woff", ".woff2", ".ttf", ".otf", ".eot",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_HASH_PREFIX_RE = re.compile(r"^[a-z0-9]+:")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# --------------------------------------------------------------------------
|
|
109
|
+
# Hash + normalization primitives
|
|
110
|
+
# --------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def sha256_of_file(path: Path) -> str:
|
|
114
|
+
"""SHA-256 of file content, with sha256: prefix. Matches the convention
|
|
115
|
+
used by skf-hash-content.py / skf-detect-scripts-assets.py."""
|
|
116
|
+
h = hashlib.sha256()
|
|
117
|
+
with path.open("rb") as fh:
|
|
118
|
+
for chunk in iter(lambda: fh.read(65536), b""):
|
|
119
|
+
h.update(chunk)
|
|
120
|
+
return "sha256:" + h.hexdigest()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def normalize_hash(value: str | None) -> str | None:
|
|
124
|
+
"""Strip a leading algorithm-name prefix (`sha256:`, `sha1:`, etc.)
|
|
125
|
+
from a stored hash so bare-hex and prefixed forms compare equal.
|
|
126
|
+
|
|
127
|
+
Returns None if input is None or non-string. Idempotent on bare hex.
|
|
128
|
+
"""
|
|
129
|
+
if not isinstance(value, str):
|
|
130
|
+
return None
|
|
131
|
+
return _HASH_PREFIX_RE.sub("", value, count=1)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --------------------------------------------------------------------------
|
|
135
|
+
# Provenance load — same shape-tolerance as skf-hash-content.load_file_entries
|
|
136
|
+
# --------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def load_file_entries(provenance_path: Path) -> list[dict]:
|
|
140
|
+
"""Extract `file_entries[]` from a provenance map. Accepts:
|
|
141
|
+
- top-level object with a `file_entries` key (canonical)
|
|
142
|
+
- top-level array (already extracted)
|
|
143
|
+
- top-level object with NO `file_entries` field → empty list
|
|
144
|
+
(a single-skill with no scripts/assets/docs may omit the field
|
|
145
|
+
entirely per skill-sections.md §file_entries)
|
|
146
|
+
|
|
147
|
+
Raises ValueError on read failure or structural defects.
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
text = provenance_path.read_text(encoding="utf-8")
|
|
151
|
+
except OSError as exc:
|
|
152
|
+
raise ValueError(
|
|
153
|
+
f"failed to read provenance file {provenance_path}: {exc}"
|
|
154
|
+
) from exc
|
|
155
|
+
try:
|
|
156
|
+
data = json.loads(text)
|
|
157
|
+
except json.JSONDecodeError as exc:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f"malformed JSON in provenance file {provenance_path}: {exc}"
|
|
160
|
+
) from exc
|
|
161
|
+
|
|
162
|
+
if isinstance(data, list):
|
|
163
|
+
return list(data)
|
|
164
|
+
if isinstance(data, dict):
|
|
165
|
+
entries = data.get("file_entries")
|
|
166
|
+
if entries is None:
|
|
167
|
+
return [] # provenance with no tracked file_entries is valid
|
|
168
|
+
if not isinstance(entries, list):
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"`file_entries` in {provenance_path} is not an array"
|
|
171
|
+
)
|
|
172
|
+
return list(entries)
|
|
173
|
+
raise ValueError(
|
|
174
|
+
f"provenance file {provenance_path} must be an object or array; "
|
|
175
|
+
f"got {type(data).__name__}"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --------------------------------------------------------------------------
|
|
180
|
+
# Walk — the inverse direction (source tree → candidate set)
|
|
181
|
+
# --------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _segment_in_excluded(rel_parts: tuple[str, ...]) -> bool:
|
|
185
|
+
return any(seg in EXCLUDED_DIR_NAMES for seg in rel_parts)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _segment_in_tracked_dir(rel_parts: tuple[str, ...]) -> bool:
|
|
189
|
+
"""True if any segment of the relative path is a tracked script/asset
|
|
190
|
+
directory."""
|
|
191
|
+
for seg in rel_parts:
|
|
192
|
+
if seg in SCRIPT_DIRS or seg in ASSET_DIRS:
|
|
193
|
+
return True
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _matches_doc_prefix(rel_posix: str) -> bool:
|
|
198
|
+
return any(rel_posix.startswith(p) for p in DOC_DIR_PREFIXES)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def candidate_source_files(source_root: Path) -> Iterable[str]:
|
|
202
|
+
"""Yield POSIX relative paths under source_root that could be tracked
|
|
203
|
+
as script / asset / doc file_entries.
|
|
204
|
+
|
|
205
|
+
Selection rules:
|
|
206
|
+
- File is under a SCRIPT_DIRS or ASSET_DIRS directory at any depth, OR
|
|
207
|
+
- File path matches a DOC_DIR_PREFIXES synthetic namespace.
|
|
208
|
+
- Binary extensions are excluded.
|
|
209
|
+
- Generated/vendored directories are pruned from the walk.
|
|
210
|
+
|
|
211
|
+
This is deliberately narrower than "every file" — audit-skill §4b only
|
|
212
|
+
tracks files matching script/asset patterns. Treating every random
|
|
213
|
+
source file as a candidate `added` row would drown real drift in noise.
|
|
214
|
+
"""
|
|
215
|
+
stack: list[Path] = [source_root]
|
|
216
|
+
while stack:
|
|
217
|
+
current = stack.pop()
|
|
218
|
+
try:
|
|
219
|
+
entries = list(current.iterdir())
|
|
220
|
+
except (PermissionError, FileNotFoundError):
|
|
221
|
+
continue
|
|
222
|
+
for entry in entries:
|
|
223
|
+
if entry.is_symlink():
|
|
224
|
+
continue
|
|
225
|
+
if entry.is_dir():
|
|
226
|
+
if entry.name in EXCLUDED_DIR_NAMES:
|
|
227
|
+
continue
|
|
228
|
+
stack.append(entry)
|
|
229
|
+
continue
|
|
230
|
+
if not entry.is_file():
|
|
231
|
+
continue
|
|
232
|
+
if entry.suffix.lower() in BINARY_EXTS:
|
|
233
|
+
continue
|
|
234
|
+
try:
|
|
235
|
+
rel = entry.relative_to(source_root)
|
|
236
|
+
except ValueError:
|
|
237
|
+
continue
|
|
238
|
+
rel_parts = rel.parts[:-1]
|
|
239
|
+
rel_posix = rel.as_posix()
|
|
240
|
+
if _segment_in_tracked_dir(rel_parts) or _matches_doc_prefix(rel_posix):
|
|
241
|
+
yield rel_posix
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# --------------------------------------------------------------------------
|
|
245
|
+
# Comparison
|
|
246
|
+
# --------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def compare(source_root: Path, provenance_path: Path) -> dict:
|
|
250
|
+
"""Compute added / removed / changed / unchanged for tracked file_entries.
|
|
251
|
+
|
|
252
|
+
Stored hashes are normalized before comparison; the diff record carries
|
|
253
|
+
the original stored value (writer-form) plus the freshly computed
|
|
254
|
+
current value (always prefixed sha256:) so reviewers see both.
|
|
255
|
+
"""
|
|
256
|
+
entries = load_file_entries(provenance_path)
|
|
257
|
+
# Build the {posix_path -> stored_hash} index from the provenance.
|
|
258
|
+
stored: dict[str, str | None] = {}
|
|
259
|
+
for entry in entries:
|
|
260
|
+
if not isinstance(entry, dict):
|
|
261
|
+
continue
|
|
262
|
+
sf = entry.get("source_file")
|
|
263
|
+
if not isinstance(sf, str) or not sf:
|
|
264
|
+
continue
|
|
265
|
+
stored[sf.replace("\\", "/")] = entry.get("content_hash") if isinstance(
|
|
266
|
+
entry.get("content_hash"), str
|
|
267
|
+
) else None
|
|
268
|
+
|
|
269
|
+
# Walk the source tree once; track which provenance entries are matched.
|
|
270
|
+
candidates_on_disk = set(candidate_source_files(source_root))
|
|
271
|
+
|
|
272
|
+
removed: list[str] = []
|
|
273
|
+
changed: list[dict] = []
|
|
274
|
+
unchanged_count = 0
|
|
275
|
+
|
|
276
|
+
for path, stored_hash in sorted(stored.items()):
|
|
277
|
+
full = source_root / path
|
|
278
|
+
if not full.is_file():
|
|
279
|
+
removed.append(path)
|
|
280
|
+
continue
|
|
281
|
+
current = sha256_of_file(full)
|
|
282
|
+
if normalize_hash(stored_hash) == normalize_hash(current):
|
|
283
|
+
unchanged_count += 1
|
|
284
|
+
else:
|
|
285
|
+
changed.append({
|
|
286
|
+
"path": path,
|
|
287
|
+
"stored_hash": stored_hash,
|
|
288
|
+
"current_hash": current,
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
# Anything on disk but NOT in the provenance store is "added".
|
|
292
|
+
added = sorted(candidates_on_disk - set(stored.keys()))
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
"added": added,
|
|
296
|
+
"removed": sorted(removed),
|
|
297
|
+
"changed": sorted(changed, key=lambda r: r["path"]),
|
|
298
|
+
"stats": {
|
|
299
|
+
"added": len(added),
|
|
300
|
+
"removed": len(removed),
|
|
301
|
+
"changed": len(changed),
|
|
302
|
+
"unchanged": unchanged_count,
|
|
303
|
+
},
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# --------------------------------------------------------------------------
|
|
308
|
+
# CLI
|
|
309
|
+
# --------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _cmd_compare(args: argparse.Namespace) -> int:
|
|
313
|
+
provenance = Path(args.provenance_map)
|
|
314
|
+
source_root = Path(args.source_root)
|
|
315
|
+
if not provenance.is_file():
|
|
316
|
+
print(f"error: provenance map not found: {provenance}", file=sys.stderr)
|
|
317
|
+
return 1
|
|
318
|
+
if not source_root.is_dir():
|
|
319
|
+
print(f"error: source root not a directory: {source_root}", file=sys.stderr)
|
|
320
|
+
return 1
|
|
321
|
+
try:
|
|
322
|
+
result = compare(source_root, provenance)
|
|
323
|
+
except ValueError as exc:
|
|
324
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
325
|
+
return 1
|
|
326
|
+
json.dump(result, sys.stdout, indent=2)
|
|
327
|
+
sys.stdout.write("\n")
|
|
328
|
+
return 0
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
332
|
+
parser = argparse.ArgumentParser(
|
|
333
|
+
prog="skf-compare-file-hashes",
|
|
334
|
+
description=(
|
|
335
|
+
"Compare provenance file_entries[] against the current source "
|
|
336
|
+
"tree to detect script/asset/doc drift (added/removed/changed)."
|
|
337
|
+
),
|
|
338
|
+
)
|
|
339
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
340
|
+
p_cmp = sub.add_parser(
|
|
341
|
+
"compare",
|
|
342
|
+
help="classify tracked file_entries[] + walk inverse to detect added files",
|
|
343
|
+
)
|
|
344
|
+
p_cmp.add_argument("provenance_map", help="path to provenance-map.json")
|
|
345
|
+
p_cmp.add_argument("source_root", help="path to the source tree root")
|
|
346
|
+
p_cmp.set_defaults(func=_cmd_compare)
|
|
347
|
+
return parser
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def main(argv: list[str] | None = None) -> int:
|
|
351
|
+
parser = _build_parser()
|
|
352
|
+
args = parser.parse_args(argv)
|
|
353
|
+
return args.func(args)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
if __name__ == "__main__":
|
|
357
|
+
raise SystemExit(main())
|