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
@@ -0,0 +1,771 @@
1
+ """
2
+ tools/coherence_checker.py
3
+ ---------------------------
4
+ Detects cross-file coherence violations in web projects.
5
+
6
+ Given a set of files (HTML, CSS, JS), reports:
7
+ - HTML classes that have no matching CSS rule (unstyled markup)
8
+ - CSS class selectors that match no HTML class and are not added by JS
9
+ - <script src>, <link href>, <img src>, and CSS url() references
10
+ that point to files which don't exist on disk
11
+ - CSS custom properties used but never defined
12
+ - JS querySelector / getElementById calls that target non-existent
13
+ classes or IDs in HTML
14
+ - JS classList.add / className assignments that add classes with no
15
+ CSS styling
16
+
17
+ The JS checks are the key architectural addition: agent context windows
18
+ routinely produce JS that queries classes the HTML never had. Static
19
+ analysis catches these mistakes deterministically without relying on any
20
+ LLM to remember the original markup.
21
+
22
+ Public API:
23
+ - check_coherence(project_root, files) -> CoherenceReport
24
+ - render_report(report) -> str
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import os
31
+ import re
32
+ from dataclasses import dataclass, field
33
+ from pathlib import Path
34
+ from typing import Dict, Iterable, List, Optional, Set, Tuple
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Safe tokens: never report these as orphan or missing
41
+ # ---------------------------------------------------------------------------
42
+ _SAFE_CSS_SELECTORS: Set[str] = {
43
+ "clearfix", "visually-hidden", "sr-only", "container", "wrapper",
44
+ "hidden", "visible", "active", "focus", "hover",
45
+ }
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Data structures
50
+ # ---------------------------------------------------------------------------
51
+ @dataclass(frozen=True)
52
+ class CoherenceIssue:
53
+ kind: str
54
+ detail: str
55
+ severity: str # "error" | "warning"
56
+ source_file: str = ""
57
+
58
+ def as_line(self) -> str:
59
+ loc = f" [{self.source_file}]" if self.source_file else ""
60
+ return f"{self.severity.upper()}: {self.kind}{loc}: {self.detail}"
61
+
62
+
63
+ @dataclass
64
+ class CoherenceReport:
65
+ html_classes: Set[str] = field(default_factory=set)
66
+ html_ids: Set[str] = field(default_factory=set)
67
+ css_selectors: Set[str] = field(default_factory=set)
68
+ css_vars_defined: Set[str] = field(default_factory=set)
69
+ css_vars_used: Set[str] = field(default_factory=set)
70
+ js_classes_queried: Set[str] = field(default_factory=set)
71
+ js_ids_queried: Set[str] = field(default_factory=set)
72
+ js_classes_added: Set[str] = field(default_factory=set)
73
+ issues: List[CoherenceIssue] = field(default_factory=list)
74
+ files_checked: List[str] = field(default_factory=list)
75
+ skipped_reason: Optional[str] = None
76
+ notes: List[str] = field(default_factory=list)
77
+
78
+ @property
79
+ def ok(self) -> bool:
80
+ return not any(i.severity == "error" for i in self.issues)
81
+
82
+ @property
83
+ def errors(self) -> List[CoherenceIssue]:
84
+ return [i for i in self.issues if i.severity == "error"]
85
+
86
+ @property
87
+ def warnings(self) -> List[CoherenceIssue]:
88
+ return [i for i in self.issues if i.severity == "warning"]
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # HTML parsing
93
+ # ---------------------------------------------------------------------------
94
+ _HTML_CLASS_RE = re.compile(r'class\s*=\s*"([^"]*)"', re.IGNORECASE)
95
+ _HTML_CLASS_RE_SINGLE = re.compile(r"class\s*=\s*'([^']*)'", re.IGNORECASE)
96
+ _VALID_CLASS_TOKEN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_-]*$")
97
+ _HTML_SCRIPT_BLOCK = re.compile(r"<script\b[^>]*>.*?</script>", re.DOTALL | re.IGNORECASE)
98
+ _HTML_STYLE_BLOCK = re.compile(r"<style\b[^>]*>.*?</style>", re.DOTALL | re.IGNORECASE)
99
+ _TAILWIND_MARKERS = re.compile(r"(?:tailwindcss|tailwind\s*cdn|@tailwind\s+|tailwind\.config)", re.IGNORECASE)
100
+ _HTML_ID_RE = re.compile(r'\bid\s*=\s*"([^"]*)"', re.IGNORECASE)
101
+ _HTML_ID_RE_SINGLE = re.compile(r"id\s*=\s*'([^']*)'", re.IGNORECASE)
102
+ _HTML_DATA_TAB = re.compile(r'data-tab\s*=\s*(["\'])([^"\']+)\1', re.IGNORECASE)
103
+ _HTML_SRC_RE = re.compile(r'\bsrc\s*=\s*"([^"]+)"', re.IGNORECASE)
104
+ _HTML_HREF_RE = re.compile(r'\bhref\s*=\s*"([^"]+)"', re.IGNORECASE)
105
+ _HTML_INLINE_SCRIPT_RE = re.compile(
106
+ r"<script(?![^>]*\bsrc\s*=)[^>]*>(.*?)</script>",
107
+ re.DOTALL | re.IGNORECASE,
108
+ )
109
+
110
+ def _extract_html_classes(html: str) -> Set[str]:
111
+ classes: Set[str] = set()
112
+ for pattern in (_HTML_CLASS_RE, _HTML_CLASS_RE_SINGLE):
113
+ for m in pattern.finditer(html):
114
+ for token in m.group(1).split():
115
+ token = token.strip()
116
+ if token and _VALID_CLASS_TOKEN.match(token):
117
+ classes.add(token)
118
+ return classes
119
+
120
+
121
+ def _strip_embedded_scripts_and_styles(html: str) -> str:
122
+ html = _HTML_SCRIPT_BLOCK.sub("", html)
123
+ html = _HTML_STYLE_BLOCK.sub("", html)
124
+ return html
125
+
126
+ def _project_uses_tailwind(project_root: str, html_files: List[str], css_files: List[str]) -> bool:
127
+ for rel in css_files[:5]:
128
+ try:
129
+ with open(os.path.join(project_root, rel), "r", encoding="utf-8", errors="replace") as f:
130
+ if "@tailwind" in f.read(8000): return True
131
+ except OSError: continue
132
+ for rel in html_files[:5]:
133
+ try:
134
+ with open(os.path.join(project_root, rel), "r", encoding="utf-8", errors="replace") as f:
135
+ if _TAILWIND_MARKERS.search(f.read(8000)): return True
136
+ except OSError: continue
137
+ return False
138
+
139
+
140
+ def _extract_html_ids(html: str) -> Set[str]:
141
+ ids: Set[str] = set()
142
+ for pattern in (_HTML_ID_RE, _HTML_ID_RE_SINGLE):
143
+ for m in pattern.finditer(html):
144
+ for token in m.group(1).split():
145
+ token = token.strip()
146
+ if token:
147
+ ids.add(token)
148
+ return ids
149
+
150
+
151
+ def _extract_html_references(html: str) -> List[str]:
152
+ refs: List[str] = []
153
+ for m in _HTML_SRC_RE.finditer(html):
154
+ refs.append(m.group(1))
155
+ for m in _HTML_HREF_RE.finditer(html):
156
+ refs.append(m.group(1))
157
+ return refs
158
+
159
+
160
+ def _extract_data_tab_values(html: str) -> Set[str]:
161
+ """
162
+ Return the set of `data-tab="X"` values from HTML.
163
+
164
+ These identify interactive tabs. Each such value X must have a
165
+ matching `<section id="view-X">` in HTML and at least one reference
166
+ to `#view-X` in some JS file (typically `$('#view-X')` in an
167
+ `el.views` map).
168
+ """
169
+ return {m.group(2).strip() for m in _HTML_DATA_TAB.finditer(html) if m.group(2).strip()}
170
+
171
+
172
+ def _extract_inline_scripts(html: str) -> List[str]:
173
+ return [m.group(1) for m in _HTML_INLINE_SCRIPT_RE.finditer(html)]
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # CSS parsing
178
+ # ---------------------------------------------------------------------------
179
+ _CSS_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
180
+ _CSS_CLASS_SELECTOR_RE = re.compile(r"\.([a-zA-Z_][a-zA-Z0-9_-]*)")
181
+ _CSS_VAR_DEF_RE = re.compile(r"(--[a-zA-Z0-9_-]+)\s*:")
182
+ _CSS_VAR_USE_RE = re.compile(r"var\(\s*(--[a-zA-Z0-9_-]+)")
183
+ _CSS_URL_RE = re.compile(r"url\(\s*['\"]?([^)'\"]+)['\"]?\s*\)")
184
+ _CSS_SKIP_PRAGMA = re.compile(r"/\*\s*vegex:\s*skip-coherence-check\s*\*/")
185
+
186
+
187
+ def _extract_css(styles: str) -> Tuple[Set[str], Set[str], Set[str], List[str]]:
188
+ """Returns (class selectors, vars defined, vars used, url refs)."""
189
+ # If the file opts out of coherence checking, return empty
190
+ if _CSS_SKIP_PRAGMA.search(styles):
191
+ logger.debug("CSS file opted out via skip-coherence-check pragma")
192
+ return set(), set(), set(), []
193
+
194
+ body = _CSS_COMMENT_RE.sub("", styles)
195
+
196
+ classes = set(_CSS_CLASS_SELECTOR_RE.findall(body))
197
+ vars_defined = set(_CSS_VAR_DEF_RE.findall(body))
198
+ vars_used = set(_CSS_VAR_USE_RE.findall(body))
199
+ url_refs = [
200
+ u for u in _CSS_URL_RE.findall(body)
201
+ if not u.startswith(("data:", "http://", "https://", "//"))
202
+ ]
203
+
204
+ return classes, vars_defined, vars_used, url_refs
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # JavaScript parsing — the key addition
209
+ # ---------------------------------------------------------------------------
210
+ _JS_BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
211
+
212
+ # Literal-string selector calls
213
+ _JS_QUERY_SELECTOR = re.compile(
214
+ r"""(?:querySelector(?:All)?|matches|closest)\s*\(\s*(['"])(.*?)\1""",
215
+ re.DOTALL,
216
+ )
217
+ _JS_GET_ELEMENT_BY_ID = re.compile(
218
+ r"""getElementById\s*\(\s*(['"])(.*?)\1""",
219
+ re.DOTALL,
220
+ )
221
+ _JS_INLINE_HTML_CLASS = re.compile(r"""class\s*=\s*(["'])([^"']*?)\1""", re.IGNORECASE)
222
+ _JS_GET_ELEMENTS_BY_CLASS = re.compile(
223
+ r"""getElementsByClassName\s*\(\s*([^)]*?)\)""",
224
+ re.DOTALL,
225
+ )
226
+ _JS_CLASS_LIST_CALL = re.compile(
227
+ r"""classList\.(?:add|remove|toggle|contains)\s*\(([^)]*?)\)""",
228
+ re.DOTALL,
229
+ )
230
+ _JS_CLASSNAME_ASSIGN = re.compile(
231
+ r"""\.className\s*=\s*(['"])([^'"]*?)\1""",
232
+ re.DOTALL,
233
+ )
234
+ _JS_SET_ATTRIBUTE_CLASS = re.compile(
235
+ r"""setAttribute\s*\(\s*['"]class['"]\s*,\s*(['"])([^'"]*?)\1""",
236
+ re.DOTALL,
237
+ )
238
+ _JS_JQUERY_SELECTOR = re.compile(
239
+ r"""\$\(\s*(['"])(.*?)\1\s*\)""",
240
+ re.DOTALL,
241
+ )
242
+
243
+ # Any reference to a view panel id in JS. Catches $(), getElementById,
244
+ # querySelector, querySelectorAll, and string literals used as selectors.
245
+ _JS_VIEW_ID_REF = re.compile(
246
+ r"""#view-([a-zA-Z0-9_-]+)""",
247
+ )
248
+
249
+ # Split a selector string into class tokens (`.foo`) and ID tokens (`#bar`)
250
+ _SELECTOR_CLASS_TOKEN = re.compile(r"\.([a-zA-Z_][a-zA-Z0-9_-]*)")
251
+ _SELECTOR_ID_TOKEN = re.compile(r"#([a-zA-Z_][a-zA-Z0-9_-]*)")
252
+
253
+ # Extract all quoted string literals from a parenthesized argument list
254
+ _JS_STRING_LITERAL = re.compile(r"""(['"])([^'"]*?)\1""")
255
+
256
+
257
+ def _extract_js_findings(js: str) -> Dict[str, Set[str]]:
258
+ """
259
+ Extract selector targets from JS source code.
260
+
261
+ Returns:
262
+ dict with keys:
263
+ 'classes_queried': classes appearing in querySelector / matches / closest
264
+ 'ids_queried': IDs appearing in getElementById / querySelector('#...')
265
+ 'classes_added': classes added via classList.add / className =
266
+ """
267
+ # Strip block comments to avoid parsing commented-out code.
268
+ # We deliberately don't strip line comments because URLs inside strings
269
+ # contain `//` which would break naive line-comment removal.
270
+ body = _JS_BLOCK_COMMENT_RE.sub("", js)
271
+
272
+ classes_queried: Set[str] = set()
273
+ ids_queried: Set[str] = set()
274
+ classes_added: Set[str] = set()
275
+
276
+ # 1. querySelector / querySelectorAll / matches / closest
277
+ for m in _JS_QUERY_SELECTOR.finditer(body):
278
+ selector = m.group(2)
279
+ classes_queried.update(_SELECTOR_CLASS_TOKEN.findall(selector))
280
+ ids_queried.update(_SELECTOR_ID_TOKEN.findall(selector))
281
+
282
+ # 2. getElementById
283
+ for m in _JS_GET_ELEMENT_BY_ID.finditer(body):
284
+ ids_queried.add(m.group(2).strip())
285
+
286
+ # 3. getElementsByClassName('a b c')
287
+ for m in _JS_GET_ELEMENTS_BY_CLASS.finditer(body):
288
+ for lit in _JS_STRING_LITERAL.finditer(m.group(1)):
289
+ for cls in lit.group(2).split():
290
+ cls = cls.strip()
291
+ if cls:
292
+ classes_queried.add(cls)
293
+
294
+ # 4. classList.add('a', 'b', 'c')
295
+ for m in _JS_CLASS_LIST_CALL.finditer(body):
296
+ for lit in _JS_STRING_LITERAL.finditer(m.group(1)):
297
+ for cls in lit.group(2).split():
298
+ cls = cls.strip()
299
+ if cls:
300
+ classes_added.add(cls)
301
+
302
+ # 5. element.className = 'a b c'
303
+ for m in _JS_CLASSNAME_ASSIGN.finditer(body):
304
+ for cls in m.group(2).split():
305
+ cls = cls.strip()
306
+ if cls:
307
+ classes_added.add(cls)
308
+
309
+ # 6. setAttribute('class', 'a b c')
310
+ for m in _JS_SET_ATTRIBUTE_CLASS.finditer(body):
311
+ for cls in m.group(2).split():
312
+ cls = cls.strip()
313
+ if cls:
314
+ classes_added.add(cls)
315
+
316
+ # 7. jQuery $(selector)
317
+ for m in _JS_JQUERY_SELECTOR.finditer(body):
318
+ selector = m.group(2)
319
+ classes_queried.update(_SELECTOR_CLASS_TOKEN.findall(selector))
320
+ ids_queried.update(_SELECTOR_ID_TOKEN.findall(selector))
321
+ # 8. class="..." inside JS string literals (innerHTML, templates).
322
+ for m in _JS_INLINE_HTML_CLASS.finditer(body):
323
+ for cls in m.group(2).split():
324
+ cls = cls.strip()
325
+ if cls and _VALID_CLASS_TOKEN.match(cls):
326
+ classes_added.add(cls)
327
+
328
+ return {
329
+ "classes_queried": classes_queried,
330
+ "ids_queried": ids_queried,
331
+ "classes_added": classes_added,
332
+ }
333
+
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # File resolution
337
+ # ---------------------------------------------------------------------------
338
+ _EXTERNAL_REF_PREFIXES = ("http://", "https://", "//", "data:", "mailto:", "tel:", "#")
339
+
340
+
341
+ def _is_external(ref: str) -> bool:
342
+ if not ref:
343
+ return True
344
+ if ref.startswith(_EXTERNAL_REF_PREFIXES):
345
+ return True
346
+ if ref.startswith("javascript:"):
347
+ return True
348
+ return False
349
+
350
+
351
+ def _resolve_reference(ref: str, project_root: str, source_file: str) -> bool:
352
+ if _is_external(ref):
353
+ return True
354
+
355
+ clean = ref.split("#", 1)[0].split("?", 1)[0]
356
+ if not clean:
357
+ return True
358
+
359
+ source_dir = os.path.dirname(os.path.join(project_root, source_file))
360
+ candidate = os.path.normpath(os.path.join(source_dir, clean))
361
+ return os.path.exists(candidate) or os.path.exists(
362
+ os.path.join(project_root, clean)
363
+ )
364
+
365
+
366
+ # ---------------------------------------------------------------------------
367
+ # Tab-routing contract check
368
+ # ---------------------------------------------------------------------------
369
+ def _check_tab_routing_contract(
370
+ data_tabs: Set[str],
371
+ html_ids: Set[str],
372
+ js_view_refs: Set[str],
373
+ report: "CoherenceReport",
374
+ ) -> None:
375
+ """
376
+ Verify that every `data-tab="X"` in HTML is wired up correctly:
377
+
378
+ 1. There must be an element with `id="view-X"` in HTML.
379
+ 2. Some JS file must reference `#view-X` (so the tab is switchable).
380
+
381
+ Both checks are deterministic. They catch the specific class of bug
382
+ where a tab button exists in the nav but the corresponding view
383
+ section is missing (or the JS map that switches views doesn't
384
+ include it).
385
+
386
+ `data_tabs` : values from data-tab="..."
387
+ `html_ids` : values from id="..." (includes view-* if present)
388
+ `js_view_refs` : suffix values from #view-... references in JS
389
+ """
390
+ if not data_tabs:
391
+ return
392
+
393
+ missing_sections: List[str] = []
394
+ unwired_tabs: List[str] = []
395
+
396
+ for tab in sorted(data_tabs):
397
+ expected_id = f"view-{tab}"
398
+ if expected_id not in html_ids:
399
+ missing_sections.append(tab)
400
+ if tab not in js_view_refs:
401
+ unwired_tabs.append(tab)
402
+
403
+ if missing_sections:
404
+ report.issues.append(CoherenceIssue(
405
+ kind="tab_missing_section",
406
+ detail=(
407
+ f"{len(missing_sections)} tab button(s) have no matching "
408
+ f"<section id='view-X'> in HTML: "
409
+ f"{', '.join(f'data-tab={t!r} → expected id=view-{t}' for t in missing_sections[:6])}"
410
+ + (" ..." if len(missing_sections) > 6 else "")
411
+ ),
412
+ severity="error",
413
+ ))
414
+
415
+ if unwired_tabs:
416
+ report.issues.append(CoherenceIssue(
417
+ kind="tab_unwired_in_js",
418
+ detail=(
419
+ f"{len(unwired_tabs)} tab button(s) reference '#view-X' "
420
+ f"nowhere in any JS file (clicking them will do nothing): "
421
+ f"{', '.join(f'data-tab={t!r} → expected #view-{t} in JS' for t in unwired_tabs[:6])}"
422
+ + (" ..." if len(unwired_tabs) > 6 else "")
423
+ ),
424
+ severity="error",
425
+ ))
426
+
427
+
428
+ # ---------------------------------------------------------------------------
429
+ # Public entry point
430
+ # ---------------------------------------------------------------------------
431
+ def check_coherence(
432
+ project_root: str,
433
+ files: Optional[Iterable[str]] = None,
434
+ ) -> CoherenceReport:
435
+ """
436
+ Inspect the project's HTML/CSS/JS files and report cross-file issues.
437
+ Never raises.
438
+ """
439
+ report = CoherenceReport()
440
+ _all_data_tabs: Set[str] = set()
441
+ _js_view_refs: Set[str] = set()
442
+
443
+ try:
444
+ html_files, css_files, js_files = _discover_files(project_root, files)
445
+ except Exception as exc:
446
+ report.skipped_reason = f"file discovery failed: {exc}"
447
+ return report
448
+
449
+ if not html_files and not css_files and not js_files:
450
+ report.skipped_reason = "no HTML, CSS, or JS files found"
451
+ return report
452
+
453
+ _tailwind_mode = _project_uses_tailwind(project_root, html_files, css_files)
454
+ if _tailwind_mode:
455
+ report.notes.append(
456
+ "Tailwind detected — skipping unstyled_html_classes and orphan_css_selectors."
457
+ )
458
+
459
+ # ---- Parse HTML ----
460
+ for rel_path in html_files:
461
+ abs_path = os.path.join(project_root, rel_path)
462
+ try:
463
+ with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
464
+ html = f.read()
465
+ except OSError as exc:
466
+ logger.debug("Could not read %s: %s", rel_path, exc)
467
+ continue
468
+
469
+ report.files_checked.append(rel_path)
470
+ html_for_static = _strip_embedded_scripts_and_styles(html)
471
+ report.html_classes |= _extract_html_classes(html_for_static)
472
+ report.html_ids |= _extract_html_ids(html_for_static)
473
+ report.html_ids |= _extract_html_ids(html)
474
+ # Tab buttons: register their data-tab values for the coherence check.
475
+ _all_data_tabs.update(_extract_data_tab_values(html))
476
+
477
+ for ref in _extract_html_references(html):
478
+ if not _resolve_reference(ref, project_root, rel_path):
479
+ report.issues.append(CoherenceIssue(
480
+ kind="missing_reference",
481
+ detail=f"'{ref}' does not exist on disk",
482
+ severity="error",
483
+ source_file=rel_path,
484
+ ))
485
+
486
+ # Inline <script> blocks are parsed as JS
487
+ for inline_js in _extract_inline_scripts(html):
488
+ if not inline_js.strip():
489
+ continue
490
+ findings = _extract_js_findings(inline_js)
491
+ report.js_classes_queried |= findings["classes_queried"]
492
+ report.js_ids_queried |= findings["ids_queried"]
493
+ report.js_classes_added |= findings["classes_added"]
494
+
495
+ # ---- Parse CSS ----
496
+ css_vars_used_in_css: Set[str] = set()
497
+ for rel_path in css_files:
498
+ abs_path = os.path.join(project_root, rel_path)
499
+ try:
500
+ with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
501
+ styles = f.read()
502
+ except OSError as exc:
503
+ logger.debug("Could not read %s: %s", rel_path, exc)
504
+ continue
505
+
506
+ report.files_checked.append(rel_path)
507
+ classes, vars_def, vars_used, urls = _extract_css(styles)
508
+ report.css_selectors |= classes
509
+ report.css_vars_defined |= vars_def
510
+ css_vars_used_in_css |= vars_used
511
+
512
+ for url in urls:
513
+ if not _resolve_reference(url, project_root, rel_path):
514
+ report.issues.append(CoherenceIssue(
515
+ kind="missing_reference",
516
+ detail=f"url('{url}') in CSS does not exist on disk",
517
+ severity="warning",
518
+ source_file=rel_path,
519
+ ))
520
+
521
+ report.css_vars_used = css_vars_used_in_css
522
+
523
+ # ---- Parse JS files ----
524
+ for rel_path in js_files:
525
+ abs_path = os.path.join(project_root, rel_path)
526
+ try:
527
+ with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
528
+ js = f.read()
529
+ except OSError as exc:
530
+ logger.debug("Could not read %s: %s", rel_path, exc)
531
+ continue
532
+
533
+ report.files_checked.append(rel_path)
534
+ # Collect view-id references so we can verify tab wiring later.
535
+ for m in _JS_VIEW_ID_REF.finditer(js):
536
+ _js_view_refs.add(m.group(1))
537
+ findings = _extract_js_findings(js)
538
+ report.js_classes_queried |= findings["classes_queried"]
539
+ report.js_ids_queried |= findings["ids_queried"]
540
+ report.js_classes_added |= findings["classes_added"]
541
+
542
+ # ---- Set-level coherence checks ----
543
+
544
+ # 1 & 2. Unstyled HTML classes / orphan CSS selectors.
545
+ # Skipped under Tailwind: utility classes are not defined as
546
+ # traditional CSS rules and would produce massive false positives.
547
+ if not _tailwind_mode:
548
+ # 1. Unstyled HTML classes.
549
+ # Every class on disk in HTML must have a CSS rule.
550
+ unstyled = report.html_classes - report.css_selectors - _SAFE_CSS_SELECTORS
551
+ if unstyled:
552
+ sample = sorted(unstyled)
553
+ report.issues.append(CoherenceIssue(
554
+ kind="unstyled_html_classes",
555
+ detail=(
556
+ f"{len(unstyled)} class(es) in HTML have no matching CSS rule: "
557
+ f"{', '.join('.' + c for c in sample[:12])}"
558
+ + (" ..." if len(sample) > 12 else "")
559
+ ),
560
+ severity="error",
561
+ ))
562
+
563
+ # 2. Orphan CSS selectors.
564
+ # A CSS rule whose class exists neither in HTML nor is added by JS
565
+ # will never match any element. ERROR.
566
+ # Accounting for js_classes_added prevents false positives on state
567
+ # classes like .is-active that JS toggles at runtime.
568
+ known_classes = (
569
+ report.html_classes
570
+ | report.js_classes_added
571
+ )
572
+ orphan = report.css_selectors - known_classes - _SAFE_CSS_SELECTORS
573
+ if orphan:
574
+ sample = sorted(orphan)
575
+ report.issues.append(CoherenceIssue(
576
+ kind="orphan_css_selectors",
577
+ detail=(
578
+ f"{len(orphan)} CSS rule(s) reference classes not present in "
579
+ f"HTML and not added by any JS: "
580
+ f"{', '.join('.' + c for c in sample[:12])}"
581
+ + (" ..." if len(sample) > 12 else "")
582
+ ),
583
+ severity="error",
584
+ ))
585
+
586
+ # 3. Undefined CSS variables.
587
+ undefined_vars = css_vars_used_in_css - report.css_vars_defined
588
+ if undefined_vars:
589
+ sample = sorted(undefined_vars)
590
+ report.issues.append(CoherenceIssue(
591
+ kind="undefined_css_variables",
592
+ detail=(
593
+ f"{len(undefined_vars)} CSS variable(s) used but never defined: "
594
+ f"{', '.join(sample[:10])}"
595
+ + (" ..." if len(sample) > 10 else "")
596
+ ),
597
+ severity="warning",
598
+ ))
599
+
600
+ # 4. JS queries classes that don't exist anywhere.
601
+ # This is THE check that catches the exact "JS silent failure" bug:
602
+ # the agent writes JS expecting .task-options but HTML has .task-answers.
603
+ # The class isn't in HTML and isn't dynamically added by JS, so any
604
+ # document.querySelector('.task-options') will return null.
605
+ missing_js_classes = (
606
+ report.js_classes_queried
607
+ - report.html_classes
608
+ - report.js_classes_added
609
+ )
610
+ if missing_js_classes:
611
+ sample = sorted(missing_js_classes)
612
+ report.issues.append(CoherenceIssue(
613
+ kind="js_query_missing_class",
614
+ detail=(
615
+ f"JS queries {len(missing_js_classes)} class(es) that exist "
616
+ f"neither in HTML nor as JS-added classes. These selectors "
617
+ f"will return null at runtime: "
618
+ f"{', '.join('.' + c for c in sample[:12])}"
619
+ + (" ..." if len(sample) > 12 else "")
620
+ ),
621
+ severity="error",
622
+ ))
623
+
624
+ # 5. JS queries IDs that don't exist in HTML.
625
+ missing_js_ids = report.js_ids_queried - report.html_ids
626
+ if missing_js_ids:
627
+ sample = sorted(missing_js_ids)
628
+ report.issues.append(CoherenceIssue(
629
+ kind="js_query_missing_id",
630
+ detail=(
631
+ f"JS queries {len(missing_js_ids)} ID(s) that do not exist in "
632
+ f"HTML. getElementById will return null: "
633
+ f"{', '.join('#' + i for i in sample[:12])}"
634
+ + (" ..." if len(sample) > 12 else "")
635
+ ),
636
+ severity="error",
637
+ ))
638
+
639
+ # 6. JS adds classes with no CSS rule.
640
+ # These classes will be added to elements but will not change their
641
+ # appearance. Often indicates a missing state styling rule
642
+ # (.is-open, .has-error, etc.).
643
+ unstyled_js_added = (
644
+ report.js_classes_added
645
+ - report.css_selectors
646
+ - _SAFE_CSS_SELECTORS
647
+ )
648
+ if unstyled_js_added:
649
+ sample = sorted(unstyled_js_added)
650
+ report.issues.append(CoherenceIssue(
651
+ kind="js_added_class_unstyled",
652
+ detail=(
653
+ f"JS adds {len(unstyled_js_added)} class(es) that have no CSS "
654
+ f"rule (state changes may be invisible): "
655
+ f"{', '.join('.' + c for c in sample[:12])}"
656
+ + (" ..." if len(sample) > 12 else "")
657
+ ),
658
+ severity="warning",
659
+ ))
660
+
661
+ # ---- Tab routing contract (HTML data-tab ↔ JS #view-X wiring) ----
662
+ try:
663
+ _check_tab_routing_contract(
664
+ data_tabs=_all_data_tabs,
665
+ html_ids=report.html_ids,
666
+ js_view_refs=_js_view_refs,
667
+ report=report,
668
+ )
669
+ except Exception as exc:
670
+ logger.debug("Tab-routing contract check failed: %s", exc)
671
+
672
+ return report
673
+
674
+
675
+ def _discover_files(
676
+ project_root: str,
677
+ files: Optional[Iterable[str]],
678
+ ) -> Tuple[List[str], List[str], List[str]]:
679
+ """Return (html_relative_paths, css_relative_paths, js_relative_paths)."""
680
+ if files is not None:
681
+ html_files = [f for f in files if f.lower().endswith((".html", ".htm"))]
682
+ css_files = [f for f in files if f.lower().endswith((".css", ".scss"))]
683
+ js_files = [f for f in files if f.lower().endswith((".js", ".mjs"))]
684
+ return html_files, css_files, js_files
685
+
686
+ ignored_dirs = {
687
+ ".git", ".vegex", "node_modules", "__pycache__", ".venv", "venv",
688
+ "dist", "build", "iteration_logs", ".pytest_cache", ".mypy_cache",
689
+ }
690
+
691
+ html_files: List[str] = []
692
+ css_files: List[str] = []
693
+ js_files: List[str] = []
694
+ root = Path(project_root).resolve()
695
+
696
+ for dirpath, dirnames, filenames in os.walk(root):
697
+ dirnames[:] = [
698
+ d for d in dirnames
699
+ if d not in ignored_dirs and not d.startswith(".")
700
+ ]
701
+ for fname in filenames:
702
+ lower = fname.lower()
703
+ rel = os.path.relpath(os.path.join(dirpath, fname), root).replace("\\", "/")
704
+ if lower.endswith((".html", ".htm")):
705
+ html_files.append(rel)
706
+ elif lower.endswith((".css", ".scss")):
707
+ css_files.append(rel)
708
+ elif lower.endswith((".js", ".mjs")):
709
+ js_files.append(rel)
710
+
711
+ return sorted(html_files), sorted(css_files), sorted(js_files)
712
+
713
+
714
+ # ---------------------------------------------------------------------------
715
+ # Rendering
716
+ # ---------------------------------------------------------------------------
717
+ def render_report(report: CoherenceReport, max_lines: int = 20) -> str:
718
+ """
719
+ Compact text representation for injection into the reviewer prompt.
720
+ """
721
+ if report.skipped_reason:
722
+ return f"[coherence] skipped: {report.skipped_reason}"
723
+
724
+ if not report.issues:
725
+ if not report.files_checked:
726
+ return "[coherence] nothing to check"
727
+ return (
728
+ f"[coherence] OK — "
729
+ f"{len(report.html_classes)} HTML classes, "
730
+ f"{len(report.css_selectors)} CSS selectors, "
731
+ f"{len(report.js_classes_queried)} JS queried classes, "
732
+ f"all coherent"
733
+ )
734
+
735
+ lines = ["[coherence report]"]
736
+ lines.append(
737
+ f"Files checked: {', '.join(report.files_checked[:6])}"
738
+ + (f" (+{len(report.files_checked) - 6} more)"
739
+ if len(report.files_checked) > 6 else "")
740
+ )
741
+ lines.append(
742
+ f"Totals: {len(report.html_classes)} HTML classes, "
743
+ f"{len(report.html_ids)} HTML IDs, "
744
+ f"{len(report.css_selectors)} CSS selectors, "
745
+ f"{len(report.js_classes_queried)} JS queried classes, "
746
+ f"{len(report.js_classes_added)} JS added classes"
747
+ )
748
+
749
+ errors = report.errors
750
+ warnings = report.warnings
751
+
752
+ if errors:
753
+ lines.append(f"ERRORS ({len(errors)}):")
754
+ for issue in errors[: max(1, max_lines - len(lines) - 2)]:
755
+ lines.append(f" • {issue.as_line()}")
756
+
757
+ if warnings:
758
+ lines.append(f"WARNINGS ({len(warnings)}):")
759
+ remaining = max(1, max_lines - len(lines))
760
+ for issue in warnings[:remaining]:
761
+ lines.append(f" • {issue.as_line()}")
762
+
763
+ return "\n".join(lines)
764
+
765
+
766
+ __all__ = [
767
+ "CoherenceIssue",
768
+ "CoherenceReport",
769
+ "check_coherence",
770
+ "render_report",
771
+ ]