Uh oh!
There was an error while loading. Please reload this page.
feat(schema): materialize anonymous callables as first-class callables (schema 2.1.0) - #93
Conversation
…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
commented
Aug 19, 2026
Container suite: greenRan locally against Podman ( One real failure surfaced and is fixed in 687406e: Full local state:
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
commented
Aug 19, 2026
Measured on OWASP Juice ShopLocal checkout at
Growth is much larger than this repository suggested (+28.9% there). Juice Shop is callback-dense in a way EXP-001, on the actual worked example
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. After — the handler exists, with its own graph:
The tainted edge targets statement Incidentally it also recovers the seven |
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:Nvertices at L4.Before,
computeSignatureForDeclreturnednullfor these nodes, soindexCallableDeclsnever saw them and no graph was built;walkBodyattributed their call sites to the enclosing callable; and the Jelly side minted a flat application-scope plaque with no body and abytes: [0, 0]span. For the Express handler idiom, noreq-rooted fact existed anywhere on the DDG.L3/L4 required no changes —
isFunctionBoundary(cfg.ts:439) already treated arrows as boundaries andcollectCallablesalready recursedinner_callables. The whole gap was L1 identity.Incidental fix
() => () => x—walkBodyiterated 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'ssymbol_table_builder.py:619gives lambdas noPyCallable, and Java does not materializeLambdaExprat 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:19scopes MAJOR to a renamed or removed label, relationship or key. None is removed here:synthesized_callablessurvives 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.:TSAnonymousCallablesurvives as a second label on the real tree node, reached byTS_DECLARESfrom 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_graphedge sources move. A consumer asking "what doeslogincall" gets a different answer. This is instance-level drift, not schema-element removal, but it is a real change. No compensatinglogin → queryedge is emitted —logindoes not callquery, it returns something that does.#91moves 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 intest/anonymous-callables.test.tsover a newtest/fixtures/anon-app)bun run typecheck— cleanbun run gen:schema—schema.neo4j.jsonregeneratedL1 ⊆ L2 ⊆ L3 ⊆ L4monotonicity gates hold with anonymous callables populatedddg: [{src: "@entry", dst: "3:5", var: "req.body.email"}, {src: "3:5", dst: "4:5", var: "email"}]reaching thequerycallbun run test:container— 4 pass, 0 fail (run against Podman viaDOCKER_HOST; see comment). One stale hardcoded2.0.0assertion fixed in 687406e.Cost
Measured on OWASP Juice Shop (388 modules,
-a 3) — full table in this comment:req-rooted DDG edges-a 3-a 3This 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>withddg @entry -> 34:5 var=req.body.email, reaching the statement holding themodels.sequelize.querycall.Known limits
class A { m = () => {} }) are still not materialized — the module-level sweep stops at class boundaries. Named-but-unmaterialized family, fix: materialize named object-literal methods so Jelly edges never dangle #57 / feat(symbol-table): materializethis.x = fnand object-literal methods as callables #85.V2Callable.ddgstaysunknown[]. Roadmap candidate 2.CHANGELOG.mdexists in this repo — release generates it from commits/PRs, so the migration note lives in this body and the commit message.