Skip to content

feat(schema): materialize anonymous callables as first-class callables (schema 2.1.0) - #93

Merged
rahlk merged 3 commits into
mainfrom
feat/issue-092-anon-callables
Aug 19, 2026
Merged

feat(schema): materialize anonymous callables as first-class callables (schema 2.1.0)#93
rahlk merged 3 commits into
mainfrom
feat/issue-092-anon-callables

Conversation

@rahlk

@rahlkrahlk commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Closes#92. Also closes#75.

What changed

An unnamed arrow or function expression is now a callable in its own right: tree-contained under its enclosing callable, with a durable positional signature segment, its own body/cfg/cdg/ddg, and @formal_in:N vertices at L4.

can://typescript/<app>/routes/login.ts/login/<anon@34:10>

Before, computeSignatureForDecl returned null for these nodes, so indexCallableDecls never saw them and no graph was built; walkBody attributed their call sites to the enclosing callable; and the Jelly side minted a flat application-scope plaque with no body and a bytes: [0, 0] span. For the Express handler idiom, no req-rooted fact existed anywhere on the DDG.

L3/L4 required no changes — isFunctionBoundary (cfg.ts:439) already treated arrows as boundaries and collectCallables already recursed inner_callables. The whole gap was L1 identity.

Incidental fix

() => () => xwalkBody iterated only the body's children, so an arrow that is a concise body was skipped entirely and its call sites attributed to the callable that returns it.

Design

Spec is in this branch: docs/design/specs/anonymous-callable-materialization.md. Prior art anchored on both mature references, which take the opposite position deliberately — Python's symbol_table_builder.py:619 gives lambdas no PyCallable, and Java does not materialize LambdaExpr at all. TypeScript diverges because a Python lambda is one expression while a JS arrow is a full body and the dominant unit of behaviour.

The <anon@L:C> production is TypeScript-local and provisional, pending roadmap candidate 4 (can:// grammar conformance, collision group A). Siblings should not adopt it before ratification.

Compatibility — why MINOR

src/build/neo4j/schema.ts:19 scopes MAJOR to a renamed or removed label, relationship or key. None is removed here:

  • synthesized_callables survives as a compatibility index: pre-2.1.0 anonymous-callable id → the tree id that replaced it. Signatures no provider could name are still homed as standalone nodes, so no call-graph endpoint dangles.
  • :TSAnonymousCallable survives as a second label on the real tree node, reached by TS_DECLARES from its enclosing callable — which is what puts it on the snapshot wipe's containment walk and closesfix(neo4j): cypher snapshot wipe misses :TSAnonymousCallable orphans #75.

Behavioural change to state plainly: call sites re-anchor from the enclosing callable to the arrow, so call_graph edge sources move. A consumer asking "what does login call" gets a different answer. This is instance-level drift, not schema-element removal, but it is a real change. No compensating login → query edge is emitted — login does not call query, it returns something that does.

#91 moves to schema 2.2.0 (comment); its <service> prefix change is orthogonal to this suffix change.

Verification

  • bun test — 128 pass, 0 fail (14 new in test/anonymous-callables.test.ts over a new test/fixtures/anon-app)
  • bun run typecheck — clean
  • bun run gen:schemaschema.neo4j.json regenerated
  • L1 ⊆ L2 ⊆ L3 ⊆ L4 monotonicity gates hold with anonymous callables populated
  • EXP-001 acceptance, asserted in the suite: ddg: [{src: "@entry", dst: "3:5", var: "req.body.email"}, {src: "3:5", dst: "4:5", var: "email"}] reaching the query call

bun run test:container — 4 pass, 0 fail (run against Podman via DOCKER_HOST; see comment). One stale hardcoded 2.0.0 assertion fixed in 687406e.

Cost

Measured on OWASP Juice Shop (388 modules, -a 3) — full table in this comment:

mainthis PRΔ
Callables1,0952,493+127.7%
CFG edges3,6808,476+130.3%
req-rooted DDG edges2434814.5×
Artifact -a 38.18 MB10.48 MB+28.1%
Wall clock -a 3~7 s~7 snot separable

This repository's own source grows far less (596 → 768, +28.9%) because it is not callback-dense. Treat +128% as the realistic figure for an Express application, and do not read "free" into the wall-clock row — 1-second granularity is too coarse to support a claim either way.

EXP-001 is resolved on the actual worked example: routes/login.ts:32's handler now exists as <anon@32:10> with ddg @entry -> 34:5 var=req.body.email, reaching the statement holding the models.sequelize.query call.

Known limits

…s (2.1.0)
An unnamed arrow or function expression was never modelled as a callable:
computeSignatureForDecl returned null for it, so indexCallableDecls never saw
it and no CFG/CDG/DDG was built, while walkBody attributed its call sites to
the callable that merely encloses it. The Jelly side minted a flat
application-scope plaque with no body and a bytes: [0, 0] span. For the Express
handler idiom no request-rooted fact existed anywhere on the DDG.
Unnamed function-like nodes now carry a positional signature segment,
<anon@line:col>, contributed to the dotted chain — durable id tier, disjoint
from the @line:col ordinal namespace body nodes use, and computable identically
by the resolver and Jelly since both know source positions. They are
tree-contained under their enclosing callable and get their own body, cfg, cdg,
ddg and formal-in vertices; L3/L4 needed no change, since isFunctionBoundary
already treated arrows as boundaries and collectCallables already recursed
inner_callables.
Also fixes a concise arrow body that is itself a callable (`() => () => x`):
walkBody iterated only the body's children, so the inner arrow was skipped and
its call sites attributed upward.
Behavioural change: call sites re-anchor from the enclosing callable to the
arrow, so call_graph edge sources move. No label, relationship type or key is
removed, so the bump is MINOR per the rule in neo4j/schema.ts.
- synthesized_callables becomes a compatibility index mapping each pre-2.1.0
anonymous-callable id onto the tree id that replaced it; signatures no
provider could name are still homed as standalone nodes so nothing dangles.
- :TSAnonymousCallable becomes a second label on the real tree node, reached by
TS_DECLARES from its enclosing callable, which puts it on the snapshot wipe's
containment walk.
Measured on this repository: 596 -> 768 callables (+28.9%), artifact +7.0% at
-a 3 over src/.
Spec: docs/design/specs/anonymous-callable-materialization.md
Closes#75
The bolt migration test hardcoded '2.0.0' as the post-push schema version, so
it broke on the 2.1.0 bump for a reason unrelated to what it covers (wiping
1.1.0 residue). Read the constant instead.
@rahlk

Copy link
Copy Markdown
ContributorAuthor

Container suite: green

Ran locally against Podman (DOCKER_HOST pointed at the podman machine socket, TESTCONTAINERS_RYUK_DISABLED=true) — the path the test header already documents. No Docker Desktop involved.

bun run test:container
4 pass
0 fail

One real failure surfaced and is fixed in 687406e: test/neo4j-bolt.test.ts hardcoded schema_version = '2.0.0' in the 1.1.0-migration assertion, so it broke on the version bump for a reason unrelated to what that test covers. It now reads SCHEMA_VERSION from the schema module, so the next bump will not break it again.

Full local state:

bun test131 pass, 0 fail, 6 skip
bun run test:container4 pass, 0 fail
bun run typecheckclean

This clears the one open item from the PR description. Juice Shop is still unmeasured — growth numbers in the description are from this repository.

@rahlk

Copy link
Copy Markdown
ContributorAuthor

Measured on OWASP Juice Shop

Local checkout at 33518f5a0, not the 6244c59 cited in #57 — but main reproduces that issue's app-wide figure of 24 req-rooted DDG edges exactly, so the comparison holds. -a 3 --tsc-only --skip-tests --no-build, fresh cache per run, 388 modules.

main (2.0.0)this PR (2.1.0)Δ
Callables1,0952,493+127.7%
↳ anonymous01,398
Callables with a CFG4371,093+150.1%
CFG edges3,6808,476+130.3%
DDG edges8,62510,191+18.2%
req-rooted DDG edges2434814.5×
↳ in routes/login.ts724
Artifact, -a 15.79 MB7.03 MB+21.4%
Artifact, -a 38.18 MB10.48 MB+28.1%
Wall clock, -a 3~7 s~7 snot separable

Growth is much larger than this repository suggested (+28.9% there). Juice Shop is callback-dense in a way cants' own source is not, so treat +128% as the realistic ceiling for an Express application. Runtime is unchanged at 1-second granularity — too coarse to claim anything either way; nobody should read "free" into that row.

EXP-001, on the actual worked example

routes/login.ts:32 is verbatim the case from the bug report:

return(req: Request,res: Response,next: NextFunction)=>{verifyPreLoginChallenges(req)models.sequelize.query(`SELECT * FROM Users WHERE email = '${req.body.email||''}' AND …`)

Before — the handler is not a node at all. login carries ddg=41, req-rooted=0; the string req.body.email appears nowhere in the file's dataflow.

After — the handler exists, with its own graph:

<anon@32:10> kind=arrow body=7 cfg=5 ddg=40 req-rooted=3
ddg @entry -> 33:5 var=req
ddg @entry -> 34:5 var=req.body.email
ddg @entry -> 34:5 var=req.body.password
body 34:5/3 kind=call method=query ← the SQL sink, same statement node

can://typescript/juice-shop/routes/login.ts/login/<anon@32:10>

The tainted edge targets statement 34:5, which is where the query call site is collapsed (per SCHEMA_DECISIONS.md L5, actuals collapse onto the containing statement). Source-to-sink reachability is on the graph.

Incidentally it also recovers the seven verifyPreLoginChallenges predicate closures (<anon@59:62><anon@65:68>), each carrying its own req.body.email / req.body.password edges — challenge-detection logic that was previously invisible.

@rahlk
rahlk merged commit 4c77752 into mainAug 19, 2026
1 check passed
@rahlk
rahlk deleted the feat/issue-092-anon-callables branch August 19, 2026 17:53
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant

@rahlk