its-magic 0.1.2-9 → 0.1.3-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/README.md +1602 -755
- package/bin/its-magic.js +121 -11
- package/bin/postinstall.js +93 -0
- package/installer.ps1 +759 -0
- package/installer.py +1036 -0
- package/installer.sh +649 -0
- package/package.json +18 -14
- package/scripts/check_intake_template_parity.py +312 -0
- package/scripts/doc_profile_lib.py +415 -0
- package/scripts/guard_installer_publish.py +88 -0
- package/scripts/intake_bug_routing_guard.py +67 -0
- package/scripts/intake_evidence_lib.py +802 -0
- package/scripts/intake_evidence_validate.py +73 -0
- package/scripts/materialize_codebase_map.py +184 -0
- package/scripts/remote_config_summary.py +243 -0
- package/template/.cursor/agents/curator.mdc +35 -0
- package/template/.cursor/agents/dev.mdc +29 -0
- package/template/.cursor/agents/po.mdc +141 -0
- package/template/.cursor/agents/qa.mdc +28 -0
- package/template/.cursor/agents/release.mdc +28 -0
- package/template/.cursor/agents/security.mdc +98 -0
- package/template/.cursor/agents/tech-lead.mdc +57 -0
- package/template/.cursor/commands/architecture.md +127 -0
- package/template/.cursor/commands/ask.md +55 -0
- package/template/.cursor/commands/auto.md +533 -0
- package/template/.cursor/commands/discovery.md +47 -0
- package/template/.cursor/commands/execute.md +343 -0
- package/template/.cursor/commands/intake.md +323 -0
- package/template/.cursor/commands/map-codebase.md +46 -0
- package/template/.cursor/commands/memory-audit.md +175 -0
- package/template/.cursor/commands/milestone-complete.md +51 -0
- package/template/.cursor/commands/milestone-start.md +59 -0
- package/template/.cursor/commands/pause.md +64 -0
- package/{.cursor/commands/gsd-phase-context.md → template/.cursor/commands/phase-context.md} +6 -2
- package/template/.cursor/commands/plan-verify.md +34 -0
- package/template/.cursor/commands/qa.md +226 -0
- package/template/.cursor/commands/quick.md +41 -0
- package/template/.cursor/commands/refresh-context.md +82 -0
- package/template/.cursor/commands/release.md +523 -0
- package/template/.cursor/commands/research.md +49 -0
- package/template/.cursor/commands/resume.md +67 -0
- package/template/.cursor/commands/security-review.md +81 -0
- package/template/.cursor/commands/sprint-plan.md +103 -0
- package/template/.cursor/commands/status-reconcile.md +95 -0
- package/template/.cursor/commands/verify-work.md +152 -0
- package/template/.cursor/dev-environment.json.example +22 -0
- package/{.cursor → template/.cursor}/hooks/README.md +3 -2
- package/{.cursor/hooks/gsd-hook.py → template/.cursor/hooks/hook.py} +15 -6
- package/template/.cursor/hooks.json +26 -0
- package/template/.cursor/remote.json +31 -0
- package/template/.cursor/rules/caveman.mdc +184 -0
- package/template/.cursor/rules/coding-standards.mdc +39 -0
- package/template/.cursor/rules/core.mdc +111 -0
- package/template/.cursor/rules/handoffs.mdc +27 -0
- package/template/.cursor/rules/quality.mdc +49 -0
- package/template/.cursor/scratchpad.local.example.md +323 -0
- package/template/.cursor/scratchpad.md +324 -0
- package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/SKILL.md +2 -2
- package/template/.cursorignore +6 -0
- package/template/.env.example +28 -0
- package/template/.github/workflows/ci.yml +194 -0
- package/{.github → template/.github}/workflows/deploy.yml +5 -2
- package/template/.its-magic-version +1 -0
- package/template/CHANGELOG.md +11 -0
- package/template/README.md +1602 -0
- package/template/decisions/DEC-0001.md +11 -0
- package/template/decisions/DEC-0002.md +11 -0
- package/template/docs/developer/README.md +44 -0
- package/template/docs/engineering/architecture.md +9 -0
- package/template/docs/engineering/artifact-ordering-policy.md +48 -0
- package/template/docs/engineering/artifact-ownership-policy.md +57 -0
- package/template/docs/engineering/auto-orchestration-reference.md +1211 -0
- package/{docs → template/docs}/engineering/codebase-map.md +0 -5
- package/template/docs/engineering/compatibility-report.md +20 -0
- package/template/docs/engineering/compatibility-signals.md +7 -0
- package/template/docs/engineering/component-scope-report.md +16 -0
- package/template/docs/engineering/component-scope.md +19 -0
- package/template/docs/engineering/context/installer-owned-paths.manifest +96 -0
- package/template/docs/engineering/context/readme-section-affinity.json +30 -0
- package/template/docs/engineering/decisions.md +16 -0
- package/template/docs/engineering/legacy-drift-audit.md +21 -0
- package/template/docs/engineering/manifests/components/api-gateway.manifest.yaml +12 -0
- package/template/docs/engineering/manifests/registry.manifest.yaml +20 -0
- package/template/docs/engineering/manifests/repo.manifest.yaml +11 -0
- package/template/docs/engineering/phase-context.md +16 -0
- package/template/docs/engineering/release-targets.json +123 -0
- package/template/docs/engineering/research.md +29 -0
- package/template/docs/engineering/runbook.md +2391 -0
- package/template/docs/engineering/runtime-connectivity.md +81 -0
- package/template/docs/engineering/security-review.md +33 -0
- package/template/docs/engineering/spec-pack/README.md +20 -0
- package/template/docs/engineering/state-archive/README.md +15 -0
- package/template/docs/engineering/state.md +20 -0
- package/template/docs/engineering/status-normalization-report.md +19 -0
- package/template/docs/engineering/token-cost-parity-manifest.md +16 -0
- package/template/docs/engineering/us-0084-remote-e2e.md +44 -0
- package/template/docs/product/acceptance.md +1 -0
- package/template/docs/product/backlog.md +5 -0
- package/template/docs/product/vision.md +11 -0
- package/template/docs/user-guides/README.md +40 -0
- package/{handoffs → template/handoffs}/dev_to_qa.md +0 -3
- package/{handoffs → template/handoffs}/po_to_tl.md +0 -3
- package/{handoffs → template/handoffs}/qa_to_dev.md +1 -2
- package/template/handoffs/release_notes.md +51 -0
- package/template/handoffs/release_queue.md +47 -0
- package/template/handoffs/release_to_dev.md +31 -0
- package/template/handoffs/releases/Sxxxx-release-notes.md +62 -0
- package/template/handoffs/releases/vX.Y.Z-release-notes.md.example +21 -0
- package/template/handoffs/resume_brief.md +14 -0
- package/{handoffs → template/handoffs}/tl_to_dev.md +0 -2
- package/template/handoffs/token_cost_runs/README.md +26 -0
- package/template/its_magic/.its-magic-version +1 -0
- package/template/its_magic/README.md +9 -0
- package/template/scripts/auto_outer_driver.py +521 -0
- package/template/scripts/caveman_compress_input.py +903 -0
- package/template/scripts/check_downstream_ci_guard.py +67 -0
- package/template/scripts/check_intake_template_parity.py +312 -0
- package/template/scripts/check_token_cost_parity.py +69 -0
- package/template/scripts/dev_environment_lib.py +601 -0
- package/template/scripts/doc_profile_lib.py +415 -0
- package/template/scripts/downstream_ci_guard_lib.py +222 -0
- package/template/scripts/enforce-triad-hot-surface.py +753 -0
- package/template/scripts/guard_installer_publish.py +88 -0
- package/template/scripts/intake_bug_resume_brief_refresh.py +303 -0
- package/template/scripts/intake_bug_routing_guard.py +67 -0
- package/template/scripts/intake_evidence_lib.py +802 -0
- package/template/scripts/intake_evidence_validate.py +73 -0
- package/template/scripts/materialize_codebase_map.py +184 -0
- package/template/scripts/pack_json_validate.py +130 -0
- package/template/scripts/project_readme_coverage_lib.py +417 -0
- package/template/scripts/readme_feature_coverage_lib.py +608 -0
- package/{scripts → template/scripts}/release-all.sh +27 -4
- package/template/scripts/release_changelog_backfill.py +153 -0
- package/template/scripts/release_changelog_lib.py +544 -0
- package/template/scripts/release_changelog_validate.py +134 -0
- package/template/scripts/remote_config_summary.py +243 -0
- package/template/scripts/sync_push_gates.py +198 -0
- package/template/scripts/token_cost_compare.py +40 -0
- package/template/scripts/token_cost_lib.py +108 -0
- package/template/scripts/uat_probe_lib.py +868 -0
- package/template/scripts/validate-and-push.ps1 +280 -0
- package/template/scripts/validate-and-push.sh +243 -0
- package/template/scripts/validate_doc_profile.py +103 -0
- package/template/scripts/validate_project_readme_coverage.py +151 -0
- package/template/scripts/validate_readme_feature_coverage.py +140 -0
- package/template/sprints/S0001/progress.md +1 -0
- package/template/sprints/S0001/qa-findings.md +9 -0
- package/template/sprints/S0001/release-findings.md +24 -0
- package/template/sprints/S0001/sprint.md +9 -0
- package/template/sprints/S0001/summary.md +7 -0
- package/template/sprints/S0001/tasks.md +1 -0
- package/template/sprints/S0001/uat.json +6 -0
- package/template/sprints/S0001/uat.md +5 -0
- package/template/sprints/quick/Q0001/summary.md +1 -0
- package/{sprints → template/sprints}/quick/Q0001/task.json +0 -1
- package/.cursor/agents/curator.mdc +0 -21
- package/.cursor/agents/dev.mdc +0 -20
- package/.cursor/agents/po.mdc +0 -19
- package/.cursor/agents/qa.mdc +0 -19
- package/.cursor/agents/release.mdc +0 -19
- package/.cursor/agents/tech-lead.mdc +0 -21
- package/.cursor/commands/gsd-architecture.md +0 -29
- package/.cursor/commands/gsd-auto.md +0 -27
- package/.cursor/commands/gsd-discovery.md +0 -27
- package/.cursor/commands/gsd-execute.md +0 -32
- package/.cursor/commands/gsd-intake.md +0 -28
- package/.cursor/commands/gsd-map-codebase.md +0 -25
- package/.cursor/commands/gsd-milestone-complete.md +0 -24
- package/.cursor/commands/gsd-milestone-start.md +0 -26
- package/.cursor/commands/gsd-pause.md +0 -25
- package/.cursor/commands/gsd-plan-verify.md +0 -26
- package/.cursor/commands/gsd-qa.md +0 -28
- package/.cursor/commands/gsd-quick.md +0 -24
- package/.cursor/commands/gsd-refresh-context.md +0 -26
- package/.cursor/commands/gsd-release.md +0 -29
- package/.cursor/commands/gsd-research.md +0 -28
- package/.cursor/commands/gsd-resume.md +0 -26
- package/.cursor/commands/gsd-sprint-plan.md +0 -30
- package/.cursor/commands/gsd-verify-work.md +0 -25
- package/.cursor/hooks.json +0 -26
- package/.cursor/plans/cursor-gsd-team-kit_8cfee9b8.plan.md +0 -57
- package/.cursor/remote.json +0 -18
- package/.cursor/rules/gsd-core.mdc +0 -18
- package/.cursor/rules/gsd-handoffs.mdc +0 -10
- package/.cursor/rules/gsd-quality.mdc +0 -15
- package/.cursor/scratchpad.md +0 -34
- package/.github/workflows/ci.yml +0 -47
- package/decisions/DEC-0001.md +0 -21
- package/decisions/DEC-0002.md +0 -21
- package/docs/engineering/architecture.md +0 -354
- package/docs/engineering/decisions.md +0 -6
- package/docs/engineering/research.md +0 -11
- package/docs/engineering/runbook.md +0 -32
- package/docs/engineering/state.md +0 -33
- package/docs/product/acceptance.md +0 -6
- package/docs/product/backlog.md +0 -7
- package/docs/product/vision.md +0 -46
- package/gsd-installer.ps1 +0 -189
- package/gsd-installer.py +0 -195
- package/gsd-installer.sh +0 -201
- package/handoffs/release_notes.md +0 -14
- package/handoffs/resume_brief.md +0 -8
- package/milestones/M0001/milestone.json +0 -7
- package/milestones/M0001/phases.json +0 -9
- package/milestones/M0001/progress.md +0 -3
- package/milestones/M0001/summary.md +0 -3
- package/scripts/generate-release-notes.ps1 +0 -74
- package/scripts/generate-release-notes.sh +0 -63
- package/scripts/release-all.ps1 +0 -423
- package/sprints/S0001/progress.md +0 -4
- package/sprints/S0001/qa-findings.md +0 -113
- package/sprints/S0001/sprint.md +0 -70
- package/sprints/S0001/summary.md +0 -46
- package/sprints/S0001/tasks.md +0 -35
- package/sprints/S0001/uat.json +0 -8
- package/sprints/S0001/uat.md +0 -8
- package/sprints/quick/Q0001/summary.md +0 -3
- /package/{.cursor/rules/gsd-escalation.mdc → template/.cursor/rules/escalation.mdc} +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/acceptance.json +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/acceptance.md +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/architecture.json +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/architecture.md +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/decision.json +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/decision.md +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/handoff.json +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/handoff.md +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/milestone.json +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/phase-context.json +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/plan-verify.json +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/sprint.json +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/sprint.md +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/story.json +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/story.md +0 -0
- /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/uat.json +0 -0
- /package/{docs → template/docs}/engineering/context/phase-template.json +0 -0
- /package/{docs → template/docs}/engineering/dependencies.json +0 -0
- /package/{sprints → template/sprints}/S0001/plan-verify.json +0 -0
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""US-0090 / DEC-0073 — Caveman-style input-side file compression (safe-mode v1).
|
|
3
|
+
|
|
4
|
+
Opt-in, default-off, script-invoked operator tool. Reads scratchpad gating
|
|
5
|
+
(`CAVEMAN_COMPRESS_INPUT=1` + non-empty `CAVEMAN_FILE_SCOPE`), refuses deny-
|
|
6
|
+
listed paths, applies a strictly-idempotent line-level minifier, and writes
|
|
7
|
+
sidecar originals under `docs/.caveman-originals/<relative/path>/<file>`
|
|
8
|
+
BEFORE target mutation. Stdlib only.
|
|
9
|
+
|
|
10
|
+
Reason-code vocabulary (DEC-0073 §7) — 9 codes in 3 families:
|
|
11
|
+
|
|
12
|
+
Gating CAVEMAN_COMPRESS_MODE_DISABLED
|
|
13
|
+
CAVEMAN_COMPRESS_FLAG_CONFLICT
|
|
14
|
+
Scope CAVEMAN_COMPRESS_SCOPE_EMPTY
|
|
15
|
+
CAVEMAN_COMPRESS_SCOPE_UNKNOWN_PROFILE
|
|
16
|
+
CAVEMAN_COMPRESS_SCOPE_VIOLATION
|
|
17
|
+
Integrity CAVEMAN_COMPRESS_DENY_HIT
|
|
18
|
+
CAVEMAN_COMPRESS_NOT_IDEMPOTENT
|
|
19
|
+
CAVEMAN_COMPRESS_LITERAL_REGION_DAMAGED
|
|
20
|
+
CAVEMAN_COMPRESS_ORIGINAL_MISSING
|
|
21
|
+
|
|
22
|
+
No post-write codes. Any addition requires a subsequent DEC revising §7.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import fnmatch
|
|
29
|
+
import hashlib
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
from pathlib import Path, PurePosixPath
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Deny-list baseline (DEC-0073 §4.1). Any edit here requires a subsequent DEC.
|
|
38
|
+
# `deny_list_version` in --report is the SHA-256 of sorted(DENY_BASELINE) as
|
|
39
|
+
# canonical JSON; T-005 subtest asserts the hash is stable (drift detection).
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
DENY_BASELINE: tuple[str, ...] = (
|
|
43
|
+
# Secrets
|
|
44
|
+
".env",
|
|
45
|
+
".env.*",
|
|
46
|
+
"**/.env",
|
|
47
|
+
"**/.env.*",
|
|
48
|
+
# Intake evidence
|
|
49
|
+
"handoffs/intake_evidence/*.json",
|
|
50
|
+
"handoffs/intake_evidence/**/*.json",
|
|
51
|
+
# Canonical product authority
|
|
52
|
+
"docs/product/backlog.md",
|
|
53
|
+
"docs/product/acceptance.md",
|
|
54
|
+
# Canonical engineering authority
|
|
55
|
+
"docs/engineering/state.md",
|
|
56
|
+
"docs/engineering/decisions.md",
|
|
57
|
+
"decisions/DEC-*.md",
|
|
58
|
+
# Sprint lifecycle evidence
|
|
59
|
+
"sprints/*/plan-verify.json",
|
|
60
|
+
"sprints/*/uat.json",
|
|
61
|
+
"sprints/*/summary.md",
|
|
62
|
+
"sprints/*/release-findings.md",
|
|
63
|
+
"sprints/*/qa-findings.md",
|
|
64
|
+
"sprints/*/tasks.md",
|
|
65
|
+
"sprints/*/sprint.md",
|
|
66
|
+
# Publish / runtime / install surfaces
|
|
67
|
+
"package.json",
|
|
68
|
+
"package-lock.json",
|
|
69
|
+
"installer.sh",
|
|
70
|
+
"installer.ps1",
|
|
71
|
+
"installer.py",
|
|
72
|
+
"installer.js",
|
|
73
|
+
"installer.cmd",
|
|
74
|
+
"installer.bat",
|
|
75
|
+
".github/workflows/*.yml",
|
|
76
|
+
".cursor/hooks/*.py",
|
|
77
|
+
"bin/its-magic.js",
|
|
78
|
+
"packaging/homebrew/*.rb",
|
|
79
|
+
# Contract surfaces
|
|
80
|
+
".cursor/rules/*.mdc",
|
|
81
|
+
".cursor/commands/*.md",
|
|
82
|
+
".cursor/skills/**/SKILL.md",
|
|
83
|
+
# Manifest / parity sources
|
|
84
|
+
"docs/engineering/context/installer-owned-paths.manifest",
|
|
85
|
+
"docs/engineering/release-targets.json",
|
|
86
|
+
"docs/engineering/token-cost-parity-manifest.md",
|
|
87
|
+
# Binaries (extension class)
|
|
88
|
+
"**/*.png",
|
|
89
|
+
"**/*.jpg",
|
|
90
|
+
"**/*.jpeg",
|
|
91
|
+
"**/*.gif",
|
|
92
|
+
"**/*.webp",
|
|
93
|
+
"**/*.pdf",
|
|
94
|
+
"**/*.zip",
|
|
95
|
+
"**/*.tar",
|
|
96
|
+
"**/*.tar.gz",
|
|
97
|
+
"**/*.tgz",
|
|
98
|
+
"**/*.ico",
|
|
99
|
+
"**/*.woff",
|
|
100
|
+
"**/*.woff2",
|
|
101
|
+
"**/*.ttf",
|
|
102
|
+
"**/*.eot",
|
|
103
|
+
"**/*.otf",
|
|
104
|
+
"**/*.mp3",
|
|
105
|
+
"**/*.mp4",
|
|
106
|
+
"**/*.mov",
|
|
107
|
+
"**/*.wav",
|
|
108
|
+
"**/*.bin",
|
|
109
|
+
"**/*.exe",
|
|
110
|
+
"**/*.dll",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Patterns from .gitignore that contribute as additional denies (§4 step 2).
|
|
114
|
+
GITIGNORE_SECRET_PREFIXES: tuple[str, ...] = (
|
|
115
|
+
".env",
|
|
116
|
+
"*secret*",
|
|
117
|
+
"*credential*",
|
|
118
|
+
"*token*",
|
|
119
|
+
"*private*",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Frozen v1 allow-list profile table (DEC-0073 §5.1).
|
|
123
|
+
FROZEN_PROFILES: dict[str, tuple[str, ...]] = {
|
|
124
|
+
"docs-prose-only": (
|
|
125
|
+
"docs/user-guides/**/*.md",
|
|
126
|
+
"docs/engineering/runbook.md",
|
|
127
|
+
"docs/engineering/state-archive/**/*.md",
|
|
128
|
+
"handoffs/archive/*.md",
|
|
129
|
+
),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
SIDECAR_ROOT_REL = "docs/.caveman-originals"
|
|
133
|
+
|
|
134
|
+
REASON_CODES_BY_FAMILY: dict[str, tuple[str, ...]] = {
|
|
135
|
+
"Gating": (
|
|
136
|
+
"CAVEMAN_COMPRESS_MODE_DISABLED",
|
|
137
|
+
"CAVEMAN_COMPRESS_FLAG_CONFLICT",
|
|
138
|
+
),
|
|
139
|
+
"Scope": (
|
|
140
|
+
"CAVEMAN_COMPRESS_SCOPE_EMPTY",
|
|
141
|
+
"CAVEMAN_COMPRESS_SCOPE_UNKNOWN_PROFILE",
|
|
142
|
+
"CAVEMAN_COMPRESS_SCOPE_VIOLATION",
|
|
143
|
+
),
|
|
144
|
+
"Integrity": (
|
|
145
|
+
"CAVEMAN_COMPRESS_DENY_HIT",
|
|
146
|
+
"CAVEMAN_COMPRESS_NOT_IDEMPOTENT",
|
|
147
|
+
"CAVEMAN_COMPRESS_LITERAL_REGION_DAMAGED",
|
|
148
|
+
"CAVEMAN_COMPRESS_ORIGINAL_MISSING",
|
|
149
|
+
),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
ALL_REASON_CODES: tuple[str, ...] = tuple(
|
|
153
|
+
code for codes in REASON_CODES_BY_FAMILY.values() for code in codes
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
VENDOR_INSTALL_LEAK_TOKEN = "npx skills add"
|
|
157
|
+
|
|
158
|
+
FENCE_RE = re.compile(r"^(`{3,}|~{3,})")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Utility: canonical JSON + hashing.
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def canonical_json(payload: object) -> str:
|
|
166
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def deny_list_version_hash(deny_entries: tuple[str, ...] = DENY_BASELINE) -> str:
|
|
170
|
+
canonical = canonical_json(sorted(deny_entries))
|
|
171
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Glob evaluation. Paths use forward slashes (POSIX) regardless of host OS
|
|
176
|
+
# per DEC-0073 §5 (grammar requires '/').
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def _normalize_rel(rel: str) -> str:
|
|
180
|
+
return str(PurePosixPath(rel.replace("\\", "/")))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def glob_match(pattern: str, rel_path: str) -> bool:
|
|
184
|
+
rel_path = _normalize_rel(rel_path)
|
|
185
|
+
pattern = pattern.replace("\\", "/")
|
|
186
|
+
if "**" in pattern:
|
|
187
|
+
# Translate ** to match any number of path segments (including zero).
|
|
188
|
+
parts = pattern.split("**")
|
|
189
|
+
regex_parts: list[str] = []
|
|
190
|
+
for i, part in enumerate(parts):
|
|
191
|
+
regex_parts.append(fnmatch.translate(part).rstrip(r"\Z"))
|
|
192
|
+
if i < len(parts) - 1:
|
|
193
|
+
regex_parts.append(r".*")
|
|
194
|
+
rx = re.compile("".join(regex_parts) + r"\Z")
|
|
195
|
+
return rx.match(rel_path) is not None
|
|
196
|
+
return fnmatch.fnmatchcase(rel_path, pattern)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def any_glob_match(patterns: tuple[str, ...] | list[str], rel_path: str) -> bool:
|
|
200
|
+
for p in patterns:
|
|
201
|
+
if glob_match(p, rel_path):
|
|
202
|
+
return True
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
# Scratchpad reader (stdlib). Parses simple KEY=VALUE lines; ignores comments.
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def read_scratchpad(path: Path) -> dict[str, str]:
|
|
211
|
+
values: dict[str, str] = {}
|
|
212
|
+
if not path.is_file():
|
|
213
|
+
return values
|
|
214
|
+
text = path.read_text(encoding="utf-8")
|
|
215
|
+
key_re = re.compile(r"^\s*([A-Z][A-Z0-9_]+)\s*=\s*(.*?)\s*$")
|
|
216
|
+
for raw in text.splitlines():
|
|
217
|
+
line = raw.strip()
|
|
218
|
+
if not line or line.startswith("#") or line.startswith("<!--"):
|
|
219
|
+
continue
|
|
220
|
+
m = key_re.match(line)
|
|
221
|
+
if m:
|
|
222
|
+
values[m.group(1)] = m.group(2)
|
|
223
|
+
return values
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# Scope grammar (DEC-0073 §5).
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
class ScopeParseError(Exception):
|
|
231
|
+
def __init__(self, code: str, detail: str) -> None:
|
|
232
|
+
self.code = code
|
|
233
|
+
self.detail = detail
|
|
234
|
+
super().__init__(f"{code}: {detail}")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def parse_scope(raw: str) -> tuple[list[str], str | None]:
|
|
238
|
+
"""Return (glob_patterns, profile_name_or_None).
|
|
239
|
+
|
|
240
|
+
Raises ScopeParseError on CAVEMAN_COMPRESS_SCOPE_EMPTY or
|
|
241
|
+
CAVEMAN_COMPRESS_SCOPE_UNKNOWN_PROFILE.
|
|
242
|
+
"""
|
|
243
|
+
if raw is None:
|
|
244
|
+
raise ScopeParseError("CAVEMAN_COMPRESS_SCOPE_EMPTY", "scope unset")
|
|
245
|
+
trimmed = raw.strip()
|
|
246
|
+
if not trimmed or trimmed.startswith("#"):
|
|
247
|
+
raise ScopeParseError("CAVEMAN_COMPRESS_SCOPE_EMPTY", "scope empty")
|
|
248
|
+
|
|
249
|
+
profile_name: str | None = None
|
|
250
|
+
globs: list[str] = []
|
|
251
|
+
|
|
252
|
+
if ";" in trimmed or trimmed.startswith("profile:") or trimmed.startswith("globs:"):
|
|
253
|
+
# Hybrid form: profile:<name>;globs:<csv> (order-independent).
|
|
254
|
+
tokens = [t.strip() for t in trimmed.split(";") if t.strip()]
|
|
255
|
+
profile_seen = False
|
|
256
|
+
globs_seen = False
|
|
257
|
+
for tok in tokens:
|
|
258
|
+
if tok.startswith("profile:"):
|
|
259
|
+
if profile_seen:
|
|
260
|
+
raise ScopeParseError(
|
|
261
|
+
"CAVEMAN_COMPRESS_SCOPE_UNKNOWN_PROFILE",
|
|
262
|
+
"second profile: token in scope (one profile per scope)",
|
|
263
|
+
)
|
|
264
|
+
profile_seen = True
|
|
265
|
+
profile_name = tok[len("profile:"):].strip()
|
|
266
|
+
elif tok.startswith("globs:"):
|
|
267
|
+
globs_seen = True
|
|
268
|
+
for g in tok[len("globs:"):].split(","):
|
|
269
|
+
g = g.strip()
|
|
270
|
+
if g:
|
|
271
|
+
globs.append(g)
|
|
272
|
+
else:
|
|
273
|
+
# Bare token inside a hybrid string: treat as unknown grammar.
|
|
274
|
+
raise ScopeParseError(
|
|
275
|
+
"CAVEMAN_COMPRESS_SCOPE_UNKNOWN_PROFILE",
|
|
276
|
+
f"unrecognized token in hybrid scope: {tok!r}",
|
|
277
|
+
)
|
|
278
|
+
if not profile_seen and not globs_seen:
|
|
279
|
+
raise ScopeParseError(
|
|
280
|
+
"CAVEMAN_COMPRESS_SCOPE_UNKNOWN_PROFILE",
|
|
281
|
+
"hybrid scope has neither profile: nor globs:",
|
|
282
|
+
)
|
|
283
|
+
elif "," in trimmed or "/" in trimmed or "*" in trimmed or trimmed.endswith(".md"):
|
|
284
|
+
# Raw CSV globs form.
|
|
285
|
+
for g in trimmed.split(","):
|
|
286
|
+
g = g.strip()
|
|
287
|
+
if g:
|
|
288
|
+
globs.append(g)
|
|
289
|
+
else:
|
|
290
|
+
# Named profile form.
|
|
291
|
+
profile_name = trimmed
|
|
292
|
+
|
|
293
|
+
if profile_name is not None:
|
|
294
|
+
if profile_name not in FROZEN_PROFILES:
|
|
295
|
+
raise ScopeParseError(
|
|
296
|
+
"CAVEMAN_COMPRESS_SCOPE_UNKNOWN_PROFILE",
|
|
297
|
+
f"unknown profile {profile_name!r}; v1 allows: "
|
|
298
|
+
f"{sorted(FROZEN_PROFILES.keys())}",
|
|
299
|
+
)
|
|
300
|
+
globs = list(FROZEN_PROFILES[profile_name]) + globs
|
|
301
|
+
|
|
302
|
+
if not globs:
|
|
303
|
+
raise ScopeParseError(
|
|
304
|
+
"CAVEMAN_COMPRESS_SCOPE_EMPTY", "resolved scope produced zero globs"
|
|
305
|
+
)
|
|
306
|
+
return globs, profile_name
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
# Deny-list resolution (DEC-0073 §4). Deny always wins.
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
def load_gitignore_secret_patterns(repo_root: Path) -> list[str]:
|
|
314
|
+
gi = repo_root / ".gitignore"
|
|
315
|
+
patterns: list[str] = []
|
|
316
|
+
if not gi.is_file():
|
|
317
|
+
return patterns
|
|
318
|
+
for raw in gi.read_text(encoding="utf-8").splitlines():
|
|
319
|
+
line = raw.strip()
|
|
320
|
+
if not line or line.startswith("#") or line.startswith("!"):
|
|
321
|
+
continue
|
|
322
|
+
for prefix in GITIGNORE_SECRET_PREFIXES:
|
|
323
|
+
if fnmatch.fnmatchcase(line, prefix) or line.startswith(prefix.strip("*")):
|
|
324
|
+
patterns.append(line)
|
|
325
|
+
break
|
|
326
|
+
return patterns
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def load_cursorignore_overlay(repo_root: Path, enabled: bool) -> list[str]:
|
|
330
|
+
if not enabled:
|
|
331
|
+
return []
|
|
332
|
+
ci = repo_root / ".cursorignore"
|
|
333
|
+
if not ci.is_file():
|
|
334
|
+
return []
|
|
335
|
+
out: list[str] = []
|
|
336
|
+
for raw in ci.read_text(encoding="utf-8").splitlines():
|
|
337
|
+
line = raw.strip()
|
|
338
|
+
if not line or line.startswith("#") or line.startswith("!"):
|
|
339
|
+
continue
|
|
340
|
+
out.append(line)
|
|
341
|
+
return out
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def resolve_deny_patterns(repo_root: Path, ingest_cursorignore: bool) -> list[str]:
|
|
345
|
+
patterns: list[str] = list(DENY_BASELINE)
|
|
346
|
+
patterns.extend(load_gitignore_secret_patterns(repo_root))
|
|
347
|
+
patterns.extend(load_cursorignore_overlay(repo_root, ingest_cursorignore))
|
|
348
|
+
return patterns
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def file_is_denied(rel_path: str, deny_patterns: list[str], repo_root: Path) -> bool:
|
|
352
|
+
if any_glob_match(deny_patterns, rel_path):
|
|
353
|
+
return True
|
|
354
|
+
# Vendor-install leak: refuse any file that contains the banned token.
|
|
355
|
+
target = repo_root / rel_path
|
|
356
|
+
if target.is_file():
|
|
357
|
+
try:
|
|
358
|
+
head = target.read_bytes()[:65536]
|
|
359
|
+
except OSError:
|
|
360
|
+
return False
|
|
361
|
+
if VENDOR_INSTALL_LEAK_TOKEN.encode("utf-8") in head:
|
|
362
|
+
return True
|
|
363
|
+
return False
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
# Safe-mode minifier (DEC-0073 §6). Strictly idempotent by construction.
|
|
368
|
+
# Inside fenced code blocks (zone 1 of DEC-0072 §4): NO mutation of line
|
|
369
|
+
# bodies; blank line runs inside fences are NOT collapsed (fence content is
|
|
370
|
+
# literal).
|
|
371
|
+
# ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
def compress_safe_mode(text: str) -> str:
|
|
374
|
+
ends_with_newline = text.endswith("\n") or text.endswith("\r\n") or text.endswith("\r")
|
|
375
|
+
# Normalize line endings first (step 3).
|
|
376
|
+
unified = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
377
|
+
lines = unified.split("\n")
|
|
378
|
+
if unified.endswith("\n"):
|
|
379
|
+
# split produced a trailing empty element for the final newline.
|
|
380
|
+
has_eof_newline = True
|
|
381
|
+
body_lines = lines[:-1]
|
|
382
|
+
else:
|
|
383
|
+
has_eof_newline = ends_with_newline
|
|
384
|
+
body_lines = lines
|
|
385
|
+
|
|
386
|
+
out: list[str] = []
|
|
387
|
+
in_fence = False
|
|
388
|
+
fence_marker: str | None = None
|
|
389
|
+
prev_blank_outside_fence = False
|
|
390
|
+
for raw in body_lines:
|
|
391
|
+
if in_fence:
|
|
392
|
+
out.append(raw)
|
|
393
|
+
stripped = raw.lstrip()
|
|
394
|
+
if fence_marker is not None and stripped.startswith(fence_marker[0] * 3):
|
|
395
|
+
mfence = FENCE_RE.match(stripped)
|
|
396
|
+
if mfence and len(mfence.group(1)) >= len(fence_marker):
|
|
397
|
+
in_fence = False
|
|
398
|
+
fence_marker = None
|
|
399
|
+
prev_blank_outside_fence = False
|
|
400
|
+
continue
|
|
401
|
+
|
|
402
|
+
mfence = FENCE_RE.match(raw.lstrip())
|
|
403
|
+
if mfence:
|
|
404
|
+
in_fence = True
|
|
405
|
+
fence_marker = mfence.group(1)
|
|
406
|
+
out.append(raw)
|
|
407
|
+
prev_blank_outside_fence = False
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
trimmed = raw.rstrip()
|
|
411
|
+
if trimmed == "":
|
|
412
|
+
if prev_blank_outside_fence:
|
|
413
|
+
continue
|
|
414
|
+
out.append("")
|
|
415
|
+
prev_blank_outside_fence = True
|
|
416
|
+
else:
|
|
417
|
+
out.append(trimmed)
|
|
418
|
+
prev_blank_outside_fence = False
|
|
419
|
+
|
|
420
|
+
joined = "\n".join(out)
|
|
421
|
+
if has_eof_newline:
|
|
422
|
+
joined += "\n"
|
|
423
|
+
return joined
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# ---------------------------------------------------------------------------
|
|
427
|
+
# Literal-region scan (DEC-0072 §4 reused verbatim — 9 zones).
|
|
428
|
+
# Safe-mode only mutates blank-line counts and trailing whitespace on non-
|
|
429
|
+
# fenced lines. We verify:
|
|
430
|
+
# (a) fenced code block content is byte-identical between input and output
|
|
431
|
+
# (zone 1);
|
|
432
|
+
# (b) non-blank, non-fence lines of input rstripped equal non-blank, non-
|
|
433
|
+
# fence lines of output rstripped in order (zones 2–9: paths, IDs,
|
|
434
|
+
# reason codes, checklists, contract markers, strict-proof tuple
|
|
435
|
+
# fields, isolation-evidence fields, git refs are line-scoped tokens
|
|
436
|
+
# that are preserved by rstrip + blank-run collapse).
|
|
437
|
+
# ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
def _extract_line_maps(text: str) -> tuple[list[tuple[int, int, str]], list[str]]:
|
|
440
|
+
"""Return (fence_segments, non_fence_content_lines).
|
|
441
|
+
|
|
442
|
+
fence_segments: list of (start_line, end_line, body_bytes_joined_with_LF)
|
|
443
|
+
non_fence_content_lines: rstripped non-blank content lines, in order.
|
|
444
|
+
"""
|
|
445
|
+
unified = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
446
|
+
lines = unified.split("\n")
|
|
447
|
+
if unified.endswith("\n") and lines and lines[-1] == "":
|
|
448
|
+
lines = lines[:-1]
|
|
449
|
+
fences: list[tuple[int, int, str]] = []
|
|
450
|
+
content: list[str] = []
|
|
451
|
+
in_fence = False
|
|
452
|
+
fence_start = -1
|
|
453
|
+
fence_marker: str | None = None
|
|
454
|
+
fence_body: list[str] = []
|
|
455
|
+
for idx, raw in enumerate(lines):
|
|
456
|
+
mfence = FENCE_RE.match(raw.lstrip())
|
|
457
|
+
if in_fence:
|
|
458
|
+
fence_body.append(raw)
|
|
459
|
+
if (
|
|
460
|
+
mfence is not None
|
|
461
|
+
and fence_marker is not None
|
|
462
|
+
and len(mfence.group(1)) >= len(fence_marker)
|
|
463
|
+
):
|
|
464
|
+
fences.append((fence_start, idx, "\n".join(fence_body)))
|
|
465
|
+
in_fence = False
|
|
466
|
+
fence_marker = None
|
|
467
|
+
fence_body = []
|
|
468
|
+
else:
|
|
469
|
+
if mfence is not None:
|
|
470
|
+
in_fence = True
|
|
471
|
+
fence_start = idx
|
|
472
|
+
fence_marker = mfence.group(1)
|
|
473
|
+
fence_body = [raw]
|
|
474
|
+
continue
|
|
475
|
+
stripped = raw.rstrip()
|
|
476
|
+
if stripped:
|
|
477
|
+
content.append(stripped)
|
|
478
|
+
if in_fence:
|
|
479
|
+
# Unterminated fence: treat body as fence for literal preservation.
|
|
480
|
+
fences.append((fence_start, len(lines) - 1, "\n".join(fence_body)))
|
|
481
|
+
return fences, content
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def literal_region_preserved(original: str, proposed: str) -> tuple[bool, str]:
|
|
485
|
+
orig_fences, orig_content = _extract_line_maps(original)
|
|
486
|
+
new_fences, new_content = _extract_line_maps(proposed)
|
|
487
|
+
if [f[2] for f in orig_fences] != [f[2] for f in new_fences]:
|
|
488
|
+
return False, "fenced-code zone mutated"
|
|
489
|
+
if orig_content != new_content:
|
|
490
|
+
return False, "non-blank content-line tokens differ"
|
|
491
|
+
return True, ""
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# ---------------------------------------------------------------------------
|
|
495
|
+
# File discovery.
|
|
496
|
+
# ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
def enumerate_scope(
|
|
499
|
+
repo_root: Path, allow_globs: list[str], deny_patterns: list[str]
|
|
500
|
+
) -> tuple[list[str], list[str]]:
|
|
501
|
+
"""Return (allowed_rel_paths, denied_rel_paths_under_allow)."""
|
|
502
|
+
matched: set[str] = set()
|
|
503
|
+
denied_under_allow: list[str] = []
|
|
504
|
+
for pat in allow_globs:
|
|
505
|
+
# Use python's recursive glob for `**`.
|
|
506
|
+
# repo_root.glob interprets `**` when included in the pattern via `**/`.
|
|
507
|
+
for p in repo_root.glob(pat):
|
|
508
|
+
if p.is_file():
|
|
509
|
+
rel = str(p.relative_to(repo_root)).replace("\\", "/")
|
|
510
|
+
matched.add(rel)
|
|
511
|
+
allowed: list[str] = []
|
|
512
|
+
for rel in sorted(matched):
|
|
513
|
+
if file_is_denied(rel, deny_patterns, repo_root):
|
|
514
|
+
denied_under_allow.append(rel)
|
|
515
|
+
else:
|
|
516
|
+
allowed.append(rel)
|
|
517
|
+
return allowed, denied_under_allow
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
# ---------------------------------------------------------------------------
|
|
521
|
+
# Atomic write helpers.
|
|
522
|
+
# ---------------------------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
def atomic_write(target: Path, data: bytes) -> None:
|
|
525
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
526
|
+
tmp = target.with_suffix(target.suffix + ".caveman.tmp")
|
|
527
|
+
try:
|
|
528
|
+
tmp.write_bytes(data)
|
|
529
|
+
os.replace(tmp, target)
|
|
530
|
+
except Exception:
|
|
531
|
+
try:
|
|
532
|
+
if tmp.exists():
|
|
533
|
+
tmp.unlink()
|
|
534
|
+
except OSError:
|
|
535
|
+
pass
|
|
536
|
+
raise
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def sidecar_for(repo_root: Path, rel_path: str) -> Path:
|
|
540
|
+
return repo_root / SIDECAR_ROOT_REL / rel_path
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# ---------------------------------------------------------------------------
|
|
544
|
+
# Fail-closed helpers.
|
|
545
|
+
# ---------------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
def fail_closed(code: str, detail: str = "") -> int:
|
|
548
|
+
msg = f"REASON_CODE={code}"
|
|
549
|
+
if detail:
|
|
550
|
+
msg += f" detail={detail}"
|
|
551
|
+
print(msg, file=sys.stderr)
|
|
552
|
+
return 2
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
# ---------------------------------------------------------------------------
|
|
556
|
+
# Report builder.
|
|
557
|
+
# ---------------------------------------------------------------------------
|
|
558
|
+
|
|
559
|
+
def build_report(
|
|
560
|
+
*,
|
|
561
|
+
deny_version: str,
|
|
562
|
+
scope_resolved: list[str],
|
|
563
|
+
files_considered: list[str],
|
|
564
|
+
files_eligible: list[str],
|
|
565
|
+
files_would_write: list[str],
|
|
566
|
+
violations: list[dict[str, str]],
|
|
567
|
+
idempotency_check: dict[str, object],
|
|
568
|
+
) -> dict[str, object]:
|
|
569
|
+
return {
|
|
570
|
+
"deny_list_version": deny_version,
|
|
571
|
+
"scope_resolved": scope_resolved,
|
|
572
|
+
"files_considered": files_considered,
|
|
573
|
+
"files_eligible": files_eligible,
|
|
574
|
+
"files_would_write": files_would_write,
|
|
575
|
+
"violations": violations,
|
|
576
|
+
"idempotency_check": idempotency_check,
|
|
577
|
+
"reason_codes_vocabulary": {
|
|
578
|
+
family: list(codes) for family, codes in REASON_CODES_BY_FAMILY.items()
|
|
579
|
+
},
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
# ---------------------------------------------------------------------------
|
|
584
|
+
# CLI.
|
|
585
|
+
# ---------------------------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
588
|
+
p = argparse.ArgumentParser(
|
|
589
|
+
prog="caveman_compress_input.py",
|
|
590
|
+
description=(
|
|
591
|
+
"US-0090 / DEC-0073 safe-mode input-side compressor. "
|
|
592
|
+
"Default off. Opt-in via scratchpad. Deny always wins over allow. "
|
|
593
|
+
"Sidecar originals under docs/.caveman-originals/."
|
|
594
|
+
),
|
|
595
|
+
add_help=True,
|
|
596
|
+
allow_abbrev=False,
|
|
597
|
+
)
|
|
598
|
+
p.add_argument(
|
|
599
|
+
"--dry-run", action="store_true",
|
|
600
|
+
help="(default) inventory + diff summary to stdout; no mutation.",
|
|
601
|
+
)
|
|
602
|
+
p.add_argument(
|
|
603
|
+
"--write", action="store_true",
|
|
604
|
+
help="Perform sidecar + target mutation on eligible files.",
|
|
605
|
+
)
|
|
606
|
+
p.add_argument(
|
|
607
|
+
"--verify-originals", action="store_true",
|
|
608
|
+
help="Walk sidecar tree and verify every sidecar has a target (and vice versa).",
|
|
609
|
+
)
|
|
610
|
+
p.add_argument(
|
|
611
|
+
"--report", action="store_true",
|
|
612
|
+
help="Emit JSON report on stdout (incompatible with --write).",
|
|
613
|
+
)
|
|
614
|
+
p.add_argument(
|
|
615
|
+
"--repo", type=Path, default=Path(__file__).resolve().parent.parent,
|
|
616
|
+
help="Repository root (default: parent of scripts/).",
|
|
617
|
+
)
|
|
618
|
+
return p
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]:
|
|
622
|
+
parser = build_parser()
|
|
623
|
+
args, unknown = parser.parse_known_args(argv)
|
|
624
|
+
return args, unknown
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def detect_flag_conflict(
|
|
628
|
+
args: argparse.Namespace, unknown: list[str]
|
|
629
|
+
) -> str | None:
|
|
630
|
+
if unknown:
|
|
631
|
+
return f"unknown flag(s): {unknown}"
|
|
632
|
+
if args.dry_run and args.write:
|
|
633
|
+
return "--dry-run with --write"
|
|
634
|
+
if args.write and args.verify_originals:
|
|
635
|
+
return "--write with --verify-originals"
|
|
636
|
+
if args.write and args.report:
|
|
637
|
+
return "--write with --report"
|
|
638
|
+
return None
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def verify_originals(repo_root: Path, deny_patterns: list[str]) -> tuple[bool, list[dict[str, str]]]:
|
|
642
|
+
sidecar_root = repo_root / SIDECAR_ROOT_REL
|
|
643
|
+
violations: list[dict[str, str]] = []
|
|
644
|
+
if not sidecar_root.is_dir():
|
|
645
|
+
return True, violations
|
|
646
|
+
for sidecar in sidecar_root.rglob("*"):
|
|
647
|
+
if not sidecar.is_file():
|
|
648
|
+
continue
|
|
649
|
+
rel_from_root = sidecar.relative_to(sidecar_root)
|
|
650
|
+
if rel_from_root.name == ".gitkeep":
|
|
651
|
+
continue
|
|
652
|
+
rel = str(rel_from_root).replace("\\", "/")
|
|
653
|
+
target = repo_root / rel
|
|
654
|
+
if not target.is_file():
|
|
655
|
+
violations.append({
|
|
656
|
+
"code": "CAVEMAN_COMPRESS_ORIGINAL_MISSING",
|
|
657
|
+
"path": rel,
|
|
658
|
+
"stage": "verify-originals",
|
|
659
|
+
"detail": "sidecar has no target",
|
|
660
|
+
})
|
|
661
|
+
return len(violations) == 0, violations
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def run(argv: list[str]) -> int:
|
|
665
|
+
args, unknown = parse_args(argv)
|
|
666
|
+
|
|
667
|
+
conflict = detect_flag_conflict(args, unknown)
|
|
668
|
+
if conflict:
|
|
669
|
+
return fail_closed("CAVEMAN_COMPRESS_FLAG_CONFLICT", conflict)
|
|
670
|
+
|
|
671
|
+
repo_root: Path = args.repo
|
|
672
|
+
|
|
673
|
+
# Read scratchpad (for gating + CAVEMAN_COMPRESS_INGEST_CURSORIGNORE overlay flag).
|
|
674
|
+
scratchpad_path = repo_root / ".cursor" / "scratchpad.md"
|
|
675
|
+
scratchpad = read_scratchpad(scratchpad_path)
|
|
676
|
+
mode = scratchpad.get("CAVEMAN_COMPRESS_INPUT", "0").strip()
|
|
677
|
+
scope_raw = scratchpad.get("CAVEMAN_FILE_SCOPE", "").strip()
|
|
678
|
+
ingest_overlay = scratchpad.get("CAVEMAN_COMPRESS_INGEST_CURSORIGNORE", "0").strip()
|
|
679
|
+
ingest_overlay_bool = ingest_overlay == "1"
|
|
680
|
+
|
|
681
|
+
deny_patterns = resolve_deny_patterns(repo_root, ingest_overlay_bool)
|
|
682
|
+
deny_version = deny_list_version_hash(DENY_BASELINE)
|
|
683
|
+
|
|
684
|
+
# Resolve scope regardless of mode so --report and --dry-run can narrate.
|
|
685
|
+
parse_err: ScopeParseError | None = None
|
|
686
|
+
allow_globs: list[str] = []
|
|
687
|
+
profile_name: str | None = None
|
|
688
|
+
if scope_raw:
|
|
689
|
+
try:
|
|
690
|
+
allow_globs, profile_name = parse_scope(scope_raw)
|
|
691
|
+
except ScopeParseError as e:
|
|
692
|
+
parse_err = e
|
|
693
|
+
|
|
694
|
+
# --verify-originals path.
|
|
695
|
+
if args.verify_originals:
|
|
696
|
+
ok, viols = verify_originals(repo_root, deny_patterns)
|
|
697
|
+
if args.report:
|
|
698
|
+
report = build_report(
|
|
699
|
+
deny_version=deny_version,
|
|
700
|
+
scope_resolved=allow_globs,
|
|
701
|
+
files_considered=[],
|
|
702
|
+
files_eligible=[],
|
|
703
|
+
files_would_write=[],
|
|
704
|
+
violations=viols,
|
|
705
|
+
idempotency_check={"status": "skipped (verify-originals mode)"},
|
|
706
|
+
)
|
|
707
|
+
print(canonical_json(report))
|
|
708
|
+
if not ok:
|
|
709
|
+
return fail_closed("CAVEMAN_COMPRESS_ORIGINAL_MISSING",
|
|
710
|
+
f"{len(viols)} orphan(s)")
|
|
711
|
+
return 0
|
|
712
|
+
|
|
713
|
+
# Gating (applies to --dry-run + --write; --report alone may still narrate).
|
|
714
|
+
mutation_mode = args.write
|
|
715
|
+
|
|
716
|
+
if mutation_mode:
|
|
717
|
+
if mode != "1":
|
|
718
|
+
return fail_closed("CAVEMAN_COMPRESS_MODE_DISABLED",
|
|
719
|
+
"CAVEMAN_COMPRESS_INPUT != 1")
|
|
720
|
+
if parse_err is not None:
|
|
721
|
+
return fail_closed(parse_err.code, parse_err.detail)
|
|
722
|
+
if not scope_raw:
|
|
723
|
+
return fail_closed("CAVEMAN_COMPRESS_SCOPE_EMPTY",
|
|
724
|
+
"CAVEMAN_FILE_SCOPE empty")
|
|
725
|
+
|
|
726
|
+
# Non-write paths: gracefully report but do not touch files.
|
|
727
|
+
if parse_err is not None and (args.report or args.dry_run or not (
|
|
728
|
+
args.write or args.verify_originals
|
|
729
|
+
)):
|
|
730
|
+
# For pure report / pure dry-run, surface the scope issue but keep
|
|
731
|
+
# exit 0 so operators can inspect via --report.
|
|
732
|
+
if args.report:
|
|
733
|
+
report = build_report(
|
|
734
|
+
deny_version=deny_version,
|
|
735
|
+
scope_resolved=[],
|
|
736
|
+
files_considered=[],
|
|
737
|
+
files_eligible=[],
|
|
738
|
+
files_would_write=[],
|
|
739
|
+
violations=[{
|
|
740
|
+
"code": parse_err.code,
|
|
741
|
+
"path": "(scope)",
|
|
742
|
+
"stage": "pre-write",
|
|
743
|
+
"detail": parse_err.detail,
|
|
744
|
+
}],
|
|
745
|
+
idempotency_check={"status": "skipped (scope unresolved)"},
|
|
746
|
+
)
|
|
747
|
+
print(canonical_json(report))
|
|
748
|
+
return 0
|
|
749
|
+
return fail_closed(parse_err.code, parse_err.detail)
|
|
750
|
+
|
|
751
|
+
# Enumerate allowed and deny-intersection.
|
|
752
|
+
files_eligible: list[str] = []
|
|
753
|
+
files_considered: list[str] = []
|
|
754
|
+
files_would_write: list[str] = []
|
|
755
|
+
violations: list[dict[str, str]] = []
|
|
756
|
+
|
|
757
|
+
if allow_globs:
|
|
758
|
+
allowed, denied_under_allow = enumerate_scope(
|
|
759
|
+
repo_root, allow_globs, deny_patterns
|
|
760
|
+
)
|
|
761
|
+
files_considered = allowed + denied_under_allow
|
|
762
|
+
for rel in denied_under_allow:
|
|
763
|
+
violations.append({
|
|
764
|
+
"code": "CAVEMAN_COMPRESS_DENY_HIT",
|
|
765
|
+
"path": rel,
|
|
766
|
+
"stage": "pre-write",
|
|
767
|
+
"detail": "file matched allow-list but intersects deny baseline",
|
|
768
|
+
})
|
|
769
|
+
files_eligible = allowed
|
|
770
|
+
|
|
771
|
+
# Safe-mode idempotency self-check on a canonical fixture string — stable
|
|
772
|
+
# identity check for --report (AC-6 by construction).
|
|
773
|
+
fixture = "a\n\n\n\nb\n trailing \n"
|
|
774
|
+
once = compress_safe_mode(fixture)
|
|
775
|
+
twice = compress_safe_mode(once)
|
|
776
|
+
idempotency_ok = once == twice
|
|
777
|
+
idempotency_status: dict[str, object] = {
|
|
778
|
+
"status": "ok" if idempotency_ok else "drift",
|
|
779
|
+
"algorithm": "safe-mode-line-collapse-trim-lf",
|
|
780
|
+
"fixture_byte_stable": idempotency_ok,
|
|
781
|
+
}
|
|
782
|
+
if not idempotency_ok:
|
|
783
|
+
violations.append({
|
|
784
|
+
"code": "CAVEMAN_COMPRESS_NOT_IDEMPOTENT",
|
|
785
|
+
"path": "(self-check)",
|
|
786
|
+
"stage": "during-write",
|
|
787
|
+
"detail": "compress(compress(fixture)) != compress(fixture)",
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
# --write path: actual mutation under atomic sidecar-first order.
|
|
791
|
+
if mutation_mode:
|
|
792
|
+
for rel in files_eligible:
|
|
793
|
+
target = repo_root / rel
|
|
794
|
+
try:
|
|
795
|
+
original_bytes = target.read_bytes()
|
|
796
|
+
except OSError as exc:
|
|
797
|
+
violations.append({
|
|
798
|
+
"code": "CAVEMAN_COMPRESS_DENY_HIT",
|
|
799
|
+
"path": rel,
|
|
800
|
+
"stage": "pre-write",
|
|
801
|
+
"detail": f"unreadable: {exc}",
|
|
802
|
+
})
|
|
803
|
+
continue
|
|
804
|
+
try:
|
|
805
|
+
original_text = original_bytes.decode("utf-8")
|
|
806
|
+
except UnicodeDecodeError:
|
|
807
|
+
violations.append({
|
|
808
|
+
"code": "CAVEMAN_COMPRESS_DENY_HIT",
|
|
809
|
+
"path": rel,
|
|
810
|
+
"stage": "pre-write",
|
|
811
|
+
"detail": "non-utf8 bytes (binary refuse)",
|
|
812
|
+
})
|
|
813
|
+
continue
|
|
814
|
+
proposed = compress_safe_mode(original_text)
|
|
815
|
+
|
|
816
|
+
ok, why = literal_region_preserved(original_text, proposed)
|
|
817
|
+
if not ok:
|
|
818
|
+
violations.append({
|
|
819
|
+
"code": "CAVEMAN_COMPRESS_LITERAL_REGION_DAMAGED",
|
|
820
|
+
"path": rel,
|
|
821
|
+
"stage": "during-write",
|
|
822
|
+
"detail": why,
|
|
823
|
+
})
|
|
824
|
+
continue
|
|
825
|
+
|
|
826
|
+
# Idempotency re-check on real file.
|
|
827
|
+
if compress_safe_mode(proposed) != proposed:
|
|
828
|
+
violations.append({
|
|
829
|
+
"code": "CAVEMAN_COMPRESS_NOT_IDEMPOTENT",
|
|
830
|
+
"path": rel,
|
|
831
|
+
"stage": "during-write",
|
|
832
|
+
"detail": "second compression differs",
|
|
833
|
+
})
|
|
834
|
+
continue
|
|
835
|
+
|
|
836
|
+
if proposed == original_text:
|
|
837
|
+
files_would_write.append(rel)
|
|
838
|
+
continue # no mutation required; skip sidecar churn
|
|
839
|
+
|
|
840
|
+
# Sidecar first, then target (atomic temp+replace on both).
|
|
841
|
+
sidecar = sidecar_for(repo_root, rel)
|
|
842
|
+
try:
|
|
843
|
+
atomic_write(sidecar, original_bytes)
|
|
844
|
+
atomic_write(target, proposed.encode("utf-8"))
|
|
845
|
+
except OSError as exc:
|
|
846
|
+
violations.append({
|
|
847
|
+
"code": "CAVEMAN_COMPRESS_DENY_HIT",
|
|
848
|
+
"path": rel,
|
|
849
|
+
"stage": "pre-write",
|
|
850
|
+
"detail": f"atomic write failed: {exc}",
|
|
851
|
+
})
|
|
852
|
+
continue
|
|
853
|
+
files_would_write.append(rel)
|
|
854
|
+
else:
|
|
855
|
+
# Dry-run: compute what would write, do not mutate.
|
|
856
|
+
for rel in files_eligible:
|
|
857
|
+
target = repo_root / rel
|
|
858
|
+
try:
|
|
859
|
+
original_text = target.read_text(encoding="utf-8")
|
|
860
|
+
except (OSError, UnicodeDecodeError):
|
|
861
|
+
continue
|
|
862
|
+
proposed = compress_safe_mode(original_text)
|
|
863
|
+
if proposed != original_text:
|
|
864
|
+
files_would_write.append(rel)
|
|
865
|
+
|
|
866
|
+
report = build_report(
|
|
867
|
+
deny_version=deny_version,
|
|
868
|
+
scope_resolved=allow_globs,
|
|
869
|
+
files_considered=files_considered,
|
|
870
|
+
files_eligible=files_eligible,
|
|
871
|
+
files_would_write=files_would_write,
|
|
872
|
+
violations=violations,
|
|
873
|
+
idempotency_check=idempotency_status,
|
|
874
|
+
)
|
|
875
|
+
if args.report:
|
|
876
|
+
print(canonical_json(report))
|
|
877
|
+
|
|
878
|
+
if violations:
|
|
879
|
+
first = violations[0]
|
|
880
|
+
return fail_closed(first["code"], first.get("detail", ""))
|
|
881
|
+
|
|
882
|
+
if not args.report:
|
|
883
|
+
# Default human-readable dry-run narration (one line per file).
|
|
884
|
+
if files_would_write:
|
|
885
|
+
print(f"caveman-compress dry-run: {len(files_would_write)} file(s) "
|
|
886
|
+
f"would be rewritten")
|
|
887
|
+
for rel in files_would_write:
|
|
888
|
+
print(f" - {rel}")
|
|
889
|
+
else:
|
|
890
|
+
print("caveman-compress dry-run: no changes")
|
|
891
|
+
print(f"deny_list_version={deny_version}")
|
|
892
|
+
if profile_name:
|
|
893
|
+
print(f"profile={profile_name}")
|
|
894
|
+
|
|
895
|
+
return 0
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def main() -> int:
|
|
899
|
+
return run(sys.argv[1:])
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
if __name__ == "__main__":
|
|
903
|
+
sys.exit(main())
|