File size: 12,071 Bytes
28a08e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
"""
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