Terminal / api /linter.py
Pulka
sync: 4 changed, 0 deleted — 02ae553d (2026-06-21 14:45)
3e8bb5a verified
Raw
History Blame Contribute Delete
12.1 kB
"""
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, '<file>').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, '<file>').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: <TagName (non self-closing, non closing, non comment)
for m in re.finditer(r'<([A-Za-z][A-Za-z0-9.]*)(?:\s[^>]*)?>(?!\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: <TagName ... />
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: </TagName>
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
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