Skip to content

feat: add PHP language support - #64

Open
williamdes wants to merge 2 commits into
NanoNets:mainfrom
williamdes:feat/php-language-support
Open

feat: add PHP language support#64
williamdes wants to merge 2 commits into
NanoNets:mainfrom
williamdes:feat/php-language-support

Conversation

@williamdes

Copy link
Copy Markdown

Add PHP language support

Closes #63.

Problem

Graft ships tree-sitter grammars for Go, Python, and TypeScript/JS, but not
PHP. On a PHP project, graft build indexes 0 .php files and reports only the
other languages; --extensions .php parses nothing because no PHP grammar is
registered. The entire PHP codebase is invisible to the graph.

What this adds

PHP as a first-class Tier-1 language, following the existing per-language
pattern (extract.ts for the walk, bindings.ts for receiver types), with a
small resolver tweak and a project marker:

  • Grammar: tree-sitter-php (.php -> the tag-aware PHP.php grammar),
    registered in EXTENSIONS, GRAMMARS, KINDS_BY_LANG.
  • Definitions: functions, methods, classes, interfaces, traits, enums. Adds
    a PHP-only trait Kind (mirrors the existing Go-only struct).
  • Visibility: phpExported() — public unless a visibility_modifier marks
    it private/protected; top-level defs are always visible.
  • Calls: generalized isCallNode() handles PHP's four call shapes
    (function / member / nullsafe-member / scoped). $this/self/static/
    parent resolve to the enclosing class; a static Foo::bar() resolves to
    type Foo.
  • Heritage: extends (base clause) and implements (interface clause),
    names de-qualified for name-based resolution.
  • Trait composition: use SomeTrait; inside a class body emits an
    implements edge to the trait; implements resolution now also accepts a
    trait target (resolve.ts).
  • Receiver-type binding (bindings.ts): type-hinted parameters
    (function f(Foo $x)) and $x = new Foo() bind $x to Foo, so
    $x->method() resolves to the right class instead of by bare method name.
  • Closures as nodes: anonymous_function / arrow_function become function
    nodes — named after the variable they're assigned to ($h = fn(...) -> h,
    like TS arrow-consts), else an anonymous {closure}. This keeps a
    closure-only file (a routing table, a DI container) structured, so the calls
    inside a callback attribute to the callback rather than vanishing into the
    file node.
  • Imports: one imports edge per use clause (like Go package paths, these
    stay unresolved-to-file by design).
  • Scopes: composer.json added to project-root MARKERS.

Every PHP tree-sitter node type/field used here was confirmed against a real
tree-sitter-php AST before implementation.

Validation

Beyond the unit tests, this was dogfooded on three real-world PHP codebases —
4,479 source files parsed in total, with zero parse failures and no tree-sitter
ERROR nodes
:

  • Clean parse across all files; the only zero-symbol files were genuinely
    symbol-free (config-array returns, bootstrap/entry scripts, DI definitions).
  • On the largest (a ~3,200-file Laravel application): 24,386 nodes /
    58,167 edges
    ; class heritage, $this->/static calls, typed-parameter member
    calls, and trait composition all resolve across files.
  • Closures had the biggest impact: a routing file that previously extracted to
    0 symbols now yields 33 closure nodes, and on the largest codebase
    1,556 resolved calls are now owned by closure nodes that were previously
    attributed to the file (or dropped).

Tests

test/graph-php.test.ts (mirrors test/graph-go.test.ts) — four tests covering
node kinds + visibility, extends/implements/$this->/Cls:: edges, trait
composition, typed-parameter binding, and closures-as-nodes.
test/graph-languages.test.ts gets the .php extension. npm test is green
(pre-existing unrelated failures aside); tsc --noEmit clean.

Limitations (deliberate, documented in code)

  • PHP 8 attributes #[...] are not emitted. Unlike TS/Python decorators
    (which are call expressions and so leave calls edges incidentally), a PHP
    attribute is metadata, not a call — a natural follow-up is a references edge
    to the attribute class.
  • Blade (.blade.php) templates are out of scope.
  • A bare $var->m() with no type hint / new still resolves by method name
    only (no full data-flow analysis) — same conservative stance as the other
    languages.

Files changed

package.json, src/graph/extract.ts, src/graph/bindings.ts,
src/graph/resolve.ts, src/graph/scopes.ts, src/graph/types.ts,
test/graph-php.test.ts, test/graph-languages.test.ts.

Applying

npm install   # picks up tree-sitter-php
npm test

@CarlLee1983

Copy link
Copy Markdown

Tested this branch against a large Laravel 11 codebase (2145 indexed files, 1353 of them PHP). The PHP support itself works well — sharing both the positive results and one reproducible defect.

What works

Built at commit 7de0bcd, npm test → 587/587 passing.

✓ wiring: 12953 nodes (7462 method, 2145 file, 1724 function, 1475 class,
  107 enum, 24 trait, 16 interface), 27194 edges, 2145 cards [javascript, php, python]
  parsed: 2145 of 2145 files

11 seconds for the full build. Coverage of app/ went from 0/1353 files on 0.9.0 to 1353/1353.

Call-edge accuracy — I diffed graft callers against grep, file by file:

graft callers verifyTransaction   → 31 callers
rg -l '\->verifyTransaction\('    → 31 files
set difference, both directions   → empty

Zero misses, zero false edges on that symbol. Signatures parse correctly too, including promoted constructor properties, implements lists, nullable/union type hints and enums.

Ambiguity handling is appropriately conservative. handle has 95 definitions in this repo (Laravel commands/jobs), and callers declines to guess rather than inventing edges:

no indexed callers — … 95 definitions share the name "handle"; a cross-file caller of an ambiguous name is dropped rather than guessed, so this may undercount.

That's the right call, and the message says so plainly.

Defect: graft check is always STALE, and non-deterministically so

Immediately after graft build --no-reuse, with no source change:

$ graft build --no-reuse   # completes cleanly
$ graft check
graph check: STALE
  + app/…/Acme.php#Acme.getItemList.{closure}
  - app/…/Acme.php#Acme.getItemList.itemGroupCallback

The diff is always a PHP closure assigned to a variable. The stored graph (and the Tier-1 extract cache) holds the variable name; check recomputes it as {closure}.

The affected file set changes between consecutive check runs with no rebuild and no edit in between:

check #1 → 1 file  (tests/…/Beta/TestCase.php#TestCase.makeRealApiClient)
check #2 → 2 files (the above + app/…/Acme.php#Acme.getItemList)
check #3 → 1 file  (app/…/Acme.php#Acme.getItemList)

Source shape in both cases is an ordinary variable-assigned closure:

$itemGroupCallback = function (ItemGroup $itemGroup) use ($categoryTypeMap, $response, &$itemList) {

Net effect: graft check can never return OK on this repo, so it is unusable as a CI freshness gate. ask, callers and skeleton are unaffected — the stored graph has the correct names.

What I ruled out

I could not pin the mechanism down, so rather than guess, here is what the evidence excludes:

  • Not source-reading driftreadFileSync(utf8) and readSourceFile() return byte-identical strings for the file (35972 chars, both).
  • Not dist/src driftdist/graph/extract.js's phpClosureName matches src/graph/extract.ts, and dist is the newer artifact.
  • Not shared-parser state leakage — extracting the 1114 app/**/*.php files that precede Acme.php in build order through the same module-level parser, then extracting Acme.php, gives the same result as extracting it alone.
  • Not a duplicate/colliding node id — all 12953 ids are unique.
  • Not a broken parse tree — parsing the file through the same chunked-callback path, the closure's parent.type is assignment_expression and childForFieldName("left").text is $itemGroupCallback, with rootNode.hasError === false.

The odd part: calling extractFile() directly on that file returns {closure}, while build — which calls the same function and persists Tier-1 output before enrichment — writes itemGroupCallback into graft/.cache/extract.*.json. Same file, same lang, same source string, opposite results, and the isolated call is stable across repeats and processes.

One suggestion regardless of cause

phpClosureName decides via reference identity:

if (parent?.type === "assignment_expression" && parent.childForFieldName("right") === node) {

=== on tree-sitter node wrappers depends on the binding handing back a cached wrapper object. Comparing .id (or startIndex/endIndex) instead would be robust to that regardless of whether it turns out to be the cause here — and bindings.ts carries a duplicated copy of the same function, so both would need it.

Happy to run further probes against this codebase if it helps — it's a decent PHP stress case (Laravel, traits, enums, heavy abstract-class hierarchies, ~2k files).

williamdes added a commit to williamdes/Graft that referenced this pull request Aug 8, 2026
phpClosureName decided whether a closure was an assignment's right-hand
side with `parent.childForFieldName("right") === node`. tree-sitter's
binding does not guarantee that two traversals to the same underlying
node return the same JS wrapper, so `===` could be false for the same
node — collapsing a variable-assigned closure to the anonymous
`{closure}` name on some passes but not others.

The stored graph then held `…#itemGroupCallback` while a later
recomputation produced `…#{closure}`, so `graft check` reported the
graph STALE (added/removed pair) immediately after a clean build, with
the affected file set varying between runs — making `check` unusable as
a CI freshness gate on closure-heavy PHP.

Compare tree-sitter node `.id` (stable per node within a tree) instead
of wrapper identity, in both extract.ts and the duplicated copy in
bindings.ts so the two scope stacks stay in lockstep.

Adds regression tests: variable-assigned (incl. `static`) closures keep
their variable name, closure-node ids are stable across repeated
extraction, and `graft check` is OK immediately after a build.

Reported by @CarlLee1983 in NanoNets#64.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@williamdes
williamdes force-pushed the feat/php-language-support branch from 7de0bcd to 980d76e Compare August 8, 2026 09:32
@williamdes

Copy link
Copy Markdown
Author

@CarlLee1983 thank you so much for this — a genuinely excellent report. The reproduction, the file-by-file callers vs rg diff, and especially the list of things you ruled out saved a huge amount of time. Testing the branch against a ~2k-file Laravel codebase is exactly the kind of stress case this needed.

I've pushed a fix taking your suggestion: phpClosureName no longer relies on === wrapper identity to decide whether a closure is an assignment's right-hand side — it now compares tree-sitter node .id, which is stable regardless of whether the binding hands back a cached wrapper. As you noted, bindings.ts carried a duplicated copy of the same function, so both were updated to keep the two scope stacks in lockstep.

You were right that reference identity was the fragile part. I couldn't force the non-deterministic path in a small isolated fixture (there === happens to return true), so rather than a flaky reproduction I locked the actual invariant your report exposed with regression tests:

  • variable-assigned closures — including top-level static function … use (…) and closures nested in a method — keep their variable name (never collapse to {closure});
  • closure-node ids are byte-identical across repeated extraction;
  • graft check reports OK immediately after a clean build (no added/removed drift) on a closure-heavy fixture — i.e. the exact "always STALE" symptom you hit.

Full suite is green. Offer to run further probes very much appreciated — if check still shows any drift on your Laravel repo after this, I'd love to hear it.

@Pushkraj19

Copy link
Copy Markdown

✅👌 please update

williamdes added a commit to williamdes/Graft that referenced this pull request Aug 12, 2026
phpClosureName decided whether a closure was an assignment's right-hand
side with `parent.childForFieldName("right") === node`. tree-sitter's
binding does not guarantee that two traversals to the same underlying
node return the same JS wrapper, so `===` could be false for the same
node — collapsing a variable-assigned closure to the anonymous
`{closure}` name on some passes but not others.

The stored graph then held `…#itemGroupCallback` while a later
recomputation produced `…#{closure}`, so `graft check` reported the
graph STALE (added/removed pair) immediately after a clean build, with
the affected file set varying between runs — making `check` unusable as
a CI freshness gate on closure-heavy PHP.

Compare tree-sitter node `.id` (stable per node within a tree) instead
of wrapper identity, in both extract.ts and the duplicated copy in
bindings.ts so the two scope stacks stay in lockstep.

Adds regression tests: variable-assigned (incl. `static`) closures keep
their variable name, closure-node ids are stable across repeated
extraction, and `graft check` is OK immediately after a build.

Reported by @CarlLee1983 in NanoNets#64.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@williamdes
williamdes force-pushed the feat/php-language-support branch from 980d76e to 1aacb5a Compare August 12, 2026 13:12
williamdes and others added 2 commits August 13, 2026 22:49
Adds a tree-sitter-php grammar and first-class PHP tier-1 extraction: definitions (classes, methods, interfaces, traits, enums, functions), the four PHP call shapes, extends/implements heritage, use-imports, trait composition, typed-parameter receiver binding, and closures-as-nodes. Closes NanoNets#63.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
phpClosureName decided whether a closure was an assignment's right-hand
side with `parent.childForFieldName("right") === node`. tree-sitter's
binding does not guarantee that two traversals to the same underlying
node return the same JS wrapper, so `===` could be false for the same
node — collapsing a variable-assigned closure to the anonymous
`{closure}` name on some passes but not others.

The stored graph then held `…#itemGroupCallback` while a later
recomputation produced `…#{closure}`, so `graft check` reported the
graph STALE (added/removed pair) immediately after a clean build, with
the affected file set varying between runs — making `check` unusable as
a CI freshness gate on closure-heavy PHP.

Compare tree-sitter node `.id` (stable per node within a tree) instead
of wrapper identity, in both extract.ts and the duplicated copy in
bindings.ts so the two scope stacks stay in lockstep.

Adds regression tests: variable-assigned (incl. `static`) closures keep
their variable name, closure-node ids are stable across repeated
extraction, and `graft check` is OK immediately after a build.

Reported by CarlLee1983 in NanoNets#64.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@williamdes
williamdes force-pushed the feat/php-language-support branch from 1aacb5a to 4c37c54 Compare August 13, 2026 20:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Add support for PHP language parsing

3 participants