diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a106ae6e..52c4ab0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,6 +77,12 @@ jobs: - name: registry-drift-check if: ${{ !cancelled() }} run: python -m dokimi.config_check + # Not a gate: file length and complexity totals and the top offenders in + # the step summary, so the trend is visible per run. The gate (no growth + # past scripts/growth_baseline.json) is tests/test_growth_ratchet.py. + - name: File length and complexity report + if: ${{ !cancelled() }} + run: python scripts/check_growth.py --report --markdown >> "$GITHUB_STEP_SUMMARY" - name: guidance self-scan (L0) if: ${{ !cancelled() }} run: python cli.py repo scan .. --manifest-file benchmarks/self-scan.repo-eval.yaml --fail-on-fail diff --git a/AGENTS.md b/AGENTS.md index 74248429..ca6b0e2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,9 @@ also holds a PR to: - Imports follow the layers in `dokimi/tests/test_import_architecture.py`: no upward import, no new cycle, LLM SDKs only in `dokimi.llm`, spawns only in `dokimi.process`. Its baselines only shrink. +- No growth: a file over 800 lines (tests 1000) or a function over complexity 10 + fails unless `scripts/growth_baseline.json` holds it, and then it may only + shrink. Split instead; after shrinking, `python scripts/check_growth.py --update`. - Tests that check behaviour: `tests/test_audit_test_quality.py` fails a test that only checks types, echoes its input, only checks a mock was called, asserts nothing or repeats another (`python scripts/audit_test_quality.py` lists them). diff --git a/dokimi/CONTRIBUTING.md b/dokimi/CONTRIBUTING.md index dbb0097a..992d3241 100644 --- a/dokimi/CONTRIBUTING.md +++ b/dokimi/CONTRIBUTING.md @@ -85,6 +85,13 @@ CI additionally holds a PR to: (front end → tracks → evaluation → components → foundation; nothing imports `cli` or `commands`), fails a new import cycle, and keeps provider SDKs behind `dokimi.llm` and child processes behind `dokimi.process`. Its baselines only shrink. +- **No growth in file length or complexity.** A first-party `.py` file over 800 + lines (1000 under `tests/`) or a function over cyclomatic complexity 10 (ruff's + C901 count) fails `tests/test_growth_ratchet.py` unless `scripts/growth_baseline.json` + records it; a recorded one may shrink, never grow, and its entry comes down with + it (`python scripts/check_growth.py --update`). Raising or adding an entry needs a + `"rationale"` of at most 300 characters. `--report` prints the totals; the static + job adds them to its step summary. - **Tests that fail loudly.** `pytest` fails a test that runs over 60 s (`@pytest.mark.timeout(N)` allows more), turns a warning into an error, and shuffles the test order (pytest-randomly). The header prints diff --git a/dokimi/changelog.d/20261020_091500_napetrov_growth-ratchet.md b/dokimi/changelog.d/20261020_091500_napetrov_growth-ratchet.md new file mode 100644 index 00000000..7f3eb2b4 --- /dev/null +++ b/dokimi/changelog.d/20261020_091500_napetrov_growth-ratchet.md @@ -0,0 +1,10 @@ +### Changed + +- **Internal refactor, no behavior change** — functions over the new + complexity cap were split into helpers. CI now fails a PR that makes a + file longer or a function more complex than its entry in + `scripts/growth_baseline.json`; `python scripts/check_growth.py --update` + lowers the baseline when code shrinks. + After rebasing onto main, run it to lower shrunk entries; `--seed` + re-records every over-cap entry at its current value. Once main has a + baseline, a raised or added entry needs a hand-written rationale. diff --git a/dokimi/dokimi/commands/answers.py b/dokimi/dokimi/commands/answers.py index 0e3b17ff..46383d4f 100644 --- a/dokimi/dokimi/commands/answers.py +++ b/dokimi/dokimi/commands/answers.py @@ -21,46 +21,59 @@ ) +def _load_runtime_config() -> dict: + import yaml + + from dokimi.config import runtime_config_path + + config_path = runtime_config_path() + if not config_path.exists(): + return {} + try: + loaded = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + print(f"Error: invalid YAML in {config_path}: {exc}", file=sys.stderr) + raise SystemExit(1) from exc + if loaded is not None and not isinstance(loaded, dict): + print(f"Error: expected a mapping in {config_path}", file=sys.stderr) + raise SystemExit(1) + return loaded or {} + + +def _load_questions(path: str) -> list: + from dokimi.questions.artifact import load_questions + + try: + return load_questions(path).questions + except ValueError as exc: # ArtifactValidationError: names the record and the fix + print(f"Error: {exc}", file=sys.stderr) + raise SystemExit(1) from exc + + +def _output_path(args: argparse.Namespace) -> Path: + if args.output: + return Path(args.output) + if getattr(args, "run_id", None): + return Path(f"results/{args.product.lower()}_{args.run_id}/answers/{args.product}.json") + return Path(f"answers/{args.product}.json") + + def cmd_answers_generate(args: argparse.Namespace) -> None: """Generate context-arm and baseline answers for questions.""" if getattr(args, "ragas", False): _require_ragas() - import yaml from dokimi.eval import Answerer from dokimi.mcp.factory import ( create_doc_source_client, resolve_library_id_for_product, ) - # Load config - from dokimi.config import runtime_config_path - - config_path = runtime_config_path() - config: dict = {} - if config_path.exists(): - try: - loaded = yaml.safe_load(config_path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - print(f"Error: invalid YAML in {config_path}: {exc}", file=sys.stderr) - raise SystemExit(1) from exc - if loaded is not None and not isinstance(loaded, dict): - print(f"Error: expected a mapping in {config_path}", file=sys.stderr) - raise SystemExit(1) - config = loaded or {} - - _product_config = config.get("products", {}).get(args.product, {}) + config = _load_runtime_config() _retrieval_cfg = config.get("retrieval", {}) if args.top_k is None: args.top_k = _retrieval_cfg.get("top_k", DEFAULT_RETRIEVAL_TOP_K) - # Load questions - from dokimi.questions.artifact import load_questions - - try: - questions = load_questions(args.questions).questions - except ValueError as exc: # ArtifactValidationError: names the record and the fix - print(f"Error: {exc}", file=sys.stderr) - raise SystemExit(1) from exc + questions = _load_questions(args.questions) print(f"Loaded {len(questions)} questions from {args.questions}") # Setup documentation source client @@ -91,13 +104,7 @@ def cmd_answers_generate(args: argparse.Namespace) -> None: debug_retrieval=args.debug_retrieval ) - if args.output: - output_path = Path(args.output) - elif getattr(args, "run_id", None): - output_path = Path(f"results/{args.product.lower()}_{args.run_id}/answers/{args.product}.json") - else: - output_path = Path(f"answers/{args.product}.json") - + output_path = _output_path(args) answers = answerer.generate_answers( library_name=args.product, library_id=library_id, diff --git a/dokimi/scripts/check_growth.py b/dokimi/scripts/check_growth.py new file mode 100755 index 00000000..ffff3f54 --- /dev/null +++ b/dokimi/scripts/check_growth.py @@ -0,0 +1,458 @@ +#!/usr/bin/env python +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""No-growth ratchet for file length and function complexity. + +Two measures over every first-party Python file (``dokimi/``, ``scripts/``, +``tests/`` and ``cli.py``, relative to this project directory): + +- **File length** in physical lines. A file under ``dokimi/`` or ``scripts/`` + may have at most ``PROD_LINE_CAP`` lines, a file under ``tests/`` at most + ``TEST_LINE_CAP``. +- **Cyclomatic complexity** of every function and method, counted the way + ruff's ``C901`` (mccabe) counts it, computed here with ``ast`` so the gate + needs nothing beyond the standard library. At most ``COMPLEXITY_CAP``. + +Code that was over a cap when the gate was introduced is recorded in +``growth_baseline.json`` at its measured value. A recorded value is exact: the +code may not grow past it, and when the code shrinks the entry must shrink with +it (or go, once the code is back under the cap), so the ceiling never keeps +slack that later growth could quietly use up. Anything new over a cap fails. + +``tests/test_growth_ratchet.py`` runs the check in the test job. The modes:: + + python scripts/check_growth.py # check; exit 1 on a violation + python scripts/check_growth.py --update # lower or drop entries the code + # outgrew downwards; never raises + python scripts/check_growth.py --seed # re-record every over-cap file and function + # at its current value (after merging a main + # that grew past the baseline; may raise, + # passes only while main has no baseline) + python scripts/check_growth.py --report [--markdown] [--json PATH] + # totals and the most complex functions + +After rebasing onto or merging main, run ``python scripts/check_growth.py --update`` +to lower the entries that main's changes shrank. + +Raising an entry (or adding one) is a hand edit of the baseline with a +``"rationale"`` of at most ``RATIONALE_MAX`` characters on that entry; the test +compares the baseline with the base branch's at the commit the PR forked from +and fails a raised or added entry without one. +""" + +from __future__ import annotations + +import argparse +import ast +import json +import os +import subprocess +import sys +from collections import Counter +from pathlib import Path +from typing import Any + +PROJECT = Path(__file__).resolve().parents[1] +BASELINE = PROJECT / "scripts" / "growth_baseline.json" + +#: Production code (``dokimi/``, ``scripts/``, ``cli.py``). When the gate was added +#: only a handful of files were over 800 lines; the next largest had 766. +PROD_LINE_CAP = 800 +#: Tests group many cases per module, so their cap is higher than for production code. +TEST_LINE_CAP = 1000 +#: Per-function cyclomatic complexity, as ruff's C901 counts it. 10 is mccabe's and +#: ruff's default ``max-complexity``, so C901 can later be switched on in ruff with +#: the same number; the baseline records the functions over it. +COMPLEXITY_CAP = 10 +#: A raised or added baseline entry says why in at most this many characters. +RATIONALE_MAX = 300 +#: Rationale ``--seed`` gives an entry it raises or adds. Once the base branch has +#: a baseline it is no rationale: a raise then needs a hand-written one. +SEED_RATIONALE = "pre-existing when the gate was introduced" + +ROOTS = ("dokimi", "scripts", "tests") +TOP_FILES = ("cli.py",) +FILES, FUNCTIONS = "files", "functions" +METRIC = {FILES: "lines", FUNCTIONS: "complexity"} + + +# --------------------------------------------------------------------------- +# Measuring +# --------------------------------------------------------------------------- + + +def python_files(project: Path) -> list[Path]: + """Every first-party .py file, sorted; caches and dot-directories skipped.""" + found = [project / name for name in TOP_FILES if (project / name).is_file()] + for root in ROOTS: + for path in (project / root).rglob("*.py"): + parts = path.relative_to(project).parts + if any(p == "__pycache__" or p.startswith(".") for p in parts): + continue + found.append(path) + return sorted(found) + + +def line_cap(rel: str) -> int: + return TEST_LINE_CAP if rel.startswith("tests/") else PROD_LINE_CAP + + +def _is_irrefutable(pattern: ast.pattern) -> bool: + if isinstance(pattern, ast.MatchAs): + return pattern.pattern is None or _is_irrefutable(pattern.pattern) + if isinstance(pattern, ast.MatchOr): + return any(_is_irrefutable(p) for p in pattern.patterns) + return False + + +def _complexity(stmts: list[ast.stmt]) -> int: + """mccabe's decision count for a statement list, as ruff's C901 adds it up: + if/elif, for, while, each except handler, a try's else, each match case + (less an irrefutable last one) and each nested def score one. Boolean + operators and comprehensions score nothing.""" + n = 0 + for stmt in stmts: + if isinstance(stmt, ast.If): + n += 1 + _complexity(stmt.body) + _complexity(stmt.orelse) + elif isinstance(stmt, (ast.For, ast.AsyncFor, ast.While)): + n += 1 + _complexity(stmt.body) + _complexity(stmt.orelse) + elif isinstance(stmt, (ast.With, ast.AsyncWith)): + n += _complexity(stmt.body) + elif isinstance(stmt, ast.Match): + n += sum(1 + _complexity(case.body) for case in stmt.cases) + last = stmt.cases[-1] if stmt.cases else None + if last is not None and last.guard is None and _is_irrefutable(last.pattern): + n -= 1 + elif isinstance(stmt, (ast.Try, ast.TryStar)): + n += _complexity(stmt.body) + _complexity(stmt.finalbody) + n += (1 if stmt.orelse else 0) + _complexity(stmt.orelse) + n += sum(1 + _complexity(h.body) for h in stmt.handlers) + elif isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)): + n += 1 + _complexity(stmt.body) + elif isinstance(stmt, ast.ClassDef): + n += _complexity(stmt.body) + return n + + +def function_complexities(tree: ast.Module) -> dict[str, int]: + """``qualified.name -> complexity`` for every def, nested ones included. A + nested def counts in its own entry and in its parent's, as in ruff. Two defs + with one name (a property setter, a conditional redefinition) keep the max.""" + out: dict[str, int] = {} + + def visit(stmts: list[ast.stmt], prefix: str) -> None: + for stmt in stmts: + if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)): + name = prefix + stmt.name + out[name] = max(out.get(name, 0), 1 + _complexity(stmt.body)) + visit(stmt.body, name + ".") + elif isinstance(stmt, ast.ClassDef): + visit(stmt.body, prefix + stmt.name + ".") + else: + # defs inside if/for/while/with/try/match blocks + for child in ast.iter_child_nodes(stmt): + if isinstance(child, ast.stmt): + visit([child], prefix) + elif isinstance(child, (ast.excepthandler, ast.match_case)): + visit(child.body, prefix) + + visit(tree.body, "") + return out + + +class Measurement: + """Lines per file (``path``) and complexity per function (``path::qualname``).""" + + def __init__(self, lines: dict[str, int] | None = None, + complexity: dict[str, int] | None = None) -> None: + self.lines = dict(lines or {}) + self.complexity = dict(complexity or {}) + + def over_cap(self) -> dict[str, dict[str, int]]: + return { + FILES: {p: n for p, n in self.lines.items() if n > line_cap(p)}, + FUNCTIONS: {k: n for k, n in self.complexity.items() if n > COMPLEXITY_CAP}, + } + + +def measure(project: Path | None = None) -> Measurement: + project = project or PROJECT + m = Measurement() + for path in python_files(project): + rel = path.relative_to(project).as_posix() + text = path.read_text(encoding="utf-8") + m.lines[rel] = len(text.splitlines()) + for name, n in function_complexities(ast.parse(text, filename=rel)).items(): + m.complexity[f"{rel}::{name}"] = n + return m + + +# --------------------------------------------------------------------------- +# Baseline +# --------------------------------------------------------------------------- + + +def load_baseline(path: Path | None = None) -> dict[str, dict[str, dict[str, Any]]]: + data = json.loads((path or BASELINE).read_text(encoding="utf-8")) + return {FILES: data.get(FILES, {}), FUNCTIONS: data.get(FUNCTIONS, {})} + + +def dump_baseline(baseline: dict[str, dict[str, dict[str, Any]]]) -> str: + """One entry per line, keys sorted: a PR's change to an entry is a one-line diff.""" + out = ["{"] + for i, section in enumerate((FILES, FUNCTIONS)): + entries = sorted(baseline[section].items()) + out.append(f' "{section}": {{' + ("" if entries else "}" + ("," if i == 0 else ""))) + for j, (key, entry) in enumerate(entries): + comma = "," if j < len(entries) - 1 else "" + out.append(f" {json.dumps(key)}: {json.dumps(entry, sort_keys=True)}{comma}") + if entries: + out.append(" }" + ("," if i == 0 else "")) + out.append("}") + return "\n".join(out) + "\n" + + +def _entry_problem(key: str, entry: dict[str, Any], section: str, now: int | None, + over: bool) -> str | None: + """What is wrong with one baseline entry, given the code's current value.""" + metric = METRIC[section] + recorded = entry.get(metric) + rationale = entry.get("rationale", "") + update = "(python scripts/check_growth.py --update)" + if not isinstance(recorded, int): + return f"{key}: baseline entry has no integer {metric!r}" + if not isinstance(rationale, str) or len(rationale) > RATIONALE_MAX: + return f"{key}: rationale must be a string of at most {RATIONALE_MAX} characters" + if now is None: + what = "file" if section == FILES else "function" + return f"{key}: {what} no longer exists; remove its baseline entry {update}" + if not over: + return f"{key}: {now} {metric}, back under the cap; remove its baseline entry {update}" + if now > recorded: + return (f"{key}: {now} {metric} grew past its baseline {recorded}; " + f"split it instead (see {BASELINE.name})") + if now < recorded: + return (f"{key}: {now} {metric}, below its baseline {recorded}; lower the entry " + f"so the gain stays {update}") + return None + + +def violations(m: Measurement, baseline: dict[str, dict[str, dict[str, Any]]]) -> list[str]: + """Everything the gate fails on, one message per finding.""" + found: list[str] = [] + actual = {FILES: m.lines, FUNCTIONS: m.complexity} + over = m.over_cap() + for section in (FILES, FUNCTIONS): + for key, entry in sorted(baseline[section].items()): + problem = _entry_problem(key, entry, section, actual[section].get(key), + key in over[section]) + if problem: + found.append(problem) + for key, now in sorted(over[section].items()): + if key not in baseline[section]: + cap = line_cap(key) if section == FILES else COMPLEXITY_CAP + fix = "split the file" if section == FILES else "split the function into helpers" + found.append(f"{key}: {now} {METRIC[section]} is over the {cap} cap and has " + f"no baseline entry; {fix}") + return found + + +def raised_without_rationale(baseline: dict[str, dict[str, dict[str, Any]]], + base: dict[str, dict[str, dict[str, Any]]]) -> list[str]: + """Entries added or raised relative to ``base`` (the base branch's baseline) + that carry no new rationale: an entry already explained at the old value needs + its rationale rewritten to cover the raise.""" + found = [] + for section in (FILES, FUNCTIONS): + metric = METRIC[section] + for key, entry in sorted(baseline[section].items()): + old = base[section].get(key, {}) + before = old.get(metric) + value = entry.get(metric) + if before is not None and isinstance(value, int) and value <= before: + continue + rationale = str(entry.get("rationale", "")).strip() + if rationale == SEED_RATIONALE: + rationale = "" + if not rationale or (before is not None and rationale == str(old.get("rationale", "")).strip()): + change = "added" if before is None else f"raised from {before} to {value}" + found.append(f"{key}: {change} without a \"rationale\" (at most " + f"{RATIONALE_MAX} characters: why it cannot be split now)") + return found + + +def base_ref() -> str: + """The branch a PR merges into: ``DOKIMI_GROWTH_BASE``, else ``origin/`` plus + GitHub's ``GITHUB_BASE_REF`` (set on pull_request runs), else ``origin/main``.""" + if os.environ.get("DOKIMI_GROWTH_BASE"): + return os.environ["DOKIMI_GROWTH_BASE"] + return "origin/" + (os.environ.get("GITHUB_BASE_REF") or "main") + + +def load_base_baseline(ref: str) -> dict[str, dict[str, dict[str, Any]]] | None: + """The baseline at the commit HEAD forked from ``ref`` (``git merge-base``; the + ref itself when that fails), so an entry ``ref`` lowered after the fork is not + read as raised by this branch. None when git, the ref or the file there is + missing (a shallow clone, or the base predates the gate).""" + try: + fork = subprocess.run(["git", "merge-base", "HEAD", ref], + cwd=PROJECT, capture_output=True, text=True, check=False) + point = fork.stdout.strip() if fork.returncode == 0 else ref + shown = subprocess.run( + ["git", "show", f"{point}:./{BASELINE.relative_to(PROJECT).as_posix()}"], + cwd=PROJECT, capture_output=True, text=True, check=False) + except OSError: + return None + if shown.returncode != 0: + return None + data = json.loads(shown.stdout) + return {FILES: data.get(FILES, {}), FUNCTIONS: data.get(FUNCTIONS, {})} + + +def tightened(m: Measurement, baseline: dict[str, dict[str, dict[str, Any]]] + ) -> dict[str, dict[str, dict[str, Any]]]: + """The baseline with every entry lowered to its current value, and entries + whose code is gone or back under the cap removed. Nothing is raised or added.""" + actual = {FILES: m.lines, FUNCTIONS: m.complexity} + over = m.over_cap() + new: dict[str, dict[str, dict[str, Any]]] = {FILES: {}, FUNCTIONS: {}} + for section in (FILES, FUNCTIONS): + metric = METRIC[section] + for key, entry in baseline[section].items(): + if key not in over[section]: + continue + recorded = entry.get(metric) + new[section][key] = dict(entry) + if isinstance(recorded, int): + new[section][key][metric] = min(recorded, actual[section][key]) + return new + + +def seeded(m: Measurement, baseline: dict[str, dict[str, dict[str, Any]]] + ) -> dict[str, dict[str, dict[str, Any]]]: + """A baseline recording every over-cap file and function at its current value. + An entry the code did not outgrow keeps its text; one raised or added gets + ``SEED_RATIONALE``. Entries for code under the cap or gone are dropped.""" + actual = {FILES: m.lines, FUNCTIONS: m.complexity} + over = m.over_cap() + new: dict[str, dict[str, dict[str, Any]]] = {FILES: {}, FUNCTIONS: {}} + for section in (FILES, FUNCTIONS): + metric = METRIC[section] + for key in over[section]: + now = actual[section][key] + old = baseline[section].get(key) + if old and isinstance(old.get(metric), int) and now <= old[metric]: + new[section][key] = {**old, metric: now} + else: + new[section][key] = {metric: now, "rationale": SEED_RATIONALE} + return new + + +# --------------------------------------------------------------------------- +# Report +# --------------------------------------------------------------------------- + + +def report(m: Measurement, top: int = 10) -> dict[str, Any]: + over = m.over_cap() + buckets = Counter() + edges = sorted({5, 10, COMPLEXITY_CAP, 20, 30, 50}) + for n in m.complexity.values(): + label = next((f"<={e}" for e in edges if n <= e), f">{edges[-1]}") + buckets[label] += 1 + order = [f"<={e}" for e in edges] + [f">{edges[-1]}"] + return { + "caps": {"prod_lines": PROD_LINE_CAP, "test_lines": TEST_LINE_CAP, + "complexity": COMPLEXITY_CAP}, + "files": len(m.lines), + "lines": sum(m.lines.values()), + "files_over_cap": len(over[FILES]), + "lines_over_cap": sum(n - line_cap(p) for p, n in over[FILES].items()), + "functions": len(m.complexity), + "functions_over_cap": len(over[FUNCTIONS]), + "complexity_over_cap": sum(n - COMPLEXITY_CAP for n in over[FUNCTIONS].values()), + "complexity_distribution": {k: buckets.get(k, 0) for k in order}, + "largest_files": sorted(m.lines.items(), key=lambda kv: (-kv[1], kv[0]))[:top], + "most_complex": sorted(m.complexity.items(), key=lambda kv: (-kv[1], kv[0]))[:top], + } + + +def render(r: dict[str, Any], markdown: bool) -> str: + caps = r["caps"] + head = [ + f"files: {r['files']} ({r['lines']} lines); over the line cap " + f"(prod {caps['prod_lines']}, tests {caps['test_lines']}): {r['files_over_cap']}, " + f"{r['lines_over_cap']} lines over in total", + f"functions: {r['functions']}; over complexity {caps['complexity']}: " + f"{r['functions_over_cap']}, {r['complexity_over_cap']} points over in total", + "complexity distribution: " + ", ".join( + f"{k}: {v}" for k, v in r["complexity_distribution"].items()), + ] + if not markdown: + lines = head + ["", "most complex:"] + lines += [f" {n:4d} {k}" for k, n in r["most_complex"]] + lines += ["", "largest files:"] + lines += [f" {n:5d} {k}" for k, n in r["largest_files"]] + return "\n".join(lines) + "\n" + lines = ["### File length and complexity (scripts/check_growth.py)", ""] + lines += [f"- {h}" for h in head] + lines += ["", "| complexity | function |", "|---:|---|"] + lines += [f"| {n} | `{k}` |" for k, n in r["most_complex"]] + lines += ["", "| lines | file |", "|---:|---|"] + lines += [f"| {n} | `{k}` |" for k, n in r["largest_files"]] + return "\n".join(lines) + "\n" + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + mode = ap.add_mutually_exclusive_group() + mode.add_argument("--update", action="store_true", + help="lower or remove baseline entries to match the code; never raises") + mode.add_argument("--seed", action="store_true", + help="record every over-cap entry at its current value (may raise)") + mode.add_argument("--report", action="store_true", + help="print totals and the top offenders; always exits 0") + ap.add_argument("--markdown", action="store_true", help="--report as Markdown") + ap.add_argument("--json", type=Path, metavar="PATH", help="--report: also write JSON here") + ap.add_argument("--top", type=int, default=10, help="--report: rows per table") + ap.add_argument("--base", default=None, + help="ref whose baseline a raised entry is compared with " + "(default: DOKIMI_GROWTH_BASE, origin/$GITHUB_BASE_REF, origin/main)") + args = ap.parse_args(argv) + + m = measure() + if args.report: + r = report(m, args.top) + sys.stdout.write(render(r, args.markdown)) + if args.json: + args.json.write_text(json.dumps(r, indent=2) + "\n", encoding="utf-8") + return 0 + baseline = load_baseline() + if args.seed: + BASELINE.write_text(dump_baseline(seeded(m, baseline)), encoding="utf-8") + print(f"wrote {BASELINE.relative_to(PROJECT)}") + return 0 + if args.update: + BASELINE.write_text(dump_baseline(tightened(m, baseline)), encoding="utf-8") + remaining = violations(m, load_baseline()) + print(f"wrote {BASELINE.relative_to(PROJECT)}; {len(remaining)} violation(s) left") + for v in remaining: + print(f" {v}") + return 1 if remaining else 0 + found = violations(m, baseline) + ref = args.base or base_ref() + base = load_base_baseline(ref) + if base is None: + print(f"note: no {BASELINE.name} at {ref}; raised entries not checked for a rationale") + else: + found += raised_without_rationale(baseline, base) + for v in found: + print(v) + if found: + print(f"\n{len(found)} growth violation(s); see the docstring of scripts/check_growth.py") + return 1 if found else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/dokimi/scripts/ci_local.py b/dokimi/scripts/ci_local.py index 1266619f..5e12ac02 100755 --- a/dokimi/scripts/ci_local.py +++ b/dokimi/scripts/ci_local.py @@ -288,7 +288,7 @@ def record(name: str, status: str, secs: float = 0.0) -> None: summary = tmp / "step-summary.md" base_env = dict(os.environ, RUNNER_TEMP=str(tmp), GITHUB_STEP_SUMMARY=str(summary), GITHUB_OUTPUT=str(tmp / "github-output"), GITHUB_WORKSPACE=str(REPO), - CI="true") + CI="true", DOKIMI_GROWTH_BASE=base_ref) for wf_name, wf in workflows: default_wd = wf.get("defaults", {}).get("run", {}).get("working-directory", ".") for job_id, job in wf["jobs"].items(): @@ -311,7 +311,7 @@ def record(name: str, status: str, secs: float = 0.0) -> None: root, env = REPO, base_env if job_id in ISOLATED: root, iso_env = make_isolated_env(tmp / job_id, py) - env = {**iso_env, **{k: base_env[k] for k in ("RUNNER_TEMP", "CI")}} + env = {**iso_env, **{k: base_env[k] for k in ("RUNNER_TEMP", "CI", "DOKIMI_GROWTH_BASE")}} for i, step in enumerate(job.get("steps", [])): if "run" not in step: continue diff --git a/dokimi/scripts/growth_baseline.json b/dokimi/scripts/growth_baseline.json new file mode 100644 index 00000000..2b0f5fe8 --- /dev/null +++ b/dokimi/scripts/growth_baseline.json @@ -0,0 +1,120 @@ +{ + "files": { + "dokimi/commands/novelty.py": {"lines": 827}, + "dokimi/harnesses/benchflow.py": {"lines": 869}, + "dokimi/harnesses/docker_solver.py": {"lines": 927, "rationale": "pre-existing when the gate was introduced"}, + "dokimi/llm.py": {"lines": 1112}, + "dokimi/novelty/bundle.py": {"lines": 1016, "rationale": "grew on main in #101 before the ratchet landed; split it in its own PR"}, + "dokimi/report/model_compare.py": {"lines": 1297, "rationale": "grew on main in #73 (judge-validity --context stored) before the ratchet landed; split it in its own PR"}, + "tests/test_arm_runner.py": {"lines": 1176, "rationale": "pre-existing when the gate was introduced"}, + "tests/test_benchflow_harness.py": {"lines": 1716, "rationale": "pre-existing when the gate was introduced"}, + "tests/test_container_agent.py": {"lines": 1251, "rationale": "pre-existing when the gate was introduced"}, + "tests/test_judge_reliability.py": {"lines": 1695, "rationale": "grew on main in #73 (judge-validity --context stored), 1505 -> 1695, before the ratchet landed; split it in its own PR"}, + "tests/test_judge_validity.py": {"lines": 2235, "rationale": "grew on main in #73 (judge-validity --context stored), 1423 -> 2235, before the ratchet landed; split it in its own PR"}, + "tests/test_pipeline_question_set.py": {"lines": 1223}, + "tests/test_repo_environment.py": {"lines": 1371, "rationale": "grew on main in #147 (CI-safe time limit for the hostile-string regex test) before the ratchet landed; split it in its own PR"}, + "tests/test_subjects.py": {"lines": 1064}, + "tests/test_task_formats.py": {"lines": 1003} + }, + "functions": { + "dokimi/commands/arms.py::_build_arms_output": {"complexity": 13}, + "dokimi/commands/arms.py::_validate_expected_skills": {"complexity": 11}, + "dokimi/commands/baseline.py::cmd_baseline_compare": {"complexity": 19, "rationale": "pre-existing when the gate was introduced"}, + "dokimi/commands/benchmark.py::cmd_benchmark_batch": {"complexity": 11}, + "dokimi/commands/benchmark.py::cmd_benchmark_preflight": {"complexity": 22}, + "dokimi/commands/catalog.py::cmd_catalog_scan": {"complexity": 12}, + "dokimi/commands/catalog.py::cmd_catalog_trigger_set": {"complexity": 18}, + "dokimi/commands/evaluate.py::cmd_eval_judge_validity": {"complexity": 29, "rationale": "grew on main in #73 (judge-validity --context stored) from 25 to 29 before the ratchet landed; split it in its own PR"}, + "dokimi/commands/judge_reliability.py::cmd_eval_cross_judge": {"complexity": 32, "rationale": "pre-existing when the gate was introduced"}, + "dokimi/commands/novelty.py::cmd_novelty_routing": {"complexity": 14}, + "dokimi/commands/repo.py::cmd_repo_scan": {"complexity": 13}, + "dokimi/commands/repo.py::cmd_repo_validate": {"complexity": 11, "rationale": "grew on main in #150 (repo validate --fail-on-warning) from 10 to 11 before the ratchet landed; split it in its own PR"}, + "dokimi/commands/report.py::cmd_report_model_compare": {"complexity": 12}, + "dokimi/commands/tasks.py::cmd_tasks_run": {"complexity": 11}, + "dokimi/commands/tools.py::cmd_tools_preference": {"complexity": 11}, + "dokimi/context/trace.py::_apply": {"complexity": 18}, + "dokimi/dashboard/aggregator.py::ResultsAggregator._load_snapshot": {"complexity": 13}, + "dokimi/eval/agent_runner.py::_summarize_tool_usage": {"complexity": 11}, + "dokimi/eval/agent_runner.py::run_agent_loop": {"complexity": 14}, + "dokimi/eval/arm_runner.py::ArmRunner.judge": {"complexity": 11}, + "dokimi/eval/checklist.py::ChecklistReport.format_markdown": {"complexity": 12}, + "dokimi/eval/degrade.py::build_pairs": {"complexity": 17, "rationale": "grew on main in #73 (judge-validity --context stored) from 10 to 17 before the ratchet landed; split it in its own PR"}, + "dokimi/eval/judge.py::Judge._judge_answer": {"complexity": 12}, + "dokimi/eval/judge_context.py::JudgeContextStore.from_artifact": {"complexity": 13}, + "dokimi/eval/judge_sequential.py::run_sequential": {"complexity": 12, "rationale": "grew on main in #73 (judge-validity --context stored) from 6 to 12 before the ratchet landed; split it in its own PR"}, + "dokimi/eval/panel.py::JudgePanel._call_judge": {"complexity": 11}, + "dokimi/eval/plugin_delta.py::_validate_pair": {"complexity": 14}, + "dokimi/eval/ragas_eval.py::RagasEvaluator.evaluate": {"complexity": 17}, + "dokimi/eval/validity_scoring.py::_score_pairs": {"complexity": 13}, + "dokimi/gate/novelty_gate.py::evaluate_novelty": {"complexity": 29, "rationale": "grew on main in #72 and #101 before the ratchet landed; split it in its own PR"}, + "dokimi/harnesses/benchflow.py::BenchflowHarness.run": {"complexity": 19}, + "dokimi/harnesses/benchflow.py::skill_loads": {"complexity": 12}, + "dokimi/harnesses/command.py::CommandHarness.run": {"complexity": 15, "rationale": "pre-existing when the gate was introduced"}, + "dokimi/harnesses/docker_solver.py::DockerSolverHarness.run": {"complexity": 20}, + "dokimi/harnesses/exploration.py::summarize_exploration": {"complexity": 15}, + "dokimi/harnesses/registry.py::_create": {"complexity": 17, "rationale": "pre-existing when the gate was introduced"}, + "dokimi/harnesses/runner.py::summarize_task_results": {"complexity": 14, "rationale": "pre-existing when the gate was introduced"}, + "dokimi/harnesses/skill_treatment.py::stream_stats": {"complexity": 16}, + "dokimi/harnesses/tasks.py::harbor_task_problem": {"complexity": 11}, + "dokimi/mcp/context7.py::Context7Client.get_library_docs": {"complexity": 12}, + "dokimi/novelty/analyze.py::analyze": {"complexity": 22}, + "dokimi/novelty/atoms.py::extract_atoms": {"complexity": 16}, + "dokimi/novelty/bundle.py::load_bundle": {"complexity": 14}, + "dokimi/novelty/items.py::discrimination": {"complexity": 11}, + "dokimi/novelty/items.py::load_probe_cells": {"complexity": 11, "rationale": "pre-existing when the gate was introduced"}, + "dokimi/novelty/probes.py::build_probe_set": {"complexity": 13}, + "dokimi/novelty/probes.py::check_wording": {"complexity": 14}, + "dokimi/novelty/report.py::render_novelty_markdown": {"complexity": 18}, + "dokimi/novelty/spans.py::segment": {"complexity": 15}, + "dokimi/orchestrator/pipeline.py::EvaluationPipeline.run": {"complexity": 11}, + "dokimi/plugins.py::create_plugin": {"complexity": 11}, + "dokimi/process.py::_select_pipes": {"complexity": 13}, + "dokimi/questions/validator.py::QuestionValidator.__init__": {"complexity": 14}, + "dokimi/questions/validator.py::QuestionValidator._deduplicate": {"complexity": 13}, + "dokimi/records.py::record": {"complexity": 14, "rationale": "pre-existing when the gate was introduced"}, + "dokimi/report/arms_report.py::render_arms_report": {"complexity": 25}, + "dokimi/report/eval_report.py::generate_report": {"complexity": 18}, + "dokimi/report/generator.py::ReportGenerator._format_markdown": {"complexity": 18}, + "dokimi/report/model_compare.py::check_run_consistency": {"complexity": 13}, + "dokimi/report/model_compare.py::extract_scores": {"complexity": 16}, + "dokimi/report/model_compare.py::generate_section_report": {"complexity": 53}, + "dokimi/report/subject_report.py::render_subject_scorecard": {"complexity": 12}, + "dokimi/report/task_runs_report.py::render_task_runs_report": {"complexity": 22}, + "dokimi/repos/budget.py::budget": {"complexity": 21, "rationale": "pre-existing when the gate was introduced"}, + "dokimi/repos/environment/__init__.py::inspect_repo": {"complexity": 25, "rationale": "pre-existing when the gate was introduced"}, + "dokimi/repos/environment/checks.py::_python": {"complexity": 11, "rationale": "pre-existing when the gate was introduced"}, + "dokimi/repos/environment/risks.py::_runs_repository_code": {"complexity": 12, "rationale": "pre-existing when the gate was introduced"}, + "dokimi/repos/environment/risks.py::_segments": {"complexity": 29, "rationale": "pre-existing when the gate was introduced"}, + "dokimi/repos/environment/risks.py::detect_capabilities": {"complexity": 16, "rationale": "pre-existing when the gate was introduced"}, + "dokimi/repos/events.py::_apply": {"complexity": 22, "rationale": "pre-existing when the gate was introduced"}, + "dokimi/repos/harness_profile.py::parse_profile": {"complexity": 11}, + "dokimi/repos/l0/adapters.py::_build_names": {"complexity": 15, "rationale": "grew on main in #115 (L0 rules for static_check parity) before the ratchet landed; split it in its own PR"}, + "dokimi/repos/l0/adapters.py::_make": {"complexity": 11}, + "dokimi/repos/l0/core.py::Scan.resolve_path": {"complexity": 19}, + "dokimi/repos/manifest.py::_check_task": {"complexity": 22, "rationale": "grew on main in #113 (reusable repo-eval workflow) before the ratchet landed; split it in its own PR"}, + "dokimi/repos/manifest.py::parse_manifest": {"complexity": 15}, + "dokimi/repos/runner.py::run_batch": {"complexity": 11, "rationale": "pre-existing when the gate was introduced"}, + "dokimi/repos/source.py::GitTree.read_many": {"complexity": 14}, + "dokimi/repos/workspace.py::_apply_rev": {"complexity": 11, "rationale": "pre-existing when the gate was introduced"}, + "dokimi/repos/workspace.py::_guidance_at": {"complexity": 11, "rationale": "pre-existing when the gate was introduced"}, + "dokimi/schema_compat.py::_compare": {"complexity": 41}, + "dokimi/subjects/loader.py::_parse_subject": {"complexity": 13}, + "dokimi/tool_preference.py::summarize": {"complexity": 12}, + "scripts/audit_test_quality.py::_constructor_inputs": {"complexity": 27, "rationale": "pre-existing when the gate was introduced"}, + "scripts/audit_test_quality.py::_is_mock_verify": {"complexity": 11, "rationale": "pre-existing when the gate was introduced"}, + "scripts/check_treatment_batch.py::check_file": {"complexity": 36}, + "scripts/check_treatment_batch.py::main": {"complexity": 17}, + "scripts/ci_local.py::run": {"complexity": 29, "rationale": "grew on main in #146 (conda-forge dependency solve) before the ratchet landed; split it in its own PR"}, + "scripts/ci_local.py::tool_specs": {"complexity": 13}, + "scripts/cli_surface.py::_value": {"complexity": 17, "rationale": "added on main in #88 (CLI surface report) before the ratchet landed; split it in its own PR"}, + "scripts/research_register.py::check": {"complexity": 19, "rationale": "added on main in #139 (research register) before the ratchet landed; split it in its own PR"}, + "scripts/research_register.py::main": {"complexity": 15, "rationale": "added on main in #139 (research register) before the ratchet landed; split it in its own PR"}, + "scripts/stack_health.py::main": {"complexity": 11}, + "tests/docker_cli_fake.py::DockerCLI._handle": {"complexity": 13, "rationale": "pre-existing when the gate was introduced"}, + "tests/outcomes/journeys.py::check": {"complexity": 11, "rationale": "pre-existing when the gate was introduced"}, + "tests/outcomes/journeys.py::parse_catalog": {"complexity": 15, "rationale": "pre-existing when the gate was introduced"}, + "tests/outcomes/journeys.py::static_problems": {"complexity": 25, "rationale": "pre-existing when the gate was introduced"}, + "tests/test_novelty_analyze.py::_scripted": {"complexity": 12}, + "tests/test_novelty_analyze.py::_scripted.transport": {"complexity": 11} + } +} diff --git a/dokimi/tests/test_answers_command.py b/dokimi/tests/test_answers_command.py new file mode 100644 index 00000000..0fab4316 --- /dev/null +++ b/dokimi/tests/test_answers_command.py @@ -0,0 +1,134 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""``answers generate``: the config, question and output-path helpers, and the command over them. + +The Answerer and the documentation-source client are replaced with fakes, so the command runs +its whole body without a model or a network. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import dokimi.eval +import dokimi.mcp.factory +import dokimi.questions.artifact +from dokimi.commands import answers +from dokimi.defaults import DEFAULT_RETRIEVAL_TOP_K + + +def _args(**overrides) -> argparse.Namespace: + base = dict(product="Lib", questions="q.json", top_k=None, model="m", provider="p", + rerank_threshold=0.5, debug_retrieval=False, max_tokens=100, concurrency=1, + output=None, run_id=None, doc_source="context7", context7_id=None) + base.update(overrides) + return argparse.Namespace(**base) + + +def _point_config(monkeypatch, path: Path) -> None: + monkeypatch.setattr("dokimi.config.runtime_config_path", lambda: path) + + +def test_missing_config_is_empty(tmp_path, monkeypatch): + _point_config(monkeypatch, tmp_path / "absent.yaml") + assert answers._load_runtime_config() == {} + + +def test_empty_config_file_is_empty(tmp_path, monkeypatch): + path = tmp_path / "products.yaml" + path.write_text("", encoding="utf-8") + _point_config(monkeypatch, path) + assert answers._load_runtime_config() == {} + + +def test_config_file_is_read(tmp_path, monkeypatch): + path = tmp_path / "products.yaml" + path.write_text("retrieval:\n top_k: 7\n", encoding="utf-8") + _point_config(monkeypatch, path) + assert answers._load_runtime_config() == {"retrieval": {"top_k": 7}} + + +def test_invalid_yaml_config_exits(tmp_path, monkeypatch, capsys): + path = tmp_path / "products.yaml" + path.write_text("products: [unclosed\n", encoding="utf-8") + _point_config(monkeypatch, path) + with pytest.raises(SystemExit) as exc: + answers._load_runtime_config() + assert exc.value.code == 1 + assert "invalid YAML" in capsys.readouterr().err + + +def test_non_mapping_config_exits(tmp_path, monkeypatch, capsys): + path = tmp_path / "products.yaml" + path.write_text("- a\n- b\n", encoding="utf-8") + _point_config(monkeypatch, path) + with pytest.raises(SystemExit) as exc: + answers._load_runtime_config() + assert exc.value.code == 1 + assert "expected a mapping" in capsys.readouterr().err + + +def test_questions_load_error_exits(monkeypatch, capsys): + def fail(path): + raise ValueError("record 3 has no question text") + + monkeypatch.setattr(dokimi.questions.artifact, "load_questions", fail) + with pytest.raises(SystemExit) as exc: + answers._load_questions("q.json") + assert exc.value.code == 1 + assert "record 3 has no question text" in capsys.readouterr().err + + +def test_output_path_precedence(): + assert answers._output_path(_args(output="x/out.json")) == Path("x/out.json") + assert answers._output_path(_args(run_id="r1")) == Path("results/lib_r1/answers/Lib.json") + assert answers._output_path(_args()) == Path("answers/Lib.json") + + +@pytest.mark.parametrize("top_k, config_text, expected_top_k", [ + (5, "retrieval:\n top_k: 7\n", 5), # the flag wins over the config and the default + (None, "retrieval:\n top_k: 7\n", 7), # else the config's retrieval.top_k + (None, None, DEFAULT_RETRIEVAL_TOP_K), # else the built-in default +]) +def test_generate_writes_answers_to_output_path(tmp_path, monkeypatch, capsys, + top_k, config_text, expected_top_k): + if config_text is None: + _point_config(monkeypatch, tmp_path / "absent.yaml") + else: + path = tmp_path / "products.yaml" + path.write_text(config_text, encoding="utf-8") + _point_config(monkeypatch, path) + monkeypatch.setattr(dokimi.questions.artifact, "load_questions", + lambda path: SimpleNamespace(questions=["q1", "q2"])) + monkeypatch.setattr(dokimi.mcp.factory, "create_doc_source_client", lambda source: "client") + monkeypatch.setattr(dokimi.mcp.factory, "resolve_library_id_for_product", + lambda product, client, explicit_id=None: "/lib/id") + calls: dict = {} + + class FakeAnswerer: + def __init__(self, **kwargs): + calls["init"] = kwargs + + def generate_answers(self, **kwargs): + calls["generate"] = kwargs + return ["a1"] + + def save_answers(self, answers_, path): + calls["saved"] = (answers_, path) + + monkeypatch.setattr(dokimi.eval, "Answerer", FakeAnswerer) + out = tmp_path / "out.json" + + answers.cmd_answers_generate(_args(output=str(out), top_k=top_k)) + + assert calls["init"]["top_k"] == expected_top_k + assert calls["generate"]["questions"] == ["q1", "q2"] + assert calls["generate"]["library_id"] == "/lib/id" + assert calls["generate"]["output_path"] == out + assert calls["saved"] == (["a1"], out) + assert f"Saved answers to {out}" in capsys.readouterr().out diff --git a/dokimi/tests/test_ci_local.py b/dokimi/tests/test_ci_local.py index f013dad3..dc67f07f 100644 --- a/dokimi/tests/test_ci_local.py +++ b/dokimi/tests/test_ci_local.py @@ -244,6 +244,24 @@ def test_base_steps_use_the_resolved_remote(env: dict[str, Any]) -> None: ] +def test_dokimi_growth_base_env_is_passed_to_steps(env: dict[str, Any]) -> None: + """ci_local.py passes the resolved base_ref as DOKIMI_GROWTH_BASE env var.""" + (cl.WORKFLOWS / "x.yml").write_text(BASE_WF) + assert cl.run(ns(remote="intel", base="dev")) == 0 + # All steps should receive DOKIMI_GROWTH_BASE in their env + for call in env["calls"]: + assert call["env"]["DOKIMI_GROWTH_BASE"] == "intel/dev" + + +def test_dokimi_growth_base_reaches_isolated_jobs(env: dict[str, Any]) -> None: + """An isolated job (`package-smoke`) runs in its own env; DOKIMI_GROWTH_BASE is still set there.""" + (cl.WORKFLOWS / "x.yml").write_text(BASE_WF.replace(" a:\n", " package-smoke:\n")) + assert cl.run(ns(remote="intel", base="dev")) == 0 + assert env["calls"], "the isolated job's steps should run" + for call in env["calls"]: + assert call["env"]["DOKIMI_GROWTH_BASE"] == "intel/dev" + + def test_unusable_base_fails_its_steps_without_running_them( env: dict[str, Any], monkeypatch: pytest.MonkeyPatch, capsys: Any ) -> None: diff --git a/dokimi/tests/test_growth_ratchet.py b/dokimi/tests/test_growth_ratchet.py new file mode 100644 index 00000000..5c80e964 --- /dev/null +++ b/dokimi/tests/test_growth_ratchet.py @@ -0,0 +1,488 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""The no-growth ratchet on file length and function complexity (scripts/check_growth.py). + +The first two tests are the gate on this tree. The rest prove it binds: each builds a +small project in a temporary directory, with the caps lowered, and checks that growth, +new over-cap code, slack and stale entries fail. +""" + +from __future__ import annotations + +import ast +import importlib.util +import json +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest + +PROJECT = Path(__file__).resolve().parents[1] +_SCRIPT = PROJECT / "scripts" / "check_growth.py" +_spec = importlib.util.spec_from_file_location("check_growth", _SCRIPT) +assert _spec and _spec.loader +cg = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(cg) + + +# --- the gate on this tree ------------------------------------------------------------- + + +def test_no_file_or_function_grew_past_its_baseline(): + found = cg.violations(cg.measure(), cg.load_baseline()) + assert not found, ( + "file length / complexity ratchet (scripts/check_growth.py):\n " + + "\n ".join(found) + + "\nSplit the code that grew. When code shrank, run `python scripts/check_growth.py " + "--update` to lower the baseline." + ) + + +def test_a_raised_or_added_baseline_entry_says_why(): + ref = cg.base_ref() + base = cg.load_base_baseline(ref) + if base is None: + pytest.skip(f"no {cg.BASELINE.name} at {ref} to compare with") + found = cg.raised_without_rationale(cg.load_baseline(), base) + assert not found, "\n".join(found) + + +def test_baseline_file_is_in_canonical_form(): + # --update rewrites it this way; a hand edit in another layout would churn on the next run. + text = cg.BASELINE.read_text(encoding="utf-8") + assert text == cg.dump_baseline(cg.load_baseline()) + + +# --- the complexity count ----------------------------------------------------------- + + +def _cx(src: str) -> dict[str, int]: + return cg.function_complexities(ast.parse(textwrap.dedent(src))) + + +@pytest.mark.parametrize("body, expected", [ + ("return 1", 1), + ("if a:\n pass\nelif b:\n pass\nelse:\n pass", 3), + ("for x in y:\n pass\nelse:\n pass", 2), + ("while a:\n pass", 2), + ("try:\n pass\nexcept A:\n pass\nexcept B:\n pass\nelse:\n pass", 4), + ("try:\n pass\nexcept* A:\n pass\nexcept* B:\n pass", 3), # try-star counts like try + ("with a:\n if b:\n pass", 2), + ("for x in y:\n pass\nelse:\n if a:\n pass", 3), # a decision in a loop else + ("while a:\n pass\nelse:\n if b:\n pass", 3), + ("try:\n pass\nexcept A:\n pass\nelse:\n if a:\n pass", 4), + ("match a:\n case 1:\n pass\n case _ if g:\n pass", 3), # guarded: counts + ("match a:\n case 1:\n pass\n case y:\n pass", 2), # capture = else + ("match a:\n case 1:\n pass\n case 2 | _:\n pass", 2), # or-pattern with _ + ("return a and b or c", 1), # boolean operators score nothing + ("return [x for x in y if x]", 1), # nor do comprehensions + ("match a:\n case 1:\n pass\n case 2:\n pass", 3), + ("match a:\n case 1:\n pass\n case _:\n pass", 2), # wildcard = else + ("match a:\n case 1:\n if b:\n pass\n case 2:\n pass", 4), # a decision in a case body + ("match a:\n case 1:\n pass\n case (2 as z):\n pass", 3), # refutable as-pattern last: counts + ("def inner():\n if a:\n pass", 3), # a nested def counts in its parent +]) +def test_complexity_counts_like_ruff_c901(body, expected): + src = "def f():\n" + textwrap.indent(body, " ") + "\n" + assert _cx(src)["f"] == expected + + +def test_functions_are_keyed_by_qualified_name(): + found = _cx(""" + class A: + def m(self): + def inner(): + pass + if X: + def g(): + pass + """) + assert found == {"A.m": 2, "A.m.inner": 1, "g": 1} + + +def test_defs_in_handlers_cases_async_and_classes_count_like_ruff_c901(): + # expected: ruff 0.17.0 `--select C901` with max-complexity 0 on this source + found = _cx(""" + try: + import x + except ImportError: + def fallback(a): + if a: + return 1 + elif a > 2: + return 2 + return 0 + match MODE: + case "x": + def in_case(a): + if a: + return 1 + return 0 + async def af(a): + async for x in a: + if x: + pass + async with a: + if a: + pass + def outer(a): + async def inner(b): + if b: + pass + class Inner: + def m(self): + if a: + pass + def withcase(a): + match a: + case 1: + pass + case 2: + pass + case (_ as z): + pass + def dup(a): + if a: + pass + elif a: + pass + def dup(): + pass + """) + assert found == {"fallback": 3, "in_case": 2, "af": 4, "outer": 5, "outer.inner": 2, + "outer.Inner.m": 2, "withcase": 3, "dup": 3} # dup: the max of 3 and 1 + + +# --- the gate binds ----------------------------------------------------------------- + + +def _complex_fn(name: str, branches: int) -> str: + return f"def {name}(x):\n" + "".join( + f" if x == {i}:\n return {i}\n" for i in range(branches)) + " return -1\n" + + +@pytest.fixture +def project(tmp_path, monkeypatch): + """A project in tmp_path with caps of 20 lines (prod), 30 (tests), complexity 3, + one file over its cap and one function over the complexity cap, both baselined.""" + monkeypatch.setattr(cg, "PROJECT", tmp_path) + monkeypatch.setattr(cg, "BASELINE", tmp_path / "scripts" / "growth_baseline.json") + monkeypatch.setattr(cg, "PROD_LINE_CAP", 20) + monkeypatch.setattr(cg, "TEST_LINE_CAP", 30) + monkeypatch.setattr(cg, "COMPLEXITY_CAP", 3) + (tmp_path / "dokimi").mkdir() + (tmp_path / "scripts").mkdir() + (tmp_path / "tests").mkdir() + (tmp_path / "dokimi" / "big.py").write_text("x = 1\n" * 22, encoding="utf-8") + (tmp_path / "dokimi" / "cx.py").write_text(_complex_fn("f", 4), encoding="utf-8") + (tmp_path / "tests" / "test_ok.py").write_text("x = 1\n" * 25, encoding="utf-8") + cg.BASELINE.write_text(cg.dump_baseline({ + "files": {"dokimi/big.py": {"lines": 22}}, + "functions": {"dokimi/cx.py::f": {"complexity": 5}}, + }), encoding="utf-8") + return tmp_path + + +def _found(root: Path) -> list[str]: + return cg.violations(cg.measure(root), cg.load_baseline()) + + +def test_cli_py_is_measured_and_dot_dirs_and_caches_are_not(project): + (project / "cli.py").write_text("x = 1\n", encoding="utf-8") + for sub in (".venv", "__pycache__"): + (project / "dokimi" / sub).mkdir() + (project / "dokimi" / sub / "v.py").write_text("x = 1\n", encoding="utf-8") + found = [p.relative_to(project).as_posix() for p in cg.python_files(project)] + assert found == ["cli.py", "dokimi/big.py", "dokimi/cx.py", "tests/test_ok.py"] + + +def test_a_non_integer_entry_fails(project): + cg.BASELINE.write_text(cg.dump_baseline({ + "files": {"dokimi/big.py": {"lines": "22"}}, + "functions": {"dokimi/cx.py::f": {"complexity": 5}}, + }), encoding="utf-8") + assert _found(project) == ["dokimi/big.py: baseline entry has no integer 'lines'"] + + +def test_the_fixture_project_passes(project): + assert _found(project) == [] + assert cg.main([]) == 0 + + +def test_a_baselined_file_that_grows_fails(project): + (project / "dokimi" / "big.py").write_text("x = 1\n" * 23, encoding="utf-8") + assert _found(project) == ["dokimi/big.py: 23 lines grew past its baseline 22; " + "split it instead (see growth_baseline.json)"] + assert cg.main([]) == 1 + + +def test_a_new_file_over_the_cap_fails(project): + (project / "scripts" / "new.py").write_text("x = 1\n" * 21, encoding="utf-8") + (project / "tests" / "test_ok.py").write_text("x = 1\n" * 31, encoding="utf-8") + found = _found(project) + assert [f.split(":")[0] for f in found] == ["scripts/new.py", "tests/test_ok.py"] + assert all("no baseline entry" in f for f in found) + + +def test_a_new_complex_function_fails(project): + (project / "dokimi" / "more.py").write_text(_complex_fn("g", 3), encoding="utf-8") + assert _found(project) == ["dokimi/more.py::g: 4 complexity is over the 3 cap and has no " + "baseline entry; split the function into helpers"] + + +def test_a_baselined_function_that_grows_fails(project): + (project / "dokimi" / "cx.py").write_text(_complex_fn("f", 5), encoding="utf-8") + assert _found(project) == ["dokimi/cx.py::f: 6 complexity grew past its baseline 5; " + "split it instead (see growth_baseline.json)"] + + +def test_slack_and_stale_entries_fail(project): + # Shrunk but still over the cap: the entry must come down with it, or the slack + # would let the file grow back unseen. + (project / "dokimi" / "big.py").write_text("x = 1\n" * 21, encoding="utf-8") + # The function is gone. + (project / "dokimi" / "cx.py").write_text("def h():\n pass\n", encoding="utf-8") + found = _found(project) + assert len(found) == 2 + assert found[0].startswith("dokimi/big.py: 21 lines, below its baseline 22") + assert found[1].startswith("dokimi/cx.py::f: function no longer exists") + + +def test_an_entry_back_under_the_cap_must_go(project): + (project / "dokimi" / "big.py").write_text("x = 1\n" * 20, encoding="utf-8") + assert _found(project)[0].startswith("dokimi/big.py: 20 lines, back under the cap") + + +def test_update_lowers_and_removes_but_never_raises_or_adds(project): + (project / "dokimi" / "big.py").write_text("x = 1\n" * 21, encoding="utf-8") + (project / "dokimi" / "cx.py").write_text("def f():\n pass\n", encoding="utf-8") + (project / "dokimi" / "new.py").write_text("x = 1\n" * 40, encoding="utf-8") + assert cg.main(["--update"]) == 1 # new.py is still a violation + assert cg.load_baseline() == {"files": {"dokimi/big.py": {"lines": 21}}, "functions": {}} + # Growth is never written back. + (project / "dokimi" / "big.py").write_text("x = 1\n" * 24, encoding="utf-8") + cg.main(["--update"]) + assert cg.load_baseline()["files"] == {"dokimi/big.py": {"lines": 21}} + + +def test_seed_records_growth_and_new_entries_with_a_rationale(project): + (project / "dokimi" / "big.py").write_text("x = 1\n" * 24, encoding="utf-8") # grew + (project / "dokimi" / "cx.py").write_text(_complex_fn("f", 3), encoding="utf-8") # shrank + (project / "dokimi" / "new.py").write_text("x = 1\n" * 40, encoding="utf-8") # new + # Unchanged: a hand-written rationale must survive, not be replaced by the seed text. + (project / "dokimi" / "keep.py").write_text("x = 1\n" * 30, encoding="utf-8") + baseline = cg.load_baseline() + baseline["files"]["dokimi/keep.py"] = {"lines": 30, "rationale": "why it stays long"} + cg.BASELINE.write_text(cg.dump_baseline(baseline), encoding="utf-8") + assert cg.main(["--seed"]) == 0 + why = cg.SEED_RATIONALE + assert cg.load_baseline() == { + "files": {"dokimi/big.py": {"lines": 24, "rationale": why}, + "dokimi/keep.py": {"lines": 30, "rationale": "why it stays long"}, + "dokimi/new.py": {"lines": 40, "rationale": why}}, + "functions": {"dokimi/cx.py::f": {"complexity": 4}}, + } + assert _found(project) == [] + + +def test_an_overlong_rationale_fails(project): + cg.BASELINE.write_text(cg.dump_baseline({ + "files": {"dokimi/big.py": {"lines": 22, "rationale": "x" * (cg.RATIONALE_MAX + 1)}}, + "functions": {"dokimi/cx.py::f": {"complexity": 5}}, + }), encoding="utf-8") + assert _found(project) == [f"dokimi/big.py: rationale must be a string of at most " + f"{cg.RATIONALE_MAX} characters"] + + +def test_raised_or_added_entries_need_a_rationale(): + base = {"files": {"a.py": {"lines": 900}}, "functions": {"a.py::f": {"complexity": 12}}} + head = { + "files": {"a.py": {"lines": 901}, "b.py": {"lines": 850, "rationale": "moved here"}}, + "functions": {"a.py::f": {"complexity": 11}, "a.py::g": {"complexity": 14}}, + } + assert cg.raised_without_rationale(head, base) == [ + 'a.py: raised from 900 to 901 without a "rationale" (at most 300 characters: ' + "why it cannot be split now)", + 'a.py::g: added without a "rationale" (at most 300 characters: ' + "why it cannot be split now)", + ] + head["files"]["a.py"]["rationale"] = " " # blank does not count + assert len(cg.raised_without_rationale(head, base)) == 2 + head["files"]["a.py"]["rationale"] = "one release; split tracked in #N" + head["functions"]["a.py::g"]["rationale"] = "dispatch table, split next" + assert cg.raised_without_rationale(head, base) == [] + + +def test_a_raised_entry_keeping_its_old_rationale_still_fails(): + base = {"files": {"a.py": {"lines": 900, "rationale": "legacy"}}, "functions": {}} + head = {"files": {"a.py": {"lines": 1500, "rationale": "legacy"}}, "functions": {}} + assert len(cg.raised_without_rationale(head, base)) == 1 + head["files"]["a.py"]["rationale"] = "grew for the new probe; split in #N" + assert cg.raised_without_rationale(head, base) == [] + head["files"]["a.py"] = {"lines": 900, "rationale": "legacy"} # unchanged: no new rationale needed + assert cg.raised_without_rationale(head, base) == [] + base["files"]["a.py"]["rationale"] = "legacy " # a whitespace edit is not a new rationale + head["files"]["a.py"]["lines"] = 1500 + assert len(cg.raised_without_rationale(head, base)) == 1 + + +def test_base_ref_follows_github_base_ref(monkeypatch): + monkeypatch.delenv("DOKIMI_GROWTH_BASE", raising=False) + monkeypatch.setenv("GITHUB_BASE_REF", "dev") + assert cg.base_ref() == "origin/dev" + monkeypatch.delenv("GITHUB_BASE_REF") + assert cg.base_ref() == "origin/main" + + +@pytest.mark.skipif(shutil.which("git") is None, reason="needs git") +def test_the_base_baseline_is_read_from_git(project, monkeypatch): + def git(*args: str) -> None: + subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], + cwd=project, check=True, capture_output=True) + + git("init", "-q", "-b", "main") + git("add", "-A") + git("commit", "-q", "-m", "base") + git("branch", "base") + raised = cg.load_baseline() + raised["files"]["dokimi/big.py"]["lines"] = 23 + cg.BASELINE.write_text(cg.dump_baseline(raised), encoding="utf-8") + (project / "dokimi" / "big.py").write_text("x = 1\n" * 23, encoding="utf-8") + + assert cg.load_base_baseline("no-such-ref") is None + base = cg.load_base_baseline("base") + assert base is not None and base["files"]["dokimi/big.py"] == {"lines": 22} + assert cg.main(["--base", "base"]) == 1 # raised from 22 to 23 with no rationale + monkeypatch.setenv("DOKIMI_GROWTH_BASE", "base") + assert cg.base_ref() == "base" + + +@pytest.mark.skipif(shutil.which("git") is None, reason="needs git") +def test_seed_cannot_explain_growth_once_the_base_has_a_baseline(project): + def git(*args: str) -> None: + subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], + cwd=project, check=True, capture_output=True) + + git("init", "-q", "-b", "main") + git("add", "-A") + git("commit", "-q", "-m", "base") + (project / "dokimi" / "big.py").write_text("x = 1\n" * 24, encoding="utf-8") + (project / "dokimi" / "new.py").write_text("x = 1\n" * 40, encoding="utf-8") + assert cg.main(["--seed"]) == 0 + assert cg.main(["--base", "main"]) == 1 + base = cg.load_base_baseline("main") + assert base is not None + found = cg.raised_without_rationale(cg.load_baseline(), base) + assert [f.split(":")[0] for f in found] == ["dokimi/big.py", "dokimi/new.py"] + + +@pytest.mark.skipif(shutil.which("git") is None, reason="needs git") +def test_the_base_baseline_is_read_when_the_project_is_a_subdirectory(tmp_path, monkeypatch): + # The real layout: the git root holds the project in a subdirectory. The baseline path + # is then only found through the "./" prefix, relative to the project. + proj = tmp_path / "proj" + monkeypatch.setattr(cg, "PROJECT", proj) + monkeypatch.setattr(cg, "BASELINE", proj / "scripts" / "growth_baseline.json") + (proj / "dokimi").mkdir(parents=True) + (proj / "scripts").mkdir() + (proj / "dokimi" / "big.py").write_text("x = 1\n" * 22, encoding="utf-8") + cg.BASELINE.write_text(cg.dump_baseline({cg.FILES: {"dokimi/big.py": {"lines": 22}}, + cg.FUNCTIONS: {}}), encoding="utf-8") + + def git(*args: str) -> None: + subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], + cwd=tmp_path, check=True, capture_output=True) + + git("init", "-q", "-b", "main") + git("add", "-A") + git("commit", "-q", "-m", "base") + git("branch", "base") + + base = cg.load_base_baseline("base") + assert base is not None and base[cg.FILES]["dokimi/big.py"] == {"lines": 22} + + cg.BASELINE.write_text(cg.dump_baseline({cg.FILES: {"dokimi/big.py": {"lines": 23}}, + cg.FUNCTIONS: {}}), encoding="utf-8") + (proj / "dokimi" / "big.py").write_text("x = 1\n" * 23, encoding="utf-8") + assert cg.main(["--base", "base"]) == 1 # raised from 22 to 23 with no rationale + + +@pytest.mark.skipif(shutil.which("git") is None, reason="needs git") +def test_an_entry_the_base_lowered_after_the_fork_is_not_blamed_on_the_branch(project): + def git(*args: str) -> None: + subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], + cwd=project, check=True, capture_output=True) + + git("init", "-q", "-b", "main") + git("add", "-A") + git("commit", "-q", "-m", "fork point") + git("tag", "fork") + git("checkout", "-q", "-b", "feature") + (project / "README.md").write_text("unrelated\n", encoding="utf-8") + git("add", "-A") + git("commit", "-q", "-m", "feature") + git("checkout", "-q", "main") + (project / "dokimi" / "big.py").write_text("x = 1\n" * 21, encoding="utf-8") + assert cg.main(["--update"]) == 0 # main lowers big.py from 22 to 21 + git("commit", "-q", "-am", "shrink") + + git("checkout", "-q", "feature") # a branch behind main + assert cg.main(["--base", "main"]) == 0 + git("checkout", "-q", "--detach", "fork") # CI's merge commit, built before the shrink + git("merge", "-q", "--no-ff", "-m", "merge", "feature") + assert cg.main(["--base", "main"]) == 0 + raised = cg.load_baseline() # a raise the branch makes is still caught + raised["files"]["dokimi/big.py"]["lines"] = 23 + cg.BASELINE.write_text(cg.dump_baseline(raised), encoding="utf-8") + (project / "dokimi" / "big.py").write_text("x = 1\n" * 23, encoding="utf-8") + assert cg.main(["--base", "main"]) == 1 + + +@pytest.mark.skipif(shutil.which("git") is None, reason="needs git") +def test_non_origin_remote_baseline_via_dokimi_growth_base(project, monkeypatch): + """Regression: ci_local.py resolves the base ref to a non-origin remote, but did not + pass it to the pytest environment, causing the growth check to use the wrong baseline.""" + def git(*args: str) -> None: + subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], + cwd=project, check=True, capture_output=True) + + # Set up git repo with a remote (simulating a fork setup). + git("init", "-q", "-b", "main") + git("add", "-A") + git("commit", "-q", "-m", "base") + # Simulate a non-origin remote by creating a local bare repo. + remote_dir = project.parent / "remote.git" + subprocess.run(["git", "init", "-q", "--bare", str(remote_dir)], check=True) + git("remote", "add", "fork", str(remote_dir)) + git("push", "-q", "fork", "main") + + # The test: baseline is at the remote ref; ci_local.py resolves fork/main as the base + # and must pass it to the check_growth script via DOKIMI_GROWTH_BASE env var. + # Without the fix, load_base_baseline would fail or use the wrong ref. + raised = cg.load_baseline() + raised["files"]["dokimi/big.py"]["lines"] = 23 + cg.BASELINE.write_text(cg.dump_baseline(raised), encoding="utf-8") + (project / "dokimi" / "big.py").write_text("x = 1\n" * 23, encoding="utf-8") + + # Simulate ci_local.py's behavior: it resolves the base to fork/main. + monkeypatch.setenv("DOKIMI_GROWTH_BASE", "fork/main") + # Now the check should pass because it compares against fork/main (the baseline). + base = cg.load_base_baseline("fork/main") + assert base is not None and base["files"]["dokimi/big.py"] == {"lines": 22} + # The gate should catch the raised entry because it has no rationale. + assert cg.main([]) == 1 + found = cg.raised_without_rationale(cg.load_baseline(), base) + assert len(found) == 1 + assert "raised from 22 to 23 without a \"rationale\"" in found[0] + + +def test_report_totals(project, tmp_path, capsys): + out = tmp_path / "r.json" + assert cg.main(["--report", "--markdown", "--json", str(out)]) == 0 + assert "| 5 | `dokimi/cx.py::f` |" in capsys.readouterr().out + r = json.loads(out.read_text(encoding="utf-8")) + assert (r["files_over_cap"], r["lines_over_cap"]) == (1, 2) + assert (r["functions_over_cap"], r["complexity_over_cap"]) == (1, 2) diff --git a/dokimi/tests/test_import_architecture.py b/dokimi/tests/test_import_architecture.py index 417d6b05..73c2107e 100644 --- a/dokimi/tests/test_import_architecture.py +++ b/dokimi/tests/test_import_architecture.py @@ -201,6 +201,28 @@ def _kinds(tree: ast.Module) -> dict[int, str]: return kinds +def _owner_module(name: str, modules: frozenset[str]) -> str | None: + """The longest prefix of ``name`` that is a module of the package, if any.""" + while name and name not in modules: + name = name.rpartition(".")[0] + return name or None + + +def _import_targets(node: ast.Import | ast.ImportFrom, here: str, + modules: frozenset[str]) -> list[str]: + """The dotted names one import statement reaches, relative imports resolved.""" + if isinstance(node, ast.Import): + return [alias.name for alias in node.names] + if node.level: + base = here.split(".")[: len(here.split(".")) - node.level + 1] + origin = ".".join(base + ([node.module] if node.module else [])) + else: + origin = node.module or "" + # `from pkg import sub` depends on pkg.sub; any other name, on pkg. + subs = [f"{origin}.{a.name}" for a in node.names if f"{origin}.{a.name}" in modules] + return subs if len(subs) == len(node.names) else [*subs, origin] + + def build_imports(root: Path, package: str = PACKAGE) -> Imports: """Parse every module under ``root/package``; nothing is imported.""" paths = {_module_name(p, root): p for p in sorted((root / package).rglob("*.py"))} @@ -208,11 +230,6 @@ def build_imports(root: Path, package: str = PACKAGE) -> Imports: trees = {m: ast.parse(p.read_text(encoding="utf-8"), filename=str(p)) for m, p in paths.items()} - def resolve(name: str) -> str | None: - while name and name not in modules: - name = name.rpartition(".")[0] - return name or None - edges: dict[str, dict[str, tuple[str, int]]] = {} external: dict[str, dict[str, int]] = {} for mod, tree in trees.items(): @@ -221,26 +238,14 @@ def resolve(name: str) -> str | None: out = edges.setdefault(mod, {}) ext = external.setdefault(mod, {}) for node in ast.walk(tree): - if isinstance(node, ast.Import): - targets = [alias.name for alias in node.names] - elif isinstance(node, ast.ImportFrom): - if node.level: - base = here.split(".")[: len(here.split(".")) - node.level + 1] - origin = ".".join(base + ([node.module] if node.module else [])) - else: - origin = node.module or "" - # `from pkg import sub` depends on pkg.sub; any other name, on pkg. - subs = [f"{origin}.{a.name}" for a in node.names - if f"{origin}.{a.name}" in modules] - targets = subs if len(subs) == len(node.names) else [*subs, origin] - else: + if not isinstance(node, (ast.Import, ast.ImportFrom)): continue kind = kinds.get(id(node), MODULE) - for target in targets: + for target in _import_targets(node, here, modules): if target != package and not target.startswith(package + "."): ext.setdefault(target.partition(".")[0], node.lineno) continue - dst = resolve(target) + dst = _owner_module(target, modules) if dst is None or dst == mod: continue if dst not in out or _RANK[kind] < _RANK[out[dst][0]]: @@ -248,6 +253,17 @@ def resolve(name: str) -> str | None: return Imports(modules, edges, external, trees) +def _pop_component(stack: list[str], on_stack: set[str], root: str) -> set[str]: + """Pop the Tarjan stack down to ``root``: that is one component.""" + component = set() + while True: + w = stack.pop() + on_stack.discard(w) + component.add(w) + if w == root: + return component + + def strongly_connected(imports: Imports, kinds: Iterable[str] = (MODULE, LAZY) ) -> list[frozenset[str]]: """Import cycles: every SCC with more than one module (Tarjan).""" @@ -271,13 +287,7 @@ def visit(v: str) -> None: elif w in on_stack: low[v] = min(low[v], index[w]) if low[v] == index[v]: - component = set() - while True: - w = stack.pop() - on_stack.discard(w) - component.add(w) - if w == v: - break + component = _pop_component(stack, on_stack, v) if len(component) > 1: found.append(frozenset(component)) @@ -340,17 +350,11 @@ def banned_imports(imports: Imports, banned=BANNED_IMPORTS) -> dict[tuple[str, s if name in banned and mod != banned[name]} -def spawn_sites(tree: ast.Module) -> list[int]: - """Lines that start a child process without :mod:`dokimi.process`. - - Each launch counts once: an attribute call (``subprocess.run(...)``) or a call - through a directly imported name (``execute(...)`` after ``from subprocess import - run as execute``). A directly imported name that is never called counts at its - import line, since it may be passed around and called there. - """ - module_aliases: dict[str, str] = {} # alias -> module name - name_aliases: dict[str, tuple[int, str]] = {} # alias -> (import line, module) - lines: list[int] = [] +def _spawn_imports(tree: ast.Module) -> tuple[dict[str, str], dict[str, tuple[int, str]]]: + """Module aliases (alias -> module) and directly imported spawn names + (alias -> (import line, module)) of a file.""" + module_aliases: dict[str, str] = {} + name_aliases: dict[str, tuple[int, str]] = {} for node in ast.walk(tree): if isinstance(node, ast.Import): for a in node.names: @@ -363,6 +367,19 @@ def spawn_sites(tree: ast.Module) -> list[int]: # Keep the earliest import (not the latest) for consistent error reporting if alias not in name_aliases: name_aliases[alias] = (node.lineno, node.module) + return module_aliases, name_aliases + + +def spawn_sites(tree: ast.Module) -> list[int]: + """Lines that start a child process without :mod:`dokimi.process`. + + Each launch counts once: an attribute call (``subprocess.run(...)``) or a call + through a directly imported name (``execute(...)`` after ``from subprocess import + run as execute``). A directly imported name that is never called counts at its + import line, since it may be passed around and called there. + """ + module_aliases, name_aliases = _spawn_imports(tree) + lines: list[int] = [] called: set[str] = set() for node in ast.walk(tree): if not isinstance(node, ast.Call):