Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 `.pl`/`.pm` extraction via tree-sitter-perl — package declarations (statement form, block form `package Foo { ... }` with correct scope restore, and mid-file package switches), sub definitions including qualified `sub Pkg::sub` declarations, and Perl's implicit `main` package materialized lazily for package-less code; extensionless `#!/usr/bin/perl` scripts dispatch through the shebang map. Iterative, budget-bounded tree walks keep pathologically deep or wide files partial instead of crashing. Imports, inheritance, and call resolution follow in subsequent changes (#419).

## 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
7 changes: 6 additions & 1 deletion graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
from graphify.extractors.go import _GO_PREDECLARED_FUNCS, extract_go # noqa: F401
from graphify.extractors.json_config import extract_json # noqa: F401
from graphify.extractors.commonlisp import extract_commonlisp # noqa: F401
from graphify.extractors.perl import extract_perl # noqa: F401
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
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 @@ -5199,6 +5201,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 +5299,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 +5315,7 @@ def add_existing_edge(edge: dict) -> None:
"lua": extract_lua,
"php": extract_php,
"julia": extract_julia,
"perl": extract_perl,
}


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
234 changes: 234 additions & 0 deletions graphify/extractors/perl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
"""Perl extractor: packages and subs.

Deliberately untyped, consistent with the other language extractors. This slice
covers package declarations (statement, block, and mid-file switch forms plus
Perl's implicit ``main``) and sub definitions (including qualified
``sub Bar::baz`` declarations). Imports, inheritance, and call resolution are
follow-up slices; calls are intentionally not collected here.
"""
from __future__ import annotations

import logging
import re
from pathlib import Path
from typing import Any

from graphify.extractors.base import _file_stem, _make_id

_LOG = logging.getLogger(__name__)

# A valid Perl package/class name: a bareword component (`Foo`, `_priv`) optionally
# joined by `::`. Package names flow raw into node labels and on into graph.json /
# the Obsidian export, so a crafted or malformed name (control chars, markdown,
# newlines, an over-long blob) must be discarded rather than labeled (zero-node
# over a bogus label).
#
# The classes are spelled out as explicit ASCII ranges (not ``\w``): Python's
# ``\w`` is Unicode-default, so accented (``Basé``), fullwidth (``Base``) and
# other non-ASCII barewords would slip through. Every ``::`` component must begin
# with a letter/underscore, which also rejects a digit-start component
# (``Acme::1x``); ``fullmatch`` (below) anchors the whole string, so a trailing
# newline cannot pass on a ``$`` alone.
_PERL_PKG_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)*")
_MAX_PERL_PKG_NAME_LEN = 256

# Coarse guard so a pathologically large or deeply nested file cannot make the
# (iterative) tree walks run away; on exhaustion the file keeps whatever was
# already extracted (file node + partial graph) instead of nothing.
_MAX_PERL_TRAVERSAL_NODES = 2_000_000


def _is_valid_perl_package_name(name: str) -> bool:
return (
bool(name)
and len(name) <= _MAX_PERL_PKG_NAME_LEN
and _PERL_PKG_NAME_RE.fullmatch(name) is not None
)


def extract_perl(path: 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 regressionextract_perl()

10 callers depend on it (afferent coupling).

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

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 regressionextract_perl()

14 callers depend on it (afferent coupling).

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

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 regressionextract_perl()

14 callers depend on it (afferent coupling).

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

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 regressionextract_perl()

14 callers depend on it (afferent coupling).

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

"""Extract packages and subs from a .pl/.pm file."""
try:
import tree_sitter_perl as tsperl
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree_sitter_perl not installed"}

try:
language = Language(tsperl.language())
parser = Parser(language)
source = path.read_bytes()
tree = parser.parse(source)
root = tree.root_node
except Exception as e:
return {"nodes": [], "edges": [], "error": str(e)}

stem = _file_stem(path)
str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()

def _text(node) -> str:
return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")

def add_node(nid: str, label: str, line: int) -> None:
if nid not in seen_ids:
seen_ids.add(nid)
nodes.append({"id": nid, "label": label, "file_type": "code",
"source_file": str_path, "source_location": f"L{line}"})

def add_edge(src: str, tgt: str, relation: str, line: int) -> None:
edges.append({"source": src, "target": tgt, "relation": relation,
"confidence": "EXTRACTED", "source_file": str_path,
"source_location": f"L{line}", "weight": 1.0})

file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)

def _package_name(node) -> str | None:
"""`package Foo::Bar;` -> the second `package`-typed child is the name."""
names = [c for c in node.children if c.type == "package"]
name = _text(names[1]) if len(names) >= 2 else None
# Root-qualified spelling `::Outer` is the same package as `Outer`
# (::Name == main::Name in Perl); canonicalize so both spellings key to
# one package node instead of diverging on an empty qualifier.
if name and name.startswith("::"):
name = name[2:]
# The name comes from arbitrary source text; validate before it becomes a
# label (zero-node over a malformed or crafted package statement).
return name if _is_valid_perl_package_name(name or "") else None

current_pkg_nid: str | None = None
current_pkg_name: str | None = None
main_pkg_nid: str | None = None

def _ensure_main_pkg() -> str:
"""Perl's implicit default package. Code with no ``package`` statement lives
in ``main``; modeling it explicitly (instead of hanging package-less subs off
the file node) means a qualified ``main::helper()`` can bind once call
resolution lands, and a bare call from a package-less file has an honest
scope. Created lazily so a file with no package-less subs gets no empty
``main`` node."""
nonlocal main_pkg_nid
if main_pkg_nid is None:
main_pkg_nid = _make_id(stem, "main")
add_node(main_pkg_nid, "main", 1)
add_edge(file_nid, main_pkg_nid, "contains", 1)
return main_pkg_nid

budget = [_MAX_PERL_TRAVERSAL_NODES]
budget_warned = [False]

def _spend() -> bool:
"""Charge one node against the shared traversal budget; False once spent so
the walkers stop instead of running away. Emits one bounded warning."""
budget[0] -= 1
if budget[0] < 0:
if not budget_warned[0]:
budget_warned[0] = True
_LOG.warning(
"perl: traversal budget exhausted for %s; graph for this file is partial",
str_path,
)
return False
return True

def walk_statements(root_node) -> None:

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 regressionwalk_statements()

fans out to 7 callees (efferent coupling).

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

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 regressionwalk_statements()

fans out to 7 callees (efferent coupling).

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

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 regressionwalk_statements()

fans out to 7 callees (efferent coupling).

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

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 regressionwalk_statements()

fans out to 7 callees (efferent coupling).

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

nonlocal current_pkg_nid, current_pkg_name
# Manual call stack in place of recursion: a pathologically deep nest of
# block-form packages would otherwise blow the Python stack, and the
# resulting RecursionError makes `_safe_extract` drop the WHOLE file. Each
# frame is an iterator over one block's statements plus the scope to restore
# once that block is exhausted; a block-form `package Foo { ... }` pushes a
# child frame under Foo's scope, so its subs are attributed to Foo and the
# prior package is restored for statements that follow the block.
stack: list[tuple[Any, str | None, str | None]] = [
(iter(root_node.children), current_pkg_nid, current_pkg_name)
]
while stack:
child_iter, restore_nid, restore_name = stack[-1]
descended = False
for child in child_iter:
# Charge every visited sibling, not once per frame: a broad flat
# file drains an unbounded number of children under a single frame,
# so a per-frame charge left them effectively free.
if not _spend():
return # budget exhausted: keep the partial graph, stop walking
line = child.start_point[0] + 1
if child.type == "package_statement":
name = _package_name(child)
if name:
pkg_nid = _make_id(stem, name)
add_node(pkg_nid, name, line)
add_edge(file_nid, pkg_nid, "contains", line)
pkg_block = next(
(c for c in child.children if c.type == "block"), None)
if pkg_block is not None:
# Descend into the block under Foo; the frame remembers
# the pre-block scope so it is restored when the block is
# fully consumed (statements after the block are not
# mis-attributed to Foo).
prev_nid, prev_name = current_pkg_nid, current_pkg_name
current_pkg_nid, current_pkg_name = pkg_nid, name
stack.append((iter(pkg_block.children), prev_nid, prev_name))
descended = True
break
else:
current_pkg_nid = pkg_nid
current_pkg_name = name
elif child.type == "block_statement":
# A bare `{ ... }` scope may itself contain package/sub
# declarations (`{ package Inner; sub f {...} }` is valid
# Perl); descend without changing scope — the frame restores
# whatever the enclosing package was once the block ends.
stack.append((iter(child.children), current_pkg_nid, current_pkg_name))
descended = True
break
elif child.type == "phaser_statement":
# BEGIN/CHECK/INIT/END/UNITCHECK blocks compile their bodies
# at the surrounding scope, so declarations inside define real
# symbols; descend into the phaser's block like any scope.
blk = next((c for c in child.children if c.type == "block"), None)
if blk is not None:
stack.append((iter(blk.children), current_pkg_nid, current_pkg_name))
descended = True
break
elif child.type == "subroutine_declaration_statement":
name = None
for c in child.children:
if c.type == "bareword" and name is None:
name = _text(c)
if name:
if "::" in name:
# Qualified declaration `sub Pkg::sub {...}` defines the
# sub IN the named package, not the current one. Container
# = that package (created if it has no `package` statement
# of its own).
pkg_qual, _, sub_name = name.rpartition("::")
if pkg_qual:
container = _make_id(stem, pkg_qual)
add_node(container, pkg_qual, line)
add_edge(file_nid, container, "contains", line)
else:
# Root-qualified `sub ::foo {...}` declares
# main::foo (::Name == main::Name).
container = _ensure_main_pkg()
else:
# Package-less sub → Perl's `main` (not the file node).
container = current_pkg_nid or _ensure_main_pkg()
sub_name = name
sub_nid = _make_id(container, sub_name)
add_node(sub_nid, f"{sub_name}()", line)
add_edge(container, sub_nid, "contains", line)
if descended:
continue
stack.pop()
current_pkg_nid, current_pkg_name = restore_nid, restore_name

walk_statements(root)

clean_edges = [e for e in edges if e["source"] in seen_ids and
e["target"] in seen_ids]
return {"nodes": nodes, "edges": clean_edges,
"input_tokens": 0, "output_tokens": 0}
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ dependencies = [
"tree-sitter-fortran>=0.6,<0.8",
"tree-sitter-bash>=0.23,<0.27",
"tree-sitter-json>=0.23,<0.26",
"tree-sitter-perl>=1.2.0,<2.0",
]

[project.urls]
Expand Down
27 changes: 27 additions & 0 deletions tests/fixtures/sample.pl
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/perl
use strict;
use warnings;
use Acme::Widget;

package Acme::Helper;

sub emit {
my ($text) = @_;
print "$text\n";
return length $text;
}

sub format {
my ($text) = @_;
return uc $text;
}

package main;

sub run_report {
my $widget = Acme::Widget->new();
$widget->render();
Acme::Helper::emit("done");
}

run_report();
Loading