""" linter.py — Background Syntax Validator (S364) Validazione sintattica leggera per file VFS. Mai blocca — sempre fire-and-forget. Risultati salvati su Supabase (lint_ok, lint_errors, lint_at). Supportato: - Python: ast.parse() — zero deps, istantaneo - JSON: json.loads() - TypeScript/JavaScript: node --check + tsc --noEmit (P40-C: type errors visibili) - TSX/JSX: S702 — _lint_tsx_jsx() structural checker (brace balance + JSX tag balance) - Altro: skip silenzioso Design: max _LINT_TIMEOUT=5s, silent failures, never raises GAP-VFS-FIX: lint_and_store() ora accetta content_updated_at (BIGINT ms). Se > 0, la UPDATE Supabase usa WHERE updated_at = content_updated_at. Se 0 righe aggiornate → il file è stato aggiornato dopo l'inizio del lint → risultato stale scartato silenziosamente. Previene lint_ok falso su contenuto già superato da una scrittura concorrente. """ from __future__ import annotations import ast as _ast import asyncio import json as _json import os import re import tempfile import logging _logger = logging.getLogger("api.linter") _LINT_TIMEOUT = 5.0 # secondi — mai blocca oltre questo def _lint_python(content: str) -> dict: """ast.parse() — controllo sintattico Python senza dipendenze aggiuntive.""" try: _ast.parse(content, mode='exec') return {'ok': True, 'errors': []} except SyntaxError as e: return {'ok': False, 'errors': [f"SyntaxError riga {e.lineno}: {e.msg}"]} except Exception as e: return {'ok': False, 'errors': [str(e)[:300]]} # S588: 200→300 def _lint_json(content: str) -> dict: """Controllo sintattico JSON via json.loads().""" try: _json.loads(content) return {'ok': True, 'errors': []} except _json.JSONDecodeError as e: return {'ok': False, 'errors': [f"JSONDecodeError: {e.msg} (riga {e.lineno})"]} async def _lint_tsc(fname: str) -> dict | None: """P40-C: TypeScript type-check via tsc --noEmit --skipLibCheck. Complementa node --check (solo sintassi V8) con type checking reale. Rileva: prop mancanti, interfacce incomplete, tipi incompatibili. Ritorna None se tsc non disponibile (fallback silenzioso a node --check). """ try: proc = await asyncio.create_subprocess_exec( 'tsc', '--noEmit', '--allowJs', '--skipLibCheck', '--strict', '--target', 'ESNext', '--moduleResolution', 'node', fname, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=8.0) if proc.returncode == 0: return {'ok': True, 'errors': [], 'checker': 'tsc'} # tsc scrive errori su stdout out_text = (stdout + stderr).decode('utf-8', errors='replace').strip() out_text = out_text.replace(fname, '').replace('/tmp/', '') # Prendi le prime 3 righe di errore per non sovraccaricare lines = [l for l in out_text.splitlines() if 'error TS' in l][:3] err_msg = '\n'.join(lines) or out_text[:500] return {'ok': False, 'errors': [err_msg[:600]], 'checker': 'tsc'} except asyncio.TimeoutError: return {'ok': True, 'errors': [], 'skipped': 'tsc timeout', 'checker': 'tsc'} except FileNotFoundError: return None # tsc non installato — usa solo node --check except Exception as e: _logger.debug("[linter] tsc silenced: %s", e) return None async def _lint_node(content: str, ext: str) -> dict: """TypeScript/JavaScript syntax check via node --check + tsc --noEmit (P40-C).""" try: with tempfile.NamedTemporaryFile(suffix=f'.{ext}', mode='w', delete=False, dir='/tmp') as f: f.write(content) fname = f.name try: # Step 1: node --check (sintassi V8, veloce) proc = await asyncio.create_subprocess_exec( 'node', '--check', fname, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=4.0) if proc.returncode != 0: err_text = stderr.decode('utf-8', errors='replace').strip() err_text = err_text.replace(fname, '').replace('/tmp/', '') # S599: 300→500 — linter stderr può contenere path + messaggio lungo return {'ok': False, 'errors': [err_text[:500]], 'checker': 'node'} # Step 2 (P40-C): tsc --noEmit per .ts/.tsx — type errors reali if ext in ('ts', 'tsx'): tsc_result = await _lint_tsc(fname) if tsc_result is not None: return tsc_result # GAP-LINT-FIX: tsc non installato → segnala esplicitamente # (prima: {'checker':'node'} senza flag → indistinguibile da successo) return {'ok': True, 'errors': [], 'checker': 'node', 'tsc_skipped': 'tsc_unavailable'} return {'ok': True, 'errors': [], 'checker': 'node'} finally: try: os.unlink(fname) except Exception as _exc: _logger.debug("[linter] silenced %s", type(_exc).__name__) # noqa: BLE001 except asyncio.TimeoutError: return {'ok': True, 'errors': [], 'skipped': 'node check timeout'} except FileNotFoundError: return {'ok': True, 'errors': [], 'skipped': 'node not found'} except Exception as e: return {'ok': True, 'errors': [], 'skipped': str(e)[:300]} # S606: 200→300 # S702: HTML void elements — non richiedono tag di chiusura in JSX _JSX_VOID_TAGS: frozenset[str] = frozenset({ 'br', 'hr', 'img', 'input', 'meta', 'link', 'area', 'base', 'col', 'embed', 'param', 'source', 'track', 'wbr', }) def _lint_tsx_jsx(content: str) -> dict: """ S702: Structural syntax checker per TSX/JSX — zero dipendenze esterne. Controlla: bilanciamento brace/paren, bilanciamento tag JSX. Ritorna partial=True (non è un full parser — checker strutturale). """ errors: list[str] = [] # Strip commenti e stringhe per ridurre falsi positivi stripped = re.sub(r'//[^\n]*', '', content) stripped = re.sub(r'/\*[\s\S]*?\*/', '', stripped) stripped = re.sub(r'"(?:[^"\\]|\\.)*"', '""', stripped) stripped = re.sub(r"'(?:[^'\\]|\\.)*'", "''", stripped) stripped = re.sub(r'`(?:[^`\\]|\\.)*`', '``', stripped) # 1. Brace/paren balance brace_delta = stripped.count('{') - stripped.count('}') paren_delta = stripped.count('(') - stripped.count(')') if abs(brace_delta) > 2: errors.append(f"Parentesi graffe sbilanciate: {{}} (delta {brace_delta:+d})") if abs(paren_delta) > 2: errors.append(f"Parentesi tonde sbilanciate: () (delta {paren_delta:+d})") # 2. JSX tag balance — solo tag con nome (non tag vuoti HTML) tag_count: dict[str, int] = {} # Opening tags: ]*)?>(?!\s*/)', content): tag = m.group(1) if tag.lower() not in _JSX_VOID_TAGS: tag_count[tag] = tag_count.get(tag, 0) + 1 # Self-closing: for m in re.finditer(r'<([A-Za-z][A-Za-z0-9.]*)[^>]*/>', content): tag = m.group(1) tag_count[tag] = tag_count.get(tag, 0) - 1 # Closing: for m in re.finditer(r'', content): tag = m.group(1) tag_count[tag] = tag_count.get(tag, 0) - 1 unbalanced = [ (t, c) for t, c in tag_count.items() if abs(c) > 1 and t.lower() not in _JSX_VOID_TAGS ] for tag, delta in sorted(unbalanced, key=lambda x: -abs(x[1]))[:2]: errors.append(f"Tag JSX non bilanciato: <{tag}> (delta {delta:+d})") return { 'ok': len(errors) == 0, 'errors': errors, 'partial': True, # checker strutturale — non full parser } async def lint_code(content: str, language: str, path: str = '') -> dict: """ Entry point principale. Ritorna {ok, errors, language, path}. Mai rilancia eccezioni. Max _LINT_TIMEOUT secondi. """ if not content or not content.strip(): return {'ok': True, 'errors': [], 'language': language, 'path': path} lang = (language or '').lower().strip() ext = path.rsplit('.', 1)[-1].lower() if '.' in path else '' try: if lang in ('python', 'py') or ext == 'py': result = _lint_python(content) elif lang in ('json',) or ext == 'json': result = _lint_json(content) elif lang in ('typescript', 'ts', 'javascript', 'js') or ext in ('ts', 'js'): result = await asyncio.wait_for( _lint_node(content, ext or ('ts' if 'typescript' in lang else 'js')), timeout=_LINT_TIMEOUT, ) elif lang in ('tsx', 'jsx') or ext in ('tsx', 'jsx'): result = _lint_tsx_jsx(content) # S702: structural checker (non più skip) else: result = {'ok': True, 'errors': [], 'skipped': f'no linter for {lang or ext or "unknown"}'} except asyncio.TimeoutError: result = {'ok': True, 'errors': [], 'skipped': 'lint timeout'} except Exception as e: result = {'ok': True, 'errors': [], 'skipped': str(e)[:300]} # S606: 200→300 result['language'] = language result['path'] = path return result async def lint_and_store( file_id: str, content: str, language: str, path: str, conversation_id: str | None = None, content_updated_at: int = 0, ) -> dict: """ Linta un file e salva il risultato su Supabase. P40-D: retry fino a 2 tentativi (0.5s sleep) invece di fire-and-forget puro. Copre il caso Railway cold-start dove le prime scritture cadono nel vuoto. GAP-VFS-FIX: content_updated_at (BIGINT ms, opzionale). Se > 0: la UPDATE usa WHERE id=? AND updated_at=content_updated_at. Se 0 righe aggiornate → il file ha ricevuto un'altra scrittura durante il lint → risultato scartato (stale lint prevention). Backward compatible: content_updated_at=0 (default) → comportamento originale. Ritorna il dict con il risultato del lint. """ result = await lint_code(content, language, path) if file_id: import time as _time payload = { 'lint_ok': result.get('ok', True), 'lint_errors': result.get('errors', []), 'lint_at': int(_time.time() * 1000), } _MAX_ATTEMPTS = 2 for _attempt in range(_MAX_ATTEMPTS): try: from .state import sb q = sb().table('vfs_files').update(payload).eq('id', file_id) if content_updated_at > 0: # GAP-VFS-FIX: confronto ottimistico su updated_at q = q.eq('updated_at', content_updated_at) res = q.execute() # Controlla se la riga è stata effettivamente aggiornata if content_updated_at > 0 and not res.data: _logger.info( "[linter] GAP-VFS: lint result discarded for file_id=%s " "(content updated_at=%d superseded by concurrent write)", file_id, content_updated_at, ) # Stale — non ritentare: il file ha una versione più recente break break # successo except Exception as _exc: if _attempt < _MAX_ATTEMPTS - 1: await asyncio.sleep(0.5) # P40-D: attendi breve prima del retry else: _logger.debug("[linter] supabase store failed after %d attempts: %s", _MAX_ATTEMPTS, _exc) # S364: silent — mai blocca VFS write return result