diff --git a/graphify/extract.py b/graphify/extract.py index 401f9830e..58026b08a 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -46,6 +46,7 @@ from graphify.extractors.markdown import extract_markdown # noqa: F401 from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form # noqa: F401 from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401 +from graphify.extractors.r import extract_r # noqa: F401 from graphify.extractors.razor import extract_razor # noqa: F401 from graphify.extractors.rust import extract_rust # noqa: F401 from graphify.extractors.sln import extract_sln # noqa: F401 @@ -115,6 +116,7 @@ _resolve_export_target, _resolve_java_type_references, _resolve_php_type_references, + _resolve_r_bare_calls, _resolve_js_import_path, _resolve_js_import_target, _resolve_js_module_path, @@ -3894,6 +3896,7 @@ def add_existing_edge(edge: dict) -> None: ".m": extract_objc, ".mm": extract_objc, ".jl": extract_julia, + ".r": extract_r, ".f": extract_fortran, ".F": extract_fortran, ".f90": extract_fortran, @@ -4787,6 +4790,16 @@ def _learn(e: dict) -> None: import logging logging.getLogger(__name__).warning("Cross-file import resolution failed, skipping: %s", exc) + # Cross-file R call resolution (bare-name lookup — R has no import syntax + # that names its target file, so this isn't an "imports" resolver like the + # blocks above; see _resolve_r_bare_calls's docstring in resolution.py). + if any(p.suffix.lower() == ".r" for p in paths): + try: + _resolve_r_bare_calls(all_nodes, all_edges) + except Exception as exc: + import logging + logging.getLogger(__name__).warning("R bare-call resolution failed, skipping: %s", exc) + # Cross-file Java import resolution java_paths = [p for p in paths if p.suffix == ".java"] if java_paths: diff --git a/graphify/extractors/__init__.py b/graphify/extractors/__init__.py index ada517094..ee259429d 100644 --- a/graphify/extractors/__init__.py +++ b/graphify/extractors/__init__.py @@ -25,6 +25,7 @@ from graphify.extractors.pascal import extract_pascal from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest +from graphify.extractors.r import extract_r from graphify.extractors.razor import extract_razor from graphify.extractors.rust import extract_rust from graphify.extractors.sln import extract_sln @@ -54,6 +55,7 @@ "pascal": extract_pascal, "powershell": extract_powershell, "powershell_manifest": extract_powershell_manifest, + "r": extract_r, "razor": extract_razor, "rust": extract_rust, "sln": extract_sln, diff --git a/graphify/extractors/base.py b/graphify/extractors/base.py index fa2e5b514..cc54c856e 100644 --- a/graphify/extractors/base.py +++ b/graphify/extractors/base.py @@ -1,6 +1,7 @@ # DO NOT import from graphify.extract here — direction is extract.py → extractors/ only. from __future__ import annotations +import re from pathlib import Path from graphify.ids import make_id @@ -31,6 +32,33 @@ "callable", "getattr", "setattr", "hasattr", "delattr", "vars", "dir", }) +# Languages that allow quoting an operator as an identifier (R's +# `%||%` <- function(a, b) ...) need it turned into an id-safe name before +# `make_id` sees it. `make_id`/`normalize_id` replace every non-word run with +# a single underscore, so an all-symbol name has no word characters left and +# collapses to the empty string — `make_id(stem, "%||%")` then equals +# `make_id(stem)`, i.e. the FILE node's own id, producing a same-file-vs- +# operator self-loop instead of a distinct node. Shared here (not per- +# language) because both the extractor that defines the operator and the +# cross-file resolver that looks it up by name must agree on the same id. +_OP_CHAR_NAMES: dict[str, str] = { + "%": "pct", "|": "pipe", "&": "amp", "/": "slash", "+": "plus", + "-": "minus", "*": "star", "^": "caret", "<": "lt", ">": "gt", + "=": "eq", "!": "bang", "~": "tilde", "?": "qmark", ":": "colon", + "@": "at", "$": "dollar", +} + + +def _symbol_safe_name(name: str) -> str: + """ASCII-transliterate an all-symbol identifier for id-building. + + A name with at least one word character is returned unchanged — normal + identifiers don't need this and `make_id` handles them correctly already. + """ + if re.sub(r"\W+", "", name, flags=re.UNICODE): + return name + return "".join(_OP_CHAR_NAMES.get(c, "x") for c in name) or "op" + def _make_id(*parts: str) -> str: return make_id(*parts) diff --git a/graphify/extractors/r.py b/graphify/extractors/r.py new file mode 100644 index 000000000..02e615058 --- /dev/null +++ b/graphify/extractors/r.py @@ -0,0 +1,351 @@ +"""R extractor. Regex-based: no tree-sitter grammar for R on PyPI (cf. apex.py).""" +from __future__ import annotations + +import re as _re +from pathlib import Path + +from graphify.extractors.base import _file_stem, _make_id, _symbol_safe_name + +# R reserved words and the base/stats/utils callables that appear in nearly every +# script. Without this filter each becomes a god-node accumulating an edge from +# every call site, which is the same failure _LANGUAGE_BUILTIN_GLOBALS guards +# against for JS/Python in base.py (#726). +_R_KEYWORDS: frozenset[str] = frozenset({ + "if", "else", "for", "while", "repeat", "function", "return", "break", + "next", "in", "switch", "invisible", "on.exit", "missing", "TRUE", "FALSE", + "NULL", "NA", "Inf", "NaN", +}) + +_R_BUILTINS: frozenset[str] = frozenset({ + # constructors / coercion + "c", "list", "vector", "character", "numeric", "integer", "logical", + "double", "complex", "factor", "data.frame", "matrix", "array", + "as.character", "as.numeric", "as.integer", "as.logical", "as.vector", + "as.factor", "as.data.frame", "as.matrix", "as.list", + "is.null", "is.na", "is.numeric", "is.character", "is.function", + "is.list", "is.data.frame", "is.finite", "is.element", + # sequence / structure + "length", "nrow", "ncol", "dim", "names", "colnames", "rownames", + "seq", "seq_len", "seq_along", "rep", "rev", "sort", "order", "unique", + "head", "tail", "which", "which.max", "which.min", "range", "setdiff", + "union", "intersect", "append", "unlist", "do.call", "Reduce", "Filter", + "Map", "lapply", "sapply", "vapply", "mapply", "apply", "tapply", + "rbind", "cbind", "merge", "split", "subset", "transform", "with", + "setNames", "unname", "pmax", "pmin", "strrep", + # math / stats + "sum", "mean", "median", "min", "max", "abs", "round", "signif", "floor", + "ceiling", "sqrt", "exp", "log", "log2", "log10", "var", "sd", "quantile", + "cumsum", "prod", "diff", "scale", "density", "approx", "table", + # strings + "paste", "paste0", "sprintf", "format", "formatC", "nchar", "substr", + "substring", "strsplit", "sub", "gsub", "grepl", "grep", "regmatches", + "regexpr", "gregexpr", "trimws", "tolower", "toupper", "startsWith", + "endsWith", "make.names", "shQuote", "sQuote", "dQuote", "encodeString", + # io / control + "cat", "print", "message", "warning", "stop", "stopifnot", "tryCatch", + "try", "suppressWarnings", "suppressMessages", "readline", "readLines", + "writeLines", "file", "file.path", "file.exists", "basename", "dirname", + "normalizePath", "path.expand", "dir.create", "list.files", "tempfile", + "unlink", "readRDS", "saveRDS", "readBin", "Sys.time", "Sys.getenv", + "Sys.setenv", "date", "format.Date", "nlevels", "levels", + # environment / meta + "exists", "get", "assign", "rm", "environment", "sys.function", + "sys.frames", "sys.call", "match.arg", "match.call", "nargs", "identity", + "inherits", "class", "attr", "attributes", "structure", "setattr", + "requireNamespace", "library", "require", "source", "options", "getOption", + "set.seed", "identical", "all", "any", "isTRUE", "isFALSE", "xor", + "ifelse", "nzchar", "Negate", "Vectorize", +}) + +_EXCLUDED = _R_KEYWORDS | _R_BUILTINS + +# R identifiers may start with a dot or letter and contain dots/underscores. +_IDENT = r"[.A-Za-z][A-Za-z0-9._]*" +# A definition name may also be backtick-quoted, which is how infix operators are +# declared: `%||%` <- function(a, b) ... +_DEF_NAME = rf"(?:`([^`\n]+)`|({_IDENT}))" + + +def _mask_literals(source: str) -> str: + """Blank out comments and string bodies, preserving length and newlines. + + Offsets in the masked text map 1:1 onto the original, so line numbers and + span arithmetic stay correct. A `#` inside a string is not a comment, and a + quote inside a comment does not open a string — tracking both in one pass is + what keeps those cases straight. + """ + out = list(source) + i, n = 0, len(source) + quote: str | None = None + in_comment = False + while i < n: + ch = source[i] + if in_comment: + if ch == "\n": + in_comment = False + else: + out[i] = " " + elif quote is not None: + if ch == "\\" and i + 1 < n: + out[i] = " " + if source[i + 1] != "\n": + out[i + 1] = " " + i += 2 + continue + if ch == quote: + quote = None + elif ch != "\n": + out[i] = " " + else: + if ch == "#": + in_comment = True + out[i] = " " + elif ch in "\"'": + # Backticks are identifier quoting in R (`%||%` <- ...), not + # string literals — masking them would erase the operator name. + quote = ch + i += 1 + return "".join(out) + + +def _match_delim(text: str, start: int, open_ch: str, close_ch: str) -> int: + """Index just past the delimiter matching the one at `start`, or -1.""" + depth = 0 + for i in range(start, len(text)): + c = text[i] + if c == open_ch: + depth += 1 + elif c == close_ch: + depth -= 1 + if depth == 0: + return i + 1 + return -1 + + +def extract_r(path: Path) -> dict: + """Extract functions, calls, imports, and source() edges from an .R file. + + Regex over a comment/string-masked copy of the source. Function bodies are + located by brace matching so nested closures nest correctly and calls are + attributed to the innermost enclosing function. Calls in top-level script + code are attributed to the file node — the common shape for an R driver + script, where nearly all work happens outside any function. + + Known limitation: a closure assigned via anything other than a literal + `name <- function(...)` / `` `%op%` <- function(...) `` RHS — e.g. + `.inv_logicle <- tryCatch({ function(x) ... })` — is not recognized as a + definition, so calls to it stay unresolved. Static extraction can't see + through an arbitrary expression to know it evaluates to a function. + """ + try: + source = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return {"nodes": [], "edges": []} + + masked = _mask_literals(source) + str_path = str(path) + stem = _file_stem(path) + file_nid = _make_id(str_path) + + nodes: list[dict] = [] + edges: list[dict] = [] + seen_ids: set[str] = set() + + line_starts = [0] + for idx, ch in enumerate(source): + if ch == "\n": + line_starts.append(idx + 1) + + def line_of(offset: int) -> int: + lo, hi = 0, len(line_starts) - 1 + while lo < hi: + mid = (lo + hi + 1) // 2 + if line_starts[mid] <= offset: + lo = mid + else: + hi = mid - 1 + return lo + 1 + + def add_node(nid: str, label: str, line: int) -> None: + if nid not in seen_ids: + seen_ids.add(nid) + nodes.append({ + "id": nid, + "label": label, + "file_type": "code", + "source_file": str_path, + "source_location": f"L{line}", + }) + + def add_edge(src: str, tgt: str, relation: str, line: int, + confidence: str = "EXTRACTED", context: str | None = None) -> None: + edge = { + "source": src, + "target": tgt, + "relation": relation, + "confidence": confidence, + "source_file": str_path, + "source_location": f"L{line}", + "weight": 1.0, + } + if context: + edge["context"] = context + edges.append(edge) + + add_node(file_nid, path.name, 1) + + # Paren nesting depth at each offset, so a `name = function(...)` sitting + # inside a call can be told apart from a statement-level definition. Without + # this, every `tryCatch(..., error = function(e) ...)` handler and every + # ggplot `labels = function(x) ...` argument is misread as a named function. + depth_at: list[int] = [] + _d = 0 + for ch in masked: + depth_at.append(_d) + if ch == "(": + _d += 1 + elif ch == ")": + _d = max(0, _d - 1) + + # ---- function definitions ------------------------------------------------- + # name <- function(...) | name = function(...) | name <<- function(...) + # `%op%` <- function(...) for infix operators. + def_re = _re.compile(rf"{_DEF_NAME}\s*(<<-|<-|=)\s*function\s*\(") + spans: list[tuple[int, int, str, str]] = [] # (start, end, name, nid) + + for m in def_re.finditer(masked): + name = m.group(1) or m.group(2) + op = m.group(3) + # `=` inside an argument list binds a parameter, not a name in scope. + if op == "=" and depth_at[m.start()] > 0: + continue + sig_open = masked.index("(", m.end() - 1) + sig_end = _match_delim(masked, sig_open, "(", ")") + if sig_end == -1: + continue + rest = masked[sig_end:] + lead = len(rest) - len(rest.lstrip()) + body_start = sig_end + lead + if body_start < len(masked) and masked[body_start] == "{": + body_end = _match_delim(masked, body_start, "{", "}") + if body_end == -1: + body_end = len(masked) + else: + # Brace-less one-liner: body runs to end of the line. + nl = masked.find("\n", sig_end) + body_end = len(masked) if nl == -1 else nl + nid = _make_id(stem, _symbol_safe_name(name)) + line = line_of(m.start()) + add_node(nid, f"{name}()", line) + spans.append((m.start(), body_end, name, nid)) + + spans.sort(key=lambda s: (s[0], -s[1])) + + def enclosing(offset: int) -> tuple[str, str] | None: + """Innermost (nid, name) whose body contains `offset`.""" + best: tuple[int, str, str] | None = None + for start, end, name, nid in spans: + if start <= offset < end and (best is None or start > best[0]): + best = (start, nid, name) + return (best[1], best[2]) if best else None + + # contains: file -> top-level fn, outer fn -> nested fn + for start, _end, _name, nid in spans: + parent = None + for s2, e2, _n2, nid2 in spans: + if s2 < start < e2 and nid2 != nid: + if parent is None or s2 > parent[0]: + parent = (s2, nid2) + add_edge(parent[1] if parent else file_nid, nid, "contains", line_of(start)) + + # ---- imports: library() / require() / pkg:: --------------------------------- + lib_re = _re.compile(rf"\b(?:library|require)\s*\(\s*[\"']?({_IDENT})[\"']?\s*\)") + for m in lib_re.finditer(masked): + pkg = m.group(1) + pkg_nid = _make_id(pkg) + line = line_of(m.start()) + add_node(pkg_nid, pkg, line) + add_edge(file_nid, pkg_nid, "imports", line, context="import") + + ns_re = _re.compile(rf"\b({_IDENT})::({_IDENT})") + seen_ns: set[str] = set() + for m in ns_re.finditer(masked): + pkg = m.group(1) + if pkg in seen_ns: + continue + seen_ns.add(pkg) + pkg_nid = _make_id(pkg) + line = line_of(m.start()) + add_node(pkg_nid, pkg, line) + add_edge(file_nid, pkg_nid, "imports", line, context="import") + + # ---- source() --------------------------------------------------------------- + # Literal path: source("R/00_utils.R"). The masking pass blanked string + # bodies, so re-read the literal from the original text at the same offset. + src_re = _re.compile(r"\bsource\s*\(") + for m in src_re.finditer(masked): + call_end = _match_delim(masked, m.end() - 1, "(", ")") + if call_end == -1: + continue + raw = source[m.end():call_end - 1] + line = line_of(m.start()) + for lit in _re.findall(r"[\"']([^\"']+\.[Rr])[\"']", raw): + tgt = _make_id(str(Path(lit))) + add_edge(file_nid, tgt, "sources", line, context="source") + # Computed path over a character vector, e.g. + # for (mod in c("00_utils", "01_load")) source(file.path(d, paste0(mod, ".R"))) + # Recover the vector members from the enclosing for-header when the + # source() argument interpolates the loop variable. + if "paste0" in raw or "file.path" in raw: + header = source[max(0, m.start() - 600):m.start()] + fm = None + for fm in _re.finditer(r"for\s*\(\s*(\w+)\s+in\s+c\s*\(", header): + pass + if fm is not None: + vec_start = header.index("(", fm.end() - 1) + vec_end = _match_delim(header, vec_start, "(", ")") + if vec_end != -1 and fm.group(1) in raw: + # Directory components come from the literal segments of the + # file.path(...) call itself: file.path(dir, "R", ...) -> "R". + # Extension literals (".R") are not path segments. + prefix = [s for s in _re.findall(r"[\"']([^\"']+)[\"']", raw) + if not s.startswith(".")] + for lit in _re.findall(r"[\"']([^\"']+)[\"']", header[vec_start:vec_end]): + tgt = _make_id(str(Path(*prefix, f"{lit}.R"))) + add_edge(file_nid, tgt, "sources", line, + confidence="INFERRED", context="source") + + # ---- calls ------------------------------------------------------------------- + call_re = _re.compile(rf"(?:({_IDENT})::)?({_IDENT})\s*\(") + for m in call_re.finditer(masked): + pkg, name = m.group(1), m.group(2) + if name in _EXCLUDED and not pkg: + continue + # Skip the definition site itself: `foo <- function(` matches `foo(`. + before = masked[:m.start()].rstrip() + if before.endswith("function"): + continue + scope = enclosing(m.start()) + scope_nid, scope_name = scope if scope else (file_nid, None) + if scope_name == name: # direct recursion — graph keeps no self-loops + continue + line = line_of(m.start()) + safe_name = _symbol_safe_name(name) + if pkg: + tgt = _make_id(pkg, safe_name) + add_edge(scope_nid, tgt, "calls", line, context="call") + else: + edge_idx = len(edges) + add_edge(scope_nid, _make_id(stem, safe_name), "calls", line, context="call") + # R has one flat namespace once every file is source()'d into + # .GlobalEnv (see the typical `for (mod in modules) source(...)` + # bootstrap loop) — a name undefined in THIS file is not necessarily + # undefined in the corpus. Stash the bare (file-independent) target + # alongside the same-file guess so `_resolve_r_bare_calls` + # (extractors/resolution.py) can repoint genuinely cross-file calls + # at their real definition once every file has been extracted. + # Internal-only field, same convention as export.py's `_src`/`_tgt`: + # never serialized, popped before graph.json is written. + edges[edge_idx]["_bare_target"] = _make_id(safe_name) + + return {"nodes": nodes, "edges": edges} diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 31a2e7979..988aa02a5 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -9,6 +9,7 @@ _file_stem, _make_id, _read_text, + _symbol_safe_name, ) import hashlib import json @@ -2582,3 +2583,43 @@ def _pascal_resolve_class(from_path: Path, class_name: str) -> str | None: if file_stem: return _make_id(file_stem, class_name) return None + + +def _resolve_r_bare_calls(all_nodes: list[dict], all_edges: list[dict]) -> None: + """Repoint dangling R `calls` edges at their unique same-name definition + elsewhere in the corpus, then strip the internal `_bare_target` hint. + + R has one flat namespace once every file is `source()`'d into + `.GlobalEnv`, so `extract_r` (extractors/r.py) cannot tell — from a single + file — whether an unresolved call is a typo, a base-R/package function, or + a function defined in a sibling file. It resolves same-file calls and + stashes a `_bare_target` hint on everything else; this corpus-level pass + (called from extract() once every file's results are merged, mirroring + `_resolve_cross_file_imports` above) does the file-independent lookup. + + Mutates `all_edges` in place. An edge whose bare name matches more than + one definition is left dangling (pruned at build) rather than guessed — + the same rule the JS barrel resolver above uses for an ambiguous + re-export: never invent an edge. + """ + node_ids = {n["id"] for n in all_nodes} + + def_by_bare: dict[str, list[str]] = {} + for n in all_nodes: + sf = str(n.get("source_file", "")) + label = str(n.get("label", "")) + if sf.lower().endswith(".r") and label.endswith("()"): + bare = _make_id(_symbol_safe_name(label[:-2])) + def_by_bare.setdefault(bare, []).append(n["id"]) + + for e in all_edges: + bare = e.pop("_bare_target", None) + if bare is None or e.get("relation") != "calls": + continue + if e.get("target") in node_ids: + continue # same-file guess already resolved; leave it alone + candidates = def_by_bare.get(bare, []) + if len(candidates) == 1: + e["target"] = candidates[0] + # 0 candidates (external/base-R call) or >1 (ambiguous): leave as the + # dangling same-file guess; build's dangling-edge prune drops it. diff --git a/tests/test_extract.py b/tests/test_extract.py index 98b73edb9..350bc6b31 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -2040,25 +2040,65 @@ def test_case_insensitive_suffix_filtering(tmp_path): -def test_extract_warns_on_code_files_with_no_ast_extractor(tmp_path, capsys): - # #1689: .r/.R is in CODE_EXTENSIONS (counted as code) but has no AST extractor, - # so R files silently contribute nothing. extract() must surface that instead of - # reporting success as if the language were mapped. - r1 = tmp_path / "analysis.R"; r1.write_text("f <- function(x) x + 1\n") - r2 = tmp_path / "helper.r"; r2.write_text("g <- function(y) y * 2\n") +def test_extract_warns_on_code_files_with_no_ast_extractor(tmp_path, capsys, monkeypatch): + # #1689: a file in CODE_EXTENSIONS (counted as code) but with no AST extractor + # wired up silently contributes nothing. extract() must surface that instead of + # reporting success as if the language were mapped. Exercised via a synthetic + # extension (not tied to any real language) so this stays true regardless of + # which languages later gain extractors — .r/.R used to be the example here + # until R got one; see test_extract_r_extracts_functions_calls_and_sources. + import graphify.detect as detect_mod + monkeypatch.setattr(detect_mod, "CODE_EXTENSIONS", + detect_mod.CODE_EXTENSIONS | {".zzznolang"}) + + r1 = tmp_path / "analysis.zzznolang"; r1.write_text("whatever\n") + r2 = tmp_path / "helper.ZZZNOLANG"; r2.write_text("whatever\n") py = tmp_path / "main.py"; py.write_text("def main():\n return 1\n") result = extract([r1, r2, py], cache_root=tmp_path) err = capsys.readouterr().err assert "no AST extractor" in err - assert ".r (2)" in err # both R files grouped under the lowercased ext + assert ".zzznolang (2)" in err # both grouped under the lowercased ext assert "#1689" in err # the Python file still extracts normally labels = [n.get("label") for n in result["nodes"]] assert any(str(l).startswith("main") for l in labels) +def test_extract_r_extracts_functions_calls_and_sources(tmp_path, capsys): + # #1689: .r/.R used to hit the no-AST-extractor path (see the synthetic- + # extension test above); this locks in that it now extracts for real. + utils = tmp_path / "utils.R" + utils.write_text("helper <- function(x) x + 1\n") + main = tmp_path / "main.R" + main.write_text( + "source(\"utils.R\")\n" + "library(stats)\n" + "run <- function() {\n" + " helper(41)\n" + "}\n" + ) + + result = extract([main, utils], cache_root=tmp_path) + err = capsys.readouterr().err + assert "no AST extractor" not in err + + labels = {n["label"] for n in result["nodes"]} + assert "helper()" in labels + assert "run()" in labels + + edges = result["edges"] + assert any(e["relation"] == "sources" for e in edges) + assert any(e["relation"] == "imports" for e in edges) + run_id = next(n["id"] for n in result["nodes"] if n["label"] == "run()") + helper_id = next(n["id"] for n in result["nodes"] if n["label"] == "helper()") + assert any( + e["relation"] == "calls" and e["source"] == run_id and e["target"] == helper_id + for e in edges + ), "expected run() -> helper() to resolve cross-file via _resolve_r_bare_calls" + + def test_extract_no_warning_when_all_code_has_extractors(tmp_path, capsys): py = tmp_path / "a.py"; py.write_text("def a():\n return 1\n") extract([py], cache_root=tmp_path) @@ -2096,15 +2136,21 @@ def test_extract_no_missing_dep_warning_when_sql_installed(tmp_path, capsys): assert "#1745" not in err -def test_extract_progress_final_line_uses_consistent_denominator(tmp_path, capsys): +def test_extract_progress_final_line_uses_consistent_denominator(tmp_path, capsys, monkeypatch): # #1693: intermediate progress lines count against uncached_work; the final # "100%" line must NOT switch to total_files (which includes cached hits and # files with no extractor), or the count appears to jump upward at the end. + # Uses a synthetic unmapped extension (not .r/.R, which now has an extractor) + # so this stays true regardless of which languages later gain one. + import graphify.detect as detect_mod + monkeypatch.setattr(detect_mod, "CODE_EXTENSIONS", + detect_mod.CODE_EXTENSIONS | {".zzznolang"}) + for i in range(100): (tmp_path / f"m{i}.py").write_text(f"def f{i}():\n return {i}\n") for i in range(5): - (tmp_path / f"s{i}.r").write_text(f"g{i} <- function(x) x\n") # no extractor - paths = sorted(tmp_path.glob("*.py")) + sorted(tmp_path.glob("*.r")) # total 105 + (tmp_path / f"s{i}.zzznolang").write_text(f"g{i}\n") # no extractor + paths = sorted(tmp_path.glob("*.py")) + sorted(tmp_path.glob("*.zzznolang")) # total 105 extract(paths, cache_root=tmp_path, parallel=False) out = capsys.readouterr().out