differix 0.1.0a1__py3-none-any.whl

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 (62) hide show
  1. agents/editor.py +455 -0
  2. agents/researcher.py +405 -0
  3. agents/reviewer.py +374 -0
  4. cli/model_cli.py +430 -0
  5. config.py +557 -0
  6. differix-0.1.0a1.dist-info/METADATA +123 -0
  7. differix-0.1.0a1.dist-info/RECORD +62 -0
  8. differix-0.1.0a1.dist-info/WHEEL +5 -0
  9. differix-0.1.0a1.dist-info/entry_points.txt +2 -0
  10. differix-0.1.0a1.dist-info/top_level.txt +6 -0
  11. main.py +2796 -0
  12. memory/__init__.py +59 -0
  13. memory/embeddings/__init__.py +29 -0
  14. memory/embeddings/factory.py +178 -0
  15. memory/embeddings/providers.py +259 -0
  16. memory/manager.py +186 -0
  17. memory/migrator.py +253 -0
  18. memory/ranker.py +203 -0
  19. memory/schemas.py +127 -0
  20. memory/storage.py +552 -0
  21. tools/agents_chat.py +152 -0
  22. tools/asset_extractor.py +545 -0
  23. tools/asset_manager.py +115 -0
  24. tools/atomic_changeset.py +264 -0
  25. tools/backup_manager.py +122 -0
  26. tools/changelog_engine.py +261 -0
  27. tools/coherence_checker.py +771 -0
  28. tools/compiler.py +531 -0
  29. tools/component_normalizer.py +130 -0
  30. tools/context_assembler.py +631 -0
  31. tools/contract_validator.py +478 -0
  32. tools/cost_predictor.py +141 -0
  33. tools/drift_guard.py +196 -0
  34. tools/file_manager.py +119 -0
  35. tools/handshake.py +351 -0
  36. tools/lesson_manager.py +570 -0
  37. tools/lesson_memory_bridge.py +398 -0
  38. tools/llm_client.py +103 -0
  39. tools/llm_selector.py +362 -0
  40. tools/model_ranker.py +535 -0
  41. tools/palette_extractor.py +1043 -0
  42. tools/plugin_manager.py +204 -0
  43. tools/project_context_gatherer.py +404 -0
  44. tools/project_scanner.py +41 -0
  45. tools/project_structure.py +244 -0
  46. tools/reasoning_logger.py +78 -0
  47. tools/reflection.py +452 -0
  48. tools/sandbox_utils.py +56 -0
  49. tools/security/__init__.py +0 -0
  50. tools/security/behavior_guard.py +83 -0
  51. tools/security/verification_firewall.py +113 -0
  52. tools/security_filter.py +213 -0
  53. tools/session_adr.py +311 -0
  54. tools/skill_manager.py +129 -0
  55. tools/state_manager.py +383 -0
  56. tools/structure_index.py +570 -0
  57. tools/test_schema_consistency.py +19 -0
  58. tools/verification_firewall.py +113 -0
  59. tools/visual_capture.py +65 -0
  60. tools/visual_diff.py +220 -0
  61. tools/visual_reviewer.py +79 -0
  62. tools/web_search.py +371 -0
tools/compiler.py ADDED
@@ -0,0 +1,531 @@
1
+ """
2
+ tools/compiler.py
3
+ -----------------
4
+ Universal multi-language syntax / build verification for VegeX.
5
+
6
+ Public API (compatible with the previous implementation):
7
+ run_universal_build(project_path, language, target_file) -> (bool, str)
8
+
9
+ Design contract:
10
+ - Every external tool invocation has a hard timeout.
11
+ - Missing toolchains return success=True with skipped=True — the pipeline
12
+ continues, the reviewer sees "SKIPPED_NO_TOOLCHAIN" in the log.
13
+ - Diagnostics are extracted as structured records so the reviewer can
14
+ cite file:line:column in its verdict.
15
+ - Python syntax is verified in-process (compile() with dont_inherit=True).
16
+ - C# project-wide build path is preserved unchanged.
17
+ - CSS and HTML get heuristic truncation checks (LLMs love to cut off halfway).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import ast
23
+ import glob
24
+ import json
25
+ import os
26
+ import re
27
+ import shutil
28
+ import subprocess
29
+ import time
30
+ import xml.etree.ElementTree as ET
31
+ from dataclasses import dataclass, field
32
+ from typing import List, Optional, Sequence, Tuple
33
+
34
+ try:
35
+ import yaml as _yaml # type: ignore
36
+ _HAS_YAML = True
37
+ except ImportError:
38
+ _yaml = None
39
+ _HAS_YAML = False
40
+
41
+ # Timeouts (seconds)
42
+ TIMEOUT_FAST = 15
43
+ TIMEOUT_CC = 30
44
+ TIMEOUT_TSC = 90
45
+ TIMEOUT_CARGO = 180
46
+ TIMEOUT_DOTNET = 240
47
+
48
+ @dataclass(frozen=True)
49
+ class Diagnostic:
50
+ file: str
51
+ line: int
52
+ column: int
53
+ message: str
54
+ severity: str # "error" | "warning" | "note"
55
+
56
+ @dataclass(frozen=True)
57
+ class CompilerResult:
58
+ success: bool
59
+ language: str
60
+ file_path: str
61
+ skipped: bool = False
62
+ skip_reason: Optional[str] = None
63
+ diagnostics: Tuple[Diagnostic, ...] = ()
64
+ raw_stdout: str = ""
65
+ raw_stderr: str = ""
66
+ elapsed_ms: float = 0.0
67
+
68
+ def as_log(self) -> str:
69
+ """Human/LLM-readable summary."""
70
+ tag = self.language.upper() if self.language else "UNKNOWN"
71
+ if self.skipped:
72
+ return (
73
+ f"SUCCESS (SKIPPED): {self.skip_reason or 'validation skipped'} "
74
+ f"for {os.path.basename(self.file_path) or self.file_path}"
75
+ )
76
+ if self.success:
77
+ base = os.path.basename(self.file_path) or self.file_path
78
+ return f"SUCCESS: {tag} validation passed for {base}"
79
+
80
+ lines = [f"BUILD_FAILED ({tag}):"]
81
+ for d in self.diagnostics:
82
+ loc = f"{d.file}:{d.line}:{d.column}"
83
+ lines.append(f" {loc}: {d.severity}: {d.message}")
84
+ if not self.diagnostics and self.raw_stderr.strip():
85
+ lines.append(" " + self.raw_stderr.strip().splitlines()[0][:200])
86
+ return "\n".join(lines)
87
+
88
+ def _run_subprocess(
89
+ cmd: Sequence[str],
90
+ cwd: Optional[str],
91
+ timeout: int,
92
+ ) -> Tuple[int, str, str, bool]:
93
+ """
94
+ Run a subprocess with a hard timeout and robust encoding fallback.
95
+ """
96
+ try:
97
+ proc = subprocess.run(
98
+ list(cmd),
99
+ cwd=cwd,
100
+ capture_output=True,
101
+ text=True,
102
+ encoding="utf-8",
103
+ errors="replace",
104
+ timeout=timeout,
105
+ shell=False,
106
+ )
107
+ return proc.returncode, proc.stdout or "", proc.stderr or "", False
108
+ except subprocess.TimeoutExpired as exc:
109
+ # FIX: TimeoutExpired has .output, not .stdout
110
+ stdout = getattr(exc, "output", "") or ""
111
+ stderr = getattr(exc, "stderr", "") or ""
112
+ if isinstance(stdout, bytes):
113
+ stdout = stdout.decode("utf-8", "replace")
114
+ if isinstance(stderr, bytes):
115
+ stderr = stderr.decode("utf-8", "replace")
116
+ return -1, stdout, stderr + f"\n[TIMEOUT after {timeout}s]", True
117
+ except OSError as exc:
118
+ return -2, "", f"{exc}", False
119
+
120
+ # Diagnostic extraction patterns
121
+ _PAT_FILE_COL_MSG = re.compile(
122
+ r"^(?P<file>[^:\s][^:]*?):(?P<line>\d+):(?P<col>\d+):\s+"
123
+ r"(?P<sev>error|warning|note|fatal error):\s+(?P<msg>.+)$"
124
+ )
125
+ _PAT_FILE_MSG = re.compile(
126
+ r"^(?P<file>[^:\s][^:]*?):(?P<line>\d+):\s+"
127
+ r"(?P<sev>error|warning|note):\s+(?P<msg>.+)$"
128
+ )
129
+ _PAT_TSC = re.compile(
130
+ r"^(?P<file>.+?)\((?P<line>\d+),(?P<col>\d+)\):\s+"
131
+ r"(?P<sev>error|warning)\s+\S+:\s+(?P<msg>.+)$"
132
+ )
133
+ _PAT_LUAC = re.compile(
134
+ r"^luac:\s+(?P<file>.+?):(?P<line>\d+):\s+(?P<msg>.+)$"
135
+ )
136
+ _PAT_NODE = re.compile(
137
+ r"^(?P<file>/[^:\s].*?):(?P<line>\d+)\s*$"
138
+ )
139
+ _PAT_PYTB = re.compile(
140
+ r'^\s*File "(?P<file>.+?)",\s+line\s+(?P<line>\d+)'
141
+ )
142
+ _PAT_GENERIC = re.compile(
143
+ r"(?i)(?:^|\s)(error|warning|fatal error):\s*(?P<msg>.+)$"
144
+ )
145
+
146
+ _PARSER_CHAIN = (
147
+ _PAT_FILE_COL_MSG,
148
+ _PAT_TSC,
149
+ _PAT_LUAC,
150
+ _PAT_FILE_MSG,
151
+ _PAT_NODE,
152
+ _PAT_PYTB,
153
+ _PAT_GENERIC,
154
+ )
155
+
156
+ def _extract_diagnostics(raw: str, default_severity: str = "error") -> List[Diagnostic]:
157
+ """Best-effort structured extraction from any toolchain output."""
158
+ diags: List[Diagnostic] = []
159
+ for line in (raw or "").splitlines():
160
+ line = line.rstrip()
161
+ if not line:
162
+ continue
163
+ for pattern in _PARSER_CHAIN:
164
+ m = pattern.match(line)
165
+ if not m:
166
+ continue
167
+ gd = m.groupdict()
168
+ diags.append(
169
+ Diagnostic(
170
+ file=gd.get("file", "") or "",
171
+ line=int(gd.get("line", 0) or 0),
172
+ column=int(gd.get("col", 0) or 0),
173
+ message=(gd.get("msg") or "").strip(),
174
+ severity=gd.get("sev", default_severity).lower(),
175
+ )
176
+ )
177
+ break
178
+ return diags
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Per-language verifiers
182
+ # ---------------------------------------------------------------------------
183
+
184
+ def _verify_python(file_path: str) -> CompilerResult:
185
+ t0 = time.perf_counter()
186
+ try:
187
+ with open(file_path, "rb") as f:
188
+ source_bytes = f.read()
189
+ except OSError as exc:
190
+ return CompilerResult(False, "python", file_path, raw_stderr=f"FILE_ERROR: {exc}", elapsed_ms=(time.perf_counter() - t0) * 1000.0)
191
+
192
+ try:
193
+ source = source_bytes.decode("utf-8")
194
+ except UnicodeDecodeError:
195
+ source = source_bytes.decode("latin-1")
196
+
197
+ try:
198
+ ast.parse(source, filename=file_path)
199
+ compile(source, file_path, "exec", dont_inherit=True)
200
+ return CompilerResult(True, "python", file_path, elapsed_ms=(time.perf_counter() - t0) * 1000.0)
201
+ except (SyntaxError, ValueError) as exc:
202
+ diag = Diagnostic(
203
+ file=file_path,
204
+ line=getattr(exc, "lineno", 1) or 1,
205
+ column=getattr(exc, "offset", 0) or 0,
206
+ message=f"{type(exc).__name__}: {getattr(exc, 'msg', str(exc))}",
207
+ severity="error",
208
+ )
209
+ return CompilerResult(False, "python", file_path, diagnostics=(diag,), raw_stderr=str(exc), elapsed_ms=(time.perf_counter() - t0) * 1000.0)
210
+
211
+ def _verify_json(file_path: str) -> CompilerResult:
212
+ t0 = time.perf_counter()
213
+ try:
214
+ with open(file_path, "r", encoding="utf-8") as f:
215
+ json.load(f)
216
+ return CompilerResult(True, "json", file_path, elapsed_ms=(time.perf_counter() - t0) * 1000.0)
217
+ except json.JSONDecodeError as exc:
218
+ diag = Diagnostic(file_path, exc.lineno, exc.colno, f"JSONDecodeError: {exc.msg}", "error")
219
+ return CompilerResult(False, "json", file_path, diagnostics=(diag,), elapsed_ms=(time.perf_counter() - t0) * 1000.0)
220
+ except OSError as exc:
221
+ return CompilerResult(False, "json", file_path, raw_stderr=f"FILE_ERROR: {exc}", elapsed_ms=(time.perf_counter() - t0) * 1000.0)
222
+
223
+ def _verify_xml(file_path: str) -> CompilerResult:
224
+ t0 = time.perf_counter()
225
+ try:
226
+ ET.parse(file_path)
227
+ return CompilerResult(True, "xml", file_path, elapsed_ms=(time.perf_counter() - t0) * 1000.0)
228
+ except ET.ParseError as exc:
229
+ line, col = getattr(exc, "position", (0, 0))
230
+ diag = Diagnostic(file_path, line, col, f"XMLParseError: {exc}", "error")
231
+ return CompilerResult(False, "xml", file_path, diagnostics=(diag,), elapsed_ms=(time.perf_counter() - t0) * 1000.0)
232
+ except OSError as exc:
233
+ return CompilerResult(False, "xml", file_path, raw_stderr=f"FILE_ERROR: {exc}", elapsed_ms=(time.perf_counter() - t0) * 1000.0)
234
+
235
+ def _verify_yaml(file_path: str) -> CompilerResult:
236
+ t0 = time.perf_counter()
237
+ if not _HAS_YAML:
238
+ return CompilerResult(True, "yaml", file_path, skipped=True, skip_reason="SKIPPED_NO_TOOLCHAIN (PyYAML not installed)", elapsed_ms=(time.perf_counter() - t0) * 1000.0)
239
+ try:
240
+ with open(file_path, "r", encoding="utf-8") as f:
241
+ _yaml.safe_load(f)
242
+ return CompilerResult(True, "yaml", file_path, elapsed_ms=(time.perf_counter() - t0) * 1000.0)
243
+ except Exception as exc:
244
+ mark = getattr(exc, "problem_mark", None)
245
+ line = mark.line + 1 if mark else 0
246
+ col = mark.column + 1 if mark else 0
247
+ diag = Diagnostic(file_path, line, col, f"YAMLError: {exc}", "error")
248
+ return CompilerResult(False, "yaml", file_path, diagnostics=(diag,), elapsed_ms=(time.perf_counter() - t0) * 1000.0)
249
+
250
+ def _verify_css(file_path: str) -> CompilerResult:
251
+ """Heuristic CSS check to catch truncation (unclosed braces/comments)."""
252
+ t0 = time.perf_counter()
253
+ try:
254
+ with open(file_path, "r", encoding="utf-8") as f:
255
+ content = f.read()
256
+
257
+ # 1. Check for unclosed comments (odd number of /* vs */)
258
+ open_comments = content.count("/*")
259
+ close_comments = content.count("*/")
260
+ if open_comments > close_comments:
261
+ msg = f"CSS Syntax Error: Unclosed comment detected. Found {open_comments} '/*' but only {close_comments} '*/'."
262
+ diag = Diagnostic(file_path, 0, 0, msg, "error")
263
+ return CompilerResult(False, "css", file_path, diagnostics=(diag,), elapsed_ms=(time.perf_counter() - t0) * 1000.0)
264
+
265
+ # 2. Strip comments to check braces
266
+ content_no_comments = re.sub(r'/\*.*?\*/', '', content, flags=re.DOTALL)
267
+ open_b = content_no_comments.count("{")
268
+ close_b = content_no_comments.count("}")
269
+
270
+ if open_b != close_b:
271
+ msg = f"CSS Syntax Error: Unbalanced braces. {open_b} '{{' vs {close_b} '}}'. Output may be truncated."
272
+ diag = Diagnostic(file_path, 0, 0, msg, "error")
273
+ return CompilerResult(False, "css", file_path, diagnostics=(diag,), elapsed_ms=(time.perf_counter() - t0) * 1000.0)
274
+
275
+ return CompilerResult(True, "css", file_path, elapsed_ms=(time.perf_counter() - t0) * 1000.0)
276
+ except OSError as exc:
277
+ return CompilerResult(False, "css", file_path, raw_stderr=f"FILE_ERROR: {exc}", elapsed_ms=(time.perf_counter() - t0) * 1000.0)
278
+
279
+ def _verify_html(file_path: str) -> CompilerResult:
280
+ """Heuristic HTML check to catch severe truncation."""
281
+ t0 = time.perf_counter()
282
+ try:
283
+ with open(file_path, "r", encoding="utf-8") as f:
284
+ content = f.read()
285
+ # Strip comments and scripts/styles to avoid false positives
286
+ content = re.sub(r'<!--.*?-->', '', content, flags=re.DOTALL)
287
+ content = re.sub(r'<script.*?</script>', '', content, flags=re.DOTALL | re.IGNORECASE)
288
+ content = re.sub(r'<style.*?</style>', '', content, flags=re.DOTALL | re.IGNORECASE)
289
+
290
+ # Check for missing closing tags if opening tag was present
291
+ if "<html" in content.lower() and "</html>" not in content.lower():
292
+ msg = "HTML Syntax Error: Missing closing </html> tag. Output likely truncated."
293
+ diag = Diagnostic(file_path, 0, 0, msg, "error")
294
+ return CompilerResult(False, "html", file_path, diagnostics=(diag,), elapsed_ms=(time.perf_counter() - t0) * 1000.0)
295
+
296
+ open_divs = len(re.findall(r'<div\b', content, re.IGNORECASE))
297
+ close_divs = len(re.findall(r'</div>', content, re.IGNORECASE))
298
+ if open_divs != close_divs:
299
+ msg = f"HTML Syntax Error: Unbalanced <div> tags. {open_divs} '<div' vs {close_divs} '</div>'. Output may be truncated."
300
+ diag = Diagnostic(file_path, 0, 0, msg, "error")
301
+ return CompilerResult(False, "html", file_path, diagnostics=(diag,), elapsed_ms=(time.perf_counter() - t0) * 1000.0)
302
+
303
+ return CompilerResult(True, "html", file_path, elapsed_ms=(time.perf_counter() - t0) * 1000.0)
304
+ except OSError as exc:
305
+ return CompilerResult(False, "html", file_path, raw_stderr=f"FILE_ERROR: {exc}", elapsed_ms=(time.perf_counter() - t0) * 1000.0)
306
+
307
+ def _verify_with_subprocess(
308
+ file_path: str,
309
+ language: str,
310
+ cmd: Sequence[str],
311
+ cwd: Optional[str],
312
+ timeout: int,
313
+ tool_name: str,
314
+ ) -> CompilerResult:
315
+ t0 = time.perf_counter()
316
+ if not shutil.which(cmd[0]):
317
+ return CompilerResult(True, language, file_path, skipped=True, skip_reason=f"SKIPPED_NO_TOOLCHAIN ({tool_name} not on PATH)", elapsed_ms=(time.perf_counter() - t0) * 1000.0)
318
+
319
+ rc, out, err, timed_out = _run_subprocess(cmd, cwd=cwd, timeout=timeout)
320
+ elapsed = (time.perf_counter() - t0) * 1000.0
321
+
322
+ if timed_out:
323
+ diag = Diagnostic(file_path, 0, 0, f"{tool_name} timed out after {timeout}s", "error")
324
+ return CompilerResult(False, language, file_path, raw_stdout=out, raw_stderr=err, diagnostics=(diag,), elapsed_ms=elapsed)
325
+
326
+ if rc == 0:
327
+ return CompilerResult(True, language, file_path, raw_stdout=out, raw_stderr=err, elapsed_ms=elapsed)
328
+
329
+ combined = (out or "") + "\n" + (err or "")
330
+ diags = tuple(_extract_diagnostics(combined))
331
+ if not diags:
332
+ diags = (Diagnostic(file_path, 0, 0, (err or out or f"{tool_name} exited with code {rc}").strip()[:400], "error"),)
333
+ return CompilerResult(False, language, file_path, diagnostics=diags, raw_stdout=out, raw_stderr=err, elapsed_ms=elapsed)
334
+
335
+ def _verify_js(file_path: str, project_root: str) -> CompilerResult:
336
+ return _verify_with_subprocess(file_path, "javascript", ["node", "--check", file_path], project_root, TIMEOUT_FAST, "node")
337
+
338
+ def _verify_ts(file_path: str, project_root: str) -> CompilerResult:
339
+ tsconfig = _find_ancestor_file(file_path, "tsconfig.json", project_root)
340
+ if tsconfig:
341
+ cmd = ["tsc", "--noEmit", "--project", tsconfig]
342
+ else:
343
+ cmd = ["tsc", "--noEmit", "--skipLibCheck", "--target", "esnext", "--module", "esnext", "--moduleResolution", "bundler", "--allowJs", file_path]
344
+ return _verify_with_subprocess(file_path, "typescript", cmd, project_root, TIMEOUT_TSC, "tsc")
345
+
346
+ def _verify_lua(file_path: str, project_root: str) -> CompilerResult:
347
+ for candidate in ("luac", "luac5.4", "luac5.3", "luac5.1"):
348
+ if shutil.which(candidate):
349
+ return _verify_with_subprocess(file_path, "lua", [candidate, "-p", file_path], project_root, TIMEOUT_FAST, candidate)
350
+ return CompilerResult(True, "lua", file_path, skipped=True, skip_reason="SKIPPED_NO_TOOLCHAIN (luac not on PATH)")
351
+
352
+ def _verify_luau(file_path: str, project_root: str) -> CompilerResult:
353
+ for candidate in ("luau-analyze", "luau_analyze"):
354
+ if shutil.which(candidate):
355
+ return _verify_with_subprocess(file_path, "luau", [candidate, file_path], project_root, TIMEOUT_FAST, candidate)
356
+ return CompilerResult(True, "luau", file_path, skipped=True, skip_reason="SKIPPED_NO_TOOLCHAIN (luau-analyze not on PATH)")
357
+
358
+ def _verify_rust(file_path: str, project_root: str) -> CompilerResult:
359
+ cargo_toml = _find_ancestor_file(file_path, "Cargo.toml", project_root)
360
+ if cargo_toml:
361
+ cargo = shutil.which("cargo")
362
+ if not cargo:
363
+ return CompilerResult(True, "rust", file_path, skipped=True, skip_reason="SKIPPED_NO_TOOLCHAIN (cargo not on PATH)")
364
+ cargo_dir = os.path.dirname(cargo_toml)
365
+ rc, out, err, timed_out = _run_subprocess([cargo, "check", "--message-format=json"], cwd=cargo_dir, timeout=TIMEOUT_CARGO)
366
+ return _parse_cargo_result(rc, out, err, timed_out, file_path)
367
+
368
+ rustc = shutil.which("rustc")
369
+ if not rustc:
370
+ return CompilerResult(True, "rust", file_path, skipped=True, skip_reason="SKIPPED_NO_TOOLCHAIN (rustc not on PATH)")
371
+ return _verify_with_subprocess(file_path, "rust", [rustc, "--edition", "2021", "--crate-type", "lib", "--emit", "metadata", "--error-format", "short", file_path], project_root, TIMEOUT_FAST, "rustc")
372
+
373
+ def _parse_cargo_result(rc: int, out: str, err: str, timed_out: bool, target_file: str) -> CompilerResult:
374
+ t0 = time.perf_counter()
375
+ if timed_out:
376
+ diag = Diagnostic(target_file, 0, 0, f"cargo check timed out after {TIMEOUT_CARGO}s", "error")
377
+ return CompilerResult(False, "rust", target_file, raw_stdout=out, raw_stderr=err, diagnostics=(diag,), elapsed_ms=(time.perf_counter() - t0) * 1000.0)
378
+
379
+ diags: List[Diagnostic] = []
380
+ for line in (out or "").splitlines():
381
+ if not line.strip().startswith("{"): continue
382
+ try:
383
+ obj = json.loads(line)
384
+ except json.JSONDecodeError:
385
+ continue
386
+ if obj.get("reason") != "compiler-message": continue
387
+ msg = obj.get("message") or {}
388
+ level = str(msg.get("level", "error")).lower()
389
+ if level in ("note", "help"): continue
390
+ spans = msg.get("spans") or []
391
+ if spans:
392
+ span = spans[0]
393
+ diags.append(Diagnostic(span.get("file_name", target_file), int(span.get("line_start", 0) or 0), int(span.get("column_start", 0) or 0), (msg.get("message") or "").strip()[:400], level))
394
+ else:
395
+ diags.append(Diagnostic(target_file, 0, 0, (msg.get("message") or "").strip()[:400], level))
396
+
397
+ if rc == 0:
398
+ return CompilerResult(True, "rust", target_file, raw_stdout=out, raw_stderr=err, elapsed_ms=(time.perf_counter() - t0) * 1000.0)
399
+
400
+ if not diags:
401
+ diags = [Diagnostic(target_file, 0, 0, (err or "cargo check failed").strip()[:400], "error")]
402
+ return CompilerResult(False, "rust", target_file, diagnostics=tuple(diags), raw_stdout=out, raw_stderr=err, elapsed_ms=(time.perf_counter() - t0) * 1000.0)
403
+
404
+ def _verify_c(file_path: str, project_root: str) -> CompilerResult:
405
+ compiler = _first_available(("gcc", "clang", "cc"))
406
+ if not compiler:
407
+ return CompilerResult(True, "c", file_path, skipped=True, skip_reason="SKIPPED_NO_TOOLCHAIN (no C compiler on PATH)")
408
+ return _verify_with_subprocess(file_path, "c", [compiler, "-fsyntax-only", "-I", project_root, "-I", os.path.dirname(file_path), file_path], project_root, TIMEOUT_CC, compiler)
409
+
410
+ def _verify_cpp(file_path: str, project_root: str) -> CompilerResult:
411
+ compiler = _first_available(("g++", "clang++"))
412
+ if not compiler:
413
+ return CompilerResult(True, "cpp", file_path, skipped=True, skip_reason="SKIPPED_NO_TOOLCHAIN (no C++ compiler on PATH)")
414
+ return _verify_with_subprocess(file_path, "cpp", [compiler, "-fsyntax-only", "-I", project_root, "-I", os.path.dirname(file_path), file_path], project_root, TIMEOUT_CC, compiler)
415
+
416
+ def _first_available(names: Sequence[str]) -> Optional[str]:
417
+ for n in names:
418
+ if shutil.which(n): return n
419
+ return None
420
+
421
+ def _find_ancestor_file(file_path: str, filename: str, stop_at: Optional[str] = None) -> Optional[str]:
422
+ current = os.path.dirname(os.path.abspath(file_path))
423
+ stop = os.path.abspath(stop_at) if stop_at else None
424
+ while True:
425
+ candidate = os.path.join(current, filename)
426
+ if os.path.isfile(candidate): return candidate
427
+ if stop and current == stop: return None
428
+ parent = os.path.dirname(current)
429
+ if parent == current: return None
430
+ current = parent
431
+
432
+ _PY_EXT = {".py", ".pyi", ".pyw"}
433
+
434
+ _DISPATCH: dict = {
435
+ ".js": _verify_js,
436
+ ".mjs": _verify_js,
437
+ ".cjs": _verify_js,
438
+ ".ts": _verify_ts,
439
+ ".tsx": _verify_ts,
440
+ ".mts": _verify_ts,
441
+ ".lua": _verify_lua,
442
+ ".luau": _verify_luau,
443
+ ".rs": _verify_rust,
444
+ ".c": _verify_c,
445
+ ".h": _verify_c,
446
+ ".cpp": _verify_cpp,
447
+ ".cc": _verify_cpp,
448
+ ".cxx": _verify_cpp,
449
+ ".hpp": _verify_cpp,
450
+ ".hh": _verify_cpp,
451
+ ".hxx": _verify_cpp,
452
+ ".css": _verify_css,
453
+ ".html": _verify_html,
454
+ ".htm": _verify_html,
455
+ }
456
+
457
+ def check_file(file_path: str, project_root: str = ".") -> CompilerResult:
458
+ abs_path = os.path.abspath(file_path)
459
+ if not os.path.exists(abs_path):
460
+ return CompilerResult(False, "unknown", abs_path, raw_stderr=f"FILE_ERROR: Target file does not exist at {abs_path}")
461
+
462
+ ext = os.path.splitext(abs_path)[1].lower()
463
+
464
+ # In-process checkers
465
+ if ext in _PY_EXT: return _verify_python(abs_path)
466
+ if ext == ".json": return _verify_json(abs_path)
467
+ if ext == ".xml": return _verify_xml(abs_path)
468
+ if ext in (".yaml", ".yml"): return _verify_yaml(abs_path)
469
+
470
+ # Subprocess / heuristic dispatch
471
+ handler = _DISPATCH.get(ext)
472
+ if handler:
473
+ # Some handlers (like CSS/HTML) only take one arg
474
+ try:
475
+ return handler(abs_path, project_root)
476
+ except TypeError:
477
+ return handler(abs_path) # type: ignore
478
+
479
+ # Unknown extension
480
+ return CompilerResult(True, ext.lstrip(".") or "unknown", abs_path, skipped=True, skip_reason=f"SKIPPED_UNKNOWN_EXT ({ext or 'no extension'})")
481
+
482
+ def validate_xml_file(file_path: str) -> Tuple[bool, str]:
483
+ result = _verify_xml(file_path)
484
+ return result.success, result.as_log()
485
+
486
+ def validate_non_cs_file(file_path: str) -> Tuple[bool, str]:
487
+ result = check_file(file_path)
488
+ return result.success, result.as_log()
489
+
490
+ def build_csharp_assembly(project_path: str) -> Tuple[bool, str]:
491
+ solutions = glob.glob(os.path.join(project_path, "*.sln"))
492
+ projects = glob.glob(os.path.join(project_path, "**", "*.csproj"), recursive=True)
493
+ target = solutions[0] if solutions else (projects[0] if projects else None)
494
+ if not target: return True, "SUCCESS: No .csproj or .sln found. Skipping C# assembly build."
495
+
496
+ if shutil.which("dotnet"):
497
+ rc, out, err, timed_out = _run_subprocess(["dotnet", "build", target, "-c", "Release", "-v", "m"], cwd=project_path, timeout=TIMEOUT_DOTNET)
498
+ if timed_out: return False, f"BUILD_FAILED: dotnet build timed out after {TIMEOUT_DOTNET}s"
499
+ combined = (out + "\n" + err).strip()
500
+ if rc == 0: return True, f"SUCCESS: C# assembly compiled successfully ({os.path.basename(target)})."
501
+ clean_lines = [line.strip() for line in combined.splitlines() if "error" in line.lower() or "warning" in line.lower()]
502
+ log = "\n".join(clean_lines[:15]) if clean_lines else combined[:500]
503
+ return False, f"BUILD_FAILED:\n{log}"
504
+
505
+ if shutil.which("msbuild"):
506
+ rc, out, err, timed_out = _run_subprocess(["msbuild", target, "/p:Configuration=Release", "/v:m"], cwd=project_path, timeout=TIMEOUT_DOTNET)
507
+ if timed_out: return False, f"BUILD_FAILED: msbuild timed out after {TIMEOUT_DOTNET}s"
508
+ if rc == 0: return True, f"SUCCESS: Assembly built via MSBuild ({os.path.basename(target)})."
509
+ return False, f"BUILD_FAILED (MSBuild):\n{(out or '')[:500]}"
510
+
511
+ return False, "BUILD_ERROR: Neither 'dotnet' nor 'msbuild' found in PATH."
512
+
513
+ def run_universal_build(project_path: str, language: str, target_file: str = "") -> Tuple[bool, str]:
514
+ ext = os.path.splitext(target_file)[1].lower() if target_file else ""
515
+ if ext == ".cs": return build_csharp_assembly(project_path)
516
+ if target_file:
517
+ full_path = target_file if os.path.isabs(target_file) else os.path.join(project_path, target_file)
518
+ result = check_file(full_path, project_root=project_path)
519
+ return result.success, result.as_log()
520
+ if language.lower() in ("c#", "csharp", "dotnet"): return build_csharp_assembly(project_path)
521
+ return True, "SUCCESS: Verified workspace edit."
522
+
523
+ __all__ = [
524
+ "Diagnostic",
525
+ "CompilerResult",
526
+ "check_file",
527
+ "run_universal_build",
528
+ "validate_xml_file",
529
+ "validate_non_cs_file",
530
+ "build_csharp_assembly",
531
+ ]
@@ -0,0 +1,130 @@
1
+ """
2
+ tools/component_normalizer.py
3
+ from plugin:webdev
4
+ ------------------------------
5
+ Rewrite extracted component .md files so their CSS uses the DifferiX
6
+ palette contract instead of site-specific token names.
7
+
8
+ Mapping (CalCook → DifferiX):
9
+ --clr-primary → --accent
10
+ --clr-dark → --text-main
11
+ --clr-muted → --text-muted
12
+ --clr-gray → --text-dim
13
+ --clr-white → --bg-primary
14
+ --ff-label → --font-heading
15
+ --ff-body → --font-body
16
+
17
+ The original token is preserved as a fallback inside var() so the
18
+ component still works on a project that only has the site's tokens:
19
+ background: var(--accent, var(--clr-primary));
20
+
21
+ Not modified: HTML, JS, and any tokens not in the map.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import logging
28
+ import re
29
+ import sys
30
+ from pathlib import Path
31
+ from typing import Dict, List
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ TOKEN_MAP: Dict[str, str] = {
37
+ "--clr-primary": "--accent",
38
+ "--clr-dark": "--text-main",
39
+ "--clr-muted": "--text-muted",
40
+ "--clr-gray": "--text-dim",
41
+ "--clr-white": "--bg-primary",
42
+ "--ff-label": "--font-heading",
43
+ "--ff-body": "--font-body",
44
+ }
45
+
46
+
47
+ def _rewrite_css_uses(css: str) -> str:
48
+ """
49
+ Rewrite `var(--clr-primary)` → `var(--accent, var(--clr-primary))`.
50
+ Leaves declarations like `--clr-primary: #ffd896` untouched (those
51
+ are definitions, not uses).
52
+ """
53
+ for old, new in TOKEN_MAP.items():
54
+ # Match var(--old) but not var(--new, var(--old)) — avoid double-wrap
55
+ pattern = re.compile(r"var\(\s*" + re.escape(old) + r"\s*\)")
56
+ css = pattern.sub(f"var({new}, {old})", css)
57
+ return css
58
+
59
+
60
+ def _extract_css_block(md: str) -> str:
61
+ m = re.search(r"```css\n(.*?)\n```", md, flags=re.DOTALL)
62
+ return m.group(1) if m else ""
63
+
64
+
65
+ def _replace_css_block(md: str, new_css: str) -> str:
66
+ return re.sub(
67
+ r"```css\n.*?\n```",
68
+ f"```css\n{new_css}\n```",
69
+ md,
70
+ count=1,
71
+ flags=re.DOTALL,
72
+ )
73
+
74
+
75
+ def normalize_file(path: Path) -> bool:
76
+ try:
77
+ md = path.read_text(encoding="utf-8")
78
+ except OSError as exc:
79
+ logger.error("Read failed for %s: %s", path, exc)
80
+ return False
81
+
82
+ original_css = _extract_css_block(md)
83
+ if not original_css:
84
+ logger.info("No CSS block in %s — skipping", path.name)
85
+ return False
86
+
87
+ rewritten_css = _rewrite_css_uses(original_css)
88
+ if rewritten_css == original_css:
89
+ logger.info("No changes for %s", path.name)
90
+ return False
91
+
92
+ new_md = _replace_css_block(md, rewritten_css)
93
+ try:
94
+ path.write_text(new_md, encoding="utf-8")
95
+ except OSError as exc:
96
+ logger.error("Write failed for %s: %s", path, exc)
97
+ return False
98
+
99
+ return True
100
+
101
+
102
+ def normalize_dir(input_dir: str) -> int:
103
+ count = 0
104
+ for path in sorted(Path(input_dir).glob("*.md")):
105
+ if normalize_file(path):
106
+ count += 1
107
+ print(f"✓ {path.name}")
108
+ return count
109
+
110
+
111
+ def _cli() -> int:
112
+ parser = argparse.ArgumentParser(
113
+ description="Normalize extracted component CSS to DifferiX tokens.",
114
+ )
115
+ parser.add_argument("input", help="A directory of .md components")
116
+ parser.add_argument("--verbose", "-v", action="store_true")
117
+ args = parser.parse_args()
118
+
119
+ logging.basicConfig(
120
+ level=logging.DEBUG if args.verbose else logging.WARNING,
121
+ format="%(levelname)s %(message)s",
122
+ )
123
+
124
+ n = normalize_dir(args.input)
125
+ print(f"\n{n} file(s) rewritten.")
126
+ return 0
127
+
128
+
129
+ if __name__ == "__main__":
130
+ sys.exit(_cli())