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.
- agents/editor.py +455 -0
- agents/researcher.py +405 -0
- agents/reviewer.py +374 -0
- cli/model_cli.py +430 -0
- config.py +557 -0
- differix-0.1.0a1.dist-info/METADATA +123 -0
- differix-0.1.0a1.dist-info/RECORD +62 -0
- differix-0.1.0a1.dist-info/WHEEL +5 -0
- differix-0.1.0a1.dist-info/entry_points.txt +2 -0
- differix-0.1.0a1.dist-info/top_level.txt +6 -0
- main.py +2796 -0
- memory/__init__.py +59 -0
- memory/embeddings/__init__.py +29 -0
- memory/embeddings/factory.py +178 -0
- memory/embeddings/providers.py +259 -0
- memory/manager.py +186 -0
- memory/migrator.py +253 -0
- memory/ranker.py +203 -0
- memory/schemas.py +127 -0
- memory/storage.py +552 -0
- tools/agents_chat.py +152 -0
- tools/asset_extractor.py +545 -0
- tools/asset_manager.py +115 -0
- tools/atomic_changeset.py +264 -0
- tools/backup_manager.py +122 -0
- tools/changelog_engine.py +261 -0
- tools/coherence_checker.py +771 -0
- tools/compiler.py +531 -0
- tools/component_normalizer.py +130 -0
- tools/context_assembler.py +631 -0
- tools/contract_validator.py +478 -0
- tools/cost_predictor.py +141 -0
- tools/drift_guard.py +196 -0
- tools/file_manager.py +119 -0
- tools/handshake.py +351 -0
- tools/lesson_manager.py +570 -0
- tools/lesson_memory_bridge.py +398 -0
- tools/llm_client.py +103 -0
- tools/llm_selector.py +362 -0
- tools/model_ranker.py +535 -0
- tools/palette_extractor.py +1043 -0
- tools/plugin_manager.py +204 -0
- tools/project_context_gatherer.py +404 -0
- tools/project_scanner.py +41 -0
- tools/project_structure.py +244 -0
- tools/reasoning_logger.py +78 -0
- tools/reflection.py +452 -0
- tools/sandbox_utils.py +56 -0
- tools/security/__init__.py +0 -0
- tools/security/behavior_guard.py +83 -0
- tools/security/verification_firewall.py +113 -0
- tools/security_filter.py +213 -0
- tools/session_adr.py +311 -0
- tools/skill_manager.py +129 -0
- tools/state_manager.py +383 -0
- tools/structure_index.py +570 -0
- tools/test_schema_consistency.py +19 -0
- tools/verification_firewall.py +113 -0
- tools/visual_capture.py +65 -0
- tools/visual_diff.py +220 -0
- tools/visual_reviewer.py +79 -0
- 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())
|