Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
98ce057
test(extract): add perl fixtures and RED language tests
ad-astra-bot Jul 10, 2026
8e6f78d
feat(extract): add perl extractor (packages, subs, imports, inherits)
ad-astra-bot Jul 10, 2026
a651d17
fix(extract): package-aware perl call resolution, inherit stubs, buil…
ad-astra-bot Jul 10, 2026
f1feb03
feat(extract): dispatch .pl/.pm and perl shebang to perl extractor
ad-astra-bot Jul 10, 2026
5cc3564
fix(extract): bare perl call resolves to sub in use-imported package
ad-astra-bot Jul 10, 2026
cbed744
fix(extract): repoint perl imports to in-corpus packages
ad-astra-bot Jul 10, 2026
9e0829d
fix(extract): treat indirect-object new as member call
ad-astra-bot Jul 10, 2026
e2766f8
fix(extract): only repoint perl imports on unique package label
ad-astra-bot Jul 10, 2026
1d678cf
fix(extract): scope perl import re-pointer by extractor provenance
ad-astra-bot Jul 10, 2026
0da9fd7
fix(extract): walk block-form perl packages without leaking package s…
ad-astra-bot Jul 10, 2026
5d0355f
fix(extract): model qualified perl sub declarations in their named pa…
ad-astra-bot Jul 10, 2026
cc7f83a
chore(extract): tidy perl extractor review notes for upstream
ad-astra-bot Jul 10, 2026
5bfef60
fix(extract): model perl main package; cache-safe re-pointer; harden …
ad-astra-bot Jul 10, 2026
d322237
refactor(extract): register perl import re-pointer via the resolver r…
ad-astra-bot Jul 10, 2026
325c5db
fix(extract): gate perl second pass on lang stamp; accept both import…
ad-astra-bot Jul 10, 2026
432b152
fix(extract): ASCII-validate perl package labels
ad-astra-bot Jul 10, 2026
6b26ac5
fix(extract): budget every node of the perl tree walks
ad-astra-bot Jul 10, 2026
ef4626a
fix(cache): bump AST cache schema so stale-shape entries invalidate
ad-astra-bot Jul 10, 2026
e4b3df6
docs(resolver): generalize the LanguageResolver hook docstring
ad-astra-bot Jul 10, 2026
5930720
docs: add perl to language table and changelog
ad-astra-bot Jul 10, 2026
f8c5044
docs: fill in PR number in changelog entry
ad-astra-bot Jul 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu

- Fix: a Java field/parameter/return-type reference to a class whose simple name is shared by two modules no longer dangles on a sourceless phantom node (#1744, thanks @aviciot). Both same-named classes already survive as distinct path-scoped nodes, but the cross-module `references` edge was left pointing at a bare no-source stub because `_resolve_java_type_references` re-pointed `implements`/`inherits`/`imports` but not `references` — so a query about the referenced class could miss it. The Java resolver now disambiguates `references` by the importing file's `import` statement (falling back to same-package), mirroring the C# resolver, and drops the orphaned phantom.

- Add: Perl support — `.pl`/`.pm` files (and `#!/usr/bin/perl` shebang scripts) extracted via tree-sitter-perl (#1788). Packages (including block form `package Foo { ... }` and mid-file package switches), subs, `use`/`require` imports with in-corpus re-pointing, and `@ISA`/`use parent`/`use base` inheritance are captured; calls resolve INFERRED through the shared package-aware second pass under a zero-edge ambiguity policy (a bare or same-named call that could bind to two packages emits no edge rather than a guess, mirroring the other cross-file resolvers' god-node guard). Method calls (`$obj->meth()`) and AUTOLOAD/symbolic refs are intentionally out of scope — without receiver types they are unresolvable and name-matching would wire spurious edges to same-named subs.

## 0.9.11 (2026-07-08)

- Fix: file enumeration no longer silently drops a directory subtree. `detect()`'s `os.walk` had no `onerror` handler, so an `os.scandir` failure (a permission error, or a directory created/deleted mid-walk by concurrent writes) was swallowed and that whole subtree vanished from the scan with no log, yielding a silently partial `graph.json`. The walk now records every skipped directory (surfaced in the result's `walk_errors`) and warns to stderr, while still enumerating the rest. Relatedly, `to_json`'s anti-shrink guard (#479) now fails safe: a non-empty but unreadable existing `graph.json` refuses the overwrite (pass `force=True` to override) instead of silently clobbering a good graph; an empty file still proceeds.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg

| Type | Extensions |
|------|-----------|
| Code (36 tree-sitter grammars) | `.py .ts .mts .cts .js .jsx .tsx .mjs .go .rs .java .c .cpp .cc .cxx .h .hpp .cu .cuh .metal .rb .cs .kt .kts .scala .php .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) |
| Code (37 tree-sitter grammars) | `.py .ts .mts .cts .js .jsx .tsx .mjs .go .rs .java .c .cpp .cc .cxx .h .hpp .cu .cuh .metal .rb .cs .kt .kts .scala .php .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .ex .exs .pl .pm .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) |
| Salesforce Apex | `.cls .trigger` (regex-based; classes, interfaces, enums, methods, triggers, SOQL/DML edges) |
| Terraform / HCL | `.tf .tfvars .hcl` (requires `uv tool install graphifyy[terraform]`) |
| MCP configs | `.mcp.json` `mcp.json` `mcp_servers.json` `claude_desktop_config.json` — extracts server nodes, package refs, env var requirements |
Expand Down
26 changes: 19 additions & 7 deletions graphify/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@
# AST cache entries are the output of graphify's own extractor code, so they
# are only valid for the version that wrote them: keying purely on file
# content means extractor fixes shipped in a new release keep serving stale
# pre-fix results. The AST cache is therefore namespaced by package version
# (cache/ast/v{version}/), with entries from other versions removed on first
# pre-fix results. The AST cache is therefore namespaced by package version AND an
# output-shape schema counter (cache/ast/v{version}-s{schema}/, see
# _AST_SCHEMA_VERSION below), with entries from other namespaces removed on first
# use. The semantic cache is deliberately NOT versioned — its entries are
# produced by the LLM from file contents, and invalidating them on every
# release would re-bill extraction for unchanged files.
Expand All @@ -30,6 +31,17 @@
except Exception:
_EXTRACTOR_VERSION = "unknown"

# Package version alone under-invalidates: an extractor OUTPUT-SHAPE change (a new
# per-node/edge/raw_calls field, a differently-shaped node) can ship WITHIN a
# release without a version bump, and a version-only namespace would keep serving
# the pre-change entries. This integer rides alongside the version in the AST cache
# dir name (``v{version}-s{schema}``); bump it whenever the AST extractor's emitted
# shape changes without a release, so old entries relocate out of the served
# namespace and are swept. Follows the integer schema-version pattern used by
# reflect.py (_LEARNING_SCHEMA_VERSION) and diagnostics.py.
# schema 1: per-call ``lang`` stamp + lazy ``main`` package node (Perl S6c).
_AST_SCHEMA_VERSION = 1

# Version dirs already swept this process — cleanup runs once per (base, version).
_cleaned_ast_dirs: set[str] = set()

Expand Down Expand Up @@ -337,16 +349,16 @@ def cache_dir(root: Path = Path("."), kind: str = "ast") -> Path:
kind is "ast" or "semantic". Separate subdirectories prevent semantic cache
entries from overwriting AST cache entries for the same source_file (#582).

AST entries live in graphify-out/cache/ast/v{version}/ — namespaced by
graphify version because they depend on extractor code, not just file
contents. Semantic entries live unversioned in graphify-out/cache/semantic/
(re-extraction costs LLM calls).
AST entries live in graphify-out/cache/ast/v{version}-s{schema}/ — namespaced
by graphify version AND output-shape schema (see _AST_SCHEMA_VERSION) because
they depend on extractor code, not just file contents. Semantic entries live
unversioned in graphify-out/cache/semantic/ (re-extraction costs LLM calls).
"""
_out = Path(_GRAPHIFY_OUT)
base = _out if _out.is_absolute() else Path(root).resolve() / _out
d = base / "cache" / kind
if kind == "ast":
d = d / f"v{_EXTRACTOR_VERSION}"
d = d / f"v{_EXTRACTOR_VERSION}-s{_AST_SCHEMA_VERSION}"
_cleanup_stale_ast_entries(d.parent, d)
d.mkdir(parents=True, exist_ok=True)
return d
Expand Down
2 changes: 1 addition & 1 deletion graphify/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class FileType(str, Enum):

_MANIFEST_PATH = str(out_path("manifest.json"))

CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger'}
CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.pl', '.pm', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger'}
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.txt', '.rst', '.html', '.yaml', '.yml'}
PAPER_EXTENSIONS = {'.pdf'}
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
Expand Down
108 changes: 107 additions & 1 deletion graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from graphify.extractors.json_config import extract_json # noqa: F401
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.perl import _resolve_perl_imports, extract_perl # noqa: F401
from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401
from graphify.extractors.razor import extract_razor # noqa: F401
from graphify.extractors.rust import extract_rust # noqa: F401
Expand Down Expand Up @@ -1785,6 +1786,7 @@ def _lang_is_case_insensitive(source_file: object) -> bool:
".lua": "lua", ".luau": "lua",
".zig": "zig",
".ex": "elixir", ".exs": "elixir",
".pl": "perl", ".pm": "perl",
".jl": "julia",
".dart": "dart",
".sh": "shell", ".bash": "shell",
Expand Down Expand Up @@ -2750,6 +2752,33 @@ def _key(label: str) -> str:
)


def _resolve_perl_imports_pass(per_file, all_nodes, all_edges, paths) -> None:
"""Re-point dangling in-corpus Perl ``imports`` edges onto the real package node.

Registered LAST so it runs after the shared cross-file call pass (which reads
the bare module-label ``use`` targets via ``_has_package_import_evidence``) and
after the member-call resolvers — the same position as its former inline call at
the tail of ``extract()``. Scoped by extractor PROVENANCE, not suffix: an
extensionless ``#!/usr/bin/perl`` script is dispatched to ``extract_perl`` by
shebang and must be re-pointed too, which is why it declares a custom
``activate`` predicate (a shebang-only corpus has no ``.pl``/``.pm`` suffix) and
takes ``paths`` (``wants_paths``) to recompute that provenance set.
"""
perl_sources = {str(p) for p in paths if _get_extractor(p) is extract_perl}
_resolve_perl_imports(all_nodes, all_edges, perl_sources)


register_language_resolver(
LanguageResolver(
"perl_import_repoint",
frozenset({".pl", ".pm"}),
_resolve_perl_imports_pass,
activate=lambda paths: any(_get_extractor(p) is extract_perl for p in paths),
wants_paths=True,
)
)


# Inline markdown link: [text](target "optional title"). The negative lookbehind
# excludes images (![alt](src)). The target stops at whitespace/closing paren so
# an optional "title" after the URL is dropped; an optional <...> wrapper is too.
Expand Down Expand Up @@ -3777,6 +3806,8 @@ def add_existing_edge(edge: dict) -> None:
".psd1": extract_powershell_manifest,
".ex": extract_elixir,
".exs": extract_elixir,
".pl": extract_perl,
".pm": extract_perl,
".m": extract_objc,
".mm": extract_objc,
".jl": extract_julia,
Expand Down Expand Up @@ -3853,7 +3884,7 @@ def add_existing_edge(edge: dict) -> None:
# routes them to the CODE path via _shebang_interpreter; _get_extractor must
# honor the same signal or these files are classified as code and then silently
# dropped by extraction. Only interpreters with a real extractor are mapped —
# detect's wider set (perl, fish, tcsh, Rscript) stays unmapped and skipped.
# detect's wider set (fish, tcsh, Rscript) stays unmapped and skipped.
_SHEBANG_DISPATCH: dict[str, Any] = {
"python": extract_python,
"python2": extract_python,
Expand All @@ -3869,6 +3900,7 @@ def add_existing_edge(edge: dict) -> None:
"lua": extract_lua,
"php": extract_php,
"julia": extract_julia,
"perl": extract_perl,
}


Expand Down Expand Up @@ -4605,6 +4637,21 @@ def extract(
# file is real ONLY if the caller imported it. So a cross-file call from one
# of these files with no import evidence is gated below (#1659).
_JS_TS_CALL_SUFFIXES = (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs")

# Enclosing-package label per node id, for the package-aware call resolution
# below. Only consulted for raw_calls that carry package qualifiers (Perl):
# a sub's direct container is its package node, whose label IS the package
# name (e.g. "Acme::Widget"), so the `contains` edge target->source gives
# sub_id -> package label. Other languages never set the package fields, so
# this map is built but never read for them.
_label_by_nid = {n["id"]: n.get("label", "") for n in all_nodes}
pkg_label_by_nid: dict[str, str] = {}
pkg_nid_by_sub_nid: dict[str, str] = {}
for e in all_edges:
if e.get("relation") == "contains":
pkg_label_by_nid[e["target"]] = _label_by_nid.get(e["source"], "")
pkg_nid_by_sub_nid[e["target"]] = e["source"]

for rc in all_raw_calls:
callee = rc.get("callee", "")
if not callee:
Expand Down Expand Up @@ -4671,6 +4718,62 @@ def _has_import_evidence(candidate_id: str) -> bool:
or (candidate_file_nid is not None and candidate_file_nid in imported_modules)
)

def _has_package_import_evidence(candidate_id: str) -> bool:
# Perl-only: `use P::A;` emits an imports edge to the MODULE label id
# (`_make_id('P::A')`), never to the sub id — so a bare call to an
# imported package's sub has no direct symbol/module evidence above.
# Bridge it: the candidate sub's enclosing package, re-idized the same
# way the `use` target is, must be among the caller file's imports.
pkg = pkg_label_by_nid.get(candidate_id, "")
if not pkg:
return False
if _make_id(pkg) in imported_symbols:
return True
# Accept the real package-node id form too. The import re-pointer rewrites
# a `use` target from the bare module-label id onto the package node id;
# matching either shape means this evidence check no longer depends on
# whether that re-pointer has run yet — the pass ordering stops being
# semantically load-bearing (A3). The current order (re-pointer after this
# pass) is kept regardless.
pkg_nid = pkg_nid_by_sub_nid.get(candidate_id)
return pkg_nid is not None and pkg_nid in imported_symbols

# Package-aware pre-filter for Perl, which tags every call with its
# enclosing package. Gated on the extractor-stamped `lang` (matching the
# cpp/csharp/java/objc raw-call consumers) rather than field-presence, so
# the branch claims exactly Perl's raw_calls and another language that
# happened to set a `*_package` field could never fall into it. Zero-edge
# over a wrong guess: an unresolvable qualifier or a foreign-package bare
# call is dropped, not bound to a same-named sub in the wrong package.
callee_package = rc.get("callee_package")
caller_package = rc.get("caller_package")
if rc.get("lang") == "perl":
if callee_package is not None:
# Qualified call `Pkg::sub()`: bind only to a sub whose enclosing
# package matches the qualifier. None or several -> drop.
candidates = [
c for c in candidates
if pkg_label_by_nid.get(c) == callee_package
]
else:
# Bare call: prefer the caller's own package. If the sub is
# defined there, that's the target. Otherwise it can only be an
# imported sub — require unique import evidence, else drop (never
# bind a same-named sub from an unrelated package).
same_pkg = [
c for c in candidates
if pkg_label_by_nid.get(c) == caller_package
]
if same_pkg:
candidates = same_pkg
else:
candidates = [
c for c in candidates
if _has_import_evidence(c) or _has_package_import_evidence(c)
]
if len(candidates) != 1:
continue

if len(candidates) == 1:
tgt = candidates[0]
has_import_evidence = _has_import_evidence(tgt)
Expand Down Expand Up @@ -4774,6 +4877,9 @@ def _has_import_evidence(candidate_id: str) -> bool:
# receiver-typed/qualified calls the shared pass skipped) with its own
# single-definition god-node guard. Registered in graphify.resolver_registry so
# a new language plugs in without editing this body (#1356 Swift, #1446 Python).
# The Perl import re-pointer (`perl_import_repoint`) is the last registered
# resolver, so it runs here — after the call pass and member-call resolvers,
# before the relativization below — the same position as its former inline call.
run_language_resolvers(paths, per_file, all_nodes, all_edges)

# Relativize source_file fields so paths are portable across machines (#555)
Expand Down
2 changes: 2 additions & 0 deletions graphify/extractors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from graphify.extractors.objc import extract_objc
from graphify.extractors.pascal import extract_pascal
from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form
from graphify.extractors.perl import extract_perl
from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest
from graphify.extractors.razor import extract_razor
from graphify.extractors.rust import extract_rust
Expand Down Expand Up @@ -52,6 +53,7 @@
"markdown": extract_markdown,
"objc": extract_objc,
"pascal": extract_pascal,
"perl": extract_perl,
"powershell": extract_powershell,
"powershell_manifest": extract_powershell_manifest,
"razor": extract_razor,
Expand Down
Loading