diff --git a/README.md b/README.md index 0c14d207c..afd45205a 100644 --- a/README.md +++ b/README.md @@ -423,6 +423,22 @@ dist/ !src/** ``` + +## Project configuration + +Create a `.graphifyrc` in the scan root for settings that belong to the project rather than to one run. One `key=value` per line, `#` comments. + +**Ambiguous extensions.** Some extensions mean different languages in different projects: `.inc` is PHP on pfSense, Pascal in a Delphi tree, SQL or assembly elsewhere; `.h` is C or C++; `.m` is Objective-C or MATLAB. graphify has to pick one global default, and when it picks wrong the file does not fail — it parses as the wrong language and yields a handful of incidental nodes, so the graph looks populated while the real symbols are missing. Declare what the extension means in your repo: + +``` +# .graphifyrc +language.inc=php # a language name ... +language.tpl=.ts # ... or an extension graphify already knows +viz_node_limit=0 # baked into the git hooks (see Team setup) +``` + +The declaration applies everywhere graphify keys a decision on the extension — file classification, extractor dispatch, cross-file resolution, and the AST cache (a file re-parsed under a different language never reuses the old entry). It is read by `graphify extract`, `graphify update`, `watch`, the hooks, the MCP server, and the `/graphify` skill alike. A typo is reported once on stderr and the scan continues with graphify's defaults. + --- ## Team setup diff --git a/graphify/cache.py b/graphify/cache.py index 8fb168ce3..440993882 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -921,11 +921,19 @@ def cache_dir(root: Path = Path("."), kind: str = "ast", return d +def _salted_key(h: str, salt: str | None) -> str: + """Derive the on-disk key from a content hash plus optional salt.""" + if not salt: + return h + return hashlib.sha256(f"{h}:{salt}".encode("utf-8")).hexdigest() + + def load_cached(path: Path, root: Path = Path("."), kind: str = "ast", cache_root: Path | None = None, prompt: "str | Path | None" = None, prompt_file: "str | Path | None" = None, allow_legacy: bool = True, - allow_partial: bool = False) -> dict | None: + allow_partial: bool = False, + salt: str | None = None) -> dict | None: """Return cached extraction for this file if hash matches, else None. Cache key: SHA256 of file contents. @@ -954,6 +962,10 @@ def load_cached(path: Path, root: Path = Path("."), kind: str = "ast", so :func:`check_semantic_cache` can report N to the user. Callers that must not mix vintages within one entry (see :func:`save_semantic_cache`'s ``merge_existing``) pass allow_legacy=False. + ``salt`` folds extra material into the key for an entry whose validity + depends on more than the file's bytes — a file the project remaps to + another language (#2961) parses to a different graph from the same + content, so it must not hit the entry produced under the old extractor. Returns None if no cache entry or file has changed. """ global _legacy_semantic_hits, _corrupt_cache_entries @@ -962,6 +974,7 @@ def load_cached(path: Path, root: Path = Path("."), kind: str = "ast", h = file_hash(path, root, cache_root=cache_root) except OSError: return None + h = _salted_key(h, salt) prompt_fp = _resolve_prompt_fp(prompt, prompt_file) entry = cache_dir(location, kind, prompt_fp) / f"{h}.json" legacy_hit = False @@ -1029,9 +1042,13 @@ def load_cached(path: Path, root: Path = Path("."), kind: str = "ast", def save_cached(path: Path, result: dict, root: Path = Path("."), kind: str = "ast", cache_root: Path | None = None, prompt: "str | Path | None" = None, - prompt_file: "str | Path | None" = None) -> None: + prompt_file: "str | Path | None" = None, + salt: str | None = None) -> None: """Save extraction result for this file. + ``salt`` must match what the corresponding :func:`load_cached` passes + (see there); it namespaces the key, not the directory. + Stores as graphify-out/cache/{kind}/{hash}.json where hash = SHA256 of current file contents. result should be a dict with 'nodes' and 'edges' lists. @@ -1078,7 +1095,7 @@ def save_cached(path: Path, result: dict, root: Path = Path("."), kind: str = "a # the entry replays portably under any root (#2257). Strictly after the # source_file pass, which owns that field's bare-relative format. _relativize_ids_in(on_disk, p, root) - h = file_hash(p, root, cache_root=cache_root) + h = _salted_key(file_hash(p, root, cache_root=cache_root), salt) location = cache_root if cache_root is not None else root target_dir = cache_dir(location, kind, _resolve_prompt_fp(prompt, prompt_file)) entry = target_dir / f"{h}.json" diff --git a/graphify/detect.py b/graphify/detect.py index d16b5800c..f699b4543 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -21,6 +21,7 @@ google_workspace_enabled, ) from graphify.paths import GRAPHIFY_OUT, out_path +from graphify.rcfile import activate_language_overrides, effective_suffix class FileType(str, Enum): @@ -512,7 +513,9 @@ def classify_file(path: Path) -> FileType | None: # Compound extensions must be checked before simple suffix lookup if path.name.lower().endswith(".blade.php"): return FileType.CODE - ext = path.suffix.lower() + # A project may declare what an ambiguous extension means to it + # (.graphifyrc `language.inc=php`, #2961); classify by the declared one. + ext = effective_suffix(path).lower() if not ext: return _shebang_file_type(path) if ext in CODE_EXTENSIONS: @@ -1514,6 +1517,9 @@ def _resolves_under_root(path: Path, root: Path) -> bool: def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace: bool | None = None, extra_excludes: list[str] | None = None, cache_root: Path | None = None, gitignore: bool = True) -> dict: root = root.resolve() + # The project's extension->language declarations (#2961) shape + # classification for this scan. + activate_language_overrides(root) configured_out_dir = root / GRAPHIFY_OUT configured_out_names = {configured_out_dir.name} try: diff --git a/graphify/extract.py b/graphify/extract.py index 89082af87..bfb44f312 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -26,6 +26,13 @@ from .pascal_resolution import resolve_pascal_inherited_calls # --- migrated to graphify/extractors/ (see graphify/extractors/MIGRATION.md) --- +from graphify.rcfile import ( + activate_language_overrides, + cache_salt, + effective_suffix, + get_language_overrides, + set_language_overrides, +) from graphify.extractors.base import ( # noqa: F401 _LANGUAGE_BUILTIN_GLOBALS, _file_stem, @@ -2193,7 +2200,7 @@ def _lang_is_case_insensitive(source_file: object) -> bool: """True when the file's language resolves identifiers case-insensitively (#1581).""" if not source_file: return False - return Path(str(source_file)).suffix.lower() in _CASE_INSENSITIVE_EXTS + return effective_suffix(str(source_file)).lower() in _CASE_INSENSITIVE_EXTS # Language interop families for cross-file call resolution. A call in one language @@ -2239,7 +2246,7 @@ def _lang_family(source_file: object) -> str | None: """Interop family of the file's language, or None when unknown/not code.""" if not source_file: return None - return _LANG_FAMILY_BY_EXT.get(Path(str(source_file)).suffix.lower()) + return _LANG_FAMILY_BY_EXT.get(effective_suffix(str(source_file)).lower()) # A language's own built-in throwable hierarchy, keyed by the interop family of @@ -5387,6 +5394,12 @@ def _get_extractor(path: Path) -> Any | None: # (#1377). apm.yml would otherwise be a .yml document handled by the LLM. if is_package_manifest_path(path): return extract_package_manifest + # A project-declared remap (.graphifyrc `language.inc=php`, #2961) wins + # over every sniff below: the user has said what the extension means in + # this repo, so a remapped `.h`/`.m` also skips the C++/ObjC probes. + remapped = effective_suffix(path) + if remapped != path.suffix: + return _DISPATCH.get(remapped) or _DISPATCH.get(remapped.lower()) # `.h` is C/C++/ObjC-ambiguous; route Objective-C headers to extract_objc # (the suffix map sends `.h` to extract_c, which can't read @interface etc.). # ObjC sniffing has priority over the C++ sniff: an Objective-C++ header can @@ -5429,6 +5442,13 @@ def _safe_extract_with_xaml_root(extractor, path: Path, root: Path) -> dict: _XAML_ACTIVE_EXTRACT_ROOT = previous_root +def _worker_init(language_overrides: dict[str, str]) -> None: + """Pool initializer. Under ``spawn`` a worker re-imports the package and + starts with no overrides; hand it the parent's so `_get_extractor` and the + cache key agree across processes (#2961).""" + set_language_overrides(language_overrides) + + def _extract_single_file(args: tuple) -> tuple[int, dict]: """Worker function for parallel extraction. Runs in a subprocess. @@ -5457,7 +5477,7 @@ def _extract_single_file(args: tuple) -> tuple[int, dict]: # Check cache first (avoid re-extraction) if not bypass_cache: - cached = load_cached(path, root, cache_root=cache_location) + cached = load_cached(path, root, cache_root=cache_location, salt=cache_salt(path)) if cached is not None: return idx, cached @@ -5472,7 +5492,7 @@ def _extract_single_file(args: tuple) -> tuple[int, dict]: # byte-stable across runs and silently blinds affected/explain to and # through the file (#1666); skipping the write lets a rerun self-heal. if not bypass_cache and "error" not in result and result.get("nodes"): - save_cached(path, result, root, cache_root=cache_location) + save_cached(path, result, root, cache_root=cache_location, salt=cache_salt(path)) return idx, result @@ -5538,7 +5558,11 @@ def _extract_parallel( failed: list[int] = [] # positions into uncached_work whose future failed _PROGRESS_INTERVAL = 100 try: - with concurrent.futures.ProcessPoolExecutor(max_workers=max_workers) as pool: + with concurrent.futures.ProcessPoolExecutor( + max_workers=max_workers, + initializer=_worker_init, + initargs=(get_language_overrides(),), + ) as pool: futures = { pool.submit(_extract_single_file, item): pos for pos, item in enumerate(work_items) @@ -5637,7 +5661,7 @@ def _extract_sequential( result = _safe_extract_with_xaml_root(extractor, path, root) # See _extract_single_file: don't cache an anomalous zero-node result (#1666). if not bypass_cache and "error" not in result and result.get("nodes"): - save_cached(path, result, root, cache_root=cache_location) + save_cached(path, result, root, cache_root=cache_location, salt=cache_salt(path)) per_file[idx] = result if total_files >= _PROGRESS_INTERVAL: # Consistent denominator with the intermediate lines (#1693). @@ -5742,6 +5766,17 @@ def extract( root = cache_root root = root.resolve() + # Project-level extension->language declarations (#2961). detect() has + # usually activated them for this root already; a direct library caller + # (the skill runbook, the MCP server, the issue's own repro) gets them here. + _overrides = activate_language_overrides(root) + if _overrides: + print( + " language overrides (.graphifyrc): " + + ", ".join(f"{k} -> {v}" for k, v in sorted(_overrides.items())), + flush=True, + ) + # #1774: the cache is an OUTPUT, so when no explicit cache_root is given it is # written under the current working directory — never `root` (the inferred # common parent of the inputs), which would drop graphify-out/ inside a @@ -5761,7 +5796,7 @@ def extract( continue bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES if not bypass_cache: - cached = load_cached(path, root, cache_root=cache_location) + cached = load_cached(path, root, cache_root=cache_location, salt=cache_salt(path)) if cached is not None: per_file[i] = cached continue @@ -6396,7 +6431,7 @@ def _learn(e: dict) -> None: _php_exts = {".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps"} _php_sel = [ (r, p) for r, p in zip(per_file, paths) - if p.suffix.lower() in _php_exts and not p.name.lower().endswith(".blade.php") + if effective_suffix(p).lower() in _php_exts and not p.name.lower().endswith(".blade.php") ] if _php_sel: try: diff --git a/graphify/hooks.py b/graphify/hooks.py index 211c074be..680ecfcc9 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -416,39 +416,14 @@ def _detached_launch(rebuild_body: str) -> str: """ -def _load_graphifyrc(root: Path) -> dict[str, str | int]: - """Load key/value options from /.graphifyrc if present. +def _load_graphifyrc(root: Path) -> dict: + """Load ``/.graphifyrc``; the parser lives in :mod:`graphify.rcfile`. - Supported options: - viz_node_limit: integer >= 0 (e.g. viz_node_limit=0) + Kept as the hooks-side name so callers and tests need not move. Returns + ``viz_node_limit`` (used here) alongside any other option the file sets. """ - rc_path = root / ".graphifyrc" - if not rc_path.is_file(): - return {} - - cfg: dict[str, str | int] = {} - content = rc_path.read_text(encoding="utf-8") - for line_num, raw in enumerate(content.splitlines(), 1): - line = raw.strip() - if not line or line.startswith("#"): - continue - if "=" not in line: - raise ValueError(f"Invalid line {line_num} in {rc_path}: {raw!r} (expected key=value)") - key, val = line.split("=", 1) - key = key.strip() - val = val.strip() - if key == "viz_node_limit": - try: - parsed_val = int(val) - if parsed_val < 0: - raise ValueError("must be a non-negative integer") - cfg["viz_node_limit"] = parsed_val - except ValueError as exc: - raise ValueError( - f"Invalid viz_node_limit in {rc_path} at line {line_num}: {val!r}. " - f"Must be a non-negative integer." - ) from exc - return cfg + from graphify.rcfile import load_graphifyrc + return load_graphifyrc(root) def _git_root(path: Path) -> Path | None: diff --git a/graphify/rcfile.py b/graphify/rcfile.py new file mode 100644 index 000000000..3a7971831 --- /dev/null +++ b/graphify/rcfile.py @@ -0,0 +1,229 @@ +"""Project-level configuration: ``/.graphifyrc``. + +One ``key=value`` per line, ``#`` comments, blank lines ignored. Keys: + +``viz_node_limit=`` + Baked into the generated git hooks (see :mod:`graphify.hooks`). + +``language.=`` + Treat files with extension ```` as the named language for + classification, extractor dispatch and cross-file resolution (#2961). + ```` may be written with or without its leading dot; the value is + either a language name from :data:`LANGUAGE_ALIASES` or an explicit + extension graphify already knows (``.php``, ``.pas``, ``.sql``, ...):: + + # pfSense: every .inc under this repo is PHP, not Pascal + language.inc=php + # a repo whose templates are plain TypeScript + language.tpl=.ts + + An ambiguous extension (``.inc`` is PHP, Pascal, SQL or assembly + depending on the project; ``.m`` is Objective-C or MATLAB; ``.h`` is C or + C++) has no single correct global mapping, so the project declares it. + +The parser is deliberately free of heavy imports: :mod:`graphify.detect` +and :mod:`graphify.extract` consult it on every scan, and the extraction +worker processes re-import it under ``spawn``. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +RC_FILENAME = ".graphifyrc" + +# Language name -> the canonical extension graphify dispatches it under. +# Deliberately a short list of names a user is likely to type; any known +# extension can be given directly instead (``language.inc=.php``). +LANGUAGE_ALIASES: dict[str, str] = { + "php": ".php", + "pascal": ".pas", "delphi": ".pas", "freepascal": ".pas", + "sql": ".sql", + "python": ".py", + "javascript": ".js", "js": ".js", + "typescript": ".ts", "ts": ".ts", + "c": ".c", + "cpp": ".cpp", "c++": ".cpp", "cxx": ".cpp", + "objc": ".mm", "objective-c": ".mm", "objectivec": ".mm", + "java": ".java", + "kotlin": ".kt", + "scala": ".scala", + "groovy": ".groovy", + "go": ".go", "golang": ".go", + "rust": ".rs", + "ruby": ".rb", + "csharp": ".cs", "c#": ".cs", + "swift": ".swift", + "lua": ".lua", + "zig": ".zig", + "elixir": ".ex", + "julia": ".jl", + "dart": ".dart", + "r": ".r", + "fortran": ".f90", + "shell": ".sh", "bash": ".sh", "sh": ".sh", + "powershell": ".ps1", + "verilog": ".v", "systemverilog": ".sv", + "terraform": ".tf", "hcl": ".hcl", + "ocaml": ".ml", + "lisp": ".lisp", "commonlisp": ".lisp", + "markdown": ".md", + "json": ".json", + "yaml": ".yaml", + "html": ".html", +} + + +def _normalise_ext(raw: str) -> str: + ext = raw.strip().lower() + if not ext.startswith("."): + ext = "." + ext + return ext + + +def parse_language_value(value: str) -> str: + """Resolve the right-hand side of ``language.=`` to a canonical suffix. + + Accepts a name from :data:`LANGUAGE_ALIASES` (case-insensitive) or an + explicit dotted extension. Raises ``ValueError`` for anything else. + """ + v = value.strip() + if not v: + raise ValueError("empty value") + if v.startswith("."): + ext = _normalise_ext(v) + if len(ext) < 2 or any(ch.isspace() for ch in ext): + raise ValueError(f"{value!r} is not an extension") + return ext + try: + return LANGUAGE_ALIASES[v.lower()] + except KeyError: + known = ", ".join(sorted(LANGUAGE_ALIASES)) + raise ValueError( + f"unknown language {value!r} (use one of: {known}; " + f"or an explicit extension such as .php)" + ) from None + + +def load_graphifyrc(root: Path) -> dict: + """Parse ``/.graphifyrc``. Returns ``{}`` when absent. + + Returned keys: ``viz_node_limit`` (int) and ``languages`` + (``{".inc": ".php", ...}``) — each present only when the file sets it. + Unknown keys are ignored so a newer graphify's options do not break an + older one. Malformed lines raise ``ValueError`` naming the line. + """ + rc_path = Path(root) / RC_FILENAME + if not rc_path.is_file(): + return {} + + cfg: dict = {} + languages: dict[str, str] = {} + content = rc_path.read_text(encoding="utf-8") + for line_num, raw in enumerate(content.splitlines(), 1): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + raise ValueError(f"Invalid line {line_num} in {rc_path}: {raw!r} (expected key=value)") + key, val = line.split("=", 1) + key = key.strip() + val = val.strip() + if key == "viz_node_limit": + try: + parsed_val = int(val) + if parsed_val < 0: + raise ValueError("must be a non-negative integer") + cfg["viz_node_limit"] = parsed_val + except ValueError as exc: + raise ValueError( + f"Invalid viz_node_limit in {rc_path} at line {line_num}: {val!r}. " + f"Must be a non-negative integer." + ) from exc + elif key.startswith("language."): + ext_part = key[len("language."):].strip() + if not ext_part or any(ch.isspace() for ch in ext_part): + raise ValueError( + f"Invalid language key in {rc_path} at line {line_num}: {key!r} " + f"(expected language.=)" + ) + try: + target = parse_language_value(val) + except ValueError as exc: + raise ValueError( + f"Invalid {key} in {rc_path} at line {line_num}: {exc}" + ) from None + languages[_normalise_ext(ext_part)] = target + if languages: + cfg["languages"] = languages + return cfg + + +# --------------------------------------------------------------------------- +# Active overrides for this process +# --------------------------------------------------------------------------- +# +# detect()/extract() activate the scan root's overrides for the run; the +# extraction pool forwards them to its workers (they do not inherit module +# state under ``spawn``). Stored lower-cased on both sides. + +_ACTIVE: dict[str, str] = {} +_warned_roots: set[str] = set() + + +def set_language_overrides(mapping: dict[str, str] | None) -> None: + """Replace the process-wide extension overrides (``{".inc": ".php"}``).""" + _ACTIVE.clear() + if mapping: + for ext, target in mapping.items(): + _ACTIVE[_normalise_ext(ext)] = _normalise_ext(target) + + +def get_language_overrides() -> dict[str, str]: + return dict(_ACTIVE) + + +def activate_language_overrides(root: Path) -> dict[str, str]: + """Load ``/.graphifyrc`` and make its language overrides active. + + A malformed file is reported once per root on stderr and treated as + having no overrides — a scan must not die on a config typo, but the + user must hear about it, or their ``.inc`` silently stays Pascal. + Returns the mapping now active. + """ + try: + cfg = load_graphifyrc(Path(root)) + except (ValueError, OSError) as exc: + key = str(root) + if key not in _warned_roots: + _warned_roots.add(key) + print(f"[graphify] warning: ignoring {RC_FILENAME}: {exc}", file=sys.stderr) + cfg = {} + set_language_overrides(cfg.get("languages")) + return get_language_overrides() + + +def effective_suffix(path: Path | str) -> str: + """The suffix graphify should treat ``path`` as having. + + Returns the override target when the file's extension is remapped, else + the real suffix untouched (case preserved, so callers that distinguish + ``.F90`` from ``.f90`` keep doing so). + """ + suffix = Path(path).suffix + if not _ACTIVE: + return suffix + return _ACTIVE.get(suffix.lower(), suffix) + + +def cache_salt(path: Path | str) -> str | None: + """Extra cache-key material for a remapped file, else ``None``. + + An AST cache entry is keyed by content, and the same bytes parse to a + different graph under a different extractor — so a ``.inc`` cached as + Pascal must not be served once the project declares it PHP. + """ + if not _ACTIVE: + return None + target = _ACTIVE.get(Path(path).suffix.lower()) + return f"language={target}" if target else None diff --git a/graphify/resolver_registry.py b/graphify/resolver_registry.py index b17478a78..ded758c00 100644 --- a/graphify/resolver_registry.py +++ b/graphify/resolver_registry.py @@ -75,7 +75,10 @@ def run_language_resolvers( exercise the driver in isolation. """ active = _REGISTRY if resolvers is None else resolvers - suffixes_present = {p.suffix for p in paths} + # Honour project-level remaps (#2961): a `.inc` declared PHP must wake + # the PHP resolvers, not Pascal's. + from graphify.rcfile import effective_suffix + suffixes_present = {effective_suffix(p) for p in paths} for resolver in active: if not (resolver.suffixes & suffixes_present): continue diff --git a/tests/test_language_overrides.py b/tests/test_language_overrides.py new file mode 100644 index 000000000..c4cd45e5a --- /dev/null +++ b/tests/test_language_overrides.py @@ -0,0 +1,346 @@ +"""A project can declare what an ambiguous extension means to it (#2961). + +`.inc` is hard-mapped to the Pascal extractor, but it is "include file" in +whatever language a project uses: PHP on pfSense, Pascal in Delphi, SQL or +assembly elsewhere. A PHP `.inc` parsed as Pascal does not fail — it yields a +handful of incidental nodes, so the graph looks populated while the shipped +runtime is missing from it (7 nodes instead of 471 on the reporter's file). + +No global mapping can be right for everyone, so the project says what it +means in `.graphifyrc`:: + + language.inc=php + +The declaration has to reach every place graphify keys a decision on the +suffix — classification, extractor dispatch, the case-folding and interop +rules for cross-file resolution, the language resolvers, and the AST cache +key (same bytes parse to a different graph under a different extractor) — +and it has to survive the trip into the extraction worker processes. +""" +from __future__ import annotations + +import concurrent.futures +import io +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path + +import pytest + +from graphify import rcfile +from graphify.detect import FileType, classify_file, detect +from graphify.extract import ( + _get_extractor, + _lang_family, + _lang_is_case_insensitive, + extract, + extract_pascal, + extract_php, +) + +try: + from graphify.extract import _worker_init +except ImportError: # pre-fix tree: the pool had no initializer + _worker_init = None +from graphify.rcfile import ( + activate_language_overrides, + cache_salt, + effective_suffix, + load_graphifyrc, + parse_language_value, + set_language_overrides, +) +from graphify.resolver_registry import LanguageResolver, run_language_resolvers + +PHP_SOURCE = """parse($path); } + private function parse(string $path): array { return []; } +} + +function pfb_update_lists(array $cfg): void { $r = new RuleSet(); $r->load('/tmp/x'); } +function pfb_apply_rules(): void { pfb_update_lists([]); } +function pfb_cron(): void { pfb_apply_rules(); } +""" + + +@pytest.fixture(autouse=True) +def _no_overrides_leak(): + """Overrides are process state; never let one test's config leak into the next.""" + set_language_overrides(None) + rcfile._warned_roots.clear() + yield + set_language_overrides(None) + rcfile._warned_roots.clear() + + +def _quiet_extract(paths, **kw): + with redirect_stdout(io.StringIO()): + return extract(paths, **kw) + + +# --------------------------------------------------------------------------- +# The .graphifyrc parser +# --------------------------------------------------------------------------- + +def test_language_line_maps_an_extension_to_a_canonical_suffix(tmp_path): + (tmp_path / ".graphifyrc").write_text("language.inc=php\n", encoding="utf-8") + assert load_graphifyrc(tmp_path) == {"languages": {".inc": ".php"}} + + +@pytest.mark.parametrize("key", ["language.inc", "language..inc", "language.INC", "language. inc "]) +def test_the_extension_key_is_normalised(tmp_path, key): + (tmp_path / ".graphifyrc").write_text(f"{key}=php\n", encoding="utf-8") + assert load_graphifyrc(tmp_path)["languages"] == {".inc": ".php"} + + +@pytest.mark.parametrize("value, expected", [ + ("php", ".php"), ("PHP", ".php"), ("pascal", ".pas"), ("delphi", ".pas"), + ("typescript", ".ts"), ("c++", ".cpp"), (".php", ".php"), (".PHP", ".php"), + ("markdown", ".md"), +]) +def test_values_accept_a_language_name_or_an_explicit_extension(value, expected): + assert parse_language_value(value) == expected + + +@pytest.mark.parametrize("value", ["", "klingon", ".", ". php", "php script"]) +def test_an_unknown_language_is_an_error_naming_the_alternatives(value): + with pytest.raises(ValueError): + parse_language_value(value) + + +def test_a_bad_language_line_reports_its_line_number(tmp_path): + (tmp_path / ".graphifyrc").write_text("viz_node_limit=5\nlanguage.inc=klingon\n", encoding="utf-8") + with pytest.raises(ValueError, match=r"language\.inc .* line 2.*klingon"): + load_graphifyrc(tmp_path) + + +def test_a_bad_language_key_is_an_error(tmp_path): + (tmp_path / ".graphifyrc").write_text("language.=php\n", encoding="utf-8") + with pytest.raises(ValueError, match="line 1"): + load_graphifyrc(tmp_path) + + +def test_the_existing_option_and_unknown_keys_still_behave(tmp_path): + (tmp_path / ".graphifyrc").write_text( + "# comment\nviz_node_limit=0\nfuture_option=whatever\nlanguage.tpl=.ts\n", + encoding="utf-8", + ) + cfg = load_graphifyrc(tmp_path) + assert cfg == {"viz_node_limit": 0, "languages": {".tpl": ".ts"}} + + +def test_hooks_still_reads_the_same_file_through_its_old_name(tmp_path): + from graphify.hooks import _load_graphifyrc + (tmp_path / ".graphifyrc").write_text("viz_node_limit=3\nlanguage.inc=php\n", encoding="utf-8") + assert _load_graphifyrc(tmp_path)["viz_node_limit"] == 3 + (tmp_path / ".graphifyrc").write_text("viz_node_limit=-1\n", encoding="utf-8") + with pytest.raises(ValueError, match="Invalid viz_node_limit"): + _load_graphifyrc(tmp_path) + + +def test_no_file_means_no_overrides(tmp_path): + assert load_graphifyrc(tmp_path) == {} + assert activate_language_overrides(tmp_path) == {} + + +# --------------------------------------------------------------------------- +# Every suffix-keyed decision sees the declared language +# --------------------------------------------------------------------------- + +def test_effective_suffix_is_the_real_one_until_a_project_says_otherwise(): + assert effective_suffix(Path("x/a.inc")) == ".inc" + assert effective_suffix(Path("x/a.F90")) == ".F90" # case preserved + set_language_overrides({".inc": ".php"}) + assert effective_suffix(Path("x/a.inc")) == ".php" + assert effective_suffix(Path("x/a.INC")) == ".php" + assert effective_suffix(Path("x/a.F90")) == ".F90" + + +def test_dispatch_goes_to_the_declared_extractor(): + assert _get_extractor(Path("a.inc")) is extract_pascal + set_language_overrides({".inc": ".php"}) + assert _get_extractor(Path("a.inc")) is extract_php + assert _get_extractor(Path("b.pas")) is extract_pascal # untouched + + +def test_a_remapped_header_skips_the_content_sniff(tmp_path): + """`.h` is normally sniffed for C++/ObjC; a declaration makes it definite.""" + from graphify.extract import extract_cpp + h = tmp_path / "plain.h" + h.write_text("int add(int a, int b);\n", encoding="utf-8") + set_language_overrides({".h": ".cpp"}) + assert _get_extractor(h) is extract_cpp + + +def test_a_remap_to_an_extension_without_an_extractor_yields_none(): + set_language_overrides({".inc": ".nosuchlang"}) + assert _get_extractor(Path("a.inc")) is None + + +def test_classification_follows_the_declaration(): + assert classify_file(Path("page.tpl")) is None # unknown extension + set_language_overrides({".tpl": ".php", ".txt": ".md"}) + assert classify_file(Path("page.tpl")) is FileType.CODE + assert classify_file(Path("notes.txt")) is FileType.DOCUMENT + + +def test_case_folding_and_interop_family_follow_the_declaration(): + assert not _lang_is_case_insensitive("lib/a.inc") + assert _lang_family("lib/a.inc") is None + set_language_overrides({".inc": ".php"}) + assert _lang_is_case_insensitive("lib/a.inc") # PHP identifiers fold case + assert _lang_family("lib/a.inc") == "php" + + +def test_language_resolvers_wake_for_the_declared_language(): + ran: list[str] = [] + resolvers = [ + LanguageResolver("php", frozenset({".php"}), lambda *a: ran.append("php")), + LanguageResolver("pascal", frozenset({".pas", ".inc"}), lambda *a: ran.append("pascal")), + ] + paths = [Path("a.inc")] + run_language_resolvers(paths, [{}], [], [], resolvers=resolvers) + assert ran == ["pascal"] + ran.clear() + set_language_overrides({".inc": ".php"}) + run_language_resolvers(paths, [{}], [], [], resolvers=resolvers) + assert ran == ["php"] + + +# --------------------------------------------------------------------------- +# The reporter's repro: same bytes, two extensions +# --------------------------------------------------------------------------- + +@pytest.fixture +def php_pair(tmp_path): + (tmp_path / "a.inc").write_text(PHP_SOURCE, encoding="utf-8") + (tmp_path / "b.php").write_text(PHP_SOURCE, encoding="utf-8") + return tmp_path + + +def _counts(root, name): + r = _quiet_extract([root / name], cache_root=root, root=root) + return len(r["nodes"]), len(r["edges"]) + + +def test_without_a_declaration_the_inc_file_is_nearly_empty(php_pair): + """The failure mode: no error, just a graph missing the runtime.""" + inc, php = _counts(php_pair, "a.inc"), _counts(php_pair, "b.php") + assert php[0] > 5 and php[1] > 5 + assert inc[0] < php[0] and inc[1] < php[1] + + +def test_with_the_declaration_the_two_files_yield_the_same_graph(php_pair): + (php_pair / ".graphifyrc").write_text("language.inc=php\n", encoding="utf-8") + assert _counts(php_pair, "a.inc") == _counts(php_pair, "b.php") + + +def test_a_library_caller_needs_no_setup_beyond_the_file(php_pair): + """extract() finds `/.graphifyrc` itself — the skill runbook and the + MCP server call it directly, never through the CLI.""" + (php_pair / ".graphifyrc").write_text("language.inc=php\n", encoding="utf-8") + set_language_overrides(None) # nothing pre-activated + assert _counts(php_pair, "a.inc") == _counts(php_pair, "b.php") + + +def test_extract_announces_the_active_overrides(php_pair): + (php_pair / ".graphifyrc").write_text("language.inc=php\n", encoding="utf-8") + out = io.StringIO() + with redirect_stdout(out): + extract([php_pair / "a.inc"], cache_root=php_pair, root=php_pair) + assert ".inc -> .php" in out.getvalue() + + +def test_detect_counts_a_declared_extension_as_code(tmp_path): + (tmp_path / "page.tpl").write_text(PHP_SOURCE, encoding="utf-8") + with redirect_stdout(io.StringIO()): + before = detect(tmp_path)["files"] + (tmp_path / ".graphifyrc").write_text("language.tpl=php\n", encoding="utf-8") + with redirect_stdout(io.StringIO()): + after = detect(tmp_path)["files"] + assert not any(p.endswith("page.tpl") for p in before.get("code", [])) + assert any(p.endswith("page.tpl") for p in after.get("code", [])) + + +# --------------------------------------------------------------------------- +# The cache must not replay the other language's parse +# --------------------------------------------------------------------------- + +def test_cache_salt_exists_only_for_remapped_files(): + assert cache_salt(Path("a.inc")) is None + set_language_overrides({".inc": ".php"}) + assert cache_salt(Path("a.inc")) == "language=.php" + assert cache_salt(Path("b.php")) is None + + +def test_changing_the_declaration_does_not_serve_the_stale_entry(php_pair): + """Extract as Pascal (cached), declare PHP, extract again: the PHP graph, + not the Pascal entry keyed by the same bytes.""" + pascal = _counts(php_pair, "a.inc") + (php_pair / ".graphifyrc").write_text("language.inc=php\n", encoding="utf-8") + php = _counts(php_pair, "a.inc") + assert php == _counts(php_pair, "b.php") != pascal + # and back again: the PHP entry must not be served for the Pascal parse + (php_pair / ".graphifyrc").write_text("language.inc=pascal\n", encoding="utf-8") + assert _counts(php_pair, "a.inc") == pascal + + +# --------------------------------------------------------------------------- +# Worker processes +# --------------------------------------------------------------------------- + +@pytest.mark.skipif(_worker_init is None, reason="pre-fix tree") +def test_worker_init_installs_the_parents_overrides(): + _worker_init({".inc": ".php"}) + assert _get_extractor(Path("a.inc")) is extract_php + + +@pytest.mark.skipif(_worker_init is None, reason="pre-fix tree") +def test_the_pool_hands_its_workers_the_overrides(php_pair, monkeypatch): + """Under `spawn` a worker starts with empty module state; the pool must + forward the mapping through its initializer.""" + seen: dict = {} + + class RecordingPool(concurrent.futures.ThreadPoolExecutor): + def __init__(self, max_workers=None, initializer=None, initargs=(), **kw): + seen["initializer"] = initializer + seen["initargs"] = initargs + super().__init__(max_workers=max_workers, initializer=initializer, initargs=initargs) + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", RecordingPool) + for i in range(25): # past _PARALLEL_THRESHOLD + (php_pair / f"f{i}.inc").write_text(PHP_SOURCE, encoding="utf-8") + (php_pair / ".graphifyrc").write_text("language.inc=php\n", encoding="utf-8") + files = sorted(php_pair.glob("f*.inc")) + result = _quiet_extract(files, cache_root=php_pair, root=php_pair, parallel=True) + assert seen["initializer"] is _worker_init + assert seen["initargs"] == ({".inc": ".php"},) + # and every file came back as PHP, not Pascal + per_file = {} + for n in result["nodes"]: + per_file.setdefault(n.get("source_file"), 0) + per_file[n.get("source_file")] += 1 + assert len(per_file) == 25 and min(per_file.values()) > 5 + + +# --------------------------------------------------------------------------- +# A config typo must be loud, not fatal +# --------------------------------------------------------------------------- + +def test_a_malformed_rc_warns_once_and_scans_without_overrides(php_pair): + (php_pair / ".graphifyrc").write_text("language.inc=klingon\n", encoding="utf-8") + err = io.StringIO() + with redirect_stderr(err): + first = _counts(php_pair, "a.inc") + _counts(php_pair, "a.inc") + assert first == _counts(php_pair, "a.inc") # Pascal, as before + assert err.getvalue().count("ignoring .graphifyrc") == 1 + assert "klingon" in err.getvalue() + + +def test_activating_a_root_without_rc_clears_a_previous_roots_overrides(tmp_path): + set_language_overrides({".inc": ".php"}) + activate_language_overrides(tmp_path) + assert effective_suffix(Path("a.inc")) == ".inc"