Repository navigation
Release/2.0 - #432
Merged
Merged
Release/2.0#432
Conversation
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.
…ot the conclusion
… as message prose, not a Diagnostic guarantee
…hort route cannot hide a clean long one
…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.
…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
chore(release): 2.0.0-rc.8
…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
….0.0 chore(release): 2.0.0
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings
mainup to date with the 2.0 line.mainis at v2.0.0-rc.6.release/2.0has 76 more commits, andmainhas none thatrelease/2.0lacks, so this merges with no conflicts.What
maingains:codeanalyzer-javapin 3.3.3locateon decorator lines (locate misses a position on a decorator line and reports it as module scope #408);codeanalyzer-pythonpin 1.5.3 (bump codeanalyzer-python pin to 1.5.3 #410)docs/architecture.mdanddocs/citations.md(docs: split the README into a user page, an architecture page and a citations page; two agent-guide notes #416)The full record is in
CHANGELOG.md.