feat(config): let a project declare what an ambiguous extension means via .graphifyrc (#2961) - #3075
Conversation
…Graphify-Labs#2961) `.inc` is hard-mapped to the Pascal extractor, but it is "include file" in whatever language a project happens to use: PHP on pfSense, Pascal in a Delphi tree, 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). Hardcoding one more extension (Graphify-Labs#1042's fix) cannot generalise, because `.inc` has no single correct global meaning. `.graphifyrc` gains `language.<ext>=<language | .ext>`: language.inc=php language.tpl=.ts The declaration reaches every place graphify keys a decision on the suffix: * detect.classify_file — a declared extension counts as code * extract._get_extractor — dispatch, ahead of the .h/.m sniffs * _lang_is_case_insensitive / — cross-file resolution rules _lang_family / the PHP pass * resolver_registry — the declared language's resolvers wake * cache.load_cached/save_cached — a `salt` folds the target language into the key, so the same bytes cached under the old extractor are never served for the new one * the extraction pool — an initializer forwards the mapping to workers, which start with empty module state under `spawn` The parser moves from hooks.py into a dependency-free graphify.rcfile so detect/extract (and the workers) can read it; hooks._load_graphifyrc keeps its name and behaviour. detect() and extract() activate `<root>/.graphifyrc` themselves, so the CLI, `update`/`watch`, the hooks, the MCP server, and the skill runbook all pick it up with no further wiring. A malformed file is reported once on stderr and the scan continues with the defaults.
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.
Formal verification. No changes could be formally verified in this run.
Graphify review — findings
Adds a .graphifyrc project config whose language.<ext>=<lang> lines remap ambiguous extensions (.inc, .h, .m, etc.) per-repo, so file classification (classify_file), extractor dispatch (_get_extractor), case-insensitivity and interop-family checks, and cross-file resolution all key off effective_suffix instead of the raw suffix, with detect/extract activating overrides for the scan root and reporting typos once on stderr. Salts the AST cache key with cache_salt via load_cached/save_cached so a file re-parsed under a different declared language never reuses the entry from the old extractor. Propagates the parent's overrides into spawn pool workers through _worker_init so _get_extractor and the cache key stay consistent across processes.
Worth a look
- classify_file now depends on previously activated project overrides —
graphify/detect.py:518· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Global language-override state activated per-scan without synchronization —
graphify/detect.py:1520· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Project language overrides are activated as process-global scan state —
graphify/detect.py:1520· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Global language overrides can race between concurrent extractions —
graphify/extract.py:5769· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- activate_language_overrides mutates process-global overrides read non-atomically by concurrent extract calls —
graphify/extract.py:5771· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 2489 functions depend on the 538 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 493 callers, 45 callees - new:
_rebuild_code()— 98 callers, 50 callees - new:
detect()— 110 callers, 16 callees - new:
save_semantic_cache()— 58 callers, 9 callees - new:
save_manifest()— 40 callers, 11 callees - new:
load_cached()— 48 callers, 8 callees - new:
extract_files_direct()— 17 callers, 20 callees - new:
extract_xaml()— 19 callers, 17 callees - …and 56 more — each is listed as a finding
Verification — 2489 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 2298 function(s) in the blast radius were not formally verified this run
Formal verification
Could not verify: Could not verify load\_cached.
The verifier did not have enough to check load\_cached, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify save\_cached.
The verifier did not have enough to check save\_cached, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify classify\_file.
The verifier did not have enough to check classify\_file, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify detect.
The verifier did not have enough to check detect, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify extract.
The verifier did not have enough to check extract, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `cache_root` is annotated `Path | None` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_extract\_parallel.
The verifier did not have enough to check \_extract\_parallel, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_extract\_sequential.
The verifier did not have enough to check \_extract\_sequential, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_extract\_single\_file.
The verifier did not have enough to check \_extract\_single\_file, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 8 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly ValueError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_get\_extractor.
The verifier did not have enough to check \_get\_extractor, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_lang\_family.
The verifier did not have enough to check \_lang\_family, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `source_file` is annotated `object` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_lang\_is\_case\_insensitive.
The verifier did not have enough to check \_lang\_is\_case\_insensitive, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `source_file` is annotated `object` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_load\_graphifyrc.
The verifier did not have enough to check \_load\_graphifyrc, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify run\_language\_resolvers.
The verifier did not have enough to check run\_language\_resolvers, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `paths` is annotated `Sequence` — outside the synthesizable primitive/collection set
· 6 grounded finding(s) anchored inline below; 58 more finding(s) on lines outside this diff (see the check run).
| return hashlib.sha256(f"{h}:{salt}".encode("utf-8")).hexdigest() | ||
|
|
||
|
|
||
| def load_cached(path: Path, root: Path = Path("."), kind: str = "ast", |
There was a problem hiding this comment.
load_cached()
fans out to 8 callees (efferent coupling); 48 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| @@ -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", | |||
There was a problem hiding this comment.
save_cached()
fans out to 7 callees (efferent coupling); 24 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| @@ -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: | |||
There was a problem hiding this comment.
detect()
fans out to 16 callees (efferent coupling); 110 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| set_language_overrides(language_overrides) | ||
|
|
||
|
|
||
| def _extract_single_file(args: tuple) -> tuple[int, dict]: |
There was a problem hiding this comment.
_extract_single_file()
fans out to 7 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| ) from None | ||
|
|
||
|
|
||
| def load_graphifyrc(root: Path) -> dict: |
There was a problem hiding this comment.
load_graphifyrc()
10 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return dict(_ACTIVE) | ||
|
|
||
|
|
||
| def activate_language_overrides(root: Path) -> dict[str, str]: |
There was a problem hiding this comment.
activate_language_overrides()
7 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
Closes #2961.
The problem
.incis hard-mapped to the Pascal extractor, but it is "include file" in whatever language a project happens to use: PHP on pfSense, Pascal in a Delphi tree, SQL or assembly elsewhere. A PHP.incparsed 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. On the reporter's file: 7 nodes instead of 471, from identical bytes.Hardcoding one more extension (what closed #1042) cannot fix this, because
.inchas no single correct global meaning. The same is true of.h(C or C++) and.m(Objective-C or MATLAB), which today get a content sniff that is right most of the time.The change
.graphifyrc— which already exists forviz_node_limit— gains a project-level declaration:The declaration reaches every place graphify keys a decision on the suffix, not just the dispatch table:
detect.classify_file.tpl→ PHP is scanned at all)extract._get_extractor.h/.mcontent sniffs_lang_is_case_insensitive,_lang_family, the PHP type passresolver_registry.run_language_resolverscache.load_cached/save_cachedsaltkwarg folds the target language into the key — the same bytes cached under the old extractor are never served for the new one, in either directioninitializerforwards the mapping to workers, which start with empty module state underspawndetect()andextract()activate<root>/.graphifyrcthemselves, so the CLI,update/watch, the git hooks, the MCP server and the skill runbook all pick it up with no further wiring — including the issue's own library-level repro. A malformed file is reported once on stderr and the scan continues with graphify's defaults; a typo must be loud, but it must not kill a scan.The parser moves from
hooks.pyinto a dependency-freegraphify/rcfile.pysodetect/extract(and the workers) can import it;hooks._load_graphifyrckeeps its name, behaviour and error messages, and the existing hooks tests pass unchanged.Before / after
Same PHP source as
a.incandb.php:Through the real CLI:
Tests
tests/test_language_overrides.py— 42 tests: the parser (aliases, explicit extensions, key normalisation, line-numbered errors, the existing option and unknown keys untouched,hooks._load_graphifyrccompatibility); each suffix-keyed site individually; the reporter's same-bytes repro; the library-caller path;detect()counting a declared extension as code; the cache round-trip in both directions; the pool initializer and the mapping it is handed; the once-only warning for a malformed file.With the wiring reverted and only the new module kept, 12 of them fail; with it, all 42 pass. The related existing suites (
test_cache,test_detect,test_extract*,test_hooks,test_incremental*,test_pascal*,test_php_type_resolution,test_extractors_registry) are unchanged; the full suite matches thev8baseline (the same platform-specific failures on Windows, nothing new).README gains a short "Project configuration" section.