From a9409eea4aebaac6338148be23be151cec1f45f5 Mon Sep 17 00:00:00 2001 From: Feitong Yang Date: Fri, 6 Mar 2026 12:15:29 -0800 Subject: [PATCH] Add semantic linter with LLM-powered code analysis (lions lint) Implements three semantic checks: intent alignment (function name vs body), architectural drift (code vs project manifesto), and silent contract (cross-file implicit dependencies). Includes v2 architecture design for natural language rules with visitor/observer pattern. Co-Authored-By: Claude Opus 4.6 --- backend/pyproject.toml | 1 + backend/src/lions/cli.py | 44 +- backend/src/lions/lint/__init__.py | 97 +++++ backend/src/lions/lint/baseline_miner.py | 317 ++++++++++++++ backend/src/lions/lint/checks/__init__.py | 0 backend/src/lions/lint/checks/arch_drift.py | 73 ++++ .../src/lions/lint/checks/intent_alignment.py | 167 ++++++++ .../src/lions/lint/checks/silent_contract.py | 117 +++++ backend/src/lions/lint/formatters.py | 118 ++++++ backend/src/lions/lint/llm.py | 98 +++++ backend/src/lions/lint/manifesto.py | 113 +++++ backend/src/lions/models/lint.py | 43 ++ backend/uv.lock | 292 +++++++++++++ docs/linter/analysis.md | 142 +++++++ docs/linter/traditional-linter-deep-dive.md | 199 +++++++++ docs/lions-code-design-v2.md | 293 +++++++++++++ docs/lions-code-design.md | 401 ++++++++++++++++++ 17 files changed, 2514 insertions(+), 1 deletion(-) create mode 100644 backend/src/lions/lint/__init__.py create mode 100644 backend/src/lions/lint/baseline_miner.py create mode 100644 backend/src/lions/lint/checks/__init__.py create mode 100644 backend/src/lions/lint/checks/arch_drift.py create mode 100644 backend/src/lions/lint/checks/intent_alignment.py create mode 100644 backend/src/lions/lint/checks/silent_contract.py create mode 100644 backend/src/lions/lint/formatters.py create mode 100644 backend/src/lions/lint/llm.py create mode 100644 backend/src/lions/lint/manifesto.py create mode 100644 backend/src/lions/models/lint.py create mode 100644 docs/linter/analysis.md create mode 100644 docs/linter/traditional-linter-deep-dive.md create mode 100644 docs/lions-code-design-v2.md create mode 100644 docs/lions-code-design.md diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 9ae0cff..6832fde 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "tree-sitter-cpp>=0.23.0", "tree-sitter-java>=0.23.0", "uvicorn>=0.41.0", + "google-genai>=1.0.0", ] [project.scripts] diff --git a/backend/src/lions/cli.py b/backend/src/lions/cli.py index 93d66b8..3b5db20 100644 --- a/backend/src/lions/cli.py +++ b/backend/src/lions/cli.py @@ -415,6 +415,40 @@ async def _run(): asyncio.run(_run()) +def cmd_lint(args): + """Run semantic lint checks on source files.""" + from lions.lint import run_lint + from lions.lint.formatters import FORMATTERS + + if args.init: + from lions.lint.baseline_miner import find_project_root, load_or_infer_rules, write_inferred_rules + from lions.lint.manifesto import gather_manifesto, write_manifesto + + root = find_project_root(args.path or ".") + rules = load_or_infer_rules(str(root), refresh=True) + rules_path = write_inferred_rules(str(root), rules) + manifesto = gather_manifesto(str(root), refresh_inferred_rules=False) + manifesto_path = write_manifesto(str(root), manifesto) + print(f"Project root: {root}") + print(f"Manifesto written to {manifesto_path}") + print(f"Inferred rules written to {rules_path} ({len(rules)} rules)") + return + + if not args.path: + print("Error: path is required (use 'lions lint ')", file=sys.stderr) + sys.exit(1) + + checks = [args.check] if args.check else None + result = run_lint(args.path, checks=checks, model=args.model) + + formatter = FORMATTERS[args.format] + print(formatter(result)) + + # Exit code: 1 if any errors found + if any(d.severity == "error" for d in result.diagnostics): + sys.exit(1) + + def cmd_serve(args): import uvicorn from lions.db import init_db @@ -475,6 +509,14 @@ def main(): p_guide.add_argument("--version", help="Version (commit SHA, default: auto-detect from DB)") p_guide.add_argument("--model", default="claude-sonnet-4-6", help="Claude model") + # lint + p_lint = sub.add_parser("lint", help="Run semantic lint checks") + p_lint.add_argument("path", nargs="?", help="File or directory to lint") + p_lint.add_argument("--format", "-f", choices=["json", "text", "sarif"], default="text", help="Output format (default: text)") + p_lint.add_argument("--check", choices=["intent", "arch", "contract"], help="Run only one check") + p_lint.add_argument("--model", default=None, help="LLM model (default: claude-haiku-4-5)") + p_lint.add_argument("--init", action="store_true", help="Generate/refresh .lions/manifesto.txt") + # costs p_costs = sub.add_parser("costs", help="Show pipeline cost summary") p_costs.add_argument("--repo", help="Filter by repo (e.g., antirez/rax)") @@ -491,7 +533,7 @@ def main(): parser.print_help() sys.exit(1) - {"parse": cmd_parse, "analyze": cmd_analyze, "annotate": cmd_annotate, "migrate": cmd_migrate, "summarize": cmd_summarize, "guide": cmd_guide, "costs": cmd_costs, "serve": cmd_serve}[ + {"parse": cmd_parse, "analyze": cmd_analyze, "annotate": cmd_annotate, "migrate": cmd_migrate, "summarize": cmd_summarize, "guide": cmd_guide, "lint": cmd_lint, "costs": cmd_costs, "serve": cmd_serve}[ args.command ](args) diff --git a/backend/src/lions/lint/__init__.py b/backend/src/lions/lint/__init__.py new file mode 100644 index 0000000..fcd422c --- /dev/null +++ b/backend/src/lions/lint/__init__.py @@ -0,0 +1,97 @@ +"""Lions Code semantic linter -- orchestrator.""" + +import sys +from pathlib import Path + +from lions.models.lint import LintDiagnostic, LintResult + + +def _collect_files(path: str) -> list[Path]: + """Collect lintable source files from a path.""" + from lions.pipeline.stage1_parse import is_language_supported + + p = Path(path).resolve() + if p.is_file(): + if is_language_supported(str(p)): + return [p] + print(f"Warning: {p} is not a supported language, skipping.", file=sys.stderr) + return [] + + if p.is_dir(): + files = [] + for f in sorted(p.rglob("*")): + if f.is_file() and is_language_supported(str(f)): + files.append(f) + return files + + print(f"Error: {path} is not a file or directory.", file=sys.stderr) + return [] + + +def run_lint( + path: str, + checks: list[str] | None = None, + model: str | None = None, +) -> LintResult: + """Run semantic lint checks on a file or directory. + + Args: + path: File or directory to lint. + checks: List of check names to run. None = all checks. + model: LLM model to use. None = default. + """ + from lions.lint.llm import DEFAULT_MODEL + + model = model or DEFAULT_MODEL + all_checks = checks or ["intent", "arch", "contract"] + files = _collect_files(path) + + if not files: + return LintResult(files_checked=0, diagnostics=[]) + + # Parse all files with Stage 1 + from lions.pipeline.stage1_parse import parse_file_extended + + file_atoms = {} + for f in files: + source = f.read_text() + file_atoms[str(f)] = (source, parse_file_extended(source, file_path=str(f))) + + diagnostics: list[LintDiagnostic] = [] + + # Run selected checks + if "intent" in all_checks: + from lions.lint.checks.intent_alignment import check_intent_alignment + + for fpath, (source, atoms) in file_atoms.items(): + diagnostics.extend(check_intent_alignment(source, atoms, model=model)) + + if "arch" in all_checks: + from lions.lint.checks.arch_drift import check_arch_drift + from lions.lint.manifesto import load_or_gather_manifesto + + root = Path(path).resolve() + if root.is_file(): + root = root.parent + manifesto = load_or_gather_manifesto(str(root)) + if manifesto: + for fpath, (source, atoms) in file_atoms.items(): + diagnostics.extend(check_arch_drift(source, atoms, manifesto, model=model)) + + if "contract" in all_checks and len(files) > 1: + from lions.lint.checks.silent_contract import check_silent_contract + from lions.models.atoms import ExtendedFileAtoms + from lions.pipeline.stage2_analyze import analyze_repo + + all_atoms: dict[str, ExtendedFileAtoms] = {fp: atoms for fp, (_, atoms) in file_atoms.items()} + analysis = analyze_repo(all_atoms, provider="local_file", resource_id=path, version="local") + diagnostics.extend(check_silent_contract(analysis, file_atoms, model=model)) + + # Sort diagnostics by file, then line + diagnostics.sort(key=lambda d: (d.location.file, d.location.line)) + + return LintResult( + files_checked=len(files), + diagnostics=diagnostics, + manifesto_used="arch" in all_checks, + ) diff --git a/backend/src/lions/lint/baseline_miner.py b/backend/src/lions/lint/baseline_miner.py new file mode 100644 index 0000000..5c8c75f --- /dev/null +++ b/backend/src/lions/lint/baseline_miner.py @@ -0,0 +1,317 @@ +"""Docless-first baseline rule miner for semantic linting.""" + +from __future__ import annotations + +import json +import re +import tomllib +from collections import Counter +from pathlib import Path + +from lions.models.lint import BaselineRule +from lions.pipeline.stage1_parse import is_language_supported, parse_file_extended + +_LIONS_DIR = ".lions" +_RULES_FILE = "rules.inferred.json" + +_ROOT_MARKERS = ( + ".git", + "pyproject.toml", + "package.json", + "Cargo.toml", + "go.mod", +) + +_SKIP_DIRS = { + ".git", + ".hg", + ".svn", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".venv", + "venv", + "node_modules", + "dist", + "build", + "__pycache__", +} + +_MAX_SCAN_FILES = 200 + +_SNAKE_RE = re.compile(r"^[a-z][a-z0-9_]*$") +_CAMEL_RE = re.compile(r"^[a-z]+(?:[A-Z][a-z0-9]*)+$") +_PASCAL_RE = re.compile(r"^[A-Z][A-Za-z0-9]*$") + + +def find_project_root(start: str) -> Path: + """Walk upwards from a file/dir and return the nearest project root marker.""" + current = Path(start).resolve() + if current.is_file(): + current = current.parent + + probe = current + while True: + if any((probe / marker).exists() for marker in _ROOT_MARKERS): + return probe + if probe.parent == probe: + return current + probe = probe.parent + + +def _iter_supported_files(root: Path, max_files: int = _MAX_SCAN_FILES) -> list[Path]: + files: list[Path] = [] + for path in sorted(root.rglob("*")): + if len(files) >= max_files: + break + if not path.is_file(): + continue + if any(part in _SKIP_DIRS for part in path.parts): + continue + if is_language_supported(str(path)): + files.append(path) + return files + + +def _safe_read(path: Path) -> str: + try: + return path.read_text() + except (OSError, UnicodeDecodeError): + return "" + + +def _infer_toolchain_rules(root: Path) -> list[BaselineRule]: + rules: list[BaselineRule] = [] + + pyproject = root / "pyproject.toml" + if pyproject.exists(): + txt = _safe_read(pyproject) + if txt: + try: + data = tomllib.loads(txt) + except tomllib.TOMLDecodeError: + data = {} + build = (data.get("build-system") or {}).get("build-backend", "") + tool = data.get("tool") or {} + if "uv" in txt or "uv_build" in build: + rules.append(BaselineRule( + id="baseline/python-package-manager", + category="toolchain", + statement="Use uv-based Python packaging/workflows consistently.", + evidence=["pyproject.toml indicates uv usage (uv/uv_build)."], + confidence=0.95, + )) + if "ruff" in tool or (root / "ruff.toml").exists(): + rules.append(BaselineRule( + id="baseline/python-lint-style", + category="toolchain", + statement="Follow Ruff linting/style conventions as project baseline.", + evidence=["ruff.toml or [tool.ruff] detected."], + confidence=0.9, + )) + + if (root / "pnpm-lock.yaml").exists(): + rules.append(BaselineRule( + id="baseline/node-package-manager", + category="toolchain", + statement="Use pnpm as package manager for JS/TS workflows.", + evidence=["pnpm-lock.yaml detected."], + confidence=0.95, + )) + elif (root / "yarn.lock").exists(): + rules.append(BaselineRule( + id="baseline/node-package-manager", + category="toolchain", + statement="Use yarn as package manager for JS/TS workflows.", + evidence=["yarn.lock detected."], + confidence=0.95, + )) + elif (root / "package-lock.json").exists(): + rules.append(BaselineRule( + id="baseline/node-package-manager", + category="toolchain", + statement="Use npm as package manager for JS/TS workflows.", + evidence=["package-lock.json detected."], + confidence=0.95, + )) + + return rules + + +def _infer_function_naming_rule(function_names: list[str]) -> BaselineRule | None: + public_names = [n for n in function_names if n and not n.startswith("_")] + if len(public_names) < 15: + return None + + counts = Counter() + for name in public_names: + if _SNAKE_RE.match(name): + counts["snake_case"] += 1 + elif _CAMEL_RE.match(name): + counts["camelCase"] += 1 + elif _PASCAL_RE.match(name): + counts["PascalCase"] += 1 + else: + counts["other"] += 1 + + style, count = counts.most_common(1)[0] + ratio = count / len(public_names) + if ratio < 0.7: + return None + + return BaselineRule( + id="baseline/function-naming-style", + category="style", + statement=f"Prefer {style} for public function naming.", + evidence=[f"{count}/{len(public_names)} sampled public functions match {style}."], + confidence=min(0.6 + ratio * 0.4, 0.95), + ) + + +def _infer_python_import_rule(modules: list[str]) -> BaselineRule | None: + if len(modules) < 20: + return None + + relative = sum(1 for m in modules if m.startswith(".")) + absolute = len(modules) - relative + + if relative / len(modules) >= 0.75: + return BaselineRule( + id="baseline/python-import-style", + category="architecture", + statement="Prefer relative imports for internal Python modules.", + evidence=[f"{relative}/{len(modules)} sampled imports are relative."], + confidence=0.8, + ) + if absolute / len(modules) >= 0.9: + return BaselineRule( + id="baseline/python-import-style", + category="architecture", + statement="Prefer absolute imports for Python modules.", + evidence=[f"{absolute}/{len(modules)} sampled imports are absolute."], + confidence=0.8, + ) + return None + + +def _infer_test_layout_rule(root: Path, file_paths: list[Path]) -> BaselineRule | None: + tests_dir = root / "tests" + if tests_dir.exists() and tests_dir.is_dir(): + return BaselineRule( + id="baseline/test-layout", + category="testing", + statement="Keep tests under the top-level tests/ directory.", + evidence=["Top-level tests/ directory detected."], + confidence=0.9, + ) + + test_files = [p for p in file_paths if "test" in p.name.lower()] + if len(test_files) >= 8: + pref_test_prefix = sum(1 for p in test_files if p.name.startswith("test_")) + pref_test_suffix = sum(1 for p in test_files if p.name.endswith("_test.py") or p.name.endswith(".test.ts")) + if pref_test_prefix > pref_test_suffix: + style = "test_* naming" + confidence = pref_test_prefix / len(test_files) + else: + style = "*_test / *.test naming" + confidence = pref_test_suffix / len(test_files) + return BaselineRule( + id="baseline/test-file-naming", + category="testing", + statement=f"Prefer {style} for test files.", + evidence=[f"{len(test_files)} test-like files detected."], + confidence=min(0.65 + confidence * 0.3, 0.9), + ) + return None + + +def infer_baseline_rules(root: str, max_files: int = _MAX_SCAN_FILES) -> list[BaselineRule]: + """Infer deterministic baseline rules from code, config, and tests.""" + root_path = find_project_root(root) + files = _iter_supported_files(root_path, max_files=max_files) + + rules: list[BaselineRule] = [] + rules.extend(_infer_toolchain_rules(root_path)) + + function_names: list[str] = [] + python_import_modules: list[str] = [] + + for file_path in files: + source = _safe_read(file_path) + if not source: + continue + try: + atoms = parse_file_extended(source, file_path=str(file_path)) + except Exception: + # Parsing failures should not fail lint initialization. + continue + + for d in atoms.definitions: + if d.kind == "function": + function_names.append(d.name) + if atoms.language == "python": + for imp in atoms.imports: + if imp.module: + python_import_modules.append(imp.module) + + naming_rule = _infer_function_naming_rule(function_names) + if naming_rule: + rules.append(naming_rule) + + import_rule = _infer_python_import_rule(python_import_modules) + if import_rule: + rules.append(import_rule) + + test_rule = _infer_test_layout_rule(root_path, files) + if test_rule: + rules.append(test_rule) + + # Stable ordering for diffability. + rules.sort(key=lambda r: r.id) + return rules + + +def render_inferred_rules(rules: list[BaselineRule]) -> str: + """Render inferred rules as compact manifesto context for the LLM.""" + if not rules: + return "No high-confidence baseline rules were inferred." + + lines = [] + for rule in rules: + lines.append(f"- [{rule.id}] ({rule.category}, confidence={rule.confidence:.2f}) {rule.statement}") + for ev in rule.evidence[:2]: + lines.append(f" evidence: {ev}") + return "\n".join(lines) + + +def _rules_cache_path(root: Path) -> Path: + lions_dir = root / _LIONS_DIR + lions_dir.mkdir(exist_ok=True) + return lions_dir / _RULES_FILE + + +def write_inferred_rules(root: str, rules: list[BaselineRule]) -> Path: + """Write inferred baseline rules to .lions/rules.inferred.json.""" + root_path = find_project_root(root) + out = _rules_cache_path(root_path) + payload = [r.model_dump() for r in rules] + out.write_text(json.dumps(payload, indent=2)) + return out + + +def load_or_infer_rules(root: str, refresh: bool = False) -> list[BaselineRule]: + """Load cached inferred rules or infer and cache them.""" + root_path = find_project_root(root) + cache_path = _rules_cache_path(root_path) + if cache_path.exists() and not refresh: + try: + data = json.loads(cache_path.read_text()) + if isinstance(data, list): + return [BaselineRule(**item) for item in data if isinstance(item, dict)] + except (OSError, json.JSONDecodeError, TypeError, ValueError): + pass + + rules = infer_baseline_rules(str(root_path)) + write_inferred_rules(str(root_path), rules) + return rules + diff --git a/backend/src/lions/lint/checks/__init__.py b/backend/src/lions/lint/checks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/lions/lint/checks/arch_drift.py b/backend/src/lions/lint/checks/arch_drift.py new file mode 100644 index 0000000..93499c7 --- /dev/null +++ b/backend/src/lions/lint/checks/arch_drift.py @@ -0,0 +1,73 @@ +"""Architectural Drift check -- detects code that violates project conventions.""" + +from lions.models.atoms import ExtendedFileAtoms +from lions.models.lint import LintDiagnostic, LintLocation + +ARCH_DRIFT_PROMPT = """\ +You are a code reviewer checking whether source code follows the project's architectural conventions. + +Below is the project's "Manifesto" -- a collection of configuration files, README excerpts, and convention documents that describe how this project should be written. + +Your task: identify places where the source code VIOLATES or CONTRADICTS these conventions. + +Look for: +- Hardcoded values that contradict constants defined in config files +- Naming conventions that don't match the project style +- Import patterns that violate the project structure +- Technology choices that contradict project preferences (e.g., using pip when the project uses uv) +- API patterns that don't match existing conventions + +Be precise and specific. Only flag clear violations, not style preferences. +For each violation, respond with a JSON array of objects: +{"line": , "message": "", "severity": "warning"} + +If no violations are found, return an empty array: [] + +Return ONLY valid JSON, no markdown fences. +""" + + +def check_arch_drift( + source: str, + atoms: ExtendedFileAtoms, + manifesto: str, + model: str = "gemini-2.0-flash", +) -> list[LintDiagnostic]: + """Check source code against project manifesto for architectural drift.""" + from lions.lint.llm import llm_call, parse_json_response + + if not manifesto or not manifesto.strip(): + return [] + + # Build numbered source + numbered = "\n".join(f"{i+1:4d} | {line}" for i, line in enumerate(source.splitlines())) + + prompt = ( + f"{ARCH_DRIFT_PROMPT}\n\n" + f"=== PROJECT MANIFESTO ===\n{manifesto}\n\n" + f"=== SOURCE CODE ({atoms.file_path}) ===\n{numbered}" + ) + + try: + response = llm_call(prompt, model=model) + results = parse_json_response(response.text) + except Exception as e: + print(f" Warning: arch drift check failed for {atoms.file_path}: {e}") + return [] + + if not isinstance(results, list): + return [] + + diagnostics = [] + for item in results: + if not isinstance(item, dict): + continue + line = item.get("line", 1) + diagnostics.append(LintDiagnostic( + rule_id="lions/arch-drift", + severity=item.get("severity", "warning"), + message=item.get("message", "Possible architectural drift detected."), + location=LintLocation(file=atoms.file_path, line=line), + )) + + return diagnostics diff --git a/backend/src/lions/lint/checks/intent_alignment.py b/backend/src/lions/lint/checks/intent_alignment.py new file mode 100644 index 0000000..47dab74 --- /dev/null +++ b/backend/src/lions/lint/checks/intent_alignment.py @@ -0,0 +1,167 @@ +"""Intent Alignment check -- detects function name vs. body mismatches.""" + +from lions.models.atoms import ExtendedFileAtoms, StructuralAtom +from lions.models.lint import LintDiagnostic, LintLocation + +# Skip functions where side effects are expected by convention +_SKIP_PREFIXES = ( + "__", # dunder methods + "_test_", # test helpers + "cmd_", # CLI command handlers + "test_", # test functions +) +_SKIP_NAMES = {"main", "setup", "teardown", "run"} +_MAX_FUNCTION_LINES = 50 +_MIN_FUNCTION_LINES = 3 +_MAX_SOURCE_PER_BATCH = 3000 # chars, roughly ~750 tokens + + +INTENT_PROMPT = """\ +You are a precise code reviewer. Your ONLY job is to find functions whose names \ +are genuinely misleading about what the function does. + +Flag a function ONLY if the name actively misleads a reader about the function's \ +primary behavior. Do NOT flag: +- Command handlers, CLI entry points, callbacks, or orchestration helpers \ +(these are expected to have side effects) +- Functions with minor side effects like logging or printing +- Private helper functions (prefixed with _) that are only used locally +- Functions whose names are reasonable shorthand even if not perfectly precise + +The bar is HIGH. Only flag clear, unambiguous mismatches such as: +- "is_*" / "has_*" returns non-boolean (list, dict, object instead of bool) +- "get_*" primarily WRITES data rather than reading it +- "validate_*" silently transforms data instead of checking validity +- "count_*" returns a collection instead of a number +- Name implies operating on entity X but actually operates on entity Y + +For each function below, I provide the name, start_line, and source. + +Respond with a JSON array. Include ONLY genuinely mismatched functions. +Each object: {"name": "", "start_line": , "mismatch_type": "", "message": ""} + +mismatch_type is one of: "return_type", "side_effect", "wrong_entity", "misleading_verb" + +If all functions are well-named, return an empty array: [] + +Return ONLY valid JSON, no markdown fences. +""" + + +def _should_check(atom: StructuralAtom) -> bool: + """Filter functions worth checking.""" + if atom.kind not in ("function", "method"): + return False + lines = atom.end_line - atom.start_line + 1 + if lines > _MAX_FUNCTION_LINES or lines < _MIN_FUNCTION_LINES: + return False + if any(atom.name.startswith(p) for p in _SKIP_PREFIXES): + return False + if atom.name in _SKIP_NAMES: + return False + # Skip nested callbacks and local helpers (defined inside another function) + if atom.parent: + return False + return True + + +def _build_batches(atoms: ExtendedFileAtoms) -> list[list[StructuralAtom]]: + """Group functions into batches for efficient LLM calls.""" + candidates = [a for a in atoms.atoms if _should_check(a)] + if not candidates: + return [] + + batches = [] + current_batch = [] + current_size = 0 + + for atom in candidates: + size = len(atom.source) + if current_size + size > _MAX_SOURCE_PER_BATCH and current_batch: + batches.append(current_batch) + current_batch = [] + current_size = 0 + current_batch.append(atom) + current_size += size + + if current_batch: + batches.append(current_batch) + + return batches + + +def check_intent_alignment( + source: str, + atoms: ExtendedFileAtoms, + model: str = "claude-haiku-4-5", +) -> list[LintDiagnostic]: + """Check function names against their body behavior.""" + from lions.lint.llm import llm_call, parse_json_response + + batches = _build_batches(atoms) + if not batches: + return [] + + # Build a lookup from (name, start_line) for precise matching + atom_by_key: dict[tuple[str, int], StructuralAtom] = {} + for batch in batches: + for atom in batch: + atom_by_key[(atom.name, atom.start_line)] = atom + + diagnostics = [] + + for batch in batches: + func_listing = [] + for atom in batch: + func_listing.append(f"--- {atom.name} (start_line={atom.start_line}) ---") + func_listing.append(atom.source) + func_listing.append("") + + prompt = f"{INTENT_PROMPT}\n\nFile: {atoms.file_path}\n\n" + "\n".join(func_listing) + + try: + response = llm_call(prompt, model=model) + results = parse_json_response(response.text) + except Exception as e: + print(f" Warning: intent alignment check failed for batch: {e}") + continue + + if not isinstance(results, list): + continue + + for item in results: + if not isinstance(item, dict) or not item.get("name"): + continue + + name = item["name"] + reported_line = item.get("start_line") or item.get("line") + + # Match by (name, start_line) first for precision + matched = None + if reported_line: + matched = atom_by_key.get((name, reported_line)) + # Fall back to scanning the batch for the name + if not matched: + candidates = [a for a in batch if a.name == name] + if len(candidates) == 1: + matched = candidates[0] + elif reported_line and candidates: + # Pick the closest by line number + matched = min(candidates, key=lambda a: abs(a.start_line - reported_line)) + + if not matched: + continue + + diagnostics.append(LintDiagnostic( + rule_id="lions/intent-alignment", + severity="warning", + message=item.get("message", f"Function '{name}' name may not match its behavior."), + location=LintLocation( + file=atoms.file_path, + line=matched.start_line, + end_line=matched.end_line, + ), + context={"mismatch_type": item.get("mismatch_type", "other")}, + )) + + return diagnostics diff --git a/backend/src/lions/lint/checks/silent_contract.py b/backend/src/lions/lint/checks/silent_contract.py new file mode 100644 index 0000000..2a1e909 --- /dev/null +++ b/backend/src/lions/lint/checks/silent_contract.py @@ -0,0 +1,117 @@ +"""Silent Contract check -- detects cross-file implicit dependencies.""" + +from lions.models.atoms import ExtendedFileAtoms +from lions.models.lint import LintDiagnostic, LintLocation +from lions.models.repo_analysis import RepoAnalysis + +SILENT_CONTRACT_PROMPT = """\ +You are a code reviewer looking for implicit contracts between a callee function and its caller. + +An "implicit contract" is a requirement that is NOT enforced by the type system or compiler, such as: +- Resource cleanup (close files, release locks, disconnect) +- Required call ordering (must call init before use, must call in a specific sequence) +- Preconditions (input must be validated, must be authenticated first) +- State mutations (function modifies global or shared state the caller may not expect) +- Error handling obligations (function can raise specific exceptions the caller doesn't catch) + +Below is a CALLEE function (the function being called) and the CALLER context (the code around the call site). + +Determine if the callee has any implicit contracts that the caller may be violating. + +Respond with a JSON array of objects. Include ONLY actual violations or risks: +{"contract_type": "", "message": "", "severity": "warning"} + +If no implicit contracts are violated, return an empty array: [] + +Return ONLY valid JSON, no markdown fences. +""" + +# Max cross-file edges to check (prioritized by callee PageRank) +_MAX_EDGES = 20 +_CALLER_CONTEXT_LINES = 5 # lines around the call site + + +def _get_source_context(source: str, line: int, context: int = _CALLER_CONTEXT_LINES) -> str: + """Extract lines around a call site.""" + lines = source.splitlines() + start = max(0, line - 1 - context) + end = min(len(lines), line + context) + return "\n".join(f"{i+1:4d} | {lines[i]}" for i in range(start, end)) + + +def check_silent_contract( + analysis: RepoAnalysis, + file_atoms: dict[str, tuple[str, ExtendedFileAtoms]], + model: str = "gemini-2.0-flash", +) -> list[LintDiagnostic]: + """Check cross-file calls for implicit contract violations.""" + from lions.lint.llm import llm_call, parse_json_response + + # Get cross-file call edges + call_edges = [ref for ref in analysis.cross_references if ref.kind == "calls"] + if not call_edges: + return [] + + # Prioritize by callee file PageRank + pagerank_map = {node.file_path: node.pagerank for node in analysis.files} + call_edges.sort(key=lambda e: pagerank_map.get(e.to_file, 0), reverse=True) + call_edges = call_edges[:_MAX_EDGES] + + diagnostics = [] + + for edge in call_edges: + # Get callee source + if edge.to_file not in file_atoms: + continue + callee_source, callee_atoms = file_atoms[edge.to_file] + + # Find callee function source + callee_func = None + for atom in callee_atoms.atoms: + if atom.name == edge.to_symbol and atom.kind in ("function", "method"): + callee_func = atom + break + if not callee_func: + continue + + # Get caller context + if edge.from_file not in file_atoms: + continue + caller_source, _ = file_atoms[edge.from_file] + caller_context = _get_source_context(caller_source, edge.from_line) + + prompt = ( + f"{SILENT_CONTRACT_PROMPT}\n\n" + f"=== CALLEE: {edge.to_symbol} in {edge.to_file} ===\n{callee_func.source}\n\n" + f"=== CALLER CONTEXT in {edge.from_file} (around line {edge.from_line}) ===\n{caller_context}" + ) + + try: + response = llm_call(prompt, model=model) + results = parse_json_response(response.text) + except Exception as e: + print(f" Warning: silent contract check failed for {edge.from_file}:{edge.from_line}: {e}") + continue + + if not isinstance(results, list): + continue + + for item in results: + if not isinstance(item, dict): + continue + diagnostics.append(LintDiagnostic( + rule_id="lions/silent-contract", + severity=item.get("severity", "warning"), + message=item.get("message", "Possible implicit contract violation."), + location=LintLocation( + file=edge.from_file, + line=edge.from_line, + ), + context={ + "contract_type": item.get("contract_type", "unknown"), + "callee": edge.to_symbol, + "callee_file": edge.to_file, + }, + )) + + return diagnostics diff --git a/backend/src/lions/lint/formatters.py b/backend/src/lions/lint/formatters.py new file mode 100644 index 0000000..f029ad9 --- /dev/null +++ b/backend/src/lions/lint/formatters.py @@ -0,0 +1,118 @@ +"""Output formatters for Lions lint diagnostics.""" + +import json + +from lions.models.lint import LintResult + +# SARIF rule metadata +_RULE_META = { + "lions/intent-alignment": { + "shortDescription": "Function name does not match body behavior", + "fullDescription": "The function name implies a different behavior than what the body actually does.", + }, + "lions/arch-drift": { + "shortDescription": "Implementation violates project conventions", + "fullDescription": "The code contradicts architectural rules defined in the project manifesto.", + }, + "lions/silent-contract": { + "shortDescription": "Cross-file implicit contract may be violated", + "fullDescription": "A called function has implicit requirements that the caller may not satisfy.", + }, +} + + +def format_json(result: LintResult) -> str: + """JSON output -- the Pydantic model IS the schema.""" + return result.model_dump_json(indent=2) + + +def format_text(result: LintResult) -> str: + """Human-readable terminal output (file:line:col severity rule).""" + lines = [] + for d in result.diagnostics: + loc = d.location + lines.append(f"{loc.file}:{loc.line}:{loc.column} {d.severity} {d.rule_id}") + lines.append(f" {d.message}") + if d.explanation: + for exp_line in d.explanation.split("\n"): + lines.append(f" {exp_line}") + lines.append("") + + errors = sum(1 for d in result.diagnostics if d.severity == "error") + warnings = sum(1 for d in result.diagnostics if d.severity == "warning") + notes = sum(1 for d in result.diagnostics if d.severity == "note") + + parts = [] + if errors: + parts.append(f"{errors} error{'s' if errors != 1 else ''}") + if warnings: + parts.append(f"{warnings} warning{'s' if warnings != 1 else ''}") + if notes: + parts.append(f"{notes} note{'s' if notes != 1 else ''}") + + summary = ", ".join(parts) if parts else "no issues" + lines.append(f"Found {summary} in {result.files_checked} file{'s' if result.files_checked != 1 else ''}.") + return "\n".join(lines) + + +def format_sarif(result: LintResult) -> str: + """SARIF 2.1.0 output for GitHub Code Scanning.""" + # Collect unique rules used + rule_ids_used = sorted(set(d.rule_id for d in result.diagnostics)) + rule_index = {rid: i for i, rid in enumerate(rule_ids_used)} + + rules = [] + for rid in rule_ids_used: + meta = _RULE_META.get(rid, {}) + rules.append({ + "id": rid, + "shortDescription": {"text": meta.get("shortDescription", rid)}, + "fullDescription": {"text": meta.get("fullDescription", "")}, + "defaultConfiguration": {"level": "warning"}, + }) + + severity_map = {"error": "error", "warning": "warning", "note": "note"} + + results = [] + for d in result.diagnostics: + region = {"startLine": d.location.line, "startColumn": d.location.column} + if d.location.end_line is not None: + region["endLine"] = d.location.end_line + if d.location.end_column is not None: + region["endColumn"] = d.location.end_column + + results.append({ + "ruleId": d.rule_id, + "ruleIndex": rule_index[d.rule_id], + "level": severity_map.get(d.severity, "warning"), + "message": {"text": d.message}, + "locations": [{ + "physicalLocation": { + "artifactLocation": {"uri": d.location.file}, + "region": region, + } + }], + }) + + sarif = { + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [{ + "tool": { + "driver": { + "name": "lions-lint", + "version": result.version, + "rules": rules, + } + }, + "results": results, + }], + } + return json.dumps(sarif, indent=2) + + +FORMATTERS = { + "json": format_json, + "text": format_text, + "sarif": format_sarif, +} diff --git a/backend/src/lions/lint/llm.py b/backend/src/lions/lint/llm.py new file mode 100644 index 0000000..4c0a1f8 --- /dev/null +++ b/backend/src/lions/lint/llm.py @@ -0,0 +1,98 @@ +"""LLM abstraction for Lions lint -- supports Claude (Anthropic) and Gemini (Google).""" + +import json +import re +from dataclasses import dataclass, field + + +@dataclass +class LLMUsage: + """Token usage from an LLM call.""" + + input_tokens: int = 0 + output_tokens: int = 0 + + def __iadd__(self, other: "LLMUsage") -> "LLMUsage": + self.input_tokens += other.input_tokens + self.output_tokens += other.output_tokens + return self + + +@dataclass +class LLMResponse: + """Normalized response from any LLM provider.""" + + text: str + usage: LLMUsage + model: str + + +def _repair_json(raw: str) -> str: + """Fix common JSON issues from LLM output.""" + raw = re.sub(r",\s*([}\]])", r"\1", raw) + raw = re.sub(r"//[^\n]*", "", raw) + return raw + + +def parse_json_response(raw: str) -> dict | list: + """Parse JSON from LLM response, handling markdown fences and common issues.""" + text = raw.strip() + if text.startswith("```"): + text = text.split("\n", 1)[1].rsplit("```", 1)[0] + try: + return json.loads(text) + except json.JSONDecodeError: + return json.loads(_repair_json(text)) + + +def _call_anthropic(prompt: str, model: str, max_tokens: int = 4096) -> LLMResponse: + """Call Anthropic Claude API.""" + from anthropic import Anthropic + + client = Anthropic() + response = client.messages.create( + model=model, + max_tokens=max_tokens, + messages=[{"role": "user", "content": prompt}], + ) + usage = LLMUsage( + input_tokens=response.usage.input_tokens, + output_tokens=response.usage.output_tokens, + ) + return LLMResponse(text=response.content[0].text, usage=usage, model=model) + + +def _call_gemini(prompt: str, model: str, max_tokens: int = 4096) -> LLMResponse: + """Call Google Gemini API.""" + from google import genai + + client = genai.Client() + response = client.models.generate_content( + model=model, + contents=prompt, + config=genai.types.GenerateContentConfig( + max_output_tokens=max_tokens, + ), + ) + usage = LLMUsage( + input_tokens=response.usage_metadata.prompt_token_count or 0, + output_tokens=response.usage_metadata.candidates_token_count or 0, + ) + return LLMResponse(text=response.text, usage=usage, model=model) + + +def llm_call(prompt: str, model: str, max_tokens: int = 4096) -> LLMResponse: + """Route an LLM call to the appropriate provider based on model name. + + - "claude-*" or "claude_*" -> Anthropic + - "gemini-*" -> Google Gemini + """ + if model.startswith("claude"): + return _call_anthropic(prompt, model, max_tokens) + elif model.startswith("gemini"): + return _call_gemini(prompt, model, max_tokens) + else: + raise ValueError(f"Unknown model provider for '{model}'. Use 'claude-*' or 'gemini-*'.") + + +DEFAULT_MODEL = "claude-haiku-4-5" diff --git a/backend/src/lions/lint/manifesto.py b/backend/src/lions/lint/manifesto.py new file mode 100644 index 0000000..16987c7 --- /dev/null +++ b/backend/src/lions/lint/manifesto.py @@ -0,0 +1,113 @@ +"""System Manifesto -- gathers project context for architectural drift detection.""" + +from pathlib import Path + +from lions.lint.baseline_miner import find_project_root, load_or_infer_rules, render_inferred_rules + +# Files to look for, in priority order +_CONFIG_FILES = [ + "README.md", + "CLAUDE.md", + "ARCHITECTURE.md", + "CONTRIBUTING.md", + "CONVENTIONS.md", + "pyproject.toml", + "package.json", + "Cargo.toml", + "go.mod", + "tsconfig.json", + "ruff.toml", + ".flake8", +] + +_MAX_LINES_PER_FILE = 200 +_MANIFESTO_DIR = ".lions" +_MANIFESTO_FILE = "manifesto.txt" +_MAX_TOTAL_CHARS = 16000 # roughly ~4000 tokens + + +def _gather_documentation_context(root_path: Path) -> str: + """Gather explicit project docs/config snippets as supplementary context.""" + parts: list[str] = [] + total_chars = 0 + + for filename in _CONFIG_FILES: + # Search in root and one level deep + candidates = [root_path / filename] + for d in root_path.iterdir(): + if d.is_dir() and not d.name.startswith("."): + candidates.append(d / filename) + + for filepath in candidates: + if not filepath.exists() or not filepath.is_file(): + continue + try: + lines = filepath.read_text().splitlines()[:_MAX_LINES_PER_FILE] + content = "\n".join(lines) + if total_chars + len(content) > _MAX_TOTAL_CHARS: + remaining = _MAX_TOTAL_CHARS - total_chars + if remaining < 200: + break + content = content[:remaining] + "\n... (truncated)" + + rel = filepath.relative_to(root_path) + parts.append(f"## {rel}") + parts.append(content) + parts.append("") + total_chars += len(content) + except (OSError, UnicodeDecodeError): + continue + + if total_chars >= _MAX_TOTAL_CHARS: + break + + return "\n".join(parts).strip() + + +def gather_manifesto(root: str, refresh_inferred_rules: bool = False) -> str: + """Gather project context for linting (docless-first, docs as enrichment). + + Returns a plaintext string suitable for LLM context injection. + """ + root_path = find_project_root(root) + parts = ["# Project Manifesto (auto-gathered)", ""] + + # Baseline rules are always included, even with no docs. + rules = load_or_infer_rules(str(root_path), refresh=refresh_inferred_rules) + parts.append("## Inferred Baseline Rules") + parts.append(render_inferred_rules(rules)) + parts.append("") + + docs = _gather_documentation_context(root_path) + if docs: + parts.append("## Supplementary Documentation Context") + parts.append(docs) + parts.append("") + + return "\n".join(parts) + + +def write_manifesto(root: str, content: str) -> Path: + """Write manifesto to .lions/manifesto.txt.""" + root_path = find_project_root(root) + manifesto_dir = root_path / _MANIFESTO_DIR + manifesto_dir.mkdir(exist_ok=True) + manifesto_path = manifesto_dir / _MANIFESTO_FILE + manifesto_path.write_text(content) + return manifesto_path + + +def load_or_gather_manifesto(root: str) -> str: + """Load cached manifesto or gather fresh if not cached.""" + root_path = find_project_root(root) + cached = root_path / _MANIFESTO_DIR / _MANIFESTO_FILE + if cached.exists(): + text = cached.read_text() + # Refresh older cached format that predates inferred baseline rules. + if "## Inferred Baseline Rules" in text: + return text + # Auto-gather and cache + manifesto = gather_manifesto(str(root_path)) + if manifesto.strip(): + write_manifesto(str(root_path), manifesto) + return manifesto diff --git a/backend/src/lions/models/lint.py b/backend/src/lions/models/lint.py new file mode 100644 index 0000000..45d8e94 --- /dev/null +++ b/backend/src/lions/models/lint.py @@ -0,0 +1,43 @@ +"""Data models for Lions Code semantic linter output.""" + +from pydantic import BaseModel + + +class LintLocation(BaseModel): + """Source location for a diagnostic (1-based, matching StructuralAtom).""" + + file: str + line: int # 1-based + column: int = 1 # 1-based + end_line: int | None = None + end_column: int | None = None + + +class LintDiagnostic(BaseModel): + """A single lint finding.""" + + rule_id: str # "lions/intent-alignment", "lions/arch-drift", "lions/silent-contract" + severity: str # "error" | "warning" | "note" + message: str # one-line summary + location: LintLocation + explanation: str = "" # longer LLM-generated reasoning + context: dict = {} # rule-specific metadata + + +class LintResult(BaseModel): + """Complete lint output for a run.""" + + version: str = "0.1" + files_checked: int + diagnostics: list[LintDiagnostic] + manifesto_used: bool = False + + +class BaselineRule(BaseModel): + """A deterministic, inferred project rule mined from code/config/test signals.""" + + id: str + category: str + statement: str + evidence: list[str] = [] + confidence: float diff --git a/backend/uv.lock b/backend/uv.lock index fd42e8f..91f48e7 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -60,6 +60,92 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -81,6 +167,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -115,6 +254,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/de/6171c3363bbc5e01686e200e0880647c9270daa476d91030435cf14d32f5/fastapi-0.132.0-py3-none-any.whl", hash = "sha256:3c487d5afce196fa8ea509ae1531e96ccd5cdd2fd6eae78b73e2c20fba706689", size = 104652, upload-time = "2026-02-23T17:56:20.836Z" }, ] +[[package]] +name = "google-auth" +version = "2.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + +[[package]] +name = "google-genai" +version = "1.65.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/f9/cc1191c2540d6a4e24609a586c4ed45d2db57cfef47931c139ee70e5874a/google_genai-1.65.0.tar.gz", hash = "sha256:d470eb600af802d58a79c7f13342d9ea0d05d965007cae8f76c7adff3d7a4750", size = 497206, upload-time = "2026-02-26T00:20:33.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/3c/3fea4e7c91357c71782d7dcaad7a2577d636c90317e003386893c25bc62c/google_genai-1.65.0-py3-none-any.whl", hash = "sha256:68c025205856919bc03edb0155c11b4b833810b7ce17ad4b7a9eeba5158f6c44", size = 724429, upload-time = "2026-02-26T00:20:32.186Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -219,6 +398,7 @@ source = { editable = "." } dependencies = [ { name = "anthropic" }, { name = "fastapi" }, + { name = "google-genai" }, { name = "httpx" }, { name = "pydantic" }, { name = "tree-sitter" }, @@ -237,6 +417,7 @@ dependencies = [ requires-dist = [ { name = "anthropic", specifier = ">=0.83.0" }, { name = "fastapi", specifier = ">=0.132.0" }, + { name = "google-genai", specifier = ">=1.0.0" }, { name = "httpx", specifier = ">=0.28.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "tree-sitter", specifier = ">=0.25.2" }, @@ -251,6 +432,36 @@ requires-dist = [ { name = "uvicorn", specifier = ">=0.41.0" }, ] +[[package]] +name = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -319,6 +530,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, ] +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -340,6 +578,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, ] +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + [[package]] name = "tree-sitter" version = "0.25.2" @@ -506,6 +753,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + [[package]] name = "uvicorn" version = "0.41.0" @@ -518,3 +774,39 @@ sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e wheels = [ { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, ] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] diff --git a/docs/linter/analysis.md b/docs/linter/analysis.md new file mode 100644 index 0000000..c8ca26d --- /dev/null +++ b/docs/linter/analysis.md @@ -0,0 +1,142 @@ +# Lions Code v2: Why v1 Failed and How to Fix It + +## What Went Wrong + +Three layers of problems, from surface to fundamental. + +### Layer 1: Bug -- wrong project root (easy fix) + +When you run `lions lint src/lions/cli.py --check arch`, the manifesto root resolves to `src/lions/` (the file's parent directory). No `CLAUDE.md`, no `pyproject.toml`, no `README.md` is found there. The manifesto is basically empty. The LLM gets no project rules, so it falls back to generic Python opinions. + +**Fix:** Walk up the directory tree to find `.git` or `pyproject.toml` to identify the real project root. + +### Layer 2: Design flaw -- manifesto is raw files, not extracted rules + +Even with the correct root, `gather_manifesto()` just concatenates raw file contents -- README prose, TOML syntax, markdown. The arch-drift prompt then says "find violations of these project rules." But the LLM has to: +- Parse TOML to understand what dependencies mean +- Extract actionable rules from documentation prose +- Distinguish conventions from casual descriptions + +The original design doc said: "Feeds the skeleton + critical files to an LLM to generate a System Manifesto (a JSON map of architectural rules)." **We skipped this step.** The manifesto was supposed to be LLM-distilled rules, not a raw text dump. + +### Layer 3: Architecture flaw -- "send code, ask for problems" is not differentiated + +The current approach for all three checks is: +1. Take some code +2. Take some context +3. Ask the LLM "find problems" + +This is what ChatGPT/Copilot already does. It's a generic LLM code review with extra steps. The LLM will either be too cautious (return `[]`) or too aggressive (return generic style nits like "hardcoded values should be constants"). Neither is useful. + +**The design doc's vision was fundamentally different:** use tree-sitter to **mechanically identify specific suspicious patterns**, then ask the LLM for a **verdict on those specific candidates only**. The LLM confirms or dismisses -- it doesn't search. + +| | Current (broken) | Design doc vision | +|---|---|---| +| Who finds candidates? | LLM (searches the whole file) | Tree-sitter + Stage 2 (mechanical) | +| What does the LLM do? | "Find problems in this code" | "Is this specific thing a real problem?" | +| Signal-to-noise | Low (LLM generates style nits) | High (pre-filtered candidates) | +| Differentiation | None (same as ChatGPT) | Novel (structural analysis + LLM verdict) | + +## The Fix: Two-Pass Architecture (Per the Original Design) + +### Pass 1: Mechanical candidate identification (no LLM) + +Use tree-sitter and Stage 2 analysis to find **specific, concrete suspicious patterns**: + +**Intent alignment candidates:** +- `is_*`/`has_*` function with no `return True`/`return False` in the AST +- `get_*` function that contains `INSERT`/`UPDATE`/`DELETE`/`.write(`/`.send(` calls +- `count_*` function that returns a list/dict (check return statement AST nodes) +- `validate_*` function that returns a transformed value instead of bool/None/raise + +**Arch drift candidates:** +- Constant `X = value` in one file, hardcoded different `value` in another file (cross-file constant shadowing) +- Import from a layer that should be lower (if layer structure is defined) +- Technology usage that contradicts config (e.g., `subprocess.run(["pip"...])` when pyproject.toml uses uv) + +**Silent contract candidates:** +- Function A calls function B; B has `__enter__`/`__exit__` but A doesn't use `with` +- Function A calls B; B raises specific exceptions but A has no try/except +- Function A calls B; B acquires a lock/resource but A never releases it + +### Pass 2: LLM verdict on specific candidates (targeted questions) + +For each mechanical candidate, ask the LLM a **binary yes/no question with full context**: + +Instead of: "Find problems in this 500-line file." +Ask: "This function is named `is_active` but the AST shows it returns a list on line 47. Here is the function source. Is this a genuine naming mismatch, or is the name reasonable in context? Answer with {verdict: true/false, explanation: string}." + +This is a fundamentally easier task for the LLM -- it's confirming/denying a specific hypothesis, not searching for unknown issues. + +### Manifesto as LLM-extracted rules + +The `--init` step should use an LLM to distill raw project files into **structured rules**: + +```json +{ + "rules": [ + {"id": "uuid-ids", "source": "CLAUDE.md", "rule": "Use UUIDs for all entity IDs, never integer auto-increment"}, + {"id": "uv-only", "source": "CLAUDE.md", "rule": "Use uv for Python package management, never pip"}, + {"id": "no-hardcoded-colors", "source": "CLAUDE.md", "rule": "No hardcoded hex colors in components, use CSS vars or tokens"}, + {"id": "layer-imports", "source": "CLAUDE.md", "rule": "5-layer architecture: lower layers never import from higher layers"} + ] +} +``` + +This is cached to `.lions/rules.json`. The arch-drift check then has concrete rules to check against, not raw prose. + +## Implementation Plan + +### Files to modify + +| File | Change | +|---|---| +| `lint/__init__.py` | Fix root path (walk up to `.git`); restructure to run Pass 1 then Pass 2 | +| `lint/manifesto.py` | Add `extract_rules()` -- LLM call to distill raw files into structured rules JSON | +| `lint/checks/intent_alignment.py` | Split into `find_candidates()` (tree-sitter, no LLM) + `verify_candidates()` (LLM verdict) | +| `lint/checks/arch_drift.py` | Split into `find_candidates()` (rule matching, no LLM) + `verify_candidates()` (LLM verdict) | +| `lint/checks/silent_contract.py` | Split into `find_candidates()` (Stage 2 pattern matching) + `verify_candidates()` (LLM verdict) | +| `models/lint.py` | Add `LintCandidate` model (pre-LLM finding) and `ManifestoRule` model | + +### New model: LintCandidate + +```python +class LintCandidate(BaseModel): + """A mechanically-identified suspicious pattern, pre-LLM verdict.""" + rule_id: str + location: LintLocation + evidence: str # what tree-sitter found ("is_* function returns list on line 47") + source_snippet: str # the relevant code + context_snippet: str # surrounding context (cross-file if needed) + +class ManifestoRule(BaseModel): + """A single extracted architectural rule.""" + id: str + source_file: str # which file the rule came from + rule: str # human-readable rule statement + keywords: list[str] # for mechanical matching ("uuid", "pip", "hardcoded") +``` + +### Build order + +| Phase | What | +|---|---| +| 1 | Fix root path discovery (walk up to `.git`) | +| 2 | Add `ManifestoRule` model; rewrite `manifesto.py` with LLM rule extraction | +| 3 | Rewrite intent alignment: tree-sitter candidate finder + LLM verdict | +| 4 | Rewrite arch drift: rule-matching candidate finder + LLM verdict | +| 5 | Rewrite silent contract: pattern-matching candidate finder + LLM verdict | + +### Verification + +The test case that currently fails: + +```bash +uv run lions lint src/lions/cli.py --check arch +``` + +**Current output:** 3 generic style nits about hardcoded values (not useful). + +**Expected output after fix:** Either (a) nothing, because cli.py follows project conventions, or (b) a specific, verifiable violation like "Line 101 defines `_FILE_EXTS` locally, but `is_language_supported()` in `stage1_parse.py` already defines the canonical extension list." + +The difference: finding (b) requires cross-file awareness (knowing the canonical list exists elsewhere), not generic "hardcoded values are bad" opinions. diff --git a/docs/linter/traditional-linter-deep-dive.md b/docs/linter/traditional-linter-deep-dive.md new file mode 100644 index 0000000..2adfebe --- /dev/null +++ b/docs/linter/traditional-linter-deep-dive.md @@ -0,0 +1,199 @@ +# Traditional Linter Deep Dive: Ruff vs Pylint, ESLint vs oxlint + +Research document for Lions v2 architecture. Understanding how traditional linters work internally to inform the design of LLM-powered semantic linting. + +The question: **what architectural primitives from traditional linters unlock new capabilities when your rule engine is an LLM instead of a pattern matcher?** + +--- + +## The Universal Pipeline + +Every linter follows the same pipeline: + +``` +Source Text -> Parse -> AST -> Traverse -> Match Rules -> Emit Diagnostics +``` + +The differences are in *how* each stage works and *what data flows between them*. + +--- + +## Python: Ruff vs Pylint + +### Parsing + +| | Ruff | Pylint | +|---|---|---| +| **Parser** | Hand-written recursive descent in Rust (`ruff_python_parser`) | CPython `ast.parse()` + astroid rebuild | +| **Output** | Custom Rust AST (`ruff_python_ast`) | astroid nodes (enriched stdlib AST) | +| **Ownership** | Full -- owns lexer, parser, AST definition | Delegates parsing to CPython, post-processes into richer tree | + +**Ruff** started with RustPython's parser but found that an interpreter's ideal AST differs from a linter's. They rewrote it as a hand-written recursive descent parser (Pratt parsing for expressions), gaining 2x parse speed and control over AST shape. + +**Pylint** uses `astroid`, which takes CPython's AST and rebuilds it into nodes with extra capabilities: `infer()` for type inference, scope resolution, navigation helpers. This is the source of Pylint's power and its performance cost. + +### Rule Execution + +**Ruff -- single-pass compiled visitor:** +- One AST traversal per file. All 900+ rules execute during the same walk. +- Four phases per node: binding -> traversal -> cleanup -> analysis. +- Rules are imperative Rust functions that pattern-match on AST node types. +- Dispatched from dedicated modules (`analyze/statement.rs`, `analyze/expression.rs`). +- No dynamic dispatch -- every rule is compiled into the binary. + +**Pylint -- multi-checker dynamic visitor:** +- Three checker types: AST (`visit_`/`leave_`), token, raw. +- At each AST node, Pylint calls the matching `visit_*` method on *every* registered checker. +- Dynamic dispatch via Python method name convention. + +```python +class MyChecker(BaseChecker): + msgs = {"W9901": ("Found eval()", "eval-used", "eval() is dangerous")} + def visit_call(self, node): + if isinstance(node.func, astroid.Name) and node.func.name == "eval": + self.add_message("eval-used", node=node) +``` + +**Key difference:** Ruff applies all rules in one compiled pass. Pylint iterates the AST once but fires N Python function calls per node (one per checker). The overhead is multiplicative. + +### Type Inference + +**Ruff:** No type inference. Tracks scopes, bindings, and references via `ruff_python_semantic`. Sufficient for "unused import" or "undefined name" but cannot answer "what type does this expression return?" The `ty` project (formerly Red Knot) is building a full type checker using Salsa for incremental computation. + +**Pylint:** Deep inference via astroid's `infer()` protocol. Every node can yield possible runtime values. For `x = foo()`, inference follows the call chain. This enables checks like "method does not exist on inferred type" but is the dominant performance bottleneck -- inference must parse and analyze imported modules transitively. + +### Cross-File Analysis + +**Ruff:** Single-file only. No import following. The ty project will eventually provide cross-file support via Salsa's incremental computation. + +**Pylint:** Genuine cross-file analysis. astroid follows imports, parses dependent modules, uses their type information. The imports checker builds a dependency graph for cycle detection. Analysis cached in pickle format. + +### Autofix + +**Ruff:** Structured edit model -- `Edit` objects (range replacement, insertion, deletion) with three safety tiers: +- **Safe:** preserves runtime semantics, applied with `--fix` +- **Unsafe:** may change behavior, requires `--unsafe-fixes` +- **DisplayOnly:** shown but never auto-applied + +After applying fixes, Ruff re-lints iteratively until convergence. + +**Pylint:** No autofix. Diagnostic-only. + +### Plugin Architecture + +**Ruff:** No plugins. All 900+ rules compiled Rust. Maximum performance, zero extensibility. + +**Pylint:** Rich plugin system. Python classes with `visit_*` methods, loaded via `--load-plugins`. Full access to astroid inference. + +### Performance + +| | Ruff | Pylint | +|---|---|---| +| **250k LOC** | <1 second | ~2.5 minutes | +| **Why** | Rust, single-pass, no inference, Rayon parallelism | Python, astroid rebuild, inference chains, GIL | + +--- + +## TypeScript: ESLint vs oxlint + +### Parsing + +| | ESLint | oxlint | +|---|---|---| +| **Parser** | Espree (Acorn-based), pluggable | oxc_parser, hand-written recursive descent in Rust | +| **AST format** | ESTree (generic `Identifier`) | Oxc AST (`BindingIdentifier`, `IdentifierReference`, `IdentifierName`) | +| **TS support** | `@typescript-eslint/parser` wraps TS compiler | Native TS parsing in oxc_parser | +| **Memory** | V8 GC heap | Arena allocator (Bumpalo), O(1) alloc/dealloc | + +**ESLint** uses ESTree, where all identifiers are the same node type. Rules must check parent context to disambiguate bindings from references. + +**oxlint** splits identifiers into three types matching ECMAScript spec semantics. Arena allocator means all nodes allocated in contiguous memory, freed in O(1). + +### Rule Execution + +**ESLint -- event-driven visitor with selectors:** +- Rules declare interest in node types via object keys. Framework only calls you for matching nodes. +- CSS-like AST selectors: `"CallExpression[callee.name='eval']"` matches without manual checking. +- Rules receive `context` with `sourceCode`, `report()`, `getScope()`, `getAncestors()`. + +```javascript +create(context) { + return { + "CallExpression[callee.name='eval']"(node) { + context.report({ node, messageId: "noEval" }); + } + }; +} +``` + +**oxlint -- Rust trait with three hooks:** +- `run(node, ctx)` -- per AST node. Rule filters via `if let AstKind::...`. +- `run_on_symbol(symbol, ctx)` -- per resolved symbol. +- `run_once(ctx)` -- once per file, for whole-file checks. + +```rust +impl Rule for NoDebugger { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + if let AstKind::DebuggerStatement(stmt) = node.kind() { + ctx.diagnostic(OxcDiagnostic::warn("...").with_label(stmt.span)); + } + } +} +``` + +**Key difference:** ESLint does framework-level filtering (only call rules for nodes they care about). oxlint does rule-level filtering (call every rule for every node, Rust matching skips irrelevant ones). + +### Cross-File Analysis + +**ESLint:** Single-file by design. Cross-file via plugins (`eslint-plugin-import` re-parses imports, notoriously slow). `@typescript-eslint` type-aware rules get cross-file info as side effect of TS type graph. + +**oxlint:** First-class module graph shared across rules. Parsing and resolution results shared. On Airbnb's 126k-file repo: 7 seconds. ESLint timed out. + +### Type-Aware Linting + +**ESLint + @typescript-eslint:** Creates a TypeScript `Program` via TS compiler API. Rules call `getTypeChecker()`. Cost: full TS compilation (10-60+ seconds). + +**oxlint + tsgolint:** Uses `typescript-go` (Microsoft's Go-based TS compiler rewrite, becoming TS v7.0). Two binaries -- oxlint (Rust) for AST rules, tsgolint (Go) for type-aware. + +| Project | oxlint + type-aware | ESLint + typescript-eslint | Speedup | +|---------|-------------------|--------------------------|---------| +| vuejs/core | 2.5s | 20.8s | 8.2x | +| outline/outline | 4.4s | 55.1s | 12.4x | + +### Plugin Architecture + +**ESLint:** Richest JS ecosystem. Plugins export `rules`, `configs`, `processors`. Flat config makes plugins plain JS objects. + +**oxlint:** Two tiers: +1. Built-in Rust plugins (520+ rules across eslint, typescript, react, import, etc.) +2. JS plugin bridge (2025): runs ESLint plugins with raw memory transfer and lazy deserialization. ~15x faster than native ESLint. + +### Performance + +| | ESLint | oxlint | +|---|---|---| +| **AST-only rules** | Baseline | 50-100x faster | +| **Type-aware rules** | Baseline | 8-12x faster | +| **JS plugins through bridge** | Baseline | ~15x faster | +| **Threading** | Single-threaded (experimental multi) | Multi-threaded via Rayon | + +### Fix/Autofix + +**ESLint:** Range-based fixer API. Fixes applied end-of-file backward. Re-runs up to 10 times for cascading fixes. + +**oxlint:** Span-based fixes + ESLint-compatible fixer API for JS plugins. + +--- + +## Summary + +| | Ruff | Pylint | ESLint | oxlint | +|---|---|---|---|---| +| **Language** | Rust | Python | JavaScript | Rust (+Go for types) | +| **Parser** | Hand-written recursive descent | CPython ast + astroid | Espree (Acorn) | Hand-written recursive descent | +| **Rule model** | Compiled Rust functions | Python visitor classes | JS event-driven visitors | Rust trait + JS bridge | +| **Cross-file** | No | Yes (via astroid imports) | Via plugins (slow) | First-class module graph | +| **Type inference** | No (ty/Salsa in progress) | Deep (astroid.infer()) | Via TS compiler | Via typescript-go | +| **Autofix** | Yes (safe/unsafe/display tiers) | No | Yes (range-based fixer) | Yes (span-based) | +| **Plugins** | None (all compiled in) | Rich (Python classes) | Richest ecosystem | Built-in + JS bridge | +| **250k LOC** | <1s | ~2.5min | 20-60s | <1s | diff --git a/docs/lions-code-design-v2.md b/docs/lions-code-design-v2.md new file mode 100644 index 0000000..208f9e7 --- /dev/null +++ b/docs/lions-code-design-v2.md @@ -0,0 +1,293 @@ +# Lions Code -- Semantic Linter Design Proposal (v2.0) + +## 1. Purpose + +This document completes v1 by specifying an implementation-ready design for a high-signal semantic linter that is meaningfully different from generic LLM code review. + +It incorporates the failure analysis in `docs/linter/analysis.md` and replaces "LLM searches for problems" with a deterministic+LLM verification pipeline. + +## 2. Problem Statement + +v1 underperformed for three reasons: + +1. Project root detection was incorrect in common file-targeted runs. +2. Manifesto content was raw text, not executable/structured rules. +3. LLM was asked to discover issues from scratch, producing either low recall or noisy generic feedback. + +The key change in v2 is to separate: + +- Candidate generation (mechanical, deterministic, explainable) +- Candidate adjudication (LLM, contextual, bounded yes/no verdicts) + +## 3. Product Goals + +1. High precision by default: low-noise output suitable for CI. +2. Global semantic awareness: detect cross-file and architecture-level drift. +3. Actionability: each diagnostic includes evidence, explanation, and remediation hints. +4. Extensibility: new rules/checks can be added without redesigning the pipeline. +5. Fast enough for local workflows: median <1.5s for file-level lint with warm cache. +6. Zero-doc bootstrap: useful results even when no project docs exist. + +## 4. Non-Goals + +1. Replacing static type checking, security scanners, or formatter/linter style rules. +2. Producing unconstrained "AI review comments." +3. Full interprocedural soundness across all dynamic language behavior. + +## 5. Design Principles + +1. LLM as judge, not search engine. +2. Deterministic evidence first; natural-language reasoning second. +3. Every finding must be traceable to concrete code spans and a rule source. +4. Confidence-aware diagnostics; uncertain findings should not block by default. +5. Tight feedback loop: user suppressions/accepts improve future precision. +6. Docless-first: infer from code/config/tests; docs enrich and strengthen, not gate functionality. + +## 6. Architecture Overview + +### Pass 0 -- Baseline Rule Inference (batch/cached, docless-first) + +Input: +- code AST/symbol graph/import graph +- build/tooling configs and CI workflows +- tests and test fixtures (if present) +- VCS metadata (optional) + +Output: +- `.lions/rules.inferred.json` + +Behavior: +- Deterministic mining infers conventions and invariants from repetition and constraints. +- LLM normalizes mined signals into enforceable rules with confidence/provenance. +- Rules are deduplicated and normalized into canonical categories. + +### Pass 0.5 -- Documentation Enrichment (optional) + +Input: +- `README.md`, `CLAUDE.md`, `ARCHITECTURE.md`, `CONTRIBUTING.md`, and other explicit convention docs (if present) + +Output: +- `.lions/rules.enriched.json` + +Behavior: +- Extract explicit policies from prose docs. +- Merge explicit doc rules with inferred baseline rules. +- Increase confidence and enforcement level where explicit policy confirms inferred behavior. + +### Pass 1 -- Candidate Mining (deterministic) + +Input: +- AST/symbol graph/tree-sitter atoms +- inferred+enriched rules +- optional cross-file index cache + +Output: +- `LintCandidate[]` with explicit evidence and code locations + +Behavior: +- Heuristics, graph checks, and pattern matchers identify suspicious candidates. +- No LLM calls in this stage. + +### Pass 2 -- Candidate Verdict (LLM, bounded) + +Input: +- one candidate at a time + local snippet + minimal global context + related rule(s) + +Output: +- `LintDiagnostic` with binary verdict and structured explanation + +Behavior: +- LLM answers: "Is this specific candidate a real violation in this context?" +- Strict schema output; no open-ended search. + +### Pass 3 -- Remediation (optional) + +Input: +- confirmed diagnostic + +Output: +- fix suggestion, risk note, and optional test suggestion + +Behavior: +- Generated only for confirmed findings. +- Configurable for local-only usage (not CI blocking path). + +## 7. Data Model + +```python +class ManifestoRule(BaseModel): + id: str + title: str + statement: str + category: Literal["intent", "architecture", "contract", "security", "reliability"] + source_files: list[str] + provenance_kind: Literal["inferred", "documented", "hybrid"] + keywords: list[str] + examples_good: list[str] = [] + examples_bad: list[str] = [] + exceptions: list[str] = [] + confidence: float # 0..1 + +class LintCandidate(BaseModel): + id: str + rule_ids: list[str] + check: Literal["intent", "arch", "contract"] + location: LintLocation + evidence: str + source_snippet: str + context_snippet: str = "" + detector_confidence: float # 0..1 + policy_weight: float = 1.0 # boosted when documented rule is explicit + +class LLMVerdict(BaseModel): + candidate_id: str + is_violation: bool + severity: Literal["error", "warning", "note"] + explanation: str + suggested_fix: str = "" + uncertainty_reasons: list[str] = [] + llm_confidence: float # 0..1 +``` + +Final severity/blocking is policy-driven from detector confidence, LLM confidence, and rule criticality. + +## 8. Check Specifications + +### 8.1 Intent Alignment (`lions/intent-alignment`) + +Candidate detectors: +- `is_/has_` functions returning non-boolean shapes. +- `get_/fetch_` functions performing writes/network side-effects unexpectedly. +- `validate_` functions returning transformed payloads instead of validation outcomes. + +LLM prompt shape: +- "Given this function name, observed behavior summary, and source snippet, is naming/intent meaningfully misaligned in this project context?" + +### 8.2 Architectural Drift (`lions/arch-drift`) + +Candidate detectors: +- Layer import violations (higher-layer dependency from lower layer). +- Constant shadowing/drift across modules. +- toolchain/process mismatches (for example, `pip` invocations against `uv` rules). + +LLM prompt shape: +- "Given rule X and this code evidence, is this a true architecture drift or an allowed exception?" + +### 8.3 Silent Contract (`lions/silent-contract`) + +Candidate detectors: +- Called API implies context-manager usage but call site is plain call. +- Known exceptions raised downstream with no handling/propagation contract. +- Resource/lock lifecycle mismatches. + +LLM prompt shape: +- "Given caller/callee contract evidence, does this call site violate required handling semantics?" + +## 9. Confidence and Noise Control + +Diagnostic score: + +`score = 0.40 * detector_confidence + 0.40 * llm_confidence + 0.10 * rule_confidence + 0.10 * policy_weight` + +Policy: +- `score >= 0.85` and severity error => CI blocking +- `0.65 <= score < 0.85` => warning +- `< 0.65` => note (non-blocking) +- purely inferred rules default to non-blocking until confirmed by repetition, user acceptance, or explicit docs + +Safeguards: +- Default top-k findings per file/check. +- Deduplicate semantically equivalent findings. +- Require non-empty evidence string and location span. + +## 10. Output Contract (v2) + +v1 output model remains, with additions: +- `confidence` (float) +- `evidence` (short deterministic proof text) +- `provenance` (rule IDs + source files) +- `status` (`new`, `known`, `suppressed`) + +This supports IDE UX, CI policies, and analytics without changing core consumer integrations. + +## 11. Caching and Performance + +Caches: +- `.lions/rules.inferred.json` (docless baseline rules) +- `.lions/rules.enriched.json` (docs-enriched rules) +- `.lions/index/` (symbol and cross-file relationship cache) +- `.lions/verdict_cache.jsonl` (candidate hash -> verdict for unchanged snippets) + +Latency targets: +- cold file lint: <3.0s +- warm file lint: <1.5s +- single-function re-lint in editor loop: <600ms best-effort + +Optimization order: +1. minimize LLM candidate count via detector precision +2. reuse snippet-hash verdict cache +3. batch low-risk candidates in a single LLM request where schema allows + +## 12. Learning Loop + +Capture user actions: +- accepted finding +- dismissed as false positive +- suppressed with reason + +Use signals to: +- adjust detector thresholds +- update rule exceptions +- improve candidate ranking + +No opaque auto-mutation of rules in CI path; changes are proposed and reviewed. + +## 13. Rollout Plan + +Phase 1: +- Fix root discovery and introduce docless baseline rule inference. +- Ship `intent-alignment` two-pass path. + +Phase 2: +- Add optional documentation enrichment merge (`rules.enriched.json`). +- Add `arch-drift` deterministic detectors + LLM verdict. +- Introduce confidence scoring and CI gating policy. + +Phase 3: +- Add `silent-contract` with cross-file call/contract graph checks. +- Add remediation suggestions and verdict cache. + +Phase 4: +- Editor/LSP integration and semantic debt trend dashboard. + +## 14. Success Metrics + +Quality: +- precision >= 85% on sampled diagnostics +- false-positive rate <= 15% for warning+ findings + +Usefulness: +- >= 60% of accepted findings are cross-file/cross-context (not catchable by syntax linters) +- median time-to-fix for confirmed findings decreases release-over-release + +Performance: +- p50/p95 latency targets met on representative repos + +Trust: +- every finding includes reproducible evidence + rule provenance + +## 15. Open Questions + +1. Should rule distillation consume PR history and issue discussions in phase 1 or later? +2. Should repo-specific rule artifacts (`rules.inferred.json`, `rules.enriched.json`) be committed or regenerated locally? +3. Which checks are allowed to block CI by default for first rollout? +4. How much autofix should be enabled before robust regression safeguards exist? + +## 16. Immediate Next Implementation Tasks + +1. Add `find_project_root()` up-walk logic (`.git`, `pyproject.toml`, etc.). +2. Implement baseline rule miners (config/test/code invariants) + `ManifestoRule` schema. +3. Implement optional doc enrichment merger with provenance tracking. +4. Refactor each check into `find_candidates()` and `verify_candidates()`. +5. Add confidence scoring, evidence/provenance fields, deduplication, and policy weighting. +6. Add baseline evaluation harness for precision/noise measurements. diff --git a/docs/lions-code-design.md b/docs/lions-code-design.md new file mode 100644 index 0000000..a8260d1 --- /dev/null +++ b/docs/lions-code-design.md @@ -0,0 +1,401 @@ +# Lions Code -- Semantic Linter Design Doc (v1.0) + +## 1. Vision and Problem Statement + +**The Problem:** Traditional tools (LSPs/Linters) are context-blind. They can tell if a line of code is legal (syntax) but not if it is right (semantics). As codebases grow, the "why" behind the code is lost, leading to "Semantic Debt." + +**The Solution:** A two-pass analysis engine that extracts a "System Manifesto" (Global Context) and uses it to peer-review code in real-time, acting as an automated John Lions. + +## 2. System Architecture + +### Pass 1: The "Knowledge Distiller" (Batch/Background) + +Runs whenever the architecture changes (e.g., merging a PR). + +- **Step A -- Skeleton Extraction:** Uses Tree-sitter to crawl the repo and extract all function signatures, class hierarchies, and type definitions without reading logic. (Reuses existing Stage 1: `pipeline/stage1_parse.py`) +- **Step B -- Intent Summarization:** Feeds the skeleton + critical files (constants.py, README.md, schema.sql) to an LLM to generate a System Manifesto (a structured map of architectural rules). (New: `lint/manifesto.py`) +- **Step C -- Semantic Indexing:** Embeds the summaries into a Vector Database for quick retrieval during Pass 2. (Future -- prototype uses direct context injection instead.) + +### Pass 2: The "Semantic Critic" (Real-time) + +Runs as a CLI linter (`lions lint`), with future LSP/VS Code extension support. + +- **Step A -- Local Retrieval:** For a given file, gathers relevant "Global Truths" from the manifesto. +- **Step B -- The "Lions" Review:** The LLM receives Current Code + Global Truths and performs a "Consistency Check." +- **Step C -- Diagnostic Output:** Results are emitted as structured diagnostics in JSON, SARIF, or human-readable text. + +## 3. Key Enrichment Rules (The "Lions" Checks) + +| Check Name | Rule ID | Target | Semantic Goal | +|---|---|---|---| +| Intent Alignment | `lions/intent-alignment` | Function Name vs. Body | Does `validate_user()` actually change database state? (Flag as Side-Effect mismatch). | +| Architectural Drift | `lions/arch-drift` | Implementation vs. Manifesto | The Manifesto says "Use UUIDs," but this function generates an Integer ID. | +| Silent Contract | `lions/silent-contract` | Cross-file Dependencies | This function calls a library that requires a specific cleanup, but no finally block is present. | + +## 4. Output Format + +### Design Principles + +The output format was designed after studying six production linters: + +| Linter | Language | JSON shape | Severity levels | Location format | SARIF support | +|---|---|---|---|---|---| +| pylint | Python | flat array of objects | 5 (C/R/W/E/F) | 1-based line+col, optional end | No | +| ruff | Python | flat array of objects | implicit from rule prefix | 1-based row+col | Yes | +| mypy | Python | newline-delimited JSON | 2 (error/note) | 1-based line+col, no end | No | +| ESLint | JS/TS | per-file wrapper with messages array | 2 (1=warning, 2=error) | 1-based line+col with end | Via plugin | +| Biome | JS/TS | RDJSON diagnostics array | 3 (error/warning/information) | 0-based line+col | Yes | +| golangci-lint | Go | top-level object with Issues array | 2 (warning/error) | 1-based via Go token.Position | Yes | + +**Decisions:** +- **Flat array** (like Ruff), not per-file wrappers (like ESLint). Simpler to parse. +- **1-based line/column** (like pylint, ruff, mypy). Matches tree-sitter StructuralAtom convention. LSP consumers subtract 1. +- **3 severity levels:** error, warning, note. Maps cleanly to SARIF (error/warning/note) and LSP (Error/Warning/Information). +- **Rule ID with namespace:** `lions/`. Follows ESLint `@scope/rule-name` convention. + +### Diagnostic Model + +```python +class LintLocation(BaseModel): + file: str + line: int # 1-based + column: int = 1 # 1-based + end_line: int | None = None + end_column: int | None = None + +class LintDiagnostic(BaseModel): + rule_id: str # "lions/intent-alignment" + severity: str # "error" | "warning" | "note" + message: str # one-line summary + location: LintLocation + explanation: str = "" # longer LLM-generated reasoning + context: dict = {} # rule-specific metadata + +class LintResult(BaseModel): + version: str = "0.1" + files_checked: int + diagnostics: list[LintDiagnostic] + manifesto_used: bool = False +``` + +### Output Formats + +**JSON** (default for piping): +```json +{ + "version": "0.1", + "files_checked": 3, + "diagnostics": [ + { + "rule_id": "lions/intent-alignment", + "severity": "warning", + "message": "Function 'is_active' returns a list, but name suggests boolean return.", + "location": {"file": "src/auth.py", "line": 42, "column": 1, "end_line": 55}, + "explanation": "The prefix 'is_' conventionally indicates a predicate...", + "context": {"expected_return": "bool", "actual_return": "list"} + } + ] +} +``` + +**Text** (for terminal): +``` +src/auth.py:42:1 warning lions/intent-alignment + Function 'is_active' returns a list, but name suggests boolean return. + +src/db.py:88:1 warning lions/arch-drift + Uses integer IDs, but project manifesto specifies UUIDs. + +Found 2 warnings, 0 errors in 3 files. +``` + +**SARIF 2.1.0** (for GitHub Code Scanning): +```json +{ + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [{ + "tool": { + "driver": { + "name": "lions-lint", + "version": "0.1", + "rules": [ + { + "id": "lions/intent-alignment", + "shortDescription": {"text": "Function name does not match body behavior"}, + "defaultConfiguration": {"level": "warning"} + } + ] + } + }, + "results": [ + { + "ruleId": "lions/intent-alignment", + "level": "warning", + "message": {"text": "Function 'is_active' returns a list, but name suggests boolean return."}, + "locations": [{ + "physicalLocation": { + "artifactLocation": {"uri": "src/auth.py"}, + "region": {"startLine": 42, "startColumn": 1, "endLine": 55} + } + }] + } + ] + }] +} +``` + +## 5. LSP Diagnostic Mapping (Future Reference) + +When Lions Code becomes an LSP server, each `LintDiagnostic` maps to an LSP `Diagnostic`: + +| Lions field | LSP field | Transform | +|---|---|---| +| `location.line` | `range.start.line` | Subtract 1 (LSP is 0-based) | +| `location.column` | `range.start.character` | Subtract 1 | +| `severity` | `severity` | "error"->1, "warning"->2, "note"->3 | +| `rule_id` | `code` | Direct string | +| `message` | `message` | Direct string | +| -- | `source` | Always "lions-lint" | +| `explanation` | `relatedInformation` or hover | Extended info | + +## 6. Manifesto Design + +The System Manifesto is a plaintext context blob (under 4000 tokens) assembled from repo artifacts: + +**Sources (in priority order):** +1. `README.md` (first 200 lines) +2. Build config: `pyproject.toml`, `package.json`, `Cargo.toml`, `go.mod` +3. Convention files: `CLAUDE.md`, `ARCHITECTURE.md`, `CONTRIBUTING.md`, `CONVENTIONS.md` +4. Linter configs: `.eslintrc*`, `tsconfig.json`, `ruff.toml`, `.flake8` +5. If available: existing `RepoSummary` from Lions annotation pipeline + +**Caching:** Auto-gathered on first `lions lint` run, cached to `.lions/manifesto.txt`. User can review/edit. Re-gathered with `lions lint --init`. + +## 7. LLM Provider Support + +The linter supports multiple LLM backends for flexibility and cost optimization. + +| Provider | SDK | Model examples | Speed | Use case | +|---|---|---|---|---| +| Google Gemini | `google-genai` | `gemini-2.0-flash` | Fast | Default for speed | +| Anthropic Claude | `anthropic` | `claude-haiku-4-5`, `claude-sonnet-4-6` | Moderate | Higher quality analysis | + +Model string prefix determines provider routing: `gemini-*` -> Google, `claude-*` -> Anthropic. + +## 8. CLI Interface + +``` +lions lint # lint file or directory +lions lint --format json|text|sarif # output format (default: text) +lions lint --check intent|arch|contract # run one check only +lions lint --model gemini-2.0-flash # model override +lions lint --init [] # generate/refresh manifesto +``` + +Exit code: 0 = clean, 1 = errors found. + +## 9. v2 Architecture: Natural Language Rules + Visitor/Observer + +### 9.1 Problem with v1 + +v1 has three hardcoded checks, each a monolithic LLM call owning its entire pipeline (extraction, prompt, LLM call, output parsing). No shared infrastructure. Adding a fourth check means writing another ~150-line module from scratch. This is analogous to a linter where every rule is a standalone script that re-parses the file. + +### 9.2 Traditional Linter Internals (Research) + +See [docs/linter/traditional-linter-deep-dive.md](linter/traditional-linter-deep-dive.md) for the full research on Ruff vs Pylint (Python) and ESLint vs oxlint (TypeScript). Key takeaways: + +**The Universal Linter Pipeline:** +``` +Source Text -> Parse -> AST -> Traverse -> Match Rules -> Emit Diagnostics +``` + +**Rule Execution -- the Visitor Pattern:** +- **Ruff:** Single-pass compiled visitor. All 900+ rules fire during one AST walk. Four phases per node: binding -> traversal -> cleanup -> analysis. +- **Pylint:** Multi-checker dynamic visitor. Three checker types (AST/token/raw). At each node, calls `visit_*` on every registered checker. +- **ESLint:** Event-driven visitor with CSS-like AST selectors. Rules declare interest in node types; framework only calls you for matching nodes. +- **oxlint:** Rust trait with three hooks: `run` (per node), `run_on_symbol` (per symbol), `run_once` (per file). + +**The key insight:** Traditional rules are predicate functions over AST nodes. The visitor pattern is an efficient way to invoke thousands of predicates during a single tree walk. + +**Cross-File Analysis:** oxlint's first-class shared module graph is the right pattern (not ESLint's bolt-on plugin approach). Lions' `RepoAnalysis` already follows this model. + +**Performance:** Ruff/oxlint achieve <1s on 250k LOC vs. Pylint's 2.5min and ESLint's 20-60s. + +### 9.3 The Hybrid Architecture + +**Core idea:** Decompose natural language rules into structured sections, and use a visitor/observer pattern to apply them at the right granularity. + +A natural language rule has structure: + +```yaml +rule: lions/intent-alignment +scope: function # What AST unit does this rule examine? +filter: # When should this rule fire? + kind: [function, method] + min_lines: 3 + max_lines: 50 + exclude_prefixes: [__, _test_, cmd_, test_] + exclude_names: [main, setup, teardown] +context: # What additional context does the rule need? + - source # The function's source code + - signature # Name, params, return type +observe: # What does the LLM evaluate? + question: | + Does the function name accurately describe what the body does? + Only flag CLEAR mismatches. Examples: + - is_*/has_* returning non-boolean + - get_* that primarily writes + - validate_* that silently transforms + output: + - mismatch_type: enum[return_type, side_effect, wrong_entity, misleading_verb] + - message: string + - severity: warning +``` + +The framework: +1. **Parses once** (tree-sitter, as today) +2. **Traverses the AST** with a visitor that knows about rule scopes +3. At each `function` node, **collects all rules scoped to `function`** +4. **Batches them** -- one LLM call with multiple rules applied to the same function, or multiple functions checked against the same rule +5. **Routes context** -- each rule declares what context it needs, framework provides exactly that + +This is the Pylint checker model, but `visit_function` asks the LLM a structured question instead of running a pattern match. + +### 9.4 Why This is Better Than Monolithic Prompts + +**Composable rules.** Users write YAML files, not Python modules: + +```yaml +rule: acme/no-raw-sql +scope: function +filter: + kind: [function, method] +context: + - source +observe: + question: | + Does this function construct SQL queries by string concatenation + or f-string interpolation instead of using parameterized queries? + output: + - message: string + - severity: error +``` + +**Intelligent batching.** The framework knows all active rules and their scopes: +- **Rule batching:** Multiple rules against same function (one LLM call, multiple questions) +- **Code batching:** Same rule against multiple functions (current approach for intent) +- **Matrix batching:** N rules x M functions, optimized by token budget +- **Hierarchical batching:** Class-level + method-level rules in one pass + +**Context routing.** Rules declare context needs (`source`, `manifesto`, `callers`, `callees`, `siblings`, `types`). Framework provides exactly what's needed. + +**Scope-aware traversal.** Rules declare scope: `function`, `class`, `module`, `edge` (cross-file call), `project`. Visitor invokes at the right level. + +**Standardized output.** Every rule gets confidence scoring, evidence extraction, and explanation for free. + +### 9.5 The Observer Pattern for Cross-File Rules + +Silent contract generalizes from a special case to any `scope: edge` rule: + +```yaml +rule: lions/silent-contract +scope: edge +filter: + edge_kind: calls + max_edges: 20 + sort_by: callee_pagerank +context: + - callee_source + - caller_context +observe: + question: | + Does the callee have implicit requirements (cleanup, ordering, + preconditions, mutations, error handling) that the caller may + not satisfy? + output: + - contract_type: enum[cleanup, ordering, precondition, mutation, error_handling] + - message: string + - severity: warning +``` + +Users can add their own edge rules without code: + +```yaml +rule: acme/api-auth-required +scope: edge +filter: + edge_kind: calls + callee_decorators: [route, get, post, put, delete] +context: + - callee_source + - caller_source +observe: + question: | + Is this API endpoint handler called without going through + authentication middleware? +``` + +### 9.6 Execution Model + +``` +Rule Registry (built-in YAML + user YAML) + | indexed by: scope -> [rules] + v +Parse Phase (tree-sitter -> ExtendedFileAtoms + RepoAnalysis) + | + v +Context Providers (manifesto, callers/callees, types, siblings) + | + v +Scope-Based Visitor + | FOR each file: + | module_rules -> batch & invoke LLM + | FOR each class: + | class_rules -> batch & invoke LLM + | FOR each function: + | func_rules -> batch & invoke LLM + | FOR each cross-file edge: + | edge_rules -> batch & invoke LLM + | project_rules -> invoke LLM once + v +Output Pipeline (confidence filter, dedup, format as JSON/text/SARIF) +``` + +### 9.7 Patterns Stolen from Traditional Linters + +| Pattern | Source | Lions Application | +|---|---|---| +| Fix safety tiers | Ruff | Safe (add docstring), Unsafe (rename function), DisplayOnly (suggest refactor) | +| Declarative filter language | ESLint selectors | `filter:` section evaluated before LLM -- zero LLM cost for filtered-out code | +| Shared module graph | oxlint | `RepoAnalysis` available to any `scope: edge` rule, not just silent_contract | +| Message control | Pylint | `# lions: disable=intent-alignment` inline, `.lionsrc` project-level | +| Incremental computation | Ruff ty/Salsa | On file change, only re-run rules depending on changed atoms/edges | + +### 9.8 What Becomes Newly Possible + +**Project-specific rules without code:** A team lead writes YAML to detect god objects, enforce naming conventions, or check domain-specific patterns. No AST pattern can detect "god objects" -- it requires understanding what methods do semantically. + +**Architecture enforcement that understands architecture:** Traditional linters check "module A imports module B." Lions can understand *why* that import violates the layer architecture. + +**Cross-repository convention enforcement:** An organization publishes rules as a shared YAML package. Every team's lions instance enforces org-wide conventions without custom code. + +**Security-aware semantic analysis:** Static taint analysis tools are noisy because they follow syntactic data flow. An LLM can understand whether data is actually dangerous in context. + +## 10. Implementation Roadmap + +| Phase | Deliverable | Description | +|---|---|---| +| 1 | The Scanner | Finalize Tree-sitter script to generate symbols.json for any directory. **Done** -- Stage 1 (`parse_file_extended`) already supports 8 languages. | +| 2 | The Manifesto | Create the prompt that turns symbols + config into an architectural summary. **Done** -- `lint/manifesto.py`. | +| 3 | The CLI Linter (v1) | `lions lint` command with three semantic checks and JSON/SARIF/text output. **Done** -- `lint/` package with intent/arch/contract checks. | +| 4 | Rule Registry (v2) | YAML rule schema, rule loader, scope-based visitor, batching engine. Refactor v1 checks into YAML rules. | +| 5 | The LSP | Build a VS Code extension that triggers a "Lions Review" on save. Requires incremental computation from Phase 4. | + +## 11. Success Criteria + +- **Zero-Config Global Awareness:** Identify a rule in `config.py` (e.g., `MAX_RETRY = 3`) and flag a violation in `api_client.py` where a dev hardcoded `while i < 5`. +- **Intent Mismatch Detection:** Correct identification of at least 80% of "Misnamed Functions" in a test suite. +- **Low-Noise Commentary:** Provide information that cannot be found in standard LSP hover (Pylance/Pyright). +- **Latency Cap:** Results in under 1.5 seconds for functions under 50 lines. +- **User-Authored Rules:** Non-engineers can add semantic lint rules via YAML without writing code.