Skip to content

feat(config): let a project declare what an ambiguous extension means via .graphifyrc (#2961) - #3075

Open
abhay-codes07 wants to merge 1 commit into
Graphify-Labs:v8from
abhay-codes07:feat/graphifyrc-language-overrides
Open

feat(config): let a project declare what an ambiguous extension means via .graphifyrc (#2961)#3075
abhay-codes07 wants to merge 1 commit into
Graphify-Labs:v8from
abhay-codes07:feat/graphifyrc-language-overrides

Conversation

@abhay-codes07

Copy link
Copy Markdown
Contributor

Closes #2961.

The problem

.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. On the reporter's file: 7 nodes instead of 471, from identical bytes.

Hardcoding one more extension (what closed #1042) cannot fix this, because .inc has 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 for viz_node_limit — gains a project-level declaration:

# .graphifyrc
language.inc=php        # a language name ...
language.tpl=.ts        # ... or an extension graphify already knows

The declaration reaches every place graphify keys a decision on the suffix, not just the dispatch table:

site effect
detect.classify_file a declared extension counts as code (so .tpl → PHP is scanned at all)
extract._get_extractor dispatch, ahead of the .h/.m content sniffs
_lang_is_case_insensitive, _lang_family, the PHP type pass cross-file resolution rules follow the declared language
resolver_registry.run_language_resolvers the declared language's resolvers wake (PHP's, not Pascal's)
cache.load_cached / save_cached a new salt kwarg folds the target language into the key — the same bytes cached under the old extractor are never served for the new one, in either direction
the extraction pool an initializer forwards the mapping to workers, which start with empty module state under spawn

detect() and extract() activate <root>/.graphifyrc themselves, 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.py into a dependency-free graphify/rcfile.py so detect/extract (and the workers) can import it; hooks._load_graphifyrc keeps its name, behaviour and error messages, and the existing hooks tests pass unchanged.

Before / after

Same PHP source as a.inc and b.php:

no .graphifyrc          a.inc  nodes=  1 edges=  0
                        b.php  nodes=  7 edges= 10
language.inc=php        a.inc  nodes=  7 edges= 10
                        b.php  nodes=  7 edges= 10
language.inc=pascal     a.inc  nodes=  1 edges=  0    <- not the cached PHP entry
language.inc=klingon    a.inc  nodes=  1 edges=  0    + one stderr warning naming line 1

Through the real CLI:

$ graphify extract . --code-only
  language overrides (.graphifyrc): .inc -> .php
[graphify extract] wrote graphify-out/graph.json — 7 nodes, 10 edges

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_graphifyrc compatibility); 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 the v8 baseline (the same platform-specific failures on Windows, nothing new).

README gains a short "Project configuration" section.

…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.
Copilot AI lite review requested due to automatic review settings August 25, 2026 08:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 overridesgraphify/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 synchronizationgraphify/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 stategraphify/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 extractionsgraphify/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 callsgraphify/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).

Comment thread graphify/cache.py
return hashlib.sha256(f"{h}:{salt}".encode("utf-8")).hexdigest()


def load_cached(path: Path, root: Path = Path("."), kind: str = "ast",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionload_cached()

fans out to 8 callees (efferent coupling); 48 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/cache.py
@@ -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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionsave_cached()

fans out to 7 callees (efferent coupling); 24 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/detect.py
@@ -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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiondetect()

fans out to 16 callees (efferent coupling); 110 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/extract.py
set_language_overrides(language_overrides)


def _extract_single_file(args: tuple) -> tuple[int, dict]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_extract_single_file()

fans out to 7 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/rcfile.py
) from None


def load_graphifyrc(root: Path) -> dict:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionload_graphifyrc()

10 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/rcfile.py
return dict(_ACTIVE)


def activate_language_overrides(root: Path) -> dict[str, str]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionactivate_language_overrides()

7 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants