Files
jhonny-editor/rag/ts_chunker.py
T
João Henrique b541f502ba feat: initial commit - Jhonny Editor
- Adicionado estrutura completa do projeto
- Configurado MCP server para Premiere Pro
- Adicionado documentação e skills
- Configurado Gitignore para o projeto
2026-09-08 09:59:31 -04:00

310 lines
11 KiB
Python

"""Corte de archivos TypeScript/JavaScript en trechos por declaración.
Hermano del `swift_chunker` y `python_chunker`, para el proyecto Jhonny
(`code/`), que es MCP en TypeScript. Antes estos archivos caían en el
cortador genérico por ventana de líneas, que graba `symbols: None` — y eso
deja vacía la columna que alimenta el lado lexical (pg_trgm) de la busca
híbrida. El resultado: buscar `OAuthResourceServer` no rescata ningún
`.ts`, porque el único señal que quedaba era el denso (embedding).
Aquí el corte sigue la estructura del archivo: el preámbulo (imports + doc
de la primera declaración exportada) vira un trecho, y cada declaración de
topo (`export class/interface/type/function/const`, o sus variantes sin
`export`) vira otro. Los identificadores exportados entran en `symbols` con
la misma faixa de `start_line`/`end_line` que el agente usa para leer sólo
esa ventana.
Tipos de declaración soportados (patrones reales vistos en code/src/):
export function buildContentSecurityPolicy(...): string
export interface AuthenticatedPrincipal { ... }
export type AuthenticationResult = A | B
export const HTTP_SECURITY_HEADERS = Object.freeze({ ... })
export class OAuthResourceServer { ... }
function bearerToken(...) // sin export sigue importando
type JwtVerifier = (...) => Promise<...>
"""
import os
import re
# Palabras que abren una declaración a nivel de tope. `type` entra por los
# alias de tipo (`export type X = ...`); `const`/`let`/`var` por las
# constantes exportadas (ej: `export const HTTP_SECURITY_HEADERS`).
_DECL_KW = "class|interface|type|enum|function|const|let|var"
# Modificadores que preceden la palabra clave de la declaración: `export`
# primero, y después `default`/`async`/`abstract`/`declare`.
_MODIFIERS = "export|default|async|abstract|declare|global"
_DECL_RE = re.compile(
rf"^(?P<indent>[ \t]*)(?:(?:{_MODIFIERS})\s+)*(?P<kw>{_DECL_KW})"
rf"\s+(?P<name>[A-Za-z_$][A-Za-z0-9_$]*)"
)
# Declaración exportada con nombre — alimenta `public_symbols` y el tipo
# principal del archivo (`file_facts`).
_EXPORT_RE = re.compile(
rf"^(?:export\s+)(?:(?:{_MODIFIERS})\s+)*(?P<kw>{_DECL_KW})"
rf"\s+(?P<name>[A-Za-z_$][A-Za-z0-9_$]*)"
)
# Declaraciones que vuelven `main_type` del archivo (tipos, no funciones
# ni constantes). Sin `export` se ignora: un helper interno no manda.
_TYPE_RE = re.compile(
rf"^(?:export\s+)(?:(?:{_MODIFIERS})\s+)*(?P<kw>class|interface|type|enum)"
rf"\s+(?P<name>[A-Za-z_$][A-Za-z0-9_$]*)"
)
# Nombres declarados en un trecho — alimenta la columna `symbols` (lado
# lexical pg_trgm). Casa cualquier `kw name` del cuerpo, incluidos métodos
# y constantes internas de una clase, que también son nombres buscables.
_SYMBOL_RE = re.compile(
rf"\b(?:{_DECL_KW})\s+(?P<name>[A-Za-z_$][A-Za-z0-9_$]*)"
)
# Líneas que no abren ni cierran escopo de verdad, para que la contada de
# llaves no se engañe con comentario o literal. TS usa comillas simples,
# dobles y backticks (template literals): cubrimos las tres.
_LINE_COMMENT_RE = re.compile(r"//.*$")
_STRING_RE = re.compile(
r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'|`(?:[^`\\]|\\.)*`'
)
# Archivos hasta este tamaño vienen un único trecho: el archivo entero.
WHOLE_FILE_MAX_LINES = 120
# Teto rígido de caracteres por trecho (nomic-embed-text: 2048 tokens).
MAX_CHARS = 5000
# Tamaño que un trecho intenta alcanzar juntando declaraciones vecinas.
TARGET_CHARS = 2000
def _strip_noise(line):
return _LINE_COMMENT_RE.sub("", _STRING_RE.sub('""', line))
def _preamble_start(lines, i):
"""Recua de `i` para incluir el doc-comment y anotaciones de la declaración."""
j = i
while j > 0:
prev = lines[j - 1].strip()
if prev.startswith("//") or prev.startswith("*") or prev.startswith("/*") \
or prev.startswith("@"):
j -= 1
else:
break
return j
def _split_long(chunk_lines, start_line, max_chars=MAX_CHARS):
"""Divide un trecho grande en pedazos, siempre en frontera de línea."""
out, buf, buf_start, size = [], [], start_line, 0
for offset, line in enumerate(chunk_lines):
if buf and size + len(line) + 1 > max_chars:
out.append((buf, buf_start))
buf, buf_start, size = [], start_line + offset, 0
buf.append(line)
size += len(line) + 1
if buf:
out.append((buf, buf_start))
return out
def _merge_small(chunks):
"""Junta declaraciones vecinas cortas hasta `TARGET_CHARS`.
Sólo funde trechos contiguos (el fin de uno encosta el comienzo de otro),
para que la faixa de líneas del trecho fundido siga siendo una ventana
única y legible con `Read(offset=…, limit=…)`.
"""
merged = []
for c in chunks:
prev = merged[-1] if merged else None
contiguous = prev is not None and prev["end_line"] + 1 == c["start_line"]
same_kind = prev is not None and prev["kind"] == c["kind"] == "decl"
fits = prev is not None and \
len(prev["content"]) + len(c["content"]) + 1 <= TARGET_CHARS
if contiguous and same_kind and fits:
prev["content"] += "\n" + c["content"]
prev["end_line"] = c["end_line"]
prev["symbols"] = " ".join(
sorted(set(prev["symbols"].split()) | set(c["symbols"].split()))
)
else:
merged.append(dict(c))
return merged
def _symbols_of(text, file_stem, main_type):
"""Nombres buscables del trecho.
El nombre del archivo entra siempre, igual que el tipo principal: es lo
que hace que una consulta por el nombre (`OAuthResourceServer`) case con
el archivo derecho — exactamente el caso en que la busca densa erraba.
"""
names = {file_stem}
if main_type:
names.add(main_type)
names.update(m.group("name") for m in _SYMBOL_RE.finditer(text))
return " ".join(sorted(n for n in names if n))
def file_facts(text, rel_path):
"""Tipo principal, símbolos públicos y resumen — alimenta `file_index`."""
lines = text.splitlines()
main_type = None
summary = None
public_symbols = []
for i, line in enumerate(lines):
m = _EXPORT_RE.match(line)
if m and m.group("name"):
public_symbols.append(m.group("name"))
tm = _TYPE_RE.match(line)
if tm and main_type is None:
main_type = tm.group("name")
# Resumen: primera línea del doc-comment justo encima del tipo.
for k in range(_preamble_start(lines, i), i):
doc = lines[k].strip()
if doc.startswith("//") or doc.startswith("*"):
summary = doc.lstrip("/").strip().lstrip("*").strip()
break
if main_type is None:
main_type = os.path.splitext(os.path.basename(rel_path))[0]
if summary is None:
for line in lines[:40]:
s = line.strip()
if s.startswith("//"):
summary = s.lstrip("/").strip()
break
return {
"main_type": main_type,
"summary": summary,
"public_symbols": sorted(set(public_symbols)),
"n_lines": len(lines),
}
def _depth_at_line_starts(lines):
"""Profundidad de llaves al INICIO de cada línea."""
depth = 0
depths = []
for line in lines:
clean = _strip_noise(line)
depths.append(depth)
depth += clean.count("{") - clean.count("}")
return depths
def _top_decls(lines):
"""Declaraciones de tope: lista de (inicio, fin) en índices medio-abiertos.
Una declaración empieza en una línea a profundidad 0 que casa `_DECL_RE` e
incluye su preámbulo (doc-comment/anotaciones); va hasta la próxima
declaración de tope o el final del archivo.
"""
depths = _depth_at_line_starts(lines)
starts = []
for i, line in enumerate(lines):
if depths[i] == 0 and _DECL_RE.match(line):
starts.append(_preamble_start(lines, i))
# Normaliza solapamientos (un preámbulo puede meter la declaración previa).
cleaned = []
for s in starts:
if not cleaned or s > cleaned[-1]:
cleaned.append(s)
starts = cleaned
out = []
for idx, s in enumerate(starts):
e = starts[idx + 1] if idx + 1 < len(starts) else len(lines)
out.append((s, e))
return out
def _body_open(lines, s, e):
"""Índice de la línea donde se abre el cuerpo `{` de una declaración de
tope (primera ida de 0 a 1 dentro de [s, e)). None si no abre cuerpo."""
depth = 0
for i in range(s, e):
clean = _strip_noise(lines[i])
opened = clean.count("{")
closed = clean.count("}")
if depth == 0 and opened > closed:
return i
depth += opened - closed
return None
def _member_starts(lines, body_start, decl_end, depths):
"""Miembros directos (nivel 1) del cuerpo de una clase/interface/enum."""
starts = []
for i in range(body_start + 1, decl_end):
if depths[i] != 1:
continue
if not _DECL_RE.match(lines[i]):
continue
s = _preamble_start(lines, i)
if s > body_start and (not starts or s > starts[-1]):
starts.append(s)
return starts
def chunk_ts(text, rel_path):
"""Divide un archivo TS/JS en trechos con faixa de líneas y símbolos.
Devuelve lista de dicts: content, start_line, end_line, symbols, kind.
Las líneas son 1-indexadas e inclusivas en las dos puntas.
"""
lines = text.splitlines()
if not lines:
return []
stem = os.path.splitext(os.path.basename(rel_path))[0]
main_type = file_facts(text, rel_path)["main_type"]
def build(start, end, kind):
out = []
for piece, piece_start in _split_long(lines[start:end], start + 1):
body = "\n".join(piece).strip()
if not body:
continue
out.append({
"content": body,
"start_line": piece_start,
"end_line": piece_start + len(piece) - 1,
"symbols": _symbols_of(body, stem, main_type),
"kind": kind,
})
return out
if len(lines) <= WHOLE_FILE_MAX_LINES:
return build(0, len(lines), "file")
top = _top_decls(lines)
if not top:
return build(0, len(lines), "window")
depths = _depth_at_line_starts(lines)
chunks = build(0, top[0][0], "header") if top[0][0] > 0 else []
for s, e in top:
size = sum(len(lines[i]) + 1 for i in range(s, e))
if size <= TARGET_CHARS:
chunks.extend(build(s, e, "decl"))
continue
# Tipo grande (clase/interface con muchos miembros): corta el cuerpo
# por miembro (nivel 1), manteniendo la firma del tipo en el primero.
body_open = _body_open(lines, s, e)
if body_open is None:
chunks.extend(build(s, e, "decl"))
continue
members = _member_starts(lines, body_open, e, depths)
if not members:
chunks.extend(build(s, e, "decl"))
continue
chunks.extend(build(s, members[0], "decl"))
for idx, si in enumerate(members):
ei = members[idx + 1] if idx + 1 < len(members) else e
chunks.extend(build(si, ei, "decl"))
return _merge_small(chunks)