@abarcenas/ai-workflow-template 1.2.0 → 1.13.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.
Files changed (77) hide show
  1. package/.agents/instructions/learned-knowledge.instructions.md +46 -0
  2. package/{.github → .agents}/skills/architecture-blueprint-generator/SKILL.md +57 -1
  3. package/.agents/skills/cavecrew/SKILL.md +82 -0
  4. package/.agents/skills/caveman/SKILL.md +74 -0
  5. package/.agents/skills/caveman-commit/SKILL.md +65 -0
  6. package/.agents/skills/caveman-compress/README.md +163 -0
  7. package/.agents/skills/caveman-compress/SECURITY.md +31 -0
  8. package/.agents/skills/caveman-compress/SKILL.md +111 -0
  9. package/.agents/skills/caveman-compress/scripts/__init__.py +9 -0
  10. package/.agents/skills/caveman-compress/scripts/__main__.py +3 -0
  11. package/.agents/skills/caveman-compress/scripts/benchmark.py +78 -0
  12. package/.agents/skills/caveman-compress/scripts/cli.py +85 -0
  13. package/.agents/skills/caveman-compress/scripts/compress.py +254 -0
  14. package/.agents/skills/caveman-compress/scripts/detect.py +121 -0
  15. package/.agents/skills/caveman-compress/scripts/validate.py +213 -0
  16. package/.agents/skills/caveman-help/SKILL.md +59 -0
  17. package/.agents/skills/caveman-review/SKILL.md +55 -0
  18. package/.agents/skills/caveman-stats/SKILL.md +10 -0
  19. package/.agents/skills/compress/SKILL.md +111 -0
  20. package/.agents/skills/compress/scripts/__init__.py +9 -0
  21. package/.agents/skills/compress/scripts/__main__.py +3 -0
  22. package/.agents/skills/compress/scripts/benchmark.py +78 -0
  23. package/.agents/skills/compress/scripts/cli.py +85 -0
  24. package/.agents/skills/compress/scripts/compress.py +254 -0
  25. package/.agents/skills/compress/scripts/detect.py +121 -0
  26. package/.agents/skills/compress/scripts/validate.py +213 -0
  27. package/AGENTS.md +149 -0
  28. package/README.md +197 -60
  29. package/opencode.mcp.example.json +54 -0
  30. package/package.json +13 -5
  31. package/scripts/mcp/playwright-mcp-launcher.js +46 -0
  32. package/scripts/sync.js +50 -12
  33. package/.github/agents/implementation-plan.agent.md +0 -192
  34. package/.github/agents/plan.agent.md +0 -135
  35. package/.github/agents/research-spike.agent.md +0 -204
  36. package/.github/prompts/copilot-instructions-blueprint-generator.prompt.md +0 -317
  37. /package/{.github → .agents}/instructions/agent-safety.instructions.md +0 -0
  38. /package/{.github → .agents}/instructions/agent-skill.instructions.md +0 -0
  39. /package/{.github → .agents}/instructions/agent.instructions.md +0 -0
  40. /package/{.github → .agents}/instructions/ai-prompt-engineering-safety.instructions.md +0 -0
  41. /package/{.github → .agents}/instructions/angular.instructions.md +0 -0
  42. /package/{.github → .agents}/instructions/context7.instructions.md +0 -0
  43. /package/{.github → .agents}/instructions/go.instructions.md +0 -0
  44. /package/{.github → .agents}/instructions/html-to-css-guide.instructions.md +0 -0
  45. /package/{.github → .agents}/instructions/instructions.instructions.md +0 -0
  46. /package/{.github → .agents}/instructions/markdown.instructions.md +0 -0
  47. /package/{.github → .agents}/instructions/memory-bank.instructions.md +0 -0
  48. /package/{.github → .agents}/instructions/nodejs-vitest.instructions.md +0 -0
  49. /package/{.github → .agents}/instructions/playwright-typescript.instructions.md +0 -0
  50. /package/{.github → .agents}/instructions/prompt.instructions.md +0 -0
  51. /package/{.github → .agents}/instructions/security-owasp.instructions.md +0 -0
  52. /package/{.github → .agents}/instructions/shell.instructions.md +0 -0
  53. /package/{.github → .agents}/instructions/task-implementation.instructions.md +0 -0
  54. /package/{.github → .agents}/instructions/typescript.instructions.md +0 -0
  55. /package/{.github → .agents}/prompts/documentation-writer.prompt.md +0 -0
  56. /package/{.github → .agents}/prompts/editconfig.prompt.md +0 -0
  57. /package/{.github → .agents}/prompts/playwright-explore.prompt.md +0 -0
  58. /package/{.github → .agents}/prompts/playwright-generate-test.prompt.md +0 -0
  59. /package/{.github → .agents}/skills/agent-governance/SKILL.md +0 -0
  60. /package/{.github → .agents}/skills/boost-prompt/SKILL.md +0 -0
  61. /package/{.github → .agents}/skills/chrome-devtools/SKILL.md +0 -0
  62. /package/{.github → .agents}/skills/context-map/SKILL.md +0 -0
  63. /package/{.github → .agents}/skills/conventional-commit/SKILL.md +0 -0
  64. /package/{.github → .agents}/skills/create-implementation-plan/SKILL.md +0 -0
  65. /package/{.github → .agents}/skills/create-readme/SKILL.md +0 -0
  66. /package/{.github → .agents}/skills/create-specification/SKILL.md +0 -0
  67. /package/{.github → .agents}/skills/editorconfig/SKILL.md +0 -0
  68. /package/{.github → .agents}/skills/gh-cli/SKILL.md +0 -0
  69. /package/{.github → .agents}/skills/git-commit/SKILL.md +0 -0
  70. /package/{.github → .agents}/skills/github-issues/SKILL.md +0 -0
  71. /package/{.github → .agents}/skills/github-issues/references/templates.md +0 -0
  72. /package/{.github → .agents}/skills/microsoft-docs/SKILL.md +0 -0
  73. /package/{.github → .agents}/skills/playwright-explore-website/SKILL.md +0 -0
  74. /package/{.github → .agents}/skills/playwright-generate-test/SKILL.md +0 -0
  75. /package/{.github → .agents}/skills/polygot-test-agent/SKILL.md +0 -0
  76. /package/{.github → .agents}/skills/polygot-test-agent/unit-test-genearation.prompt.md +0 -0
  77. /package/{.github → .agents}/skills/prompt-builder/SKILL.md +0 -0
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Caveman Memory Compression Orchestrator
4
+
5
+ Usage:
6
+ python scripts/compress.py <filepath>
7
+ """
8
+
9
+ import os
10
+ import re
11
+ import subprocess
12
+ from pathlib import Path
13
+ from typing import List
14
+
15
+ OUTER_FENCE_REGEX = re.compile(
16
+ r"\A\s*(`{3,}|~{3,})[^\n]*\n(.*)\n\1\s*\Z", re.DOTALL
17
+ )
18
+
19
+ # Filenames and paths that almost certainly hold secrets or PII. Compressing
20
+ # them ships raw bytes to the Anthropic API — a third-party data boundary that
21
+ # developers on sensitive codebases cannot cross. detect.py already skips .env
22
+ # by extension, but credentials.md / secrets.txt / ~/.aws/credentials would
23
+ # slip through the natural-language filter. This is a hard refuse before read.
24
+ SENSITIVE_BASENAME_REGEX = re.compile(
25
+ r"(?ix)^("
26
+ r"\.env(\..+)?"
27
+ r"|\.netrc"
28
+ r"|credentials(\..+)?"
29
+ r"|secrets?(\..+)?"
30
+ r"|passwords?(\..+)?"
31
+ r"|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?"
32
+ r"|authorized_keys"
33
+ r"|known_hosts"
34
+ r"|.*\.(pem|key|p12|pfx|crt|cer|jks|keystore|asc|gpg)"
35
+ r")$"
36
+ )
37
+
38
+ SENSITIVE_PATH_COMPONENTS = frozenset({".ssh", ".aws", ".gnupg", ".kube", ".docker"})
39
+
40
+ SENSITIVE_NAME_TOKENS = (
41
+ "secret", "credential", "password", "passwd",
42
+ "apikey", "accesskey", "token", "privatekey",
43
+ )
44
+
45
+
46
+ def is_sensitive_path(filepath: Path) -> bool:
47
+ """Heuristic denylist for files that must never be shipped to a third-party API."""
48
+ name = filepath.name
49
+ if SENSITIVE_BASENAME_REGEX.match(name):
50
+ return True
51
+ lowered_parts = {p.lower() for p in filepath.parts}
52
+ if lowered_parts & SENSITIVE_PATH_COMPONENTS:
53
+ return True
54
+ # Normalize separators so "api-key" and "api_key" both match "apikey".
55
+ lower = re.sub(r"[_\-\s.]", "", name.lower())
56
+ return any(tok in lower for tok in SENSITIVE_NAME_TOKENS)
57
+
58
+
59
+ def strip_llm_wrapper(text: str) -> str:
60
+ """Strip outer ```markdown ... ``` fence when it wraps the entire output."""
61
+ m = OUTER_FENCE_REGEX.match(text)
62
+ if m:
63
+ return m.group(2)
64
+ return text
65
+
66
+ from .detect import should_compress
67
+ from .validate import validate
68
+
69
+ MAX_RETRIES = 2
70
+
71
+
72
+ # ---------- Claude Calls ----------
73
+
74
+
75
+ def call_claude(prompt: str) -> str:
76
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
77
+ if api_key:
78
+ try:
79
+ import anthropic
80
+
81
+ client = anthropic.Anthropic(api_key=api_key)
82
+ msg = client.messages.create(
83
+ model=os.environ.get("CAVEMAN_MODEL", "claude-sonnet-4-5"),
84
+ max_tokens=8192,
85
+ messages=[{"role": "user", "content": prompt}],
86
+ )
87
+ return strip_llm_wrapper(msg.content[0].text.strip())
88
+ except ImportError:
89
+ pass # anthropic not installed, fall back to CLI
90
+ # Fallback: use claude CLI (handles desktop auth)
91
+ try:
92
+ result = subprocess.run(
93
+ ["claude", "--print"],
94
+ input=prompt,
95
+ text=True,
96
+ capture_output=True,
97
+ check=True,
98
+ )
99
+ return strip_llm_wrapper(result.stdout.strip())
100
+ except subprocess.CalledProcessError as e:
101
+ raise RuntimeError(f"Claude call failed:\n{e.stderr}")
102
+
103
+
104
+ def build_compress_prompt(original: str) -> str:
105
+ return f"""
106
+ Compress this markdown into caveman format.
107
+
108
+ STRICT RULES:
109
+ - Do NOT modify anything inside ``` code blocks
110
+ - Do NOT modify anything inside inline backticks
111
+ - Preserve ALL URLs exactly
112
+ - Preserve ALL headings exactly
113
+ - Preserve file paths and commands
114
+ - Return ONLY the compressed markdown body — do NOT wrap the entire output in a ```markdown fence or any other fence. Inner code blocks from the original stay as-is; do not add a new outer fence around the whole file.
115
+
116
+ Only compress natural language.
117
+
118
+ TEXT:
119
+ {original}
120
+ """
121
+
122
+
123
+ def build_fix_prompt(original: str, compressed: str, errors: List[str]) -> str:
124
+ errors_str = "\n".join(f"- {e}" for e in errors)
125
+ return f"""You are fixing a caveman-compressed markdown file. Specific validation errors were found.
126
+
127
+ CRITICAL RULES:
128
+ - DO NOT recompress or rephrase the file
129
+ - ONLY fix the listed errors — leave everything else exactly as-is
130
+ - The ORIGINAL is provided as reference only (to restore missing content)
131
+ - Preserve caveman style in all untouched sections
132
+
133
+ ERRORS TO FIX:
134
+ {errors_str}
135
+
136
+ HOW TO FIX:
137
+ - Missing URL: find it in ORIGINAL, restore it exactly where it belongs in COMPRESSED
138
+ - Code block mismatch: find the exact code block in ORIGINAL, restore it in COMPRESSED
139
+ - Heading mismatch: restore the exact heading text from ORIGINAL into COMPRESSED
140
+ - Do not touch any section not mentioned in the errors
141
+
142
+ ORIGINAL (reference only):
143
+ {original}
144
+
145
+ COMPRESSED (fix this):
146
+ {compressed}
147
+
148
+ Return ONLY the fixed compressed file. No explanation.
149
+ """
150
+
151
+
152
+ # ---------- Core Logic ----------
153
+
154
+
155
+ def compress_file(filepath: Path) -> bool:
156
+ # Resolve and validate path
157
+ filepath = filepath.resolve()
158
+ MAX_FILE_SIZE = 500_000 # 500KB
159
+ if not filepath.exists():
160
+ raise FileNotFoundError(f"File not found: {filepath}")
161
+ if filepath.stat().st_size > MAX_FILE_SIZE:
162
+ raise ValueError(f"File too large to compress safely (max 500KB): {filepath}")
163
+
164
+ # Refuse files that look like they contain secrets or PII. Compressing ships
165
+ # the raw bytes to the Anthropic API — a third-party boundary — so we fail
166
+ # loudly rather than silently exfiltrate credentials or keys. Override is
167
+ # intentional: the user must rename the file if the heuristic is wrong.
168
+ if is_sensitive_path(filepath):
169
+ raise ValueError(
170
+ f"Refusing to compress {filepath}: filename looks sensitive "
171
+ "(credentials, keys, secrets, or known private paths). "
172
+ "Compression sends file contents to the Anthropic API. "
173
+ "Rename the file if this is a false positive."
174
+ )
175
+
176
+ print(f"Processing: {filepath}")
177
+
178
+ if not should_compress(filepath):
179
+ print("Skipping (not natural language)")
180
+ return False
181
+
182
+ original_text = filepath.read_text(errors="ignore")
183
+ backup_path = filepath.with_name(filepath.stem + ".original.md")
184
+
185
+ if not original_text.strip():
186
+ print("❌ Refusing to compress: file is empty or whitespace-only.")
187
+ return False
188
+
189
+ # Check if backup already exists to prevent accidental overwriting
190
+ if backup_path.exists():
191
+ print(f"⚠️ Backup file already exists: {backup_path}")
192
+ print("The original backup may contain important content.")
193
+ print("Aborting to prevent data loss. Please remove or rename the backup file if you want to proceed.")
194
+ return False
195
+
196
+ # Step 1: Compress
197
+ print("Compressing with Claude...")
198
+ compressed = call_claude(build_compress_prompt(original_text))
199
+
200
+ if compressed is None or not compressed.strip():
201
+ print("❌ Compression aborted: Claude returned an empty response.")
202
+ print(" Original file is untouched (no backup created).")
203
+ return False
204
+
205
+ if compressed.strip() == original_text.strip():
206
+ print("❌ Compression aborted: output is identical to input.")
207
+ print(" Likely causes: Claude refused, returned the prompt verbatim, or the file is")
208
+ print(" already in caveman form. Original file is untouched (no backup created).")
209
+ return False
210
+
211
+ # Save original as backup, then verify the backup readback before
212
+ # touching the input file. If the filesystem dropped bytes (encoding,
213
+ # antivirus, disk full), unlink the bad backup and abort instead of
214
+ # leaving the user with a corrupt backup + compressed primary.
215
+ backup_path.write_text(original_text)
216
+ backup_readback = backup_path.read_text(errors="ignore")
217
+ if backup_readback != original_text:
218
+ print(f"❌ Backup write verification failed: {backup_path}")
219
+ print(" In-memory original differs from on-disk backup. Aborting before touching the input file.")
220
+ try:
221
+ backup_path.unlink()
222
+ except OSError:
223
+ pass
224
+ return False
225
+ filepath.write_text(compressed)
226
+
227
+ # Step 2: Validate + Retry
228
+ for attempt in range(MAX_RETRIES):
229
+ print(f"\nValidation attempt {attempt + 1}")
230
+
231
+ result = validate(backup_path, filepath)
232
+
233
+ if result.is_valid:
234
+ print("Validation passed")
235
+ break
236
+
237
+ print("❌ Validation failed:")
238
+ for err in result.errors:
239
+ print(f" - {err}")
240
+
241
+ if attempt == MAX_RETRIES - 1:
242
+ # Restore original on failure
243
+ filepath.write_text(original_text)
244
+ backup_path.unlink(missing_ok=True)
245
+ print("❌ Failed after retries — original restored")
246
+ return False
247
+
248
+ print("Fixing with Claude...")
249
+ compressed = call_claude(
250
+ build_fix_prompt(original_text, compressed, result.errors)
251
+ )
252
+ filepath.write_text(compressed)
253
+
254
+ return True
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env python3
2
+ """Detect whether a file is natural language (compressible) or code/config (skip)."""
3
+
4
+ import json
5
+ import re
6
+ from pathlib import Path
7
+
8
+ # Extensions that are natural language and compressible
9
+ COMPRESSIBLE_EXTENSIONS = {".md", ".txt", ".markdown", ".rst", ".typ", ".typst", ".tex"}
10
+
11
+ # Extensions that are code/config and should be skipped
12
+ SKIP_EXTENSIONS = {
13
+ ".py", ".js", ".ts", ".tsx", ".jsx", ".json", ".yaml", ".yml",
14
+ ".toml", ".env", ".lock", ".css", ".scss", ".html", ".xml",
15
+ ".sql", ".sh", ".bash", ".zsh", ".go", ".rs", ".java", ".c",
16
+ ".cpp", ".h", ".hpp", ".rb", ".php", ".swift", ".kt", ".lua",
17
+ ".dockerfile", ".makefile", ".csv", ".ini", ".cfg",
18
+ }
19
+
20
+ # Patterns that indicate a line is code
21
+ CODE_PATTERNS = [
22
+ re.compile(r"^\s*(import |from .+ import |require\(|const |let |var )"),
23
+ re.compile(r"^\s*(def |class |function |async function |export )"),
24
+ re.compile(r"^\s*(if\s*\(|for\s*\(|while\s*\(|switch\s*\(|try\s*\{)"),
25
+ re.compile(r"^\s*[\}\]\);]+\s*$"), # closing braces/brackets
26
+ re.compile(r"^\s*@\w+"), # decorators/annotations
27
+ re.compile(r'^\s*"[^"]+"\s*:\s*'), # JSON-like key-value
28
+ re.compile(r"^\s*\w+\s*=\s*[{\[\(\"']"), # assignment with literal
29
+ ]
30
+
31
+
32
+ def _is_code_line(line: str) -> bool:
33
+ """Check if a line looks like code."""
34
+ return any(p.match(line) for p in CODE_PATTERNS)
35
+
36
+
37
+ def _is_json_content(text: str) -> bool:
38
+ """Check if content is valid JSON."""
39
+ try:
40
+ json.loads(text)
41
+ return True
42
+ except (json.JSONDecodeError, ValueError):
43
+ return False
44
+
45
+
46
+ def _is_yaml_content(lines: list[str]) -> bool:
47
+ """Heuristic: check if content looks like YAML."""
48
+ yaml_indicators = 0
49
+ for line in lines[:30]:
50
+ stripped = line.strip()
51
+ if stripped.startswith("---"):
52
+ yaml_indicators += 1
53
+ elif re.match(r"^\w[\w\s]*:\s", stripped):
54
+ yaml_indicators += 1
55
+ elif stripped.startswith("- ") and ":" in stripped:
56
+ yaml_indicators += 1
57
+ # If most non-empty lines look like YAML
58
+ non_empty = sum(1 for l in lines[:30] if l.strip())
59
+ return non_empty > 0 and yaml_indicators / non_empty > 0.6
60
+
61
+
62
+ def detect_file_type(filepath: Path) -> str:
63
+ """Classify a file as 'natural_language', 'code', 'config', or 'unknown'.
64
+
65
+ Returns:
66
+ One of: 'natural_language', 'code', 'config', 'unknown'
67
+ """
68
+ ext = filepath.suffix.lower()
69
+
70
+ # Extension-based classification
71
+ if ext in COMPRESSIBLE_EXTENSIONS:
72
+ return "natural_language"
73
+ if ext in SKIP_EXTENSIONS:
74
+ return "code" if ext not in {".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".env"} else "config"
75
+
76
+ # Extensionless files (like CLAUDE.md, TODO) — check content
77
+ if not ext:
78
+ try:
79
+ text = filepath.read_text(errors="ignore")
80
+ except (OSError, PermissionError):
81
+ return "unknown"
82
+
83
+ lines = text.splitlines()[:50]
84
+
85
+ if _is_json_content(text[:10000]):
86
+ return "config"
87
+ if _is_yaml_content(lines):
88
+ return "config"
89
+
90
+ code_lines = sum(1 for l in lines if l.strip() and _is_code_line(l))
91
+ non_empty = sum(1 for l in lines if l.strip())
92
+ if non_empty > 0 and code_lines / non_empty > 0.4:
93
+ return "code"
94
+
95
+ return "natural_language"
96
+
97
+ return "unknown"
98
+
99
+
100
+ def should_compress(filepath: Path) -> bool:
101
+ """Return True if the file is natural language and should be compressed."""
102
+ if not filepath.is_file():
103
+ return False
104
+ # Skip backup files
105
+ if filepath.name.endswith(".original.md"):
106
+ return False
107
+ return detect_file_type(filepath) == "natural_language"
108
+
109
+
110
+ if __name__ == "__main__":
111
+ import sys
112
+
113
+ if len(sys.argv) < 2:
114
+ print("Usage: python detect.py <file1> [file2] ...")
115
+ sys.exit(1)
116
+
117
+ for path_str in sys.argv[1:]:
118
+ p = Path(path_str).resolve()
119
+ file_type = detect_file_type(p)
120
+ compress = should_compress(p)
121
+ print(f" {p.name:30s} type={file_type:20s} compress={compress}")
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env python3
2
+ import re
3
+ from collections import Counter
4
+ from pathlib import Path
5
+
6
+ URL_REGEX = re.compile(r"https?://[^\s)]+")
7
+ FENCE_OPEN_REGEX = re.compile(r"^(\s{0,3})(`{3,}|~{3,})(.*)$")
8
+ HEADING_REGEX = re.compile(r"^(#{1,6})\s+(.*)", re.MULTILINE)
9
+ BULLET_REGEX = re.compile(r"^\s*[-*+]\s+", re.MULTILINE)
10
+
11
+ # crude but effective path detection
12
+ # Requires either a path prefix (./ ../ / or drive letter) or a slash/backslash within the match
13
+ PATH_REGEX = re.compile(r"(?:\./|\.\./|/|[A-Za-z]:\\)[\w\-/\\\.]+|[\w\-\.]+[/\\][\w\-/\\\.]+")
14
+
15
+
16
+ class ValidationResult:
17
+ def __init__(self):
18
+ self.is_valid = True
19
+ self.errors = []
20
+ self.warnings = []
21
+
22
+ def add_error(self, msg):
23
+ self.is_valid = False
24
+ self.errors.append(msg)
25
+
26
+ def add_warning(self, msg):
27
+ self.warnings.append(msg)
28
+
29
+
30
+ def read_file(path: Path) -> str:
31
+ return path.read_text(errors="ignore")
32
+
33
+
34
+ # ---------- Extractors ----------
35
+
36
+
37
+ def extract_headings(text):
38
+ return [(level, title.strip()) for level, title in HEADING_REGEX.findall(text)]
39
+
40
+
41
+ def extract_code_blocks(text):
42
+ """Line-based fenced code block extractor.
43
+
44
+ Handles ``` and ~~~ fences with variable length (CommonMark: closing
45
+ fence must use same char and be at least as long as opening). Supports
46
+ nested fences (e.g. an outer 4-backtick block wrapping inner 3-backtick
47
+ content).
48
+ """
49
+ blocks = []
50
+ lines = text.split("\n")
51
+ i = 0
52
+ n = len(lines)
53
+ while i < n:
54
+ m = FENCE_OPEN_REGEX.match(lines[i])
55
+ if not m:
56
+ i += 1
57
+ continue
58
+ fence_char = m.group(2)[0]
59
+ fence_len = len(m.group(2))
60
+ open_line = lines[i]
61
+ block_lines = [open_line]
62
+ i += 1
63
+ closed = False
64
+ while i < n:
65
+ close_m = FENCE_OPEN_REGEX.match(lines[i])
66
+ if (
67
+ close_m
68
+ and close_m.group(2)[0] == fence_char
69
+ and len(close_m.group(2)) >= fence_len
70
+ and close_m.group(3).strip() == ""
71
+ ):
72
+ block_lines.append(lines[i])
73
+ closed = True
74
+ i += 1
75
+ break
76
+ block_lines.append(lines[i])
77
+ i += 1
78
+ if closed:
79
+ blocks.append("\n".join(block_lines))
80
+ # Unclosed fences are silently skipped — they indicate malformed markdown
81
+ # and including them would cause false-positive validation failures.
82
+ return blocks
83
+
84
+
85
+ def extract_urls(text):
86
+ return set(URL_REGEX.findall(text))
87
+
88
+
89
+ def extract_paths(text):
90
+ return set(PATH_REGEX.findall(text))
91
+
92
+
93
+ def count_bullets(text):
94
+ return len(BULLET_REGEX.findall(text))
95
+
96
+
97
+ def extract_inline_codes(text):
98
+ text_without_fences = re.sub(r"^```[\s\S]*?^```", "", text, flags=re.MULTILINE)
99
+ text_without_fences = re.sub(r"^~~~[\s\S]*?^~~~", "", text_without_fences, flags=re.MULTILINE)
100
+ return re.findall(r"`([^`]+)`", text_without_fences)
101
+
102
+
103
+ # ---------- Validators ----------
104
+
105
+
106
+ def validate_headings(orig, comp, result):
107
+ h1 = extract_headings(orig)
108
+ h2 = extract_headings(comp)
109
+
110
+ if len(h1) != len(h2):
111
+ result.add_error(f"Heading count mismatch: {len(h1)} vs {len(h2)}")
112
+
113
+ if h1 != h2:
114
+ result.add_warning("Heading text/order changed")
115
+
116
+
117
+ def validate_code_blocks(orig, comp, result):
118
+ c1 = extract_code_blocks(orig)
119
+ c2 = extract_code_blocks(comp)
120
+
121
+ if c1 != c2:
122
+ result.add_error("Code blocks not preserved exactly")
123
+
124
+
125
+ def validate_urls(orig, comp, result):
126
+ u1 = extract_urls(orig)
127
+ u2 = extract_urls(comp)
128
+
129
+ if u1 != u2:
130
+ result.add_error(f"URL mismatch: lost={u1 - u2}, added={u2 - u1}")
131
+
132
+
133
+ def validate_paths(orig, comp, result):
134
+ p1 = extract_paths(orig)
135
+ p2 = extract_paths(comp)
136
+
137
+ if p1 != p2:
138
+ result.add_warning(f"Path mismatch: lost={p1 - p2}, added={p2 - p1}")
139
+
140
+
141
+ def validate_bullets(orig, comp, result):
142
+ b1 = count_bullets(orig)
143
+ b2 = count_bullets(comp)
144
+
145
+ if b1 == 0:
146
+ return
147
+
148
+ diff = abs(b1 - b2) / b1
149
+
150
+ if diff > 0.15:
151
+ result.add_warning(f"Bullet count changed too much: {b1} -> {b2}")
152
+
153
+
154
+ def validate_inline_codes(orig, comp, result):
155
+ c1 = Counter(extract_inline_codes(orig))
156
+ c2 = Counter(extract_inline_codes(comp))
157
+
158
+ if c1 != c2:
159
+ lost = set(c1.keys()) - set(c2.keys())
160
+ added = set(c2.keys()) - set(c1.keys())
161
+ for code, count in c1.items():
162
+ if code in c2 and c2[code] < count:
163
+ lost.add(f"{code} (lost {count - c2[code]} of {count} occurrences)")
164
+ if lost:
165
+ result.add_error(f"Inline code lost: {lost}")
166
+ if added:
167
+ result.add_warning(f"Inline code added: {added}")
168
+
169
+
170
+ # ---------- Main ----------
171
+
172
+
173
+ def validate(original_path: Path, compressed_path: Path) -> ValidationResult:
174
+ result = ValidationResult()
175
+
176
+ orig = read_file(original_path)
177
+ comp = read_file(compressed_path)
178
+
179
+ validate_headings(orig, comp, result)
180
+ validate_code_blocks(orig, comp, result)
181
+ validate_urls(orig, comp, result)
182
+ validate_paths(orig, comp, result)
183
+ validate_bullets(orig, comp, result)
184
+ validate_inline_codes(orig, comp, result)
185
+
186
+ return result
187
+
188
+
189
+ # ---------- CLI ----------
190
+
191
+ if __name__ == "__main__":
192
+ import sys
193
+
194
+ if len(sys.argv) != 3:
195
+ print("Usage: python validate.py <original> <compressed>")
196
+ sys.exit(1)
197
+
198
+ orig = Path(sys.argv[1]).resolve()
199
+ comp = Path(sys.argv[2]).resolve()
200
+
201
+ res = validate(orig, comp)
202
+
203
+ print(f"\nValid: {res.is_valid}")
204
+
205
+ if res.errors:
206
+ print("\nErrors:")
207
+ for e in res.errors:
208
+ print(f" - {e}")
209
+
210
+ if res.warnings:
211
+ print("\nWarnings:")
212
+ for w in res.warnings:
213
+ print(f" - {w}")