Skip to content

Commit 53525bd

Browse files
committed
PR Graphify-Labs#3789: feat(config): let projects declare languages for ambiguous extensions via .graphifyrc
Squashed from origin/feat/rcfile-language-overrides (de53a62) onto upstream/v8.
1 parent 1d04502 commit 53525bd

22 files changed

Lines changed: 1862 additions & 124 deletions

‎README.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,29 @@ dist/
495495
!src/**
496496
```
497497

498+
499+
## Project configuration
500+
501+
Create a `.graphifyrc` in the scan root for settings that belong to the project rather than to one run. One `key=value` per line; lines beginning with `#` are comments.
502+
503+
**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:
504+
505+
```
506+
# .graphifyrc
507+
# A language name:
508+
language.inc=php
509+
# Or an extension graphify already knows:
510+
language.tpl=.ts
511+
# Baked into the git hooks (see Team setup):
512+
viz_node_limit=0
513+
```
514+
515+
The declaration controls file classification, extractor selection and language-specific grammar and resolution, and the AST cache: a file re-parsed under a different language never reuses the old entry. Original source paths and physical format conventions, such as Fortran `.F90` preprocessing and C/C++ header/implementation pairing, are preserved. Optional language backends must still be installed. A typo is reported once on stderr and the scan continues with graphify's defaults.
516+
517+
Adding, changing, or removing a language declaration invalidates affected incremental manifest entries even when the source bytes have not changed. `graphify watch` recognizes declared source extensions and root `.graphifyrc` changes, including atomic replacement; configuration changes schedule an AST rebuild and flag semantic work for the next extraction. The same configuration is read by `graphify extract`, `graphify update`, the hooks, the MCP server, and the `/graphify` skill.
518+
519+
Extensionless local JS/TS imports can also resolve declared suffixes. Native suffixes retain precedence within file and directory-index lookup, and file candidates are tried before directory indexes. Python module and package lookups also support declared Python suffixes, while retaining native `.py` precedence.
520+
498521
---
499522

500523
## Team setup

‎graphify/cache.py‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -962,11 +962,19 @@ def cache_dir(root: Path = Path("."), kind: str = "ast",
962962
return d
963963

964964

965+
def _salted_key(h: str, salt: str | None) -> str:
966+
"""Derive the on-disk key from a content hash plus optional salt."""
967+
if not salt:
968+
return h
969+
return hashlib.sha256(f"{h}:{salt}".encode("utf-8")).hexdigest()
970+
971+
965972
def load_cached(path: Path, root: Path = Path("."), kind: str = "ast",
966973
cache_root: Path | None = None, prompt: "str | Path | None" = None,
967974
prompt_file: "str | Path | None" = None,
968975
allow_legacy: bool = True,
969-
allow_partial: bool = False) -> dict | None:
976+
allow_partial: bool = False,
977+
salt: str | None = None) -> dict | None:
970978
"""Return cached extraction for this file if hash matches, else None.
971979
972980
Cache key: SHA256 of file contents.
@@ -995,6 +1003,10 @@ def load_cached(path: Path, root: Path = Path("."), kind: str = "ast",
9951003
so :func:`check_semantic_cache` can report N to the user. Callers that must
9961004
not mix vintages within one entry (see :func:`save_semantic_cache`'s
9971005
``merge_existing``) pass allow_legacy=False.
1006+
``salt`` folds extra material into the key for an entry whose validity
1007+
depends on more than the file's bytes — a file the project remaps to
1008+
another language (#2961) parses to a different graph from the same
1009+
content, so it must not hit the entry produced under the old extractor.
9981010
Returns None if no cache entry or file has changed.
9991011
"""
10001012
global _legacy_semantic_hits, _corrupt_cache_entries
@@ -1003,6 +1015,7 @@ def load_cached(path: Path, root: Path = Path("."), kind: str = "ast",
10031015
h = file_hash(path, root, cache_root=cache_root)
10041016
except OSError:
10051017
return None
1018+
h = _salted_key(h, salt)
10061019
prompt_fp = _resolve_prompt_fp(prompt, prompt_file)
10071020
entry = cache_dir(location, kind, prompt_fp) / f"{h}.json"
10081021
legacy_hit = False
@@ -1070,9 +1083,13 @@ def load_cached(path: Path, root: Path = Path("."), kind: str = "ast",
10701083

10711084
def save_cached(path: Path, result: dict, root: Path = Path("."), kind: str = "ast",
10721085
cache_root: Path | None = None, prompt: "str | Path | None" = None,
1073-
prompt_file: "str | Path | None" = None) -> None:
1086+
prompt_file: "str | Path | None" = None,
1087+
salt: str | None = None) -> None:
10741088
"""Save extraction result for this file.
10751089
1090+
``salt`` must match what the corresponding :func:`load_cached` passes
1091+
(see there); it namespaces the key, not the directory.
1092+
10761093
Stores as graphify-out/cache/{kind}/{hash}.json where hash = SHA256 of current file contents.
10771094
result should be a dict with 'nodes' and 'edges' lists.
10781095
@@ -1119,7 +1136,7 @@ def save_cached(path: Path, result: dict, root: Path = Path("."), kind: str = "a
11191136
# the entry replays portably under any root (#2257). Strictly after the
11201137
# source_file pass, which owns that field's bare-relative format.
11211138
_relativize_ids_in(on_disk, p, root)
1122-
h = file_hash(p, root, cache_root=cache_root)
1139+
h = _salted_key(file_hash(p, root, cache_root=cache_root), salt)
11231140
location = cache_root if cache_root is not None else root
11241141
target_dir = cache_dir(location, kind, _resolve_prompt_fp(prompt, prompt_file))
11251142
entry = target_dir / f"{h}.json"

‎graphify/csharp_dispatch.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"""
2727
from __future__ import annotations
2828

29-
_CSHARP_SUFFIXES = (".cs",)
29+
from graphify.extractors.csharp import _is_cs_file
3030

3131
DISPATCH_RELATION = "dispatches_to"
3232

@@ -44,7 +44,7 @@ def _is_csharp(node: dict | None) -> bool:
4444
if not node:
4545
return False
4646
source_file = node.get("source_file")
47-
return bool(source_file) and str(source_file).endswith(_CSHARP_SUFFIXES)
47+
return bool(source_file) and _is_cs_file(str(source_file))
4848

4949

5050
def _method_label(node: dict) -> str:
@@ -74,11 +74,11 @@ def resolve_csharp_interface_dispatch(
7474
place, since the call site really does name the interface.
7575
"""
7676
if not any(
77-
str(result.get("source_file", "")).endswith(_CSHARP_SUFFIXES)
77+
_is_csharp(result)
7878
for result in per_file
7979
if isinstance(result, dict)
8080
) and not any(
81-
str(n.get("source_file", "")).endswith(_CSHARP_SUFFIXES) for n in all_nodes
81+
_is_csharp(n) for n in all_nodes
8282
):
8383
return
8484

‎graphify/detect.py‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
google_workspace_enabled,
2121
)
2222
from graphify.paths import GRAPHIFY_OUT, out_path
23+
from graphify.rcfile import activate_language_overrides, cache_salt, effective_suffix
2324

2425

2526
class FileType(str, Enum):
@@ -522,7 +523,9 @@ def classify_file(path: Path) -> FileType | None:
522523
# Compound extensions must be checked before simple suffix lookup
523524
if path.name.lower().endswith(".blade.php"):
524525
return FileType.CODE
525-
ext = path.suffix.lower()
526+
# A project may declare what an ambiguous extension means to it
527+
# (.graphifyrc `language.inc=php`, #2961); classify by the declared one.
528+
ext = effective_suffix(path).lower()
526529
if not ext:
527530
return _shebang_file_type(path)
528531
if ext in CODE_EXTENSIONS:
@@ -1737,6 +1740,9 @@ def _resolves_under_root(path: Path, root: Path) -> bool:
17371740

17381741
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:
17391742
root = root.resolve()
1743+
# The project's extension->language declarations (#2961) shape
1744+
# classification for this scan.
1745+
activate_language_overrides(root)
17401746
configured_out_dir = root / GRAPHIFY_OUT
17411747
configured_out_names = {configured_out_dir.name}
17421748
try:
@@ -2318,6 +2324,9 @@ def save_manifest(
23182324
re-queues the file after the failure is fixed, without deleting
23192325
graphify-out/.
23202326
"""
2327+
# Another root may have activated different overrides in this thread.
2328+
if root is not None:
2329+
activate_language_overrides(Path(root))
23212330
existing = load_manifest(manifest_path, root=root)
23222331

23232332
# Index both raw and NFC forms so scan/clear membership survives the
@@ -2438,12 +2447,19 @@ def _normalise_entry(entry):
24382447
mtime, h = hashed[f]
24392448
key = _nfc(f)
24402449
prev = _normalise_entry(existing.get(key, {})) or {}
2450+
# Hash workers do not inherit this thread's language overrides.
2451+
current_lang = cache_salt(f)
2452+
lang_changed = current_lang != prev.get("language")
24412453
if kind in ("ast", "both"):
24422454
ast_h = h
2455+
elif lang_changed:
2456+
ast_h = ""
24432457
else:
24442458
ast_h = prev.get("ast_hash", "")
24452459
if kind in ("semantic", "both"):
24462460
sem_h = h
2461+
elif lang_changed:
2462+
sem_h = ""
24472463
else:
24482464
# Preserve semantic_hash only when content is unchanged
24492465
sem_h = prev.get("semantic_hash", "") if h == prev.get("ast_hash", "") else ""
@@ -2456,6 +2472,7 @@ def _normalise_entry(entry):
24562472
and mtime == prev.get("mtime")
24572473
and (ast_h == prev.get("ast_hash", "") if kind in ("ast", "both") else True)
24582474
and (sem_h == prev.get("semantic_hash", "") if kind in ("semantic", "both") else True)
2475+
and not lang_changed
24592476
and not _in_clear_ast(f)
24602477
and not _in_clear(f)
24612478
)
@@ -2465,6 +2482,9 @@ def _normalise_entry(entry):
24652482
"ast_hash": ast_h,
24662483
"semantic_hash": sem_h,
24672484
}
2485+
if current_lang is not None:
2486+
# No override keeps legacy rows unchanged.
2487+
entry["language"] = current_lang
24682488
manifest[key] = entry
24692489
if root is not None:
24702490
# Persist in portable form: forward-slash relative paths. Keys outside
@@ -2609,7 +2629,11 @@ def detect_incremental(
26092629
# the graph drifted from disk (#1859). No stored hash means we
26102630
# cannot verify content — any mtime delta forces a re-extract,
26112631
# and the next save promotes the entry into the dict schema.
2612-
if isinstance(stored, (int, float)):
2632+
# A language change invalidates even identical bytes and legacy rows.
2633+
stored_lang = stored.get("language") if isinstance(stored, dict) else None
2634+
if stored is not None and cache_salt(f) != stored_lang:
2635+
changed = True
2636+
elif isinstance(stored, (int, float)):
26132637
changed = current_mtime != stored
26142638
elif isinstance(stored, dict):
26152639
# Normalise legacy {mtime, hash} to new schema

0 commit comments

Comments
 (0)