Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,4 @@ jobs:

# Container suite is opt-in; ubuntu-latest ships Docker, so testcontainers works out of the box.
- name: Neo4j bolt container tests
run: RUN_CONTAINER_TESTS=1 bun test test/neo4j-bolt.test.ts
run: bun run test:container
40 changes: 34 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ Options:
Neo4j schema.json contract) (default: "json")
--app-name <name> logical application name for the graph
:Application anchor (default: input dir name)
--namespace <name> application identity namespace (default: app name)
--neo4j-uri <uri> push the graph to a live Neo4j over Bolt
(incremental); omit to write graph.cypher (env:
NEO4J_URI)
Expand Down Expand Up @@ -229,12 +230,24 @@ Options:
cants --input ./my-ts-project --target-files src/a.ts src/b.ts
```

4. **Force a clean rebuild with a custom cache directory:**
4. **Group applications in a namespace:**
```sh
cants --input ./monorepo/packages/web --app-name web --namespace monorepo
# application id: can://monorepo/web
# symbol_table key: src/app.ts (relative to --input)
```
Namespace defaults to the application name. Both root segments are always present.
Raw identity names are case sensitive and percent encoded; empty names, separators,
control characters, and dot names are rejected. Version 2 identities require
`identity_version: 2` on the envelope and application anchor. Regenerate legacy output
from source. Cache reuse requires the same namespace and application.

5. **Force a clean rebuild with a custom cache directory:**
```sh
cants --input ./my-ts-project --eager --cache-dir /path/to/custom-cache
```

5. **Program graphs (level 3): CFG/PDG/SDG in `analysis.json`:**
6. **Program graphs (level 3): CFG/PDG/SDG in `analysis.json`:**
```sh
cants --input ./my-ts-project -a 3 # full program_graphs section
cants --input ./my-ts-project -a 3 --graphs cfg,pdg # scope the emitted graphs
Expand All @@ -252,9 +265,10 @@ deeply; each level only ever *adds*.

```jsonc
{
"schema_version": "2.0.0", "language": "typescript", "max_level": 4, "k_limit": 3,
"schema_version": "2.0.0", "identity_version": 2, "language": "typescript", "max_level": 4, "k_limit": 3,
"application": {
"id": "can://<app>", "name": "<app>", "kind": "application",
"id": "can://<namespace>/<app>", "name": "<app>", "kind": "application",
"identity_version": 2, "application_name": "<app>", "identity_namespace": "<namespace>",
"symbol_table": { // L1: the tree, keyed by file path
"<file>": { "kind": "module", "source": "…",
"types": { /* class | interface | enum | type_alias | namespace nodes */ },
Expand Down Expand Up @@ -325,7 +339,7 @@ Levels 1/2 are unaffected: nothing in level 3 runs unless `-a 3` is requested.
**always full-depth** — analysis levels gate the JSON path only, so combining `-a`/`--graphs` with
`--emit neo4j` is an error:

Every node id is `can://<app>/…`, so `can://<app>` is a prefix of every node the application
Every node id is `can://<namespace>/<app>/…`, so `can://<namespace>/<app>` is a prefix of every node the application
emits — the `typescript` and `javascript` namespaces are two segments *inside* it, not two
top-level namespaces a consumer has to enumerate. That single prefix is what the destructive
statements scope on, and `:Application` is keyed on the id rather than on `--app-name`.
Expand All @@ -336,6 +350,20 @@ statements scope on, and `:Application` is keyed on the id rather than on `--app
content hash changed are rewritten, and on a full run modules whose source file vanished are
pruned. Every graph carries a `schema_version` on its `:Application` node.

`--eager` purges this application's code nodes but not its `:Artifact`/`:ConfigKey` nodes, which
are shared with the sibling analyzers. An `--eager` full run (no `--target-files`) reconciles them
instead, so deleted or renamed files and removed dependencies within the selected scope
leave nothing stale. Legacy roots and other namespaces remain outside this scope. TypeScript-owned artifact edges (`HAS_ARTIFACT`,
`DECLARES_DEPENDENCY`/`LOCKS` to `pkg:npm/…`, `TS_PROVIDES`, `TS_USES_CONFIG`,
`TS_READS_CONFIG_UNRESOLVED`, `TS_UNRESOLVED_IMPORT`) are rebuilt. An `:Artifact` or `:ConfigKey`
the run no longer emits loses its `:TSCanNode` label. An artifact is then deleted only if no other
analyzer's `HAS_ARTIFACT` still points at it; a config key only if no other analyzer's marker
(`:JCanNode`, `:PyCanNode`) claims it. Other analyzers' edges are never removed. An
`--eager --target-files` push reconciles nothing, so stale artifacts wait for the next full
`--eager` push. The `graph.cypher` snapshot is a full rebuild by design: its wipe deletes every
node this application wrote, shared artifacts included, and other analyzers restore their edges
on their next push.

The connection options also read the standard Neo4j environment variables — `NEO4J_URI`,
`NEO4J_USERNAME`, `NEO4J_PASSWORD`, `NEO4J_DATABASE` — when the corresponding flag is omitted (an
explicit flag wins). Prefer the env var for the password so it doesn't land in shell history or the
Expand Down Expand Up @@ -379,7 +407,7 @@ Apache 2.0 — see [LICENSE](./LICENSE).

## Polyglot applications: all languages, or none

A `--emit neo4j` push is **destructive**. It sweeps everything under `can://<app>/` that this
A `--emit neo4j` push is **destructive**. It sweeps everything under `can://<namespace>/<app>/` that this
analyzer marked, then rewrites what it found. Since the id grammar puts the application outermost,
every analyzer over the same `<app>` shares that prefix — so a push reclaims stale rows belonging to
*this* analyzer and, in the shared namespaces, sweeps rows a sibling wrote.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"build": "bun build ./src/main.ts ./src/dataflow/worker.ts --compile --outfile dist/cants",
"gen:schema": "bun run src/index.ts --emit schema > schema.neo4j.json",
"gen:readme": "bun run scripts/update-readme.ts",
"test:container": "RUN_CONTAINER_TESTS=1 bun test test/neo4j-bolt.test.ts",
"test:container": "RUN_CONTAINER_TESTS=1 bun test test/neo4j-bolt.test.ts test/neo4j-artifact-reconcile.test.ts",
"typecheck": "tsc --noEmit"
},
"license": "Apache-2.0",
Expand Down
3 changes: 3 additions & 0 deletions schema.neo4j.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
"properties": {
"id": "string",
"name": "string",
"identity_version": "integer",
"application_name": "string",
"identity_namespace": "string",
"schema_version": "string",
"language": "string",
"max_level": "integer",
Expand Down
91 changes: 81 additions & 10 deletions src/build/neo4j/bolt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* 4. upsert edges owned by changed modules (+ the shared edges).
* 5. on a FULL run only, prune modules whose source file vanished.
*
* A full `--eager` run also reconciles the app's artifacts (RELEASE_VANISHED_ARTIFACTS).
*
* Nodes are MERGE-upserted, never blindly deleted, so a declaration another (unchanged) module
* still references survives and its incoming edges stay valid. `:TSExternal` nodes are shared (no
* `_module`) and are MERGE-only.
Expand Down Expand Up @@ -58,7 +60,49 @@ export function shouldForceFullUpsert(dbVersion: string | null, producerVersion:
* `applicationPrefix`, never a bare app id — a bare id also matches `can://appXtra/...`.
*/
export const EAGER_PURGE =
`MATCH (n:${TS_MARKER}) WHERE n.id STARTS WITH $prefix CALL { WITH n DETACH DELETE n } IN TRANSACTIONS OF 5000 ROWS`;
`MATCH (n:${TS_MARKER}) WHERE n.id STARTS WITH $prefix AND NOT (n:Artifact OR n:ConfigKey) ` +
`CALL { WITH n DETACH DELETE n } IN TRANSACTIONS OF 5000 ROWS`;

/**
* Artifact reconciliation (`--eager` full runs). Artifacts and config keys are shared with the
* sibling analyzers, so EAGER_PURGE skips them and only TS-owned edges are dropped and rebuilt:
* HAS_ARTIFACT from a :TSApplication, DECLARES_DEPENDENCY/LOCKS to `pkg:npm/`, and incoming TS_
* edges. Not sha-diffed: siblings overwrite the shared `sha256`.
*/
export const PURGE_ARTIFACT_EDGES =
"UNWIND $pres AS pre " +
"MATCH (t) WHERE (t:Artifact OR t:ConfigKey) AND t.id STARTS WITH pre " +
"CALL { WITH t " +
"WITH [(t)<-[r]-(s) WHERE (type(r) = 'HAS_ARTIFACT' AND s:TSApplication) OR type(r) STARTS WITH 'TS_' | r] " +
"+ [(t)-[d:DECLARES_DEPENDENCY|LOCKS]->(p:Package) WHERE p.id STARTS WITH 'pkg:npm/' | d] AS rs " +
"FOREACH (r IN rs | DELETE r) } IN TRANSACTIONS OF 1000 ROWS";
/** The app root's unresolved-read/import edges, and dependency → import edges into its ghosts. */
export const PURGE_APP_IMPORT_EDGES =
"MATCH (:TSApplication {id: $appId})-[r:TS_UNRESOLVED_IMPORT|TS_READS_CONFIG_UNRESOLVED]->() " +
"CALL { WITH r DELETE r } IN TRANSACTIONS OF 1000 ROWS";
export const PURGE_PROVIDES =
"MATCH (:Package)-[r:TS_PROVIDES]->(g:TSExternal) WHERE g.id STARTS WITH $prefix " +
"CALL { WITH r DELETE r } IN TRANSACTIONS OF 1000 ROWS";
/**
* Release this analyzer's claim on artifacts/config keys the run no longer emits. An artifact is
* deleted once no HAS_ARTIFACT is left (every analyzer inventories with one, marked or not); a
* config key once no other analyzer's `*CanNode` claims it. Only the selected scope is touched.
*/
export const RELEASE_VANISHED_ARTIFACTS =
"UNWIND $pres AS pre " +
"MATCH (n) WHERE (n:Artifact OR n:ConfigKey) AND n.id STARTS WITH pre AND NOT n.id IN $keep " +
`AND n:${TS_MARKER} ` +
`CALL { WITH n REMOVE n:${TS_MARKER} ` +
"WITH n WHERE (n:Artifact AND NOT ()-[:HAS_ARTIFACT]->(n)) " +
"OR (n:ConfigKey AND none(l IN labels(n) WHERE l ENDS WITH 'CanNode')) " +
"DETACH DELETE n } IN TRANSACTIONS OF 1000 ROWS RETURN count(*) AS released";

/** Artifact prefix within the selected namespace/application only. */
export function artifactPrefixes(prefix: string): string[] {
return [`${prefix}artifact/`];
}

const isArtifactLabel = (label: string): boolean => label === "Artifact" || label === "ConfigKey";
/** The per-module purge (#140): the module by equality, its subtree by `/`-prefix; `$keys` survive. */
const PURGE_MODULE_EDGES =
`MATCH (x:${TS_MARKER}) WHERE x.id = $mid OR x.id STARTS WITH $pre MATCH (x)-[r]->() DELETE r`;
Expand Down Expand Up @@ -88,11 +132,6 @@ export async function boltWriter(
const session = () => driver.session(sessionOpts);

try {
// 1. schema (DDL runs in its own autocommit transactions).
await withSession(session, async (s) => {
for (const stmt of [...CONSTRAINTS, ...INDEXES]) await s.run(stmt);
});

// Partition nodes by owning module; shared nodes have no _module.
const byModule = new Map<string, NodeRow[]>();
const shared: NodeRow[] = [];
Expand All @@ -119,12 +158,25 @@ export async function boltWriter(
if (appId !== null) {
await withSession(session, async (s) => {
const res = await s.run(
"MATCH (a:Application {id: $appId}) RETURN a.schema_version AS v LIMIT 1",
"MATCH (a:Application {id: $appId}) RETURN a.schema_version AS v, a.identity_version AS identity_version, a.application_name AS application_name, a.identity_namespace AS identity_namespace LIMIT 1",
{ appId },
);
dbSchemaVersion = res.records[0]?.get("v") ?? null;
const existing = res.records[0];
if (existing) {
const marker = existing.get("identity_version");
const incoming = rows.nodes.find(n => n.labels.includes("Application"))!.props;
if (!(neo4j.isInt(marker) && marker.toNumber() === 2) || existing.get("application_name") !== incoming.application_name || existing.get("identity_namespace") !== incoming.identity_namespace) {
throw new Error("Incompatible graph application identity; regenerate from source in a compatible scope");
}
}
dbSchemaVersion = existing?.get("v") ?? null;
});
}
// 1. schema (DDL runs in its own autocommit transactions).
await withSession(session, async (s) => {
for (const stmt of [...CONSTRAINTS, ...INDEXES]) await s.run(stmt);
});

// Version mismatch no longer deletes anything: a schema change forces a full re-UPSERT, and
// MERGE overwrites in place. Removing nodes is `--eager`'s job alone (#116).
const forceAll = shouldForceFullUpsert(dbSchemaVersion, SCHEMA_VERSION);
Expand All @@ -143,6 +195,24 @@ export async function boltWriter(
log.info(`neo4j(bolt): --eager, purged the existing graph under ${prefix}`);
}

// Artifacts survive EAGER_PURGE; a full eager run reconciles them instead. Auto-commit and
// batched, so a partial failure is finished by the next eager push.
const reconcile = fullRun && eager;
if (reconcile) {
const pres = artifactPrefixes(prefix);
const keep = rows.nodes.filter((n) => isArtifactLabel(n.labels[0]!)).map((n) => n.value);
const params = { pres, keep, appId, prefix };
let released = 0;
await withSession(session, async (s) => {
await s.run(PURGE_ARTIFACT_EDGES, params);
await s.run(PURGE_APP_IMPORT_EDGES, params);
await s.run(PURGE_PROVIDES, params);
const res = await s.run(RELEASE_VANISHED_ARTIFACTS, params);
released = Number(res.records[0]?.get("released") ?? 0);
});
log.info(`neo4j(bolt): released ${released} vanished artifact/config node(s)`);
}

// 3. diff content_hash.
const dbHash = new Map<string, string | null>();
await withSession(session, async (s) => {
Expand Down Expand Up @@ -196,10 +266,11 @@ export async function boltWriter(
await upsertNodes(session, neo4j, nodes);
}

// 6. upsert edges owned by a changed module (owner = source node's module) or shared.
// 6. upsert edges owned by a changed module (owner = source node's module) or shared; after
// reconciliation also every edge into an artifact, which it purged.
const edges = rows.edges.filter((e) => {
const owner = moduleOf.get(e.from.value);
return owner === undefined || changed.has(owner);
return owner === undefined || changed.has(owner) || (reconcile && isArtifactLabel(e.to.label));
});
await upsertEdges(session, neo4j, edges);

Expand Down
17 changes: 7 additions & 10 deletions src/build/neo4j/project.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { applicationIdOf } from "../../schema/ids";
/**
* project() — the pure projection from the schema-v2 additive-CPG tree (`TSAnalysis`) to graph
* rows. It walks the uniform tree (module → types/functions/fields → callables → body) emitting one
Expand Down Expand Up @@ -46,11 +47,15 @@ const KIND_LABEL: Record<string, string> = {
};

export function project(app: TSAnalysis, _appName?: string, builder?: RowBuilder): GraphRows {
if (app.identity_version !== 2 || app.application.identity_version !== 2 || typeof app.application.application_name !== "string" || typeof app.application.identity_namespace !== "string" || app.application.id !== applicationIdOf(app.application.application_name, app.application.identity_namespace)) throw new Error("Incompatible application identity");
const b = builder ?? new RowBuilder(); // a streaming writer supplies its own
const root: TSApplication = app.application;

const appRef = b.node(["Application", "TSApplication"], "id", root.id, prune({
id: root.id,
identity_version: 2,
application_name: root.application_name,
identity_namespace: root.identity_namespace,
name: root.name,
schema_version: SCHEMA_VERSION,
language: app.language,
Expand All @@ -68,8 +73,7 @@ export function project(app: TSAnalysis, _appName?: string, builder?: RowBuilder
entrypoint_report_json: reportJson(root.entrypoint_report),
}));

for (const mod of Object.values(root.symbol_table)) {
const fileKey = moduleKeyOf(mod);
for (const [fileKey, mod] of Object.entries(root.symbol_table)) {
const modRef = b.node([CAN, "TSModule"], "id", mod.id, moduleProps(mod, fileKey));
b.edge("TS_HAS_MODULE", appRef, modRef);
projectScope(b, mod, modRef, fileKey, mod.source ?? "");
Expand Down Expand Up @@ -393,7 +397,7 @@ function typeProps(t: TSType, fileKey: string, source: string): Props {
is_entrypoint: t.kind === "class" ? (t.is_entrypoint ?? false) : null,
entrypoint_frameworks: t.kind === "class" ? frameworksOf(t.entrypoints) : null,
is_exported: t.is_exported, is_ambient: t.is_ambient,
code: spanCode(source, t.span), ...span(t),
code: spanCode(source, t.span), ...span(t), _module: fileKey,
});
}

Expand Down Expand Up @@ -436,13 +440,6 @@ const edges = (x: unknown): Edge[] => (Array.isArray(x) ? (x as Edge[]) : []);
/** A cross-edge endpoint is already the graph node's can:// id — pass it through unchanged. */
const idOf = (endpoint: string): string => endpoint;

function moduleKeyOf(mod: TSModule): string {
// id = can://<app>/<lang>/<fileKey>; the fileKey is everything past the app and language
// segments. Positional, so it survived the segment reorder unchanged.
const m = /^can:\/\/[^/]+\/[^/]+\/(.+)$/.exec(mod.id);
return m ? (m[1] as string) : mod.id;
}

function span(n: { span?: { start: [number, number]; end: [number, number]; bytes?: [number, number] } }): Record<string, number | undefined> {
if (!n.span) return {};
return {
Expand Down
11 changes: 5 additions & 6 deletions src/build/neo4j/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,8 @@ export const CAN_SCHEME = "can://";
export const TS_MARKER = "TSCanNode";
export const JS_MARKER = "JSCanNode";

// Positional, never first-segment: the app is segment 1 and may legitimately be NAMED
// "typescript" or "javascript". The language is segment 2.
const JS_NAMESPACE = /^can:\/\/[^/]+\/javascript\//;
// Positional: namespace and application precede the language; either may name a language.
const JS_NAMESPACE = /^can:\/\/[^/]+\/[^/]+\/javascript\//;

/** The marker labels for a `can://` id — empty for a node keyed on its own natural identity. */
export function markersFor(id: string): string[] {
Expand All @@ -81,13 +80,13 @@ export function descendantPrefix(canId: string): string {
}

/**
* The scope of every destructive statement: `can://<app>/`, one prefix spanning both language
* The scope of every destructive statement: `can://<namespace>/<app>/`, one prefix spanning both language
* namespaces. Refuses anything that is not a bare application id — `STARTS WITH ''` would match
* every node in the database, and a deeper id would silently narrow the scope to a subtree.
*/
export function applicationPrefix(appId: string | null | undefined): string {
const name = appId?.startsWith(CAN_SCHEME) ? appId.slice(CAN_SCHEME.length) : "";
if (!name || name.includes("/")) {
if (!name || !/^[^/]+\/[^/]+$/.test(name)) {
throw new Error("neo4j: refusing a destructive statement without an application id");
}
return descendantPrefix(appId as string);
Expand Down Expand Up @@ -141,7 +140,7 @@ 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 labels ride every `can://<app>/` id, as index anchors.
// never emitted. The marker labels ride every `can://<namespace>/<app>/` id, as index anchors.
const { _module, ...rest } = props as Props & { _module?: unknown };
const module = typeof _module === "string" ? _module : undefined;
const markers = keyProp === "id" ? markersFor(value) : [];
Expand Down
2 changes: 1 addition & 1 deletion src/build/neo4j/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export const NODE_LABELS: NodeLabel[] = [
mergeLabel: "Application",
key: "id",
properties: {
id: "string", name: "string", schema_version: "string", language: "string", max_level: "integer", k_limit: "integer",
id: "string", name: "string", identity_version: "integer", application_name: "string", identity_namespace: "string", schema_version: "string", language: "string", max_level: "integer", k_limit: "integer",
// Analyzer identity — mirrors the JSON envelope's `analyzer{name,version}` (issue #43),
// namespaced (not bare name/version) to avoid colliding with the application name / every
// other CanNode's bare `name`.
Expand Down
Loading
Loading