diff --git a/.claude/SCHEMA_DECISIONS.md b/.claude/SCHEMA_DECISIONS.md index 0b4cf60..1cbf901 100644 --- a/.claude/SCHEMA_DECISIONS.md +++ b/.claude/SCHEMA_DECISIONS.md @@ -163,3 +163,13 @@ an unresolved read is `reason: "non-literal"` (key never closes on one literal) | I2 | **`TSCallableParameter.id`** = `@formal_in:`, present at EVERY level | a forward reference below L4 to the vertex that carries the parameter; at L4 `body["@formal_in:i"].id === parameters[i].id` and `.of === parameters[i].name` | python emits it at every level for the same reason; consumers key parameter flow on it before L4 exists | | I3 | **One definition**: `globalOrdinal` in `src/schema/ids.ts`; `project.ts` and `attach.ts` delegate | the rule used to live in two private `fq` copies | two copies of a join key drift; the agreement test pins JSON `id` == graph merge key | | I4 | **`SCHEMA_VERSION` unmoved** (2.0.0) | additive fields; no label/relationship/property change in the graph | #144: one version until every analyzer re-baselines together | + +## Prefix-scoped destructive statements (2026-09-06, #140 — org spec `2026-09-02-prune-scope-on-can-id-prefix.md`) + +| # | Concept | Decision | Rationale | +|---|---|---|---| +| P1 | **Every destructive statement scopes on the `can://` id prefix**: the node by equality, descendants by `id + '/'` | `--eager` purge, per-module purge, orphan prune (bolt) and the snapshot wipe (cypher) | `_module` was application-blind: two apps sharing a file key deleted each other's nodes. A bare `STARTS WITH id` is wrong too — it also matches `…/foo.tsx` under `…/foo.ts`, and `appXtra` under `app` | +| P2 | **`_module` retired from the graph**; `NodeRow.module` keeps the grouping in memory | the incremental diff is keyed by module id inside the app prefixes | the property carried no scope; the id carries language, app and file | +| P3 | **Markers `TSCanNode` / `JSCanNode`** on every `can:///` id, with a range index on `id` each | index anchors only; anchor label chosen from the id's own namespace | property indexes are label-scoped; `STARTS WITH` seeks only on a range index. Two markers because this analyzer emits two namespaces. `CanNode` stays until #95 | +| P4 | **Empty application refused** (`applicationPrefixes` throws) | on any push: the diff itself is app-scoped | `STARTS WITH ''` matches the whole store | +| P5 | **`SCHEMA_VERSION` stays 2.0.0** despite a removed property | supersedes #140's "MAJOR bump" goal | #144 / python #186: one version until every analyzer re-baselines together | diff --git a/schema.neo4j.json b/schema.neo4j.json index 536844b..34e6502 100644 --- a/schema.neo4j.json +++ b/schema.neo4j.json @@ -1,7 +1,10 @@ { "schema_version": "2.0.0", "generator": "codeanalyzer-typescript", - "marker_labels": [], + "marker_labels": [ + "TSCanNode", + "JSCanNode" + ], "node_labels": [ { "label": "TSApplication", @@ -73,7 +76,6 @@ "properties": { "id": "string", "kind": "string", - "_module": "string", "name": "string", "is_tsx": "boolean", "is_declaration_file": "boolean", @@ -89,7 +91,6 @@ "properties": { "id": "string", "kind": "string", - "_module": "string", "signature": "string", "name": "string", "base_classes": "string[]", @@ -111,7 +112,6 @@ "properties": { "id": "string", "kind": "string", - "_module": "string", "signature": "string", "name": "string", "base_classes": "string[]", @@ -129,7 +129,6 @@ "properties": { "id": "string", "kind": "string", - "_module": "string", "signature": "string", "name": "string", "is_const": "boolean", @@ -147,7 +146,6 @@ "properties": { "id": "string", "kind": "string", - "_module": "string", "signature": "string", "name": "string", "aliased_type": "string", @@ -165,7 +163,6 @@ "properties": { "id": "string", "kind": "string", - "_module": "string", "signature": "string", "name": "string", "is_exported": "boolean", @@ -182,7 +179,6 @@ "properties": { "id": "string", "kind": "string", - "_module": "string", "signature": "string", "name": "string", "return_type": "string", @@ -210,7 +206,6 @@ "properties": { "id": "string", "kind": "string", - "_module": "string", "name": "string", "type": "string", "start_line": "integer", @@ -224,7 +219,6 @@ "properties": { "id": "string", "kind": "string", - "_module": "string", "of": "string", "parent": "string", "callee": "string", @@ -239,7 +233,6 @@ "properties": { "id": "string", "kind": "string", - "_module": "string", "name": "string", "module": "string" } @@ -251,7 +244,6 @@ "properties": { "id": "string", "kind": "string", - "_module": "string", "signature": "string", "name": "string", "return_type": "string", @@ -573,6 +565,7 @@ "CREATE INDEX callable_name IF NOT EXISTS FOR (c:TSCallable) ON (c.name)", "CREATE FULLTEXT INDEX ts_code_fts IF NOT EXISTS FOR (c:TSCallable) ON EACH [c.code]", "CREATE INDEX cannode_kind IF NOT EXISTS FOR (n:CanNode) ON (n.kind)", - "CREATE INDEX cannode_module IF NOT EXISTS FOR (n:CanNode) ON (n._module)" + "CREATE INDEX tscannode_id IF NOT EXISTS FOR (n:TSCanNode) ON (n.id)", + "CREATE INDEX jscannode_id IF NOT EXISTS FOR (n:JSCanNode) ON (n.id)" ] } diff --git a/src/build/neo4j/bolt.ts b/src/build/neo4j/bolt.ts index ebc1f68..f2a2c42 100644 --- a/src/build/neo4j/bolt.ts +++ b/src/build/neo4j/bolt.ts @@ -20,7 +20,7 @@ import type { Logger } from "../../utils"; import type { EdgeRow, GraphRows, NodeRow, Prop } from "./rows"; -import { chunk } from "./rows"; +import { JS_MARKER, TS_MARKER, applicationPrefixes, chunk, descendantPrefix, markerFor } from "./rows"; import { CONSTRAINTS, INDEXES, SCHEMA_VERSION } from "./schema"; export interface BoltConfig { @@ -30,7 +30,6 @@ export interface BoltConfig { database: string | null; } -const DESCENDANTS = "[:TS_DECLARES|TS_HAS_METHOD|TS_HAS_FIELD|TS_HAS_BODY_NODE*1..]"; const BATCH = 1000; /** #68: a DB written by a different schema version must be fully re-upserted, not hash-diffed — @@ -52,9 +51,36 @@ export function shouldForceFullUpsert(dbVersion: string | null, producerVersion: * Batched, because deleting a whole application in one transaction exhausts * `dbms.memory.transaction.total.max` on a modestly-sized server (#116, measured at 2.7 GiB). */ -export const EAGER_PURGE = - "MATCH (n:CanNode) WHERE n.id STARTS WITH $prefix " + - "CALL { WITH n DETACH DELETE n } IN TRANSACTIONS OF 5000 ROWS"; +/** + * `--eager` purge (#140): everything under this application's prefix, per language namespace, + * anchored on that namespace's marker so the prefix predicate seeks an index. `$prefix` is the + * `/`-terminated descendant prefix from `applicationPrefixes`, never a bare app id — a bare id also + * matches `can://typescript/appXtra/...`. + */ +export const EAGER_PURGE = eagerPurge(TS_MARKER); +export const EAGER_PURGE_JS = eagerPurge(JS_MARKER); +function eagerPurge(marker: string): string { + return `MATCH (n:${marker}) WHERE n.id STARTS WITH $prefix CALL { WITH n DETACH DELETE n } IN TRANSACTIONS OF 5000 ROWS`; +} +/** The per-module purge (#140): the module by equality, its subtree by `/`-prefix; `$keys` survive. */ +function purgeModuleEdges(marker: string): string { + return `MATCH (x:${marker}) WHERE x.id = $mid OR x.id STARTS WITH $pre MATCH (x)-[r]->() DELETE r`; +} +function purgeModuleStale(marker: string): string { + return `MATCH (x:${marker}) WHERE (x.id = $mid OR x.id STARTS WITH $pre) AND NOT x.id IN $keys DETACH DELETE x`; +} +/** The orphan prune (#140): modules inside this app's prefix that the run no longer emits, with their subtrees. */ +function pruneVanished(marker: string): string { + return ( + `MATCH (m:TSModule:${marker}) WHERE m.id STARTS WITH $prefix AND NOT m.id IN $present ` + + `CALL { WITH m MATCH (x:${marker}) WHERE x.id = m.id OR x.id STARTS WITH m.id + '/' DETACH DELETE x } ` + + `IN TRANSACTIONS OF 1000 ROWS RETURN count(DISTINCT m) AS pruned` + ); +} +/** The TSModule row of one module's group carries the module's `can://` id and its content_hash. */ +function moduleRow(nodes: NodeRow[]): NodeRow | undefined { + return nodes.find((n) => n.labels.includes("TSModule")); +} export async function boltWriter( rows: GraphRows, @@ -80,7 +106,7 @@ export async function boltWriter( const shared: NodeRow[] = []; const moduleOf = new Map(); // node value → owning module for (const n of rows.nodes) { - const m = n.props._module; + const m = n.module; if (typeof m === "string") { bucket(byModule, m).push(n); moduleOf.set(n.value, m); @@ -93,6 +119,10 @@ export async function boltWriter( // app's :Application (by id) — an unscoped `MATCH (a:Application)` could read a foreign analyzer's // node in a shared database and misjudge the version. Absent id → null → forces (safe default). const appId = rows.nodes.find((n) => n.labels[0] === "Application")?.value ?? null; + // Every scoped statement below — the diff, the purges, the prune — needs the application's + // prefixes, and applicationPrefixes refuses an empty one (#140). project() always emits the + // Application row, so this only trips on a hand-built GraphRows. + const prefixes = applicationPrefixes(appId); let dbSchemaVersion: string | null = null; if (appId !== null) { await withSession(session, async (s) => { @@ -114,21 +144,35 @@ export async function boltWriter( // --eager: drop this application's own nodes and rebuild. Without it the push only ever adds // and updates -- managing the database's lifetime is the operator's call, not the analyzer's. - if (eager && appId !== null) { - await withSession(session, (s) => s.run(EAGER_PURGE, { prefix: appId })); - log.info(`neo4j(bolt): --eager, purged the existing graph for ${appId}`); + if (eager) { + await withSession(session, async (s) => { + await s.run(EAGER_PURGE, { prefix: prefixes.ts }); + await s.run(EAGER_PURGE_JS, { prefix: prefixes.js }); + }); + log.info(`neo4j(bolt): --eager, purged the existing graph under ${prefixes.ts} and ${prefixes.js}`); } // 3. diff content_hash. const dbHash = new Map(); await withSession(session, async (s) => { - const res = await s.run("MATCH (m:TSModule) RETURN m._module AS k, m.content_hash AS h"); + // Keyed by module ID inside this application's prefixes (#140): a file key alone collides + // across applications; the id carries language, application and file. + const res = await s.run( + "MATCH (m:TSModule) WHERE m.id STARTS WITH $ts OR m.id STARTS WITH $js RETURN m.id AS k, m.content_hash AS h", + { ts: prefixes.ts, js: prefixes.js }, + ); for (const rec of res.records) dbHash.set(rec.get("k"), rec.get("h")); }); + const moduleIdOf = new Map(); // file key → the module's can:// id const changed = new Set(); for (const [m, nodes] of byModule) { + const mid = moduleRow(nodes)?.value; + if (!mid || !(mid.startsWith(prefixes.ts) || mid.startsWith(prefixes.js))) { + throw new Error(`neo4j: module ${m} has no can:// id under ${prefixes.ts} / ${prefixes.js}; refusing to scope a purge on it`); + } + moduleIdOf.set(m, mid); const rowHash = hashOf(nodes, m); - if (forceAll || !dbHash.has(m) || rowHash === undefined || rowHash !== dbHash.get(m)) changed.add(m); + if (forceAll || !dbHash.has(mid) || rowHash === undefined || rowHash !== dbHash.get(mid)) changed.add(m); } log.info( `neo4j(bolt): ${byModule.size} modules (${changed.size} changed), ${shared.length} shared nodes, ` + @@ -147,13 +191,15 @@ export async function boltWriter( // operator's call (#116). Anchored on :CanNode either way, so a sibling analyzer's nodes // sharing this `_module` key are never in scope. if (eager) { + // The module by equality, its subtree by `/`-prefix, anchored on the module's own + // language marker (#140). Application-scoped by construction: the id carries the app. + const mid = moduleIdOf.get(m)!; + const marker = markerFor(mid)!; + const params = { mid, pre: descendantPrefix(mid), keys }; await withSession(session, async (s) => { await s.executeWrite(async (tx: any) => { - await tx.run(`MATCH (x:CanNode {_module: $m})-[r]->() DELETE r`, { m }); - await tx.run( - `MATCH (x:CanNode {_module: $m}) WHERE x.id IS NULL OR NOT x.id IN $keys DETACH DELETE x`, - { m, keys }, - ); + await tx.run(purgeModuleEdges(marker), params); + await tx.run(purgeModuleStale(marker), params); }); }); } @@ -169,20 +215,18 @@ export async function boltWriter( // 7. orphan prune — only safe on a full run (a targeted run can't tell deleted from untargeted). // appId === null would make `STARTS WITH ""` match every node in the store. - if (fullRun && eager && appId !== null) { - const present = [...byModule.keys()]; + if (fullRun && eager) { + // Scoped on this app's prefixes and the module's own marker (#140); a second TypeScript app + // in the same database, whose modules are all "not in $present", is outside the prefix. + const present = [...moduleIdOf.values()]; + let pruned = 0; await withSession(session, async (s) => { - // Anchored on :CanNode AND this app's id prefix, same as EAGER_PURGE. `MATCH (m:TSModule)` - // alone would reach a SECOND TypeScript application in the same database -- every one of - // its modules is "not in this app's $present" -- and any 1.x twin-labelled node too (#116). - const res = await s.run( - `MATCH (m:TSModule:CanNode) WHERE m.id STARTS WITH $prefix AND NOT m._module IN $present ` + - `OPTIONAL MATCH (m)-${DESCENDANTS}->(x) DETACH DELETE x, m RETURN count(DISTINCT m) AS pruned`, - { present, prefix: appId }, - ); - const pruned = res.records[0]?.get("pruned") ?? 0; - log.info(`neo4j(bolt): pruned ${pruned} vanished module(s)`); + for (const [marker, prefix] of [[TS_MARKER, prefixes.ts], [JS_MARKER, prefixes.js]] as const) { + const res = await s.run(pruneVanished(marker), { present, prefix }); + pruned += Number(res.records[0]?.get("pruned") ?? 0); + } }); + log.info(`neo4j(bolt): pruned ${pruned} vanished module(s)`); } else { log.info("neo4j(bolt): orphan pruning skipped (use --eager to remove vanished modules)"); } @@ -257,8 +301,8 @@ function bucket(map: Map, key: K): V[] { } function hashOf(nodes: NodeRow[], _fileKey: string): string | undefined { - // Every node in `nodes` shares the same _module; the Module row (labels include "TSModule") carries the hash. - const mod = nodes.find((n) => n.labels.includes("TSModule")); + // Every node in `nodes` shares one owning module; its TSModule row carries the hash. + const mod = moduleRow(nodes); const h = mod?.props.content_hash; return typeof h === "string" ? h : undefined; } diff --git a/src/build/neo4j/cypher.ts b/src/build/neo4j/cypher.ts index 72efee7..79164cf 100644 --- a/src/build/neo4j/cypher.ts +++ b/src/build/neo4j/cypher.ts @@ -9,6 +9,7 @@ import * as fs from "node:fs"; import type { EdgeRow, GraphRows, NodeRow, Props } from "./rows"; +import { JS_MARKER, TS_CAN_PREFIX, TS_MARKER, applicationPrefixes } from "./rows"; import { cypherMap, cypherValue } from "./rows"; import { CONSTRAINTS, INDEXES } from "./schema"; @@ -38,7 +39,7 @@ function* cypherBlocks(rows: GraphRows, appId: string): Generator { yield ""; yield "// ── wipe this project's prior subgraph (external targets are shared) ──"; - yield wipe(appId); + yield wipe(rows, appId); yield ""; yield "// ── nodes ──"; @@ -50,13 +51,22 @@ function* cypherBlocks(rows: GraphRows, appId: string): Generator { yield ""; } -function wipe(appId: string): string { - const id = cypherValue(appId); +function wipe(rows: GraphRows, appIdArg: string): string { + // Scoped on the `can://` id prefix per namespace (#140), not on a relationship walk from the + // Application node: the prefix reaches every node the app owns — including ones a walk would + // miss — and nothing another app owns, even one whose file keys collide. The id comes from the + // rows' own Application node (the argument is a fallback for callers that pass the bare name); + // rows with no application id get NO destructive statement — refused visibly, never `STARTS + // WITH ''`. + const appId = rows.nodes.find((n) => n.labels[0] === "Application")?.value ?? appIdArg; + if (!appId.startsWith(TS_CAN_PREFIX)) { + return "// no can:// application id in these rows — no wipe emitted (#140 refuses an unscoped delete)"; + } + const { ts, js } = applicationPrefixes(appId); return [ - `MATCH (a:Application {id: ${id}})`, - "OPTIONAL MATCH (a)-[:TS_HAS_MODULE]->(m:TSModule)", - "OPTIONAL MATCH (m)-[:TS_DECLARES|TS_HAS_METHOD|TS_HAS_FIELD|TS_HAS_BODY_NODE*1..]->(x)", - "DETACH DELETE x, m, a;", + `MATCH (x:${TS_MARKER}) WHERE x.id STARTS WITH ${cypherValue(ts)} DETACH DELETE x;`, + `MATCH (x:${JS_MARKER}) WHERE x.id STARTS WITH ${cypherValue(js)} DETACH DELETE x;`, + `MATCH (a:Application {id: ${cypherValue(appId)}}) DETACH DELETE a;`, ].join("\n"); } diff --git a/src/build/neo4j/project.ts b/src/build/neo4j/project.ts index c5f5d2c..33ffdca 100644 --- a/src/build/neo4j/project.ts +++ b/src/build/neo4j/project.ts @@ -6,7 +6,8 @@ * No I/O: the writers (cypher snapshot / bolt incremental) consume the returned `GraphRows`. * * The graph is a second projection of the SAME v2 envelope the JSON path emits (finalizeAnalysis), - * so JSON and graph never diverge. Every project-owned node carries `_module` (its owning file key, + * so JSON and graph never diverge. Every project-owned node passes `_module` (its owning file key) + * to the RowBuilder, which lifts it OFF the graph into NodeRow.module for the incremental diff (#140); * for the incremental writer's per-module isolation); shared nodes (External) carry none. */ diff --git a/src/build/neo4j/rows.ts b/src/build/neo4j/rows.ts index 48bda37..f1c6b95 100644 --- a/src/build/neo4j/rows.ts +++ b/src/build/neo4j/rows.ts @@ -28,6 +28,51 @@ export interface NodeRow { keyProp: string; value: string; props: Props; + /** + * The owning module's file key, for the incremental writer's per-module diff. IN MEMORY ONLY + * (#140): it used to be emitted as `_module` and every destructive statement matched on it, which + * is application-blind — two apps sharing a file key deleted each other's nodes. Scope now comes + * from the `can://` id prefix; this field only groups rows. + */ + module?: string; +} + +/** + * The marker labels (#140): one per language namespace this analyzer emits, on every node keyed by + * a `can:///` id. They are INDEX ANCHORS, nothing more — Neo4j property indexes are + * label-scoped, so `id STARTS WITH $p` needs a label to seek on. Safety comes from the prefix, which + * carries language, application and module. + */ +export const TS_CAN_PREFIX = "can://typescript/"; +export const JS_CAN_PREFIX = "can://javascript/"; +export const TS_MARKER = "TSCanNode"; +export const JS_MARKER = "JSCanNode"; + +/** The marker for a `can://` id, or null for ids outside both language namespaces (artifacts, packages). */ +export function markerFor(id: string): string | null { + if (id.startsWith(TS_CAN_PREFIX)) return TS_MARKER; + if (id.startsWith(JS_CAN_PREFIX)) return JS_MARKER; + return null; +} + +/** + * The prefix that matches a node's descendants and nothing else. The separator is the point: + * `can://typescript/app/src/foo.ts` is also a prefix of `can://typescript/app/src/foo.tsx`, so + * descendants match on `id + '/'` and the node itself by equality. + */ +export function descendantPrefix(canId: string): string { + return `${canId}/`; +} + +/** + * The scope of every destructive statement: this application's two namespaces, + * `can://typescript//` and `can://javascript//`. Refuses a missing or empty application: + * `STARTS WITH ''` would match every node in the database. + */ +export function applicationPrefixes(appId: string | null | undefined): { ts: string; js: string } { + const name = appId?.startsWith(TS_CAN_PREFIX) ? appId.slice(TS_CAN_PREFIX.length) : ""; + if (!name) throw new Error("neo4j: refusing a destructive statement without an application id"); + return { ts: descendantPrefix(`${TS_CAN_PREFIX}${name}`), js: descendantPrefix(`${JS_CAN_PREFIX}${name}`) }; } export interface EdgeRow { @@ -77,12 +122,19 @@ export class RowBuilder { */ node(labels: string[], keyProp: string, value: string, props: Props): NodeRef { const id = `${labels[0]}\0${value}`; + // `_module` is lifted off the graph (#140): it groups rows for the incremental diff and is + // never emitted. The marker label rides every `can:///` id, as an index anchor. + const { _module, ...rest } = props as Props & { _module?: unknown }; + const module = typeof _module === "string" ? _module : undefined; + const marker = keyProp === "id" ? markerFor(value) : null; + const allLabels = marker && !labels.includes(marker) ? [...labels, marker] : [...labels]; const existing = this.nodes.get(id); if (existing) { - Object.assign(existing.props, props); - for (const l of labels) if (!existing.labels.includes(l)) existing.labels.push(l); + Object.assign(existing.props, rest); + for (const l of allLabels) if (!existing.labels.includes(l)) existing.labels.push(l); + if (module !== undefined) existing.module = module; } else { - this.nodes.set(id, { labels: [...labels], keyProp, value, props }); + this.nodes.set(id, { labels: allLabels, keyProp, value, props: rest, ...(module !== undefined ? { module } : {}) }); } this.keys.add(value); return { label: labels[0], keyProp, value }; diff --git a/src/build/neo4j/schema.ts b/src/build/neo4j/schema.ts index 002b725..fc4d569 100644 --- a/src/build/neo4j/schema.ts +++ b/src/build/neo4j/schema.ts @@ -40,7 +40,10 @@ export interface RelType { } /** Labels layered onto a node in addition to its primary/specific label. */ -export const MARKER_LABELS = [] as const; +// One per language namespace this analyzer emits (#140): `TSCanNode` on every `can://typescript/` +// id, `JSCanNode` on every `can://javascript/` id. Index anchors for the prefix-scoped destructive +// statements — they carry no safety claim of their own. Alongside `CanNode` until #95 retires it. +export const MARKER_LABELS = ["TSCanNode", "JSCanNode"] as const; /** The namespace prefix every specific node label and relationship type carries at 2.0.0 (#66). */ export const TS_PREFIX = "TS"; @@ -49,7 +52,9 @@ export const TS_PREFIX = "TS"; const CAN = "CanNode"; const SPAN = { start_line: "integer", end_line: "integer" } as const; /** Every can://-keyed node carries these. */ -const COMMON = { id: "string", kind: "string", _module: "string" } as const; +// `_module` is gone from the graph (#140): scope is the `can://` id prefix. The writer keeps the +// grouping in memory (NodeRow.module). +const COMMON = { id: "string", kind: "string" } as const; export const NODE_LABELS: NodeLabel[] = [ { @@ -91,7 +96,7 @@ export const NODE_LABELS: NodeLabel[] = [ // A decorator APPLICATION's shared target (#82, python `:PyDecorator` parity). Merged on the // resolved `qualified_name` when the checker supplies one, so `@Get` and `@Get(':id')` land on // one node instead of two. Per-application facts (the arguments) ride on TS_DECORATED_BY, not - // here: this node is shared across modules, carries no `_module`, and is never pruned, so + // here: this node is shared across modules, lives outside every `can:///` prefix, and is never pruned, so // anything application-specific on it would accumulate across every project in the database. label: "TSDecorator", mergeLabel: "TSDecorator", @@ -265,9 +270,10 @@ export const INDEXES: readonly string[] = [ // Mirrors python's `py_code_fts` — a declaration's text is only useful in the graph if it is searchable. "CREATE FULLTEXT INDEX ts_code_fts IF NOT EXISTS FOR (c:TSCallable) ON EACH [c.code]", "CREATE INDEX cannode_kind IF NOT EXISTS FOR (n:CanNode) ON (n.kind)", - // Backs the bolt writer's per-module edge-delete + vanished-decl sweep, which anchor on - // `(:CanNode {_module})` — without this they would scan the whole node store. - "CREATE INDEX cannode_module IF NOT EXISTS FOR (n:CanNode) ON (n._module)", + // Back every destructive statement (#140): `id STARTS WITH $prefix` seeks on a range index only + // when anchored on a label that has one. `STARTS WITH` is index-backed; CONTAINS/ENDS WITH are not. + "CREATE INDEX tscannode_id IF NOT EXISTS FOR (n:TSCanNode) ON (n.id)", + "CREATE INDEX jscannode_id IF NOT EXISTS FOR (n:JSCanNode) ON (n.id)", ]; export interface SchemaDocument { diff --git a/test/bolt-version-gate.test.ts b/test/bolt-version-gate.test.ts index 1cbe3ab..e5f0731 100644 --- a/test/bolt-version-gate.test.ts +++ b/test/bolt-version-gate.test.ts @@ -16,8 +16,9 @@ describe("--eager purge is scoped to this analyzer AND this app (#116)", () => { // so that predicate described THEIR nodes exactly: pointing cants at a shared database deleted // the python and java graphs. It only failed loudly because the delete exhausted transaction // memory and rolled back. - test("anchors on :CanNode, so a sibling analyzer's nodes can never match", () => { - expect(EAGER_PURGE).toContain("MATCH (n:CanNode)"); + test("anchors on this analyzer's own marker and a /-terminated prefix, so no sibling or sibling-app node can match (#140)", () => { + expect(EAGER_PURGE).toContain("MATCH (n:TSCanNode)"); + expect(EAGER_PURGE).toContain("STARTS WITH $prefix"); // The lethal shape: reaching nodes by the ABSENCE of our own marker. expect(EAGER_PURGE).not.toContain("NOT n:CanNode"); expect(EAGER_PURGE).not.toContain("_module IS NOT NULL"); diff --git a/test/neo4j-bolt.test.ts b/test/neo4j-bolt.test.ts index 9c33493..0d25c7b 100644 --- a/test/neo4j-bolt.test.ts +++ b/test/neo4j-bolt.test.ts @@ -151,22 +151,52 @@ containerSuite("neo4j bolt writer", () => { const rows = project(finalizeAnalysis(app, result.program_graphs ?? null, opts).application); + // #140: nodes are found by id prefix now, never by a `_module` property. + const appId = rows.nodes.find((n) => n.labels[0] === "Application")!.value; + const victimId = `${appId}/${victim}`; + const victimCount = () => num("MATCH (n:TSCanNode) WHERE n.id = $mid OR n.id STARTS WITH $pre RETURN count(n)", { mid: victimId, pre: `${victimId}/` }); + // Default push: deletion is the operator's call, so the vanished module's nodes stay. await boltWriter(rows, cfg, log, true, false); - expect(await num("MATCH (n {_module:$m}) RETURN count(n)", { m: victim })).toBeGreaterThan(0); + expect(await victimCount()).toBeGreaterThan(0); // --eager: purge this application and rebuild, so the vanished module goes. await boltWriter(rows, cfg, log, true, true); - expect(await num("MATCH (n {_module:$m}) RETURN count(n)", { m: victim })).toBe(0); + expect(await victimCount()).toBe(0); - // The surviving module-scoped graph matches the reduced projection. (Shared :TSExternal - // nodes are MERGE-only and intentionally never pruned, so we compare only _module-tagged nodes.) - const moduleScoped = rows.nodes.filter((n) => "_module" in n.props).length; - expect(await num("MATCH (n) WHERE n._module IS NOT NULL RETURN count(n)")).toBe(moduleScoped); + // The surviving module-owned graph matches the reduced projection. Shared nodes + // (:TSExternal — MERGE-only, never pruned) sit under the app prefix too, so exclude them. + const moduleOwned = rows.nodes.filter((n) => n.module !== undefined).length; + expect(await num("MATCH (n:TSCanNode) WHERE n.id STARTS WITH $pre AND NOT n:TSExternal RETURN count(n)", { pre: `${appId}/` })).toBe(moduleOwned); }, 120_000, ); + test( + "a second application in the same language, with colliding module paths, survives every purge (#140)", + async () => { + // Same fixture, two application names — every file key collides. `saX` is chosen so that + // `can://typescript/sa` is a string prefix of `can://typescript/saX`: the boundary case. + const a = project((await analyze(optsFor({ appName: "sa" }))).application); + const b = project((await analyze(optsFor({ appName: "saX" }))).application); + const under = (app: string) => num("MATCH (n:TSCanNode) WHERE n.id STARTS WITH $p RETURN count(n)", { p: `can://typescript/${app}/` }); + + await boltWriter(a, cfg, log, true, true); + const a0 = await under("sa"); + expect(a0).toBeGreaterThan(0); + await boltWriter(b, cfg, log, true, true); // saX's --eager purge + prune must not touch sa + expect(await under("sa")).toBe(a0); + const b0 = await under("saX"); + expect(b0).toBeGreaterThan(0); + await boltWriter(a, cfg, log, true, true); // sa's --eager purge + prune must not touch saX (prefix boundary) + expect(await under("saX")).toBe(b0); + expect(await under("sa")).toBe(a0); + // and nothing carries the retired property + expect(await num("MATCH (n) WHERE n._module IS NOT NULL RETURN count(n)")).toBe(0); + }, + 180_000, + ); + test( "a 1.x graph in the same store is left alone, not wiped (#116)", async () => { diff --git a/test/neo4j-prefix-scope.test.ts b/test/neo4j-prefix-scope.test.ts new file mode 100644 index 0000000..1863cdd --- /dev/null +++ b/test/neo4j-prefix-scope.test.ts @@ -0,0 +1,73 @@ +/** + * Prefix scoping of destructive Neo4j statements (#140), no container needed. The container + * suite proves the behaviour against a live store; this pins the pieces it is built from: the + * helpers that produce the prefixes, the marker-label injection, `_module` never reaching a row's + * props, and the shape of every destructive statement. + */ +import { describe, expect, test } from "bun:test"; +import * as path from "node:path"; +import { analyze } from "../src/core"; +import { EAGER_PURGE, EAGER_PURGE_JS } from "../src/build/neo4j/bolt"; +import { project, renderCypher, MARKER_LABELS } from "../src/build/neo4j"; +import { RowBuilder, applicationPrefixes, descendantPrefix, markerFor } from "../src/build/neo4j/rows"; +import type { AnalysisOptions } from "../src/options"; + +const FIXTURE = path.resolve(import.meta.dir, "fixtures/sample-app"); +const opts = { input: FIXTURE, appName: "ps", analysisLevel: 1, eager: true, noBuild: true, emit: "neo4j", entrypointRules: null } as unknown as AnalysisOptions; + +describe("can:// prefix scoping (#140)", () => { + test("prefixes are /-terminated per namespace, and an empty application is refused", () => { + expect(applicationPrefixes("can://typescript/app")).toEqual({ ts: "can://typescript/app/", js: "can://javascript/app/" }); + expect(descendantPrefix("can://typescript/app/src/a.ts")).toBe("can://typescript/app/src/a.ts/"); + for (const bad of [null, undefined, "", "can://typescript/", "can://javascript/app", "app"]) { + expect(() => applicationPrefixes(bad as never)).toThrow(/refusing a destructive statement/); + } + }); + + test("the marker follows the id's namespace; ids outside both get none", () => { + expect(markerFor("can://typescript/app/src/a.ts/f")).toBe("TSCanNode"); + expect(markerFor("can://javascript/app/src/a.js/f")).toBe("JSCanNode"); + expect(markerFor("can://artifact/app/package.json")).toBeNull(); + expect(markerFor("Get")).toBeNull(); + }); + + test("RowBuilder lifts _module off the row and adds the marker for id-keyed can:// nodes", () => { + const b = new RowBuilder(); + b.node(["CanNode", "TSCallable"], "id", "can://typescript/app/src/a.ts/f", { id: "x", _module: "src/a.ts", name: "f" }); + b.node(["CanNode", "TSCallable"], "id", "can://javascript/app/src/b.js/g", { id: "y", _module: "src/b.js", name: "g" }); + b.node(["Artifact"], "id", "can://artifact/app/package.json", { id: "z", path: "package.json" }); + b.node(["TSDecorator"], "name", "Get", { name: "Get" }); + const rows = b.finish(); + const byValue = new Map(rows.nodes.map((n) => [n.value, n])); + const ts = byValue.get("can://typescript/app/src/a.ts/f")!; + expect(ts.labels).toEqual(["CanNode", "TSCallable", "TSCanNode"]); + expect(ts.module).toBe("src/a.ts"); + expect("_module" in ts.props).toBe(false); + expect(byValue.get("can://javascript/app/src/b.js/g")!.labels).toContain("JSCanNode"); + expect(byValue.get("can://artifact/app/package.json")!.labels).toEqual(["Artifact"]); + expect(byValue.get("Get")!.labels).toEqual(["TSDecorator"]); + }); + + test("a real projection: no row carries _module; every can://typescript node carries TSCanNode", async () => { + const rows = project((await analyze(opts)).application); + expect(rows.nodes.some((n) => "_module" in n.props)).toBe(false); + const ts = rows.nodes.filter((n) => n.value.startsWith("can://typescript/")); + expect(ts.length).toBeGreaterThan(10); + for (const n of ts) expect(n.labels).toContain("TSCanNode"); + expect(rows.nodes.filter((n) => n.module !== undefined).length).toBeGreaterThan(10); + expect([...MARKER_LABELS]).toEqual(["TSCanNode", "JSCanNode"]); + }); + + test("every destructive statement anchors on a marker and a /-terminated prefix", async () => { + for (const stmt of [EAGER_PURGE, EAGER_PURGE_JS]) { + expect(stmt).toMatch(/^MATCH \(n:(TS|JS)CanNode\) WHERE n\.id STARTS WITH \$prefix/); + expect(stmt).not.toContain("_module"); + } + const cypher = renderCypher(project((await analyze(opts)).application), "ps"); + // the snapshot wipe: two marker-scoped deletes on /-terminated prefixes, then the app node by equality + expect(cypher).toContain("MATCH (x:TSCanNode) WHERE x.id STARTS WITH 'can://typescript/ps/' DETACH DELETE x;"); + expect(cypher).toContain("MATCH (x:JSCanNode) WHERE x.id STARTS WITH 'can://javascript/ps/' DETACH DELETE x;"); + expect(cypher).toContain("MATCH (a:Application {id: 'can://typescript/ps'}) DETACH DELETE a;"); + expect(cypher).not.toContain("_module"); + }); +}); diff --git a/test/neo4j-schema.test.ts b/test/neo4j-schema.test.ts index 6e186eb..96b968f 100644 --- a/test/neo4j-schema.test.ts +++ b/test/neo4j-schema.test.ts @@ -93,9 +93,18 @@ describe("neo4j schema conformance", () => { expect(onDisk).toBe(fresh); }); - test("2.0.0 does not advertise never-populated surfaces (issues #55/#60)", () => { + test("2.0.0 does not advertise never-populated surfaces (issues #55/#60)", async () => { const doc = buildSchemaDocument(); - expect(doc.marker_labels.length).toBe(0); + // #140: the two marker labels ARE populated — one per language namespace this analyzer emits. + // Prove each is emitted by a real projection, so this stays a "no dead surface" check. + expect([...doc.marker_labels]).toEqual(["TSCanNode", "JSCanNode"]); + const projectOf = async (fixture: string) => { + const o = { input: path.resolve(import.meta.dir, "fixtures", fixture), appName: "mk", analysisLevel: 1, eager: true, + noBuild: true, emit: "neo4j", entrypointRules: null } as unknown as AnalysisOptions; + return project((await analyze(o)).application); + }; + expect((await projectOf("sample-app")).nodes.some((n) => n.labels.includes("TSCanNode"))).toBe(true); + expect((await projectOf("unresolvable-js-app")).nodes.some((n) => n.labels.includes("JSCanNode"))).toBe(true); const allProps = doc.node_labels.flatMap((n) => Object.keys(n.properties)); for (const dead of ["framework", "detection_source", "route_path", "http_methods", "entrypoint_count", "accessed_symbols_json"]) { expect(allProps, `dead property still advertised: ${dead}`).not.toContain(dead);