Skip to content

feat(typescript): take up codeanalyzer-typescript 1.5.0 — bindings, UTF-8 spans, per-facet ids - #370

Merged
rahlk merged 2 commits into
release/2.0from
feat/issue-368-cants-140
Sep 8, 2026
Merged

rahlk merged 2 commits into
release/2.0from
feat/issue-368-cants-140

Conversation

@rahlk

@rahlk rahlk commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Closes #368. Takes the pin from 1.3.0 to 1.5.0 in two steps, and closes four upstream issues this SDK had filed.

The correctness fix

1.5.0 ships fix(schema)!: span.bytes are UTF-8 byte offsets (codeanalyzer-typescript#179). The SDK sliced those offsets as Python characters, so on any non-ASCII file the text drifted after the first multi-byte character.

Measured against the regenerated fixture: 28 spanned nodes live in non-ASCII modules, and 14 of them were sliced wrong — src/models.Robot.describe returned 'scribe(): string {…' instead of 'describe(): string {…'.

Fixed by mirroring Java's JCompilationUnit.slice() rather than inventing a second approach: _source_bytes is encoded once per module and left None when the source is ASCII, so an ASCII file pays nothing and takes the plain-index path. The new test asserts the drift count is non-zero, so it fails loudly rather than going vacuous if the corpus ever turns ASCII.

Behaviour gained, not just defects closed

codeanalyzer-typescript#177 (declaration merging mints one id per facet) is a capability, not a bug fix. On the re-emitted graph, zero nodes now carry two declaration labels and 7 signatures name two nodes each. All 14 facets are reachable: class X + interface X answers from both get_classes() and get_interfaces(); const X (arrow) + interface X resolves as a callable and appears in get_interfaces(). Previously the facet the last-written kind didn't name was simply lost.

Consequence worth stating: a signature is no longer unique per node, which is why the kind-alongside-label predicates stay — they are what keeps an accessor to its own facet.

codeanalyzer-typescript#179 — the four strict xfails came back XPASS(strict) → FAILED, which is what strict=True exists to produce. They are ordinary parity assertions now.

The binding layer (1.4.0)

get_imports, get_exports, get_method_parameters over Neo4j, and the config-read pair now answer. The refusal path is kept, measured from the data: one probe OR-ing three existence checks over the application's own id prefixes, with no version literal anywhere in the decision. Three carriers because each is null-when-empty individually — an exports-only probe would false-refuse a real graph whose app has no export statements. Both directions pinned offline.

parameters_json / exports_json decode through a helper that raises on malformed input rather than yielding [], so a decode failure cannot become the ambiguous empty this closes.

Two facts worth recording

  • schema_version stayed 2.0.0 across a !-marked breaking change. The contract version is not a signal that a breaking change landed — verified on the parsed payload, and called out in the CHANGELOG.
  • The models earned their keep. extra="forbid" caught 1.4.0's new resolved_module field as 12 loud validation errors instead of silently dropping data. 1.5.0 needed no further model change, and its schema.neo4j.json diff against 1.4.0 is empty.

Verification

Full gate 1577 passed, 370 skipped, 85.08%. TypeScript live against the re-emitted 1.5.0 graph with every gate satisfied: 592 passed, 1 xfailed, 0 failed, 0 skipped. The remaining xfail is CALLSITE_FACETS_ABSENT, a different and still-real gap.

Re-emitted 7692: 1,841 modules unchanged, callables 11,085 → 11,116, classes 207 → 212 — that +31/+5 is #177 splitting facets into their own nodes.

1.4.0 (cants#182) adds TS_IMPORTS / TS_RE_EXPORTS edges,
:TSModule.exports_json, :TSCallable.parameters_json and
TS_READS_CONFIG_UNRESOLVED. get_imports, get_exports,
get_method_parameters and get_unresolved_config_reads answer over Neo4j
on such a graph instead of refusing; parameters and exports also reach
TSCallable.parameters and TSModule.exports on a rebuilt node, so the
symbol table and the accessors cannot disagree.

The model change comes first and is not optional: every TS model is
extra="forbid", so 1.4.0's new TSImport/TSExport.resolved_module makes
TSAnalysis.model_validate_json raise outright on a 1.4.0 analysis.json
(12 errors on the sample app). The field is added to both models and the
four checked-in analysis.json fixtures are regenerated at the pin.

A graph emitted before 1.4.0 is still attachable — the floor stays 1.3.0
— and those four still refuse there, because [] would read as "imports
nothing" / "takes no parameters" / "every read resolved". The decision is
measured from the application's own data, one statement asking whether
any of the three carriers is present, never from analyzer_version: the
pattern leg 3 established for Java's port probe, so a re-emitted graph
starts answering with no change here. Three carriers OR-ed because each
is null when empty on its own node and an emitter writes all or none.
Cached, and paid only on first use of one of the four.

parameters_json and exports_json are decoded strictly: a malformed value
raises rather than collapsing to [], which would recreate the ambiguous
empty this closes. Imports are the one lossy half and say so — the edge
folds every binding between a module pair into sorted sets, so an alias,
an import_kind and a span are not recoverable, and the emitter drops a
relative specifier that resolved to no emitted module.

Both directions are pinned. Offline, one stub answers the probe and one
does not, over identical rows. Over the write gate the sample app's real
parameters and import bindings are compared to the sources and across
backends. Over the re-emitted superset-frontend reference graph every
expectation is derived from the graph at run time — the count the
aggregate implies, the encoded export lists, the edge count.

Still open upstream and untouched by 1.4.0: cants#177 (declaration-merge
id collision), #179 (:TSCallable.code one line short, still pinned by
four strict xfails), #180, #181.
1.5.0 closes the four ceilings the 1.4.0 uptake documented, so this is
mostly deletion — but one of them is breaking and needs SDK code.

TSSpan.bytes are UTF-8 byte offsets now, on every node at every level
(cants#179). They were UTF-16 code units, which Python sliced as code
points and the Neo4j projection sliced as bytes: three units that agreed
only on ASCII, which is why the mess hid. _Spanned.code now decodes the
module's UTF-8 bytes the way JCompilationUnit.slice() already did —
encoded once per module in the same validator that threads the source,
not at all when the file is ASCII. Measured on the regenerated fixture:
28 spanned nodes live in non-ASCII modules and 14 of them would have been
sliced wrong by the old character index. A graph-rebuilt node is
unaffected; it carries its own code with a character-counted span and no
_source_bytes.

Everything else is removal, each verified rather than assumed:

:TSCallable.code is no longer one line short over Neo4j. The four
xfail(strict=True) marks pinned to that were run against a 1.5.0 graph
first and all four XPASS(strict) — which is what strict was there to make
loud — so the marks are gone and the tests are ordinary parity
assertions again.

Declaration merging mints one id per facet (cants#177). On the re-emitted
superset graph no node carries two declaration labels any more, and 7
signatures name two nodes apiece (the second suffixed #interface /
#type). This is a behaviour gain, not just a defect closing: both facets
are now served — a class X + interface X pair answers from get_classes
AND get_interfaces, where before the facet the last-written kind did not
name was lost. The two live tests that asserted the collision now assert
the split, both still deriving every expectation from the graph. The
kind-alongside-label predicates stay: they are what keeps an accessor to
its own facet now that a signature is no longer unique.

cants#180 and #181 had no SDK workaround to remove.

schema_version is still 2.0.0 across a change the analyzer marks
breaking, so the contract version is not a signal that one landed. Said
plainly in the changelog rather than left for the next reader to find.
@rahlk
rahlk merged commit c38bb52 into release/2.0 Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant