Skip to content

fix(neo4j)!: scope every destructive statement on the can:// id prefix; retire _module - #166

Merged
rahlk merged 1 commit into
mainfrom
fix/issue-140-prefix-scoped-prune
Sep 6, 2026
Merged

fix(neo4j)!: scope every destructive statement on the can:// id prefix; retire _module#166
rahlk merged 1 commit into
mainfrom
fix/issue-140-prefix-scoped-prune

Conversation

@rahlk

@rahlk rahlk commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Closes #140. Implements the org spec 2026-09-02-prune-scope-on-can-id-prefix.md (epic codellm-devkit/.github#50) for TypeScript, matching python's #183.

The bug

The per-module purge matched (x:CanNode {_module: $m}). That predicate is true across languages and false across applications: two TypeScript projects in one database sharing a file key deleted each other's nodes for it. Found while implementing: the --eager purge, scoped on the bare application id can://typescript/<app>, never reached a can://javascript/ node at all — the JS namespace from #115 was invisible to it.

What changes

  • Every destructive statement scopes on the can:// id: the node by equality, descendants by id + '/'. The separator is the point — a bare prefix also matches …/foo.tsx under …/foo.ts and appXtra under app. Applied to the --eager purge, the per-module purge, the orphan prune (bolt) and the snapshot wipe (cypher), each run once per namespace this analyzer emits.
  • _module retired from the graph, the catalog (schema.neo4j.json) and its index. RowBuilder lifts it off the row into NodeRow.module, which only groups rows; the incremental diff is now keyed by module id inside the app prefixes, not by a file key that collides across apps.
  • TSCanNode / JSCanNode markers on every node keyed by a can://<lang>/ id, with a range index on id each. Index anchors only — STARTS WITH seeks only on a range index, and property indexes are label-scoped. CanNode stays until neo4j: merge labels Application and CanNode are unprefixed — reconcile with the cross-repo prefix rule #95 retires it.
  • Empty application refused on any push (the diff itself is app-scoped). The snapshot renderer emits no wipe — visibly, with a comment — when its rows carry no can:// application id, rather than STARTS WITH ''.

Verification

  • Container test (Neo4j: scope destructive statements on the can:// id prefix; retire _module #140's acceptance): the same fixture pushed as sa and saX — every file key collides, and can://typescript/sa is a string prefix of can://typescript/saX — survives each other's --eager purge and prune, and no node carries _module. The existing vanished-module test was rewritten to find nodes by id prefix.
  • Mutation-checked: dropping the / from the descendant prefix makes that test fail.
  • No-container unit test pins the helpers, the marker injection, _module never reaching props, and the shape of every destructive statement including the snapshot wipe.
  • Snapshot diff against main on sample-app: 415 lines each way, accounted for entirely by _module removals (398), marker-label additions (14) and the wipe header. No other property, label or edge changed.
  • bun test green; container suite 5 pass; schema.neo4j.json regenerated; decisions recorded in .claude/SCHEMA_DECISIONS.md

Deviations from #140 as written

…x; retire _module

The per-module purge matched `(x:CanNode {_module: $m})`. That predicate is true
across languages and false across applications: two TypeScript projects in one
database that share a file key deleted each other's nodes for it. And the --eager
purge, scoped on the bare application id, never reached a `can://javascript/`
node at all -- the JS namespace (#115) was invisible to it.

Every destructive statement now scopes on the can:// id: the node by equality,
its descendants by `id + '/'`. The separator is the point -- a bare prefix also
matches `.../foo.tsx` under `.../foo.ts` and `appXtra` under `app`. The --eager
purge, the per-module purge and the orphan prune (bolt) and the snapshot wipe
(cypher) all use it, per namespace: this analyzer emits two, so each statement
runs once under `can://typescript/<app>/` and once under `can://javascript/<app>/`.

`_module` is gone from the graph, the catalog and the index. The RowBuilder lifts
it off the row into NodeRow.module, which only groups rows for the incremental
diff; that diff is keyed by module id inside the application's prefixes instead of
by a file key that collides across applications.

`TSCanNode` / `JSCanNode` ride every node keyed by a `can://<lang>/` id, as index
anchors: property indexes are label-scoped, and `STARTS WITH` seeks only on a range
index. They carry no safety claim; the prefix does. `CanNode` stays until #95.

An empty application is refused on any push -- the diff itself is app-scoped, and
`STARTS WITH ''` is the whole store. The snapshot renderer emits no wipe at all
when its rows carry no can:// application id.

Container test: the same fixture pushed as `sa` and `saX` -- every file key
collides, and `can://typescript/sa` is a string prefix of `can://typescript/saX`
-- survives each other's --eager purge and prune. Mutation-checked: dropping the
`/` from the descendant prefix makes that test fail.

SCHEMA_VERSION stays 2.0.0 (#144, python #186) despite the removed property; that
supersedes #140's "MAJOR bump" goal. The sample-app snapshot diff against main is
exactly: `_module` gone, the marker added, the wipe rewritten.
@rahlk
rahlk force-pushed the fix/issue-140-prefix-scoped-prune branch from 0f60c15 to 030f291 Compare September 6, 2026 13:00
@rahlk
rahlk merged commit af6983b into main Sep 6, 2026
@rahlk
rahlk deleted the fix/issue-140-prefix-scoped-prune branch September 6, 2026 13:00
rahlk added a commit that referenced this pull request Sep 6, 2026
ANALYZER_VERSION moves with package.json because it is the cache-invalidation key:
this release changes the cached tree shape (body nodes and parameters carry ids,
modules carry INTERNAL call_sites), so a warm 1.2.0 cache must not be reused.

README --help block regenerated for --program, --list-programs, --emit-ir and
--no-repo-sections (#149), which shipped without it.

Released as a MINOR despite one breaking-marked commit (#166: `_module` retired
from the Neo4j graph, marker labels added), by the same decision as 1.2.0; the
release notes lead with it and its migration. Graph contract stays 2.0.0 (#144).
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.

Neo4j: scope destructive statements on the can:// id prefix; retire _module

1 participant