Skip to content

Release/2.0 - #432

Merged
rahlk merged 76 commits into
mainfrom
release/2.0
Oct 15, 2026
Merged

rahlk merged 76 commits into
mainfrom
release/2.0

Conversation

@rahlk

@rahlk rahlk commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator

Brings main up to date with the 2.0 line. main is at v2.0.0-rc.6. release/2.0 has 76 more commits, and main has none that release/2.0 lacks, so this merges with no conflicts.

What main gains:

The full record is in CHANGELOG.md.

rahlk added 30 commits September 9, 2026 05:37
The last leg of the 2.0 query facade. taint() is the one verb in epic
.github#55's release plan that no language has; this locks its design
across Python, Java and TypeScript.

Nine decisions, four of which reject a concept the earlier §5.4 sketch
would have added: no framework catalogue (sources stay the caller's),
no verdict enum (BoundedResult's `complete` plus a stored `refuted` set
carries all three cases), no Selector type (the existing (name, within)
pair, made plural), and no fourth copy of the path Cypher — the shared
skeleton lifts into commons/graphs.py instead.

The framing that drove the rest: proving a flow already works, because
locate() addresses the sink, paths_between returns the witness and
.weakest caps the claim. Refuting one has no answer today, and in triage
a wrong refutation closes a live alert. So taint() is designed as a
refutation instrument, which is why depth is unbounded by default and
why the sanitizer predicate must live inside the query rather than
filter its results.

Two findings from the probe run against Neo4j 5.26.30, both recorded in
§7 with the plans that produced them:

- A relationship-property predicate inlines into allShortestPaths, so
  the search returns the shortest *unsanitized* path instead of
  filtering the shortest one and reporting nothing. No exhaustive
  fallback appears, including on the zero-row case. This is what makes
  the server-side cut correct rather than merely faster.
- Only the DDG relationship carries `var` — the projection writes
  PARAM_IN and PARAM_OUT with no properties at all — so a naive
  `r.var <> $v` predicate is null on every cross-call hop and falsely
  refutes every interprocedural flow, silently. coalesce() is mandatory,
  and the tripwire for it is the leg's first test.

Two issues found while designing this and filed separately, neither
gating the leg: codeanalyzer-python#195 and python-sdk#381.
…uted

`refuted` read as a claim about the program. What the SDK can certify is
narrower: the traversal was unbounded, found nothing, and no diagnostic
implicated the pair. A triage caller seeing "refuted" closes the alert,
and the gap between the word and the guarantee was invisible at the call
site.

So the field is `exhausted`, and §5 now states its three membership
conditions as rules rather than prose:

1. depth is None for the call
2. zero paths for that pair
3. no unresolved diagnostic implicates that pair

Condition 1 was previously only implied — the spec said a bounded depth
meant taint "could essentially never" report the verdict. It is now a
rule: when depth is not None, `exhausted` is empty. A pair with no path
within five hops is not refuted, it is unmeasured, which is the
bounded-boolean error leg 1.5 named. Test 4 asserts it.

§5 also spells out why the step from `exhausted` to "false positive" is
the caller's and not the SDK's. The graph approximates asymmetrically:
the DDG's points-to edges are may-edges, over-approximate and safe for an
absence claim, but the call structure is under-approximate wherever
dispatch is unresolved, so a missing call edge means a real flow can
exist with no path in the graph. Absence of path bounds the program only
where the frontier was fully resolved, which is what `unresolved`
enumerates.

The verdict table gains a column separating what is certified from what a
caller may do with it, and §11's caveat now names the ways the ledger can
be silent because the analyzer never saw the route to report it:
framework-mediated routing, reflection, FFI, unanalysed files, and
cross-request DB persistence.
…not unscoped

I filed #381 claiming Python's _SLICE/_PATHS/_VALUE_REACHES omit an
application-scope predicate that Java's and TypeScript's carry, and that
the omission was undocumented, untested, and the #349 shape. Every part
of that is wrong, and #381 is closed as not a defect.

tests/analysis/python/test_neo4j_multi_application_scope.py audits exactly
this. It sanctions four scope kinds — introspection, application, prefix,
and *id* — and its parametrised audit requires every class-level and
harvested inline statement to use one. Keying by id qualifies, on stated
grounds: a body-node id embeds the application and the emitter only ever
links nodes from its own run, so such a statement is scoped by
construction. The three templates take that route deliberately, for a
measured reason — the predicate would mean testing 195,784 reached nodes
against a list. A sibling test asserts the audit can *see* those three
statements, so they cannot drift out of it.

Java writes the predicate anyway under a stricter rule ("the audit judges
the predicate that is written, not the graph that happens to be
attached"), for ~4%. Two documented standards, each measured on its own
corpus. Reasoning by analogy between them is precisely what leg 1.6's
:PyCanNode result warned against.

§2 now records this, and says why leg 4 still follows Java's stricter
convention: taint()'s traversal is new code whose cost is unmeasured, and
its sanitizer predicate filters the path interior regardless, so the
scope predicate rides along in an all() that is already there.

Also recorded, because it shortens the lift: _VIA_CASE and _PATH_ORDER
are byte-identical across all three backends, so only _PATHS genuinely
differs — by node label and projection. And _PATH_ORDER already spells
coalesce(relationships(p)[i].var, ''), so §4's null-safe predicate is
established practice in these files rather than a new idiom.

The methodological note, since it is the reusable part: I grepped the
backend for $prefix and concluded the predicate was absent and
unexplained. The explanation lived in the test suite, where that search
could not reach — the same shape as proving read-only-ness by grepping
for CREATE/MERGE when the write goes through a library call (#324).
Two plans, because the spec's §8 steps are separable and each produces
testable software on its own. 4a is a no-behaviour-change refactor across
three shipped backends; 4b is the new verb that sits on it.

4a (20 steps). _VIA_CASE and _PATH_ORDER turn out to be byte-identical in
all three backends, so they lift as via_case(P)/path_order(P) with nothing
to parameterise. Only _PATHS genuinely differs, in five places rather than
the three the spec assumed: node label, endpoint scope, interior scope,
node projection, and — found while writing the plan — the relationship
variable, which Java spells `e` and the other two spell `r`. That last one
is semantically inert, so it becomes a parameter instead of a judgement
call: byte-identity is the whole safety property of this step, and
normalising a variable name would forfeit it for nothing.

Every task in 4a completes against string equality with the constant it
replaces, and Task 4 adds live confirmation because string equality cannot
catch a mis-wired import. It also asserts the witness order is unchanged:
max_paths truncates a total order, so a reordered ORDER BY would silently
change which witnesses callers see.

4b (47 steps). The load-bearing task is 2, and it is the one an
implementer would get wrong. shortest_walks is BFS-then-DFS: the BFS
records the distance each node was first reached at, and the DFS replay is
pinned to dist[dst]. Filtering only the replay — the obvious place —
leaves dist describing the unfiltered graph, so a sanitized 2-hop route
pins the target length and a clean 3-hop route is never visited. The pair
flows and taint reports nothing. That is the same false refutation T7
rejects, reappearing on the local backends while Neo4j stays correct
because the planner inlines its predicate into the search. The predicate
therefore enters both passes through one steps() helper, and the
adversarial graph is a test rather than a comment.

Also recorded in 4b: taint needs five implementations, not three. Python
and TypeScript each have a local and a Neo4j backend implementing
paths_between independently; Java has one, on the ABC, because its Neo4j
backend rebuilds the canonical JApplication and answers from it. The plan
says how to confirm that rather than asserting it.

Self-review found two of the spec's eight tests with no task — per-pair
starvation and the self-loop regression — and both are now steps with the
measurement needed to pick a fixture: odoo's heaviest callable holds 1.39M
of 5.13M DDG edges for the prolific pair, and the self-loop counts are 978
in Java and 64,702 in odoo.
… backends

Leg 4a, Task 1 of docs/design/plans/2026-09-09-leg-4a-shared-path-skeleton.md.

Additive only: the three backends still carry their own _VIA_CASE and
_PATH_ORDER, and Task 3 replaces them. Nothing changes behaviour yet.

The test corrects a claim the plan and spec both got wrong. I had written
that the two constants are byte-identical across the three backends. They
are not: three distinct values each, because every CASE arm names its own
language's relationship types (J_DDG at 244 characters against PY_DDG at
250). What was triplicated is the *expression*; the result was always
per-language, which is exactly why the lifted forms take P and are
functions rather than constants.

So the test asserts the honest property in two parts. Each backend's
constant is reproduced byte-identically by via_case(P) / path_order(P) --
that is the safety property, since a changed CASE arm changes which word a
hop is reported under and a changed ORDER BY term changes which paths
max_paths keeps. And the three results differ in the relationship prefix
and in nothing else, checked by substituting the prefix. A future
divergence beyond the prefix fails there rather than being absorbed into a
parameter silently.

path_order's docstring now also records why its coalesce is load-bearing
rather than defensive: of the five SDG relationship types only {P}_DDG
carries var, so the bare property is null on every control, argument,
return and summary hop, and a null term would make the whole sort key null
and the ordering arbitrary. That is the same fact leg 4b's sanitizer
predicate turns on, and it was already being handled correctly here.

Both names are registered in this file's LIFTED table, so the existing
"lives in commons, and python re-exports the same object" audit covers
them alongside their siblings.
Two corrections, both found by doing the work rather than reading it.

**The analyzers moved.** codeanalyzer-python 1.5.1 (#196), codeanalyzer-java
3.1.2 (#250) and codeanalyzer-typescript 1.5.3 (#197) all fix #195 in
lockstep: PARAM_IN and PARAM_OUT now carry the bound formal's var. §2's
fourth fact was written against the rc.4 pins and is now
generation-dependent, so it says so.

The null-safe predicate stays mandatory for three reasons that survive the
fix: CDG and SUMMARY still carry no properties at all; the fix writes
prune({"var": e.var}) so the key is absent whenever the formal has no
variable, and present-usually is not present-always; and both Neo4j
backends attach to graphs emitted at floors below these releases, where the
param legs carry nothing.

What the fix changes is precision, in the direction #195 argued for: a
variable-granular cut can now follow a value across a call boundary,
because the var sits on the param edge rather than only on the DDG hops
either side. On an older graph the degradation points the safe way —
coalesce yields '', the hop is kept, and a sanitized path is reported as a
flow, so the caller investigates something already sanitized. That is the
opposite of the null-logic trap, which cuts the path and reports a
refutation. It is measurable from data, so §6 reports it rather than
leaving it silent.

§8 no longer claims no pin moves. Three patch releases now exist that
improve taint()'s headline mechanism without enabling it, so moving the
pins is a judgement call the spec states and deliberately does not make —
no code differs either way, because the coalesce stays.

**The byte-identical claim was wrong.** Plan 4a's Task 1 asserted the three
backends' _VIA_CASE and _PATH_ORDER were identical strings. Writing the
test found three distinct values each: every CASE arm names its own
language's relationship types, so J_DDG gives 244 characters against
PY_DDG's 250. What was triplicated is the expression; the result was always
per-language, which is why the lifted forms take P. The plan now asserts
the honest property — each constant reproduced byte-identically from P, and
the three differing in the prefix and nothing else.

Task 1's expectations are corrected too: 22 passed, not 7, and the run
needs --no-cov because one test file exercises ~42% of the package against
a 50% floor and reports a coverage failure over a green suite.
…te-identically

Leg 4a, Task 2. Still additive: the three backends keep their own _PATHS
and Task 3 replaces them, so nothing changes behaviour yet.

Five things differ between the three statements, not the three the spec
first assumed, and all five are parameters:

  node label       PyBodyNode / JBodyNode / CanNode:TSBodyNode
  endpoint scope   none / n.id STARTS WITH $prefix / none
  interior scope   none / n.id STARTS WITH $prefix / (n.id STARTS WITH $p)
  projection       Python adds n.var, TypeScript also n.of, Java neither
  relationship     r, except Java's e

The last one was found while writing this and is the reason byte-identity
is achievable at all. It is semantically inert, so it is a parameter rather
than a normalisation: byte-identity is this step's entire safety property,
and these are shipped statements on a release branch mid-rc. Normalising a
variable name would forfeit the property for nothing.

The two empty scopes are also not oversights, and the docstring says so
where someone tempted to "fix" them will read it. Python's statements are
keyed by a body-node id, which embeds the application, and
tests/analysis/python/test_neo4j_multi_application_scope.py sanctions
id-keying as one of four scope kinds for a measured reason: the predicate
would mean testing 195,784 reached nodes against a list. Java writes it
anyway under the stricter rule that the audit judges the predicate written
rather than the graph attached, for a measured ~4%.

A test pins which backend scopes what, separately from the equality
assertions, so an edit that moved Python onto the prefix predicate fails
with a reason instead of quietly changing a measured decision.

One test of my own was wrong on the first run and is corrected here rather
than papered over: it asserted the formatted statement contains no braces.
It must contain single ones -- Cypher map literals need them, so
{{id:$src}} resolving to {id:$src} is the point. The property worth
asserting is that no *doubled* brace survives, which would mean an
unresolved escape and a statement the server would reject.
…d scopes what

Two fixes from implementing it.

The formatted-template test asserted the statement contains no braces. It
must contain single ones: Cypher map literals need them, so {{id:$src}}
resolving to {id:$src} is exactly what should happen. The property worth
asserting is that no doubled brace survives, which would mean an unresolved
escape and a statement the server would reject.

And byte-equality alone does not pin which backend scopes what -- an edit
that changed the generator and the arguments together would still pass. So
Task 2 now adds a test asserting Python's statement carries no STARTS WITH,
Java's carries three, and TypeScript's one. Python's omission is a measured
decision sanctioned by the scope-kind audit, not a gap, and a test that
says so fails with a reason instead of quietly absorbing a change to it.

Expected count corrected to 29 passed, with --no-cov.
…backends

Replace each backend's own _VIA_CASE / _PATH_ORDER / _PATHS with calls to
via_case, path_order and sdg_path_query in cldk.analysis.commons.graphs
(lifted in the two prior commits). Python and TypeScript each had a second
_PATH_ORDER consumer in _CALL_PATHS, now pointed at path_order("PY") /
path_order("TS") inline since the attribute is gone. Java has no such
statement. The generator's arguments are the ones verified byte-identical
against each backend's old constant in test_lifted_helpers.py.

No behaviour change: every generated statement is identical to what it
replaces.
…_ORDER

The two Task-1 regression tests asserted via_case(P) == backend._VIA_CASE and
path_order(P) == backend._PATH_ORDER, which held while the backends still
carried their own copies alongside the lifted functions. Task 3 deletes both
attributes, so there is nothing left to compare against; assert the fragment
is embedded in the backend's surviving _PATHS statement instead, which is
what these tests actually care about.
…tripwire

test_via_case_reproduces_each_backends_constant and
test_path_order_reproduces_each_backends_constant compared via_case(P)/path_order(P)
against backend attributes that Task 3 deleted; my rewrite in the previous commit made
them tautological (sdg_path_query always embeds path_order, which always embeds
via_case, so membership held for any correctly-P-parametrized call regardless of the
backend's own arguments) and is deleted rather than kept in any weakened form.

test_sdg_path_query_reproduces_each_backends_paths no longer catches a regression in
the generator itself either: _PATHS is now that same call, so both sides of the
equality move together when sdg_path_query, path_order or via_case changes, and only a
PATHS_ARGS/backend-argument mismatch can still fail it. PATHS_DIGESTS pins a SHA-256
prefix of each backend's current _PATHS so a change to the statement itself -- through
any of those three layers -- still fails somewhere.
endpoint_scope and interior_scope were plain strings, spliced in with a
textual .replace('n.', 'a.')/('n.', 'b.') -- a fragment containing fn.id
would become fa.id. Both are now Callable[[str], str] | None, called with
the variable name (endpoint_scope('a'), endpoint_scope('b'),
interior_scope('n')) instead. Matches the shape TypeScript's _scoped(var)
already had.

Also drops the orphan #: comment block above via_case (attached to
nothing -- it sat before a blank line and a def) and restores four
measurements the old Python _PATHS comment carried that the docstring
here lost: the repro query, the odoo-slim-19 corpus, the exact seed name,
and the reachable-case timing.

No generated statement changes; all three backends' _PATHS still hash to
the pinned digests.
Java now passes _scoped for both endpoint_scope and interior_scope.
TypeScript passes _scoped as interior_scope instead of _scoped("n") --
a called string, which the new callable-shaped parameter no longer
accepts.

Also trims the duplicated rationale above each backend's _PATHS: the
U+0001/elementId paragraph and the allShortestPaths/$cap paragraph both
restate path_order's own docstring almost verbatim, so both become a
one-line pointer to it. Java's allShortestPaths/$cap paragraph is a
straight duplicate and is removed outright (the string no longer
appears anywhere in the file). Kept, verbatim: Java's CFG_ORDER/
CDG_ORDER/DDG_ORDER tie-break paragraph (Java-specific, re-opened to
read after its now-removed predecessor) and its leg-2.5b scope-leak
witness paragraph; TypeScript's _KEEPS_SCOPE paragraph.

No generated statement changes.
PATHS_ARGS and test_sdg_path_query_reproduces_each_backends_paths
compared generator(args A) against generator(args B) -- two hand-kept
copies of the same argument list, on a branch whose point is removing
duplicates kept in sync by hand. The digest test already catches every
argument change, so both are deleted outright.

test_the_generated_statement_still_formats and
test_the_scope_predicates_are_written_where_the_backend_writes_them now
assert against backend._PATHS directly instead of reconstructing a
statement from PATHS_ARGS -- they judge the statement that ships, not a
second copy of its inputs. Same assertions, same messages.

Also gives the digest tripwire a useful failure message
(backend._PATHS instead of two bare hashes) and updates both section
header comments to stop describing PATHS_ARGS: the arguments live at
each backend's own sdg_path_query(...) call site now.
… as message prose, not a Diagnostic guarantee
…st_walks

Adds the mirror of the sanitized-shortest-route test: a sanitized parallel
edge must not ride along in a walk the unfiltered pass would still emit
(false confirmation), and a source inside a cut callable must return no
walk. Both were previously unguarded -- verified by watching each fail on
its respective mutant before restoring.
Multi-source, multi-sink shortest-path statement behind taint(). Mirrors
sdg_path_query's callable-based endpoint_scope/interior_scope injection
points rather than the brief's string-replace sketch, since a per-pair
statement binds more variables than a/b and a textual .replace('n.', 'a.')
would corrupt any fragment containing fn.. The sanitizer cut (cut_callables
as an id-prefix test, cut_vars against coalesce(r.var, '')) lives inside the
allShortestPaths pattern so it inlines into the ShortestPath operator instead
of filtering rows after an unsanitized shortest path has already won. Cap is
per pair via an ordered WITH + collect(p)[0..$cap], not a flat LIMIT.
…llable

resolve_sanitizers() turns caller-written sanitizer selectors (a bare
callable name, or a (variable, within) pair) into what the amended
sdg_taint_query predicate needs: a list of callable-id prefixes to cut
entirely, and a list of {var, prefix} cuts scoped to the callable named
by `within`.

Departs from the original brief in four ways, all load-bearing:

- A variable selector is validated by checking it against the variable
  names actually present on SDG edges within the named callable, via an
  injected edge_vars_in callable, not resolve_value. resolve_value only
  covers formal_in parameters, which would reject a real, resolvable
  sanitizer target that is a local (a DDG edge var that is not a
  parameter).
- The cut carries the resolved callable's ref as `prefix`, so the
  predicate can scope the cut with startNode(r).id STARTS WITH c.prefix
  instead of removing a variable name everywhere in the application.
- An empty or whitespace-only variable selector raises: the predicate's
  coalesce(r.var, '') would otherwise let '' cut every hop with no var
  in that callable.
- Selector shape and resolution now fully agree: a bare name that fails
  callable resolution raises there, never falls back to variable
  resolution, and a (name, within) pair whose name happens to also be a
  callable is still resolved as a pair. One resolver's own failure is
  the error; no shape is retried as the other.

sdg_taint_query's cut predicate is amended to match: $cut_vars (flat,
unscoped) becomes $cuts, a list of {var, prefix} maps, and the relation
filter becomes
  all(r IN relationships(p) WHERE NOT any(c IN $cuts
      WHERE coalesce(r.var, '') = c.var AND startNode(r).id STARTS WITH c.prefix))
startNode(r) rather than either endpoint is deliberate: under-cutting
is the safe failure direction, since over-cutting closes a live alert.

Verified against the live leg4a graph that the amended predicate still
inlines into ShortestPath (no separate Filter, no
ExhaustiveShortestPath), and that a real (non-EXPLAIN) run with a
concrete cuts entry lengthens the shortest src/dst path from 2 to 3 as
expected, while an empty cuts list reproduces the uncut length-2 path.
…e contract

The policy layer, concrete on the contract exactly as describe() already is: bounds before any
name, names before any sanitizer, sanitizers before the walk — so a typo in depth costs no round
trip and is not reported second. The traversal and the edge-var lookup are private hooks, so the
ordered contract is written once per language instead of once per backend.

Two divergences from paths_between, both deliberate. A same-position pair is skipped with a
diagnostic rather than raised: raising would discard a forty-pair batch over one degenerate pair
that a caller assembling sources programmatically produces by accident. And an explicit depth
yields no exhausted pair at all — a pair with no path within five hops is unmeasured, not refuted,
and exhausted is the field a caller closes an alert on.

The hooks ship as stubs rather than abstract methods. ABCMeta refuses to instantiate a class with
an unimplemented abstract method, so declaring them now would make every backend in every language
un-instantiable until the last implementation lands; they are called, not constructed, so a
missing one is refused at the call.

The level gate is not in this body: _require_dataflow is a local backend's method, and asking the
graph backends a question their attach probe never measures would buy one better message for two
implementations that cannot honestly answer. It opens each local _taint_walk instead.
…peScript's vocabulary

The Python leg's body, unchanged where the policy is language-neutral — which is all of it except
what the docstring must say here: a transforming sanitizer is encodeURIComponent or
DOMPurify.sanitize, and every hop cants establishes carries reaching-defs, so weakest caps every
TypeScript witness at one tier. That is a fact to state, not a gap to close, and a caller comparing
two flows here compares hops rather than provenance.

Same two hooks, same stubs, same reason: the level gate opens each local _taint_walk because the
graph backend's attach probe never measures the dependence relationships, and an abstract method
would make every backend un-instantiable until the last implementation lands.
…names

The same ordered body as the other two ABCs plus Java's one extra clause:
_require_connected_ports("taint") sits after resolution, where the four existing forward value
accessors put it. A caller with a typo in a parameter name hears about their typo; only a caller
whose names all resolve hears that this analysis's port lattice carries no dependence edge — which
is the order every sibling accessor already answers in, and the reason taint could not simply gate
first and skip the resolution work.

The gate is still asked of the data, never of an analyzer version, so output that joins the port
layer to the statement dependence graph makes taint answer with no change here.

Same two hooks, same stubs, same reason as the Python and TypeScript legs. The level gate stays out
of this body: it opens each local _taint_walk, because the graph backend's attach probe never
measures the dependence relationships.

_require_connected_ports' own docstring drops the count — it now guards five accessors, and the
number was only ever prose.
…tified exhausted

The pair loop read blocked.get((a.ref, b.ref)) and that was the only read, so a diagnostic filed
under any other key — a reversed pair, one arm of a callable frontier, a combination the caller did
not request — was discarded, and the pair it named came back in exhausted with a clean ledger.
Measured before the fix: a walk keying its entry (dst, src) yielded unresolved=[], exhausted=[('x',
'y')], complete=True. That is a certified refutation of a flow that was in fact blocked, which is
the one output this accessor exists to refuse, and a bound reported as nothing.

Fixed in taint() rather than in five walks, because the walks are where the same mistake gets made
five times: the loop now claims each key as it reads it, and every unclaimed key is swept into the
ledger afterwards. Nothing there can attribute a stray key to a requested pair, so exhausted is
emptied rather than trusted — refusing to certify is the direction that cannot close a live alert,
and unresolved says why.

The fan-out rule the signature cannot express is stated in _taint_walk's docstring instead: an
unresolved dispatch is a property of a callable frontier, the mapping has no key meaning "every
pair", so a walk files the same diagnostic under each key it affects. Under-filing no longer loses
the entry, but it does cost every pair its certification, which is the cost worth naming.
…cannot double the cap

max_paths is documented as most witnesses per pair, and roots has always deduplicated by ref, but
the pair loop ran the raw m*n cross product: sources=[("x","f"),("x","f")] with one witness returned
2 paths, and with max_paths=2 over three rows it returned 4 for one distinct pair — its own contract
contradicted by its own loop. The pairs are now keyed by (a.ref, b.ref) in first-seen order, which is
the same rule roots follows one line later.

The same-position skip exists because a caller assembling sources programmatically hits that by
accident; a duplicated entry is that accident, and consistency is the whole reason the skip is not a
raise. Keeping the first spelling means exhausted still names what the caller wrote.
…diagnosed as a port lattice

Ruling F kept the level gate out of taint()'s body on the premise that _require_dataflow lives only
on the local backends. That premise holds for Python and TypeScript and not for Java: this ABC
declares one at :2196, a concrete no-op the in-memory backend overrides, because --emit neo4j is
always full depth.

Measured on the committed a1 fixture at symbol_table: both names resolve — Java's resolve_value reads
the declared parameter list, not the formal_in vertices — so taint() reached
_require_connected_ports and answered PORTS_DISCONNECTED, "this analysis carries no data or control
dependence edge out of a callable's formal_in vertices". That helper's own docstring makes it a claim
about what the analyzer emitted, so the caller is sent to codeanalyzer-java#227 when the remedy is
analysis_level='system_dependency_graph'. A wrong diagnosis is worse than a coarse one.

The gate goes immediately before the port-lattice gate, so the ordered contract is untouched: a
caller still hears about their own typo before either gap in the analysis, and the two Java gates sit
together. Python and TypeScript are left alone, where the premise does hold.
…ree times

Ruling E keeps ``taint()`` concrete on the three language ABCs -- the docstring
is the surface a caller reads and it belongs where the accessor is -- but the
arithmetic after the walk was byte-identical in all three: the pair fan-out, the
per-pair trim, the three ``exhausted`` conditions, the ledger sweep, ``roots``.
Items 1, 2 and 6 of this fix round are each one edit times three files, and the
first one applied to two files instead of three is precisely the drift Ruling E
was written to prevent.

``taint_verdict`` joins ``sdg_taint_query`` and ``slice_resolved`` in
``commons/graphs.py``, which already owns the shared shape of this surface. The
three bodies keep everything a reader needs in place -- docstring, argument
checks, resolution, the sanitizer resolve, Java's two gates -- and now differ
only in those: python and typescript are identical, java adds the two gate
lines.
…exts are true again

Task 5 shipped the same-position skip as ``code="no_match"`` because the closed
vocabulary had nothing better, and that borrowed one code and falsified two
published texts. ``docs/agent-api-reference.md`` says of ``no_match`` and its
four siblings that **nothing emits them** -- resolution failures are raised, not
attached -- and ``TaintResult.unresolved`` promises every entry carries
``code="unresolved_dispatch"``. A caller filtering the ledger on that code
dropped the skip entirely, and one reading ``no_match`` as a failed search read
a search that never ran.

The member is additive: ``Literal`` gains ``degenerate_pair`` at the end, next to
the other two late additions, so no existing value changes meaning and nothing a
caller already destructures moves. ``no_match`` is unemitted again, which is what
the doc row claims; ``unresolved``'s text now names both codes it can carry and
says what each one means for the pair.
… ways

The code -- ``not truncated and not ledger`` -- is the conservative choice and
stays: reporting ``True`` beside a non-empty ledger would tell a caller who
checks only the flag that a batch was fully searched when part of it was not.
What was wrong is every text describing it. ``FlowPaths.complete`` says
truncation is the only way to be ``False``, ``TaintResult`` claimed "nothing is
redefined", and ``taint()``'s own Returns said "nothing truncated and no pair
skipped", which omits the blocked pair.

Their disagreement has a caller-visible cost: on a clean batch whose only
irregularity is one degenerate pair, ``complete`` is ``False``, and the inherited
text sends that caller to re-run with a bigger ``max_paths`` for the same answer.
So each text now says the same thing, and the two properties a caller has to
predict -- one ledger entry anywhere flips the batch, a bigger cap does not
unflip it -- are pinned by a test rather than left to the prose.
rahlk and others added 23 commits September 10, 2026 13:54
…tion

The gate in `taint()` is unreachable through that method today: the two `resolve_value`
calls below it address `formal_in` ports that only level 4 emits, so a shallow caller is
refused by name with `SelectorNotInGraph` before the gate can speak. Measured by a new
level-parametrized test rather than assumed, and documented as a backstop for the day
`resolve_value` grows a fallback that answers below level 4 -- Java already has one.

Also: `PythonAnalysis.taint` gets its own pin, because it is the origin the other two
facades mirror and a drift there moves all three at once; the live and dataflow modules
say why they call the backend rather than the facade; and two stale cross-references
now name the modules that exist.
The 3.1.2 param-edge assertion constructed a JParamEdge and read its own attributes
back, so it proved that Pydantic keeps a field it was handed and never reached
codeanalyzer.py:519, the line that writes the var into the adjacency label.

Replace it with a payload built from the a4 fixture by addition -- a var on each of
its 355 param_in/param_out edges, named after the formal position its own endpoint
already spells -- the mirror of how _without_port_crossings is built by subtraction,
and for the same reason: the shape under test comes from the real payload, so a
regeneration that changes what is added fails the count.

That drives the real _sdg() traversal and pins both consumers: the exact label
Counter per formal name, _edge_vars_in's delta over sell's crossings, and an
end-to-end scoped cut that takes the taint answer from 9 paths to 3 while the same
name under another scope cuts nothing.
…declares

Every bare-str sanitizer example named a library function -- html.escape,
StringEscapeUtils.escapeHtml4, DOMPurify.sanitize, encodeURIComponent. The bare
shape is resolved with resolve_callable, whose candidate domain is the callables
the analyzer emitted for the application under analysis, so a reader following the
example verbatim learns the shape from a name that cannot select anything in it.

The examples now name a callable the application itself declares, and the prose
says where the library function belongs: it is what the application's own wrapper
calls, not the selector.
The helper claimed its fabricated names were the shape 3.1.2 emits, and the test
said whether the pinned analyzer writes var at all was unverifiable here. Both were
wrong: the pinned wheel carries the jar and jdk4py the JVM, so the pin is runnable
in this repo, and running it at -a 4 over the same daytrader8 sources names every
one of 1,932 param_in and 909 param_out edges.

What it names them is not what the helper wrote. A param_in var is the actual
argument's name (tSIA, volume), which a 3.1.0 payload cannot yield because it records
the argument's position only -- so those names stay fabricated from the position and
are now labelled as such. A param_out var is $ret, so the fixture writes that instead
of ret, and the label assertion follows.
…es / get_view_dispatchers / get_unresolved_view_dispatches, Neo4j J_DISPATCHES_TO; pin codeanalyzer-java 3.3.3 (#404)

Three accessors on JavaAnalysis over both backends, one implementation
on the contract; the Neo4j backend reads J_DISPATCHES_TO back and
refuses the JSON-only unresolved record. The layer is the one overlay
gated on the analyzer generation, because 3.3.x added no always-present
key to probe. Pin 3.2.0 -> 3.3.3; release/2.0 absorbs main at rc.6.
…dispatches

feat(java): view dispatches on the Java facade; pin codeanalyzer-java 3.3.3; release 2.0.0-rc.7
Two analyzer fixes, neither moving the schema: the analyzer no longer
ingests its own output when the cache or output directory sits inside
the input (codeanalyzer-python#207), and a call site keeps one body node
even when two calls share a start position (codeanalyzer-python#215).
Both pins move together; body_key_column already parses the suffixed
key. Python tier: 482 passed, 168 skipped, same as on 1.5.2.
… callable (#408)

PyCallable.start_line is the def line (ast.FunctionDef.lineno) and the
AST puts decorators above it, so a position on @http.route(...) sat in
no callable span and fell through to module_scope. Both backends now
read where the decorator is applied: _find_innermost admits a callable
whose decorators[].span starts at or before the line and above its def;
_LOCATE_QUERY gains one EXISTS disjunct over PY_DECORATED_BY.start_line,
which codeanalyzer-python 1.5.2 emits. An older graph or analysis carries
no span there, the comparison is null, and the position stays module
scope. Ranking is unchanged (def-based width), so a decorator on a
nested callable resolves to the nested one. A class decorator is still
module scope, pinned. Fixture gains cached/outer.helper/old/Config;
fourteen new tests, parity included.
odoo-slim-19 re-emitted by codeanalyzer-python 1.5.3, 40 positions,
median of 5, same graph both ways: 71.0 ms placing 20/40 before the
PY_DECORATED_BY disjunct, 70.2 ms placing 40/40 with it; the plan still
seeks pysymbol_id.
…zer-python-1.5.3

chore(deps): bump codeanalyzer-python 1.5.2 -> 1.5.3
…rator-line

fix(python): locate places a decorator-line position on the decorated callable
…itations page (#416)

The README keeps what a user needs: install, quick start, the query
surface by family, backend selection, and where to read next. The
contributor module map moves to docs/architecture.md (linked from
CONTRIBUTING) and the Cited By list to docs/citations.md, both verbatim.
The agent guide gains two operational notes: project_path and the
analysis cache, and the JDK a Java call graph needs from level 2 up.
docs: split the README into a user page, an architecture page and a citations page
@rahlk rahlk added enhancement New feature or request breaking Breaking Changes kind/feature New feature(s) fix Bug fixes release ✨ New Release labels Oct 15, 2026
@rahlk
rahlk merged commit c125d48 into main Oct 15, 2026
1 check passed
@rahlk
rahlk deleted the release/2.0 branch October 15, 2026 21:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Breaking Changes enhancement New feature or request fix Bug fixes kind/feature New feature(s) release ✨ New Release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant