crafting-table/crafting_table/parsers/__init__.py
Kayos d467b2f5be v0.1 wave 2A (steps 5+6): per-language parsers + findings extraction
- parsers/ package: rust / python / go / typescript / generic
- parser registry with language+recipe -> fallback resolution
- fingerprint hash (kind+file+line+code) for cross-run dedup
- runner.py post-exec hook: parse log, persist findings, count on job row
  (extraction runs before mark_job_finished so callers polling on terminal
  status see findings_count populated atomically)
- db.insert_finding / list_findings / increment_findings_count DAOs already
  shipped in wave 1; wired here
- GET /jobs/{id}/findings now returns real data (server route already
  shipped; was returning empty list because nothing populated the table)
- tests/test_parsers/: 6 modules + 11 fixtures (rust/python/go/typescript)
- tests/test_runner_findings.py: 3 integration tests
- README: tick steps 2-6, add Findings section

Suite: 108 passing (62 wave-1 + 46 new).
Spec: memory/spec-crafting-table.md
2026-04-29 08:36:16 -07:00

71 lines
2.4 KiB
Python

"""Per-language parsers — turn tool output into structured Finding rows.
Wave 2A (steps 5+6 of the spec). The runner calls `find_parser(language,
recipe)` after a job's subprocess exits, hands the parser the captured log +
exit code + recipe, and gets back a list of Finding dataclasses to persist.
Resolution order in `find_parser`:
1. exact match `language:recipe` (e.g. ``rust:audit``)
2. language-only match (e.g. ``rust`` for any rust recipe)
3. `GenericParser` fallback — emits one ``recipe_fail`` finding when the
recipe exited non-zero, otherwise empty.
Each parser is best-effort: if its expected JSON shape doesn't parse it
should fall back to "no findings, don't crash" rather than raising. The
runner wraps the call in a try/except as a belt-and-braces second line.
"""
from __future__ import annotations
from .base import Finding, Parser, fingerprint
from .generic import GenericParser
from .go import GoParser
from .python import PythonParser
from .rust import RustParser
from .typescript import TypeScriptParser
# Order matters only for the language-only fallback step inside
# find_parser — exact-match keys are fully qualified so collisions are
# impossible there.
PARSERS: dict[str, type[Parser]] = {
"rust": RustParser,
"python": PythonParser,
"go": GoParser,
"typescript": TypeScriptParser,
# JS uses the same eslint/tsc machinery as ts.
"javascript": TypeScriptParser,
}
def find_parser(language: str, recipe: str) -> type[Parser]:
"""Pick the most specific parser for (language, recipe).
The Parser protocol exposes a `matches` classmethod so a single parser
class can self-gate on (language, recipe) — useful when one class
handles multiple recipes (e.g. RustParser handles audit / lint / test).
The resolution loop here is a thin wrapper around that.
"""
language = (language or "").strip().lower()
recipe = (recipe or "").strip().lower()
# Step 1+2: ask each registered parser if it claims this combo.
candidate = PARSERS.get(language)
if candidate is not None and candidate.matches(language, recipe):
return candidate
# Step 3: generic fallback. Always claims everything.
return GenericParser
__all__ = [
"Finding",
"Parser",
"fingerprint",
"find_parser",
"PARSERS",
"GenericParser",
"RustParser",
"PythonParser",
"GoParser",
"TypeScriptParser",
]