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
|
@@ -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
|
+
]
|