Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9f508e7
test(extract): add perl fixtures and RED language tests
ad-astra-bot Jul 10, 2026
12742f3
feat(extract): add perl extractor (packages, subs, imports, inherits)
ad-astra-bot Jul 10, 2026
c8d6b12
fix(extract): package-aware perl call resolution, inherit stubs, buil…
ad-astra-bot Jul 10, 2026
8ffd179
feat(extract): dispatch .pl/.pm and perl shebang to perl extractor
ad-astra-bot Jul 10, 2026
9ada0df
fix(extract): bare perl call resolves to sub in use-imported package
ad-astra-bot Jul 10, 2026
2d1dfe4
fix(extract): repoint perl imports to in-corpus packages
ad-astra-bot Jul 10, 2026
a9ff973
fix(extract): treat indirect-object new as member call
ad-astra-bot Jul 10, 2026
9c3d007
fix(extract): only repoint perl imports on unique package label
ad-astra-bot Jul 10, 2026
fd11f56
fix(extract): scope perl import re-pointer by extractor provenance
ad-astra-bot Jul 10, 2026
1b58f6b
fix(extract): walk block-form perl packages without leaking package s…
ad-astra-bot Jul 10, 2026
6772976
fix(extract): model qualified perl sub declarations in their named pa…
ad-astra-bot Jul 10, 2026
08e75d1
chore(extract): tidy perl extractor review notes for upstream
ad-astra-bot Jul 10, 2026
082bf9e
fix(extract): model perl main package; cache-safe re-pointer; harden …
ad-astra-bot Jul 10, 2026
65c1870
refactor(extract): register perl import re-pointer via the resolver r…
ad-astra-bot Jul 10, 2026
82fefdc
fix(extract): gate perl second pass on lang stamp; accept both import…
ad-astra-bot Jul 10, 2026
7a8b8da
fix(extract): ASCII-validate perl package labels
ad-astra-bot Jul 10, 2026
b655d8a
fix(extract): budget every node of the perl tree walks
ad-astra-bot Jul 10, 2026
816be55
docs(resolver): generalize the LanguageResolver hook docstring
ad-astra-bot Jul 10, 2026
673a6ad
docs: add perl to language table and changelog
ad-astra-bot Jul 10, 2026
577721d
docs: fill in PR number in changelog entry
ad-astra-bot Jul 11, 2026
741aa80
docs: move Perl changelog entry to Unreleased after rebase onto 0.9.20
ad-astra-bot Jul 19, 2026
bafa76f
fix(perl): hardening pass from slice review — blocks, phasers, lazy l…
ad-astra-bot Aug 24, 2026
a30a640
docs(changelog): move the Perl entry out of a released section
ad-astra-bot Aug 26, 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)

## 0.9.52 (unreleased)

- Feature: 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.51 (2026-08-28)

- Fix: the incomplete-build shrink guard now stays armed when a chunk came back hollow, unparseable, or omitting files, so a run that silently lost content can no longer overwrite the existing graph with a smaller one; a complete run and a retry-recovered chunk are unaffected, and `--allow-partial` still overrides (#3105, thanks @abhay-codes07).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg

| Type | Extensions |
|------|-----------|
| 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 .m .mm .ml .mli .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]`, `.ml`/`.mli` requires `uv tool install graphifyy[ocaml]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) |
| Code (38 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 .ml .mli .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]`, `.ml`/`.mli` requires `uv tool install graphifyy[ocaml]`; `.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]`) |
| OCaml | `.ml .mli` (requires `uv tool install graphifyy[ocaml]`) |
Expand Down
2 changes: 1 addition & 1 deletion graphify/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class FileType(str, Enum):
_MTIME_COARSE_S = 2.0
_MTIME_SUBSECOND_S = 0.05

CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.ml', '.mli', '.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', '.lisp', '.cl', '.lsp', '.asd'}
CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.pl', '.pm', '.m', '.mm', '.ml', '.mli', '.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', '.lisp', '.cl', '.lsp', '.asd'}
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.txt', '.rst', '.html', '.yaml', '.yml'}
PAPER_EXTENSIONS = {'.pdf'}
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
Expand Down
117 changes: 116 additions & 1 deletion graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
from graphify.extractors.markdown import extract_markdown, _MD_LINK_INDEX_CACHE # noqa: F401
from graphify.extractors.ocaml import extract_ocaml # 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 @@ -2242,6 +2243,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 @@ -4151,6 +4153,42 @@ def _resolve_kotlin_qualified_calls(
)


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. Scoped by extractor provenance over BOTH
this run's paths AND the resolution context's source files: on an incremental
rebuild an unchanged package (extensionless shebang-Perl included) must stay a
re-point candidate, or full and incremental graphs diverge.
"""
perl_sources = {str(p) for p in paths if _get_extractor(p) is extract_perl}
seen_sf: set[str] = set()
for n in all_nodes:
sf = str(n.get("source_file") or "")
if not sf or sf in seen_sf:
continue
seen_sf.add(sf)
try:
if _get_extractor(Path(sf)) is extract_perl:
perl_sources.add(sf)
except Exception:
continue
_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 @@ -5199,6 +5237,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 @@ -5295,7 +5335,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 @@ -5311,6 +5351,7 @@ def add_existing_edge(edge: dict) -> None:
"lua": extract_lua,
"php": extract_php,
"julia": extract_julia,
"perl": extract_perl,
}


Expand Down Expand Up @@ -6668,6 +6709,21 @@ def _looks_like_bash(result: object) -> bool:
# of these files with no import evidence is gated below (#1659).
_JS_TS_CALL_SUFFIXES = (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs")
_go_module_cache: dict[Path, str | None] = {}

# 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 @@ -6763,6 +6819,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 = go_exact_import or _has_import_evidence(tgt)
Expand Down Expand Up @@ -6872,6 +6984,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.
#
# #2437: on an incremental rebuild the resolvers must also see the unchanged
# corpus — its nodes (types/methods, from resolution_nodes above) and its
Expand Down
2 changes: 2 additions & 0 deletions graphify/extractors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,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 @@ -54,6 +55,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