its-magic 0.1.2-9 → 0.1.2

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.
Files changed (232) hide show
  1. package/README.md +1602 -755
  2. package/bin/its-magic.js +121 -11
  3. package/bin/postinstall.js +21 -0
  4. package/installer.ps1 +759 -0
  5. package/installer.py +981 -0
  6. package/installer.sh +649 -0
  7. package/package.json +18 -14
  8. package/scripts/check_intake_template_parity.py +284 -0
  9. package/scripts/doc_profile_lib.py +415 -0
  10. package/scripts/guard_installer_publish.py +88 -0
  11. package/scripts/intake_bug_routing_guard.py +67 -0
  12. package/scripts/intake_evidence_lib.py +802 -0
  13. package/scripts/intake_evidence_validate.py +73 -0
  14. package/scripts/materialize_codebase_map.py +184 -0
  15. package/scripts/remote_config_summary.py +243 -0
  16. package/template/.cursor/agents/curator.mdc +35 -0
  17. package/template/.cursor/agents/dev.mdc +29 -0
  18. package/template/.cursor/agents/po.mdc +141 -0
  19. package/template/.cursor/agents/qa.mdc +28 -0
  20. package/template/.cursor/agents/release.mdc +28 -0
  21. package/template/.cursor/agents/security.mdc +98 -0
  22. package/template/.cursor/agents/tech-lead.mdc +57 -0
  23. package/template/.cursor/commands/architecture.md +127 -0
  24. package/template/.cursor/commands/ask.md +55 -0
  25. package/template/.cursor/commands/auto.md +533 -0
  26. package/template/.cursor/commands/discovery.md +47 -0
  27. package/template/.cursor/commands/execute.md +343 -0
  28. package/template/.cursor/commands/intake.md +323 -0
  29. package/template/.cursor/commands/map-codebase.md +46 -0
  30. package/template/.cursor/commands/memory-audit.md +175 -0
  31. package/template/.cursor/commands/milestone-complete.md +51 -0
  32. package/template/.cursor/commands/milestone-start.md +59 -0
  33. package/template/.cursor/commands/pause.md +64 -0
  34. package/{.cursor/commands/gsd-phase-context.md → template/.cursor/commands/phase-context.md} +6 -2
  35. package/template/.cursor/commands/plan-verify.md +34 -0
  36. package/template/.cursor/commands/qa.md +226 -0
  37. package/template/.cursor/commands/quick.md +41 -0
  38. package/template/.cursor/commands/refresh-context.md +82 -0
  39. package/template/.cursor/commands/release.md +505 -0
  40. package/template/.cursor/commands/research.md +49 -0
  41. package/template/.cursor/commands/resume.md +67 -0
  42. package/template/.cursor/commands/security-review.md +81 -0
  43. package/template/.cursor/commands/sprint-plan.md +103 -0
  44. package/template/.cursor/commands/status-reconcile.md +95 -0
  45. package/template/.cursor/commands/verify-work.md +152 -0
  46. package/template/.cursor/dev-environment.json.example +22 -0
  47. package/{.cursor → template/.cursor}/hooks/README.md +3 -2
  48. package/{.cursor/hooks/gsd-hook.py → template/.cursor/hooks/hook.py} +15 -6
  49. package/template/.cursor/hooks.json +26 -0
  50. package/template/.cursor/remote.json +31 -0
  51. package/template/.cursor/rules/caveman.mdc +184 -0
  52. package/template/.cursor/rules/coding-standards.mdc +39 -0
  53. package/template/.cursor/rules/core.mdc +111 -0
  54. package/template/.cursor/rules/handoffs.mdc +27 -0
  55. package/template/.cursor/rules/quality.mdc +49 -0
  56. package/template/.cursor/scratchpad.local.example.md +323 -0
  57. package/template/.cursor/scratchpad.md +324 -0
  58. package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/SKILL.md +2 -2
  59. package/template/.cursorignore +6 -0
  60. package/template/.env.example +28 -0
  61. package/template/.github/workflows/ci.yml +194 -0
  62. package/{.github → template/.github}/workflows/deploy.yml +5 -2
  63. package/template/.its-magic-version +1 -0
  64. package/template/README.md +1602 -0
  65. package/template/decisions/DEC-0001.md +11 -0
  66. package/template/decisions/DEC-0002.md +11 -0
  67. package/template/docs/developer/README.md +44 -0
  68. package/template/docs/engineering/architecture.md +9 -0
  69. package/template/docs/engineering/artifact-ordering-policy.md +48 -0
  70. package/template/docs/engineering/artifact-ownership-policy.md +57 -0
  71. package/template/docs/engineering/auto-orchestration-reference.md +1211 -0
  72. package/{docs → template/docs}/engineering/codebase-map.md +0 -5
  73. package/template/docs/engineering/compatibility-report.md +20 -0
  74. package/template/docs/engineering/compatibility-signals.md +7 -0
  75. package/template/docs/engineering/component-scope-report.md +16 -0
  76. package/template/docs/engineering/component-scope.md +19 -0
  77. package/template/docs/engineering/context/installer-owned-paths.manifest +96 -0
  78. package/template/docs/engineering/context/readme-section-affinity.json +30 -0
  79. package/template/docs/engineering/decisions.md +16 -0
  80. package/template/docs/engineering/legacy-drift-audit.md +21 -0
  81. package/template/docs/engineering/manifests/components/api-gateway.manifest.yaml +12 -0
  82. package/template/docs/engineering/manifests/registry.manifest.yaml +20 -0
  83. package/template/docs/engineering/manifests/repo.manifest.yaml +11 -0
  84. package/template/docs/engineering/phase-context.md +16 -0
  85. package/template/docs/engineering/release-targets.json +123 -0
  86. package/template/docs/engineering/research.md +29 -0
  87. package/template/docs/engineering/runbook.md +2320 -0
  88. package/template/docs/engineering/runtime-connectivity.md +81 -0
  89. package/template/docs/engineering/security-review.md +33 -0
  90. package/template/docs/engineering/spec-pack/README.md +20 -0
  91. package/template/docs/engineering/state-archive/README.md +15 -0
  92. package/template/docs/engineering/state.md +20 -0
  93. package/template/docs/engineering/status-normalization-report.md +19 -0
  94. package/template/docs/engineering/token-cost-parity-manifest.md +16 -0
  95. package/template/docs/engineering/us-0084-remote-e2e.md +44 -0
  96. package/template/docs/product/acceptance.md +1 -0
  97. package/template/docs/product/backlog.md +5 -0
  98. package/template/docs/product/vision.md +11 -0
  99. package/template/docs/user-guides/README.md +40 -0
  100. package/{handoffs → template/handoffs}/dev_to_qa.md +0 -3
  101. package/{handoffs → template/handoffs}/po_to_tl.md +0 -3
  102. package/{handoffs → template/handoffs}/qa_to_dev.md +1 -2
  103. package/template/handoffs/release_notes.md +51 -0
  104. package/template/handoffs/release_queue.md +47 -0
  105. package/template/handoffs/release_to_dev.md +31 -0
  106. package/template/handoffs/releases/Sxxxx-release-notes.md +62 -0
  107. package/template/handoffs/resume_brief.md +14 -0
  108. package/{handoffs → template/handoffs}/tl_to_dev.md +0 -2
  109. package/template/handoffs/token_cost_runs/README.md +26 -0
  110. package/template/its_magic/.its-magic-version +1 -0
  111. package/template/its_magic/README.md +9 -0
  112. package/template/scripts/auto_outer_driver.py +521 -0
  113. package/template/scripts/caveman_compress_input.py +903 -0
  114. package/template/scripts/check_downstream_ci_guard.py +67 -0
  115. package/template/scripts/check_intake_template_parity.py +284 -0
  116. package/template/scripts/check_token_cost_parity.py +69 -0
  117. package/template/scripts/dev_environment_lib.py +463 -0
  118. package/template/scripts/doc_profile_lib.py +415 -0
  119. package/template/scripts/downstream_ci_guard_lib.py +222 -0
  120. package/template/scripts/enforce-triad-hot-surface.py +753 -0
  121. package/template/scripts/guard_installer_publish.py +88 -0
  122. package/template/scripts/intake_bug_resume_brief_refresh.py +303 -0
  123. package/template/scripts/intake_bug_routing_guard.py +67 -0
  124. package/template/scripts/intake_evidence_lib.py +802 -0
  125. package/template/scripts/intake_evidence_validate.py +73 -0
  126. package/template/scripts/materialize_codebase_map.py +184 -0
  127. package/template/scripts/pack_json_validate.py +130 -0
  128. package/template/scripts/project_readme_coverage_lib.py +417 -0
  129. package/template/scripts/readme_feature_coverage_lib.py +608 -0
  130. package/template/scripts/remote_config_summary.py +243 -0
  131. package/template/scripts/sync_push_gates.py +198 -0
  132. package/template/scripts/token_cost_compare.py +40 -0
  133. package/template/scripts/token_cost_lib.py +108 -0
  134. package/template/scripts/uat_probe_lib.py +868 -0
  135. package/template/scripts/validate-and-push.ps1 +280 -0
  136. package/template/scripts/validate-and-push.sh +243 -0
  137. package/template/scripts/validate_doc_profile.py +103 -0
  138. package/template/scripts/validate_project_readme_coverage.py +151 -0
  139. package/template/scripts/validate_readme_feature_coverage.py +140 -0
  140. package/template/sprints/S0001/progress.md +1 -0
  141. package/template/sprints/S0001/qa-findings.md +9 -0
  142. package/template/sprints/S0001/release-findings.md +24 -0
  143. package/template/sprints/S0001/sprint.md +9 -0
  144. package/template/sprints/S0001/summary.md +7 -0
  145. package/template/sprints/S0001/tasks.md +1 -0
  146. package/template/sprints/S0001/uat.json +6 -0
  147. package/template/sprints/S0001/uat.md +5 -0
  148. package/template/sprints/quick/Q0001/summary.md +1 -0
  149. package/{sprints → template/sprints}/quick/Q0001/task.json +0 -1
  150. package/.cursor/agents/curator.mdc +0 -21
  151. package/.cursor/agents/dev.mdc +0 -20
  152. package/.cursor/agents/po.mdc +0 -19
  153. package/.cursor/agents/qa.mdc +0 -19
  154. package/.cursor/agents/release.mdc +0 -19
  155. package/.cursor/agents/tech-lead.mdc +0 -21
  156. package/.cursor/commands/gsd-architecture.md +0 -29
  157. package/.cursor/commands/gsd-auto.md +0 -27
  158. package/.cursor/commands/gsd-discovery.md +0 -27
  159. package/.cursor/commands/gsd-execute.md +0 -32
  160. package/.cursor/commands/gsd-intake.md +0 -28
  161. package/.cursor/commands/gsd-map-codebase.md +0 -25
  162. package/.cursor/commands/gsd-milestone-complete.md +0 -24
  163. package/.cursor/commands/gsd-milestone-start.md +0 -26
  164. package/.cursor/commands/gsd-pause.md +0 -25
  165. package/.cursor/commands/gsd-plan-verify.md +0 -26
  166. package/.cursor/commands/gsd-qa.md +0 -28
  167. package/.cursor/commands/gsd-quick.md +0 -24
  168. package/.cursor/commands/gsd-refresh-context.md +0 -26
  169. package/.cursor/commands/gsd-release.md +0 -29
  170. package/.cursor/commands/gsd-research.md +0 -28
  171. package/.cursor/commands/gsd-resume.md +0 -26
  172. package/.cursor/commands/gsd-sprint-plan.md +0 -30
  173. package/.cursor/commands/gsd-verify-work.md +0 -25
  174. package/.cursor/hooks.json +0 -26
  175. package/.cursor/plans/cursor-gsd-team-kit_8cfee9b8.plan.md +0 -57
  176. package/.cursor/remote.json +0 -18
  177. package/.cursor/rules/gsd-core.mdc +0 -18
  178. package/.cursor/rules/gsd-handoffs.mdc +0 -10
  179. package/.cursor/rules/gsd-quality.mdc +0 -15
  180. package/.cursor/scratchpad.md +0 -34
  181. package/.github/workflows/ci.yml +0 -47
  182. package/decisions/DEC-0001.md +0 -21
  183. package/decisions/DEC-0002.md +0 -21
  184. package/docs/engineering/architecture.md +0 -354
  185. package/docs/engineering/decisions.md +0 -6
  186. package/docs/engineering/research.md +0 -11
  187. package/docs/engineering/runbook.md +0 -32
  188. package/docs/engineering/state.md +0 -33
  189. package/docs/product/acceptance.md +0 -6
  190. package/docs/product/backlog.md +0 -7
  191. package/docs/product/vision.md +0 -46
  192. package/gsd-installer.ps1 +0 -189
  193. package/gsd-installer.py +0 -195
  194. package/gsd-installer.sh +0 -201
  195. package/handoffs/release_notes.md +0 -14
  196. package/handoffs/resume_brief.md +0 -8
  197. package/milestones/M0001/milestone.json +0 -7
  198. package/milestones/M0001/phases.json +0 -9
  199. package/milestones/M0001/progress.md +0 -3
  200. package/milestones/M0001/summary.md +0 -3
  201. package/scripts/generate-release-notes.ps1 +0 -74
  202. package/scripts/generate-release-notes.sh +0 -63
  203. package/scripts/release-all.ps1 +0 -423
  204. package/scripts/release-all.sh +0 -226
  205. package/sprints/S0001/progress.md +0 -4
  206. package/sprints/S0001/qa-findings.md +0 -113
  207. package/sprints/S0001/sprint.md +0 -70
  208. package/sprints/S0001/summary.md +0 -46
  209. package/sprints/S0001/tasks.md +0 -35
  210. package/sprints/S0001/uat.json +0 -8
  211. package/sprints/S0001/uat.md +0 -8
  212. package/sprints/quick/Q0001/summary.md +0 -3
  213. /package/{.cursor/rules/gsd-escalation.mdc → template/.cursor/rules/escalation.mdc} +0 -0
  214. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/acceptance.json +0 -0
  215. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/acceptance.md +0 -0
  216. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/architecture.json +0 -0
  217. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/architecture.md +0 -0
  218. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/decision.json +0 -0
  219. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/decision.md +0 -0
  220. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/handoff.json +0 -0
  221. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/handoff.md +0 -0
  222. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/milestone.json +0 -0
  223. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/phase-context.json +0 -0
  224. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/plan-verify.json +0 -0
  225. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/sprint.json +0 -0
  226. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/sprint.md +0 -0
  227. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/story.json +0 -0
  228. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/story.md +0 -0
  229. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/uat.json +0 -0
  230. /package/{docs → template/docs}/engineering/context/phase-template.json +0 -0
  231. /package/{docs → template/docs}/engineering/dependencies.json +0 -0
  232. /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())