From e7ba73a31f887b30133350c56f3c361ef6d37296 Mon Sep 17 00:00:00 2001 From: George Safta Date: Sun, 25 Oct 2026 00:11:28 +0300 Subject: [PATCH 1/2] feat: --workspace-root makes published file keys and ids repo-relative (#219) With --workspace-root, symbol_table keys, module ids and every id below them, and artifact keys/paths/ids are relative to the workspace root instead of --input, matching codeanalyzer-java. The re-key happens at emission only: signatures, entrypoint path rules, test-file detection and the cache keep the input-relative key. --input outside the root is an InputError. --- README.md | 16 +++- src/cli.ts | 2 + src/core.ts | 14 +++ src/options/options.ts | 6 ++ src/schema/assignIds.ts | 15 +++- src/schema/emit.ts | 39 ++++++++- test/workspace-root.test.ts | 170 ++++++++++++++++++++++++++++++++++++ 7 files changed, 253 insertions(+), 9 deletions(-) create mode 100644 test/workspace-root.test.ts diff --git a/README.md b/README.md index 2627655..78d410b 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,9 @@ Options: Neo4j schema.json contract) (default: "json") --app-name logical application name for the graph :Application anchor (default: input dir name) + --workspace-root directory file keys, module ids and artifact + ids are relative to (default: --input); must + contain --input --neo4j-uri push the graph to a live Neo4j over Bolt (incremental); omit to write graph.cypher (env: NEO4J_URI) @@ -229,12 +232,21 @@ 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. **Repository-relative paths for a project inside a larger repo:** + ```sh + cants --input ./monorepo/packages/web --workspace-root ./monorepo + # symbol_table key: packages/web/src/app.ts + ``` + Only the published keys and ids change: the application id (`can://`) and + `signature`s stay as they are, entrypoint path rules still match from `--input`, and the cache + is reused either way. + +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 diff --git a/src/cli.ts b/src/cli.ts index beaa281..6ceddd3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,6 +17,7 @@ export function buildProgram(): Command { .option("-o, --output ", "output directory (omit ⇒ compact output to stdout)") .option("--emit ", "output target: json (analysis.json, default) | neo4j (graph.cypher or live push) | schema (the Neo4j schema.json contract)", "json") .option("--app-name ", "logical application name for the graph :Application anchor (default: input dir name)") + .option("--workspace-root ", "directory file keys, module ids and artifact ids are relative to (default: --input); must contain --input") // The four Neo4j connection options also read the standard NEO4J_* environment variables when // the flag is omitted (an explicit flag wins). Prefer NEO4J_PASSWORD over the flag — a flag // value is visible in shell history / the process list. Commander renders the `(env: …)` hint. @@ -146,6 +147,7 @@ export function parseArgs(argv: string[]): AnalysisOptions { output: o.output ? path.resolve(String(o.output)) : null, emit, appName: o.appName ? String(o.appName) : null, + workspaceRoot: o.workspaceRoot ? path.resolve(String(o.workspaceRoot)) : null, neo4jUri: o.neo4jUri ? String(o.neo4jUri) : null, neo4jUser: String(o.neo4jUser), neo4jPassword: String(o.neo4jPassword), diff --git a/src/core.ts b/src/core.ts index d66d479..694c5f0 100644 --- a/src/core.ts +++ b/src/core.ts @@ -51,8 +51,22 @@ function assertInputDir(input: string): void { if (!stat.isDirectory()) throw new InputError(`input path is not a directory: ${input}`); } +/** + * Published keys are relativized against the workspace root, so --input must be that root or inside + * it — otherwise a key would need `..`, which the schema forbids (relative, never absolute or `..`). + * Checked before any work, like the input itself. + */ +function assertInsideWorkspace(input: string, workspaceRoot: string | null | undefined): void { + if (!workspaceRoot) return; + const rel = path.relative(path.resolve(workspaceRoot), path.resolve(input)); + if (rel.startsWith("..") || path.isAbsolute(rel)) { + throw new InputError(`--input ${input} is not inside --workspace-root ${workspaceRoot}`); + } +} + export async function analyze(opts: AnalysisOptions): Promise { assertInputDir(opts.input); + assertInsideWorkspace(opts.input, opts.workspaceRoot); const log = new Logger(opts.verbosity); log.info(`analyzing ${opts.input} (level ${opts.analysisLevel})`); resetCheckerFailures(); diff --git a/src/options/options.ts b/src/options/options.ts index ffb3788..941897c 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -13,6 +13,12 @@ export interface AnalysisOptions { emit: EmitTarget; /** Logical application name for the graph's :Application anchor; null ⇒ derived from input. */ appName: string | null; + /** + * Directory the PUBLISHED file keys (and the module/artifact ids built from them) are relative to + * (absolute); null ⇒ `input`. Must contain `input`. Emission-only: signatures, entrypoint + * path rules, test detection and the cache keep the input-relative key. + */ + workspaceRoot?: string | null; /** Bolt URI for a live Neo4j push (incremental). null ⇒ write a graph.cypher snapshot to -o. */ neo4jUri: string | null; neo4jUser: string; diff --git a/src/schema/assignIds.ts b/src/schema/assignIds.ts index e5df414..a34fc16 100644 --- a/src/schema/assignIds.ts +++ b/src/schema/assignIds.ts @@ -19,7 +19,12 @@ export interface AssignedIds { collisions: string[]; // signatures that mapped to two distinct ids (L1 id-uniqueness gate) } -export function assignIds(app: AnalysisInternal, appName: string): AssignedIds { +/** + * @param keyPrefix prepended to every file key an id is minted from (`--workspace-root`: the + * input's path under the workspace, with a trailing `/`; `""` when unset). Ids only — the + * signature registry stays keyed on the input-relative module prefix every other pass joins on. + */ +export function assignIds(app: AnalysisInternal, appName: string, keyPrefix = ""): AssignedIds { const appId = applicationIdOf(appName); const idBySig = new Map(); const typeIdBySig = new Map(); @@ -70,7 +75,7 @@ export function assignIds(app: AnalysisInternal, appName: string): AssignedIds { }; for (const [fileKey, mod] of Object.entries(app.symbol_table)) { - const moduleId = moduleIdOf(appName, fileKey); + const moduleId = moduleIdOf(appName, keyPrefix + fileKey); const modulePrefix = modulePrefixOf(fileKey); mod.id = moduleId; // Module-scope execution is a call-graph SOURCE (python #131 parity: a call in module scope @@ -86,7 +91,7 @@ export function assignIds(app: AnalysisInternal, appName: string): AssignedIds { // language-NEUTRAL (`can:///artifact/...`); dependency/import records are flat evidence // rows with no node id of their own (the graph's :Package node is purl-keyed). for (const [relPath, art] of Object.entries(app.artifacts ?? {})) { - art.id = artifactIdOf(appName, relPath); + art.id = artifactIdOf(appName, keyPrefix + relPath); // Deployment-env id disambiguation (#101 unit D fix round 1, python v1.3.0 parity verbatim): // the `key` FIELD always stays the bare variable name — env-namespace resolution still joins // on a plain `key ===` match — but a bare name can collide across mints on the SAME artifact @@ -106,7 +111,9 @@ export function assignIds(app: AnalysisInternal, appName: string): AssignedIds { const artPath = dep.declared_in; // scanners record the REL PATH; re-stamp onto the id // slice(4) drops `can:`, ``, ``, `artifact` — the same depth the old `can://artifact//` // shape had, so the reorder left this re-stamp guard alone. - dep.declared_in = artifactIdOf(appName, artPath.startsWith("can://") ? artPath.split("/").slice(4).join("/") : artPath); + dep.declared_in = artPath.startsWith("can://") + ? artifactIdOf(appName, artPath.split("/").slice(4).join("/")) + : artifactIdOf(appName, keyPrefix + artPath); } return { appId, idBySig, typeIdBySig, callableBySig, collisions }; diff --git a/src/schema/emit.ts b/src/schema/emit.ts index 193c4f6..dc67047 100644 --- a/src/schema/emit.ts +++ b/src/schema/emit.ts @@ -19,6 +19,7 @@ import * as path from "node:path"; import type { Project } from "ts-morph"; import type { AnalysisOptions } from "../options"; +import { relPosix } from "../utils/fs"; import { ANALYZER_VERSION } from "../utils/version"; import type { AnalysisInternal, TSAnalysis, TSApplication } from "./schema"; import type { ProgramGraphs } from "./graphs"; @@ -72,6 +73,37 @@ function stripInternal(root: TSApplication): void { } } +/** + * `--workspace-root`: the input's path under the workspace root, `/`-terminated, prepended to every + * PUBLISHED file key; `""` when unset or equal to the input. Emission-only — the live tree, the + * signatures, entrypoint path rules and the cache all keep the input-relative key. + */ +function workspaceKeyPrefix(opts: AnalysisOptions): string { + if (!opts.workspaceRoot || !opts.input) return ""; + const rel = relPosix(opts.workspaceRoot, opts.input); + return rel === "" ? "" : `${rel}/`; +} + +/** + * The wire's other path-keyed maps, re-keyed under `keyPrefix`: artifacts (map key + `path`) and the + * synthesized callables' owning-module `path`. Ids were already minted with the prefix by + * assignIds; this only renames. New objects, so the live tree keeps its input-relative paths. + */ +function rekeyRepoPaths(root: TSApplication, keyPrefix: string): Partial { + if (!keyPrefix) return {}; + const artifacts: TSApplication["artifacts"] = {}; + for (const [key, art] of Object.entries(root.artifacts)) artifacts[keyPrefix + key] = { ...art, path: keyPrefix + art.path }; + const out: Partial = { artifacts }; + if (root.synthesized_callables) { + const synthesized: NonNullable = {}; + for (const [id, sc] of Object.entries(root.synthesized_callables)) { + synthesized[id] = sc.path === undefined ? sc : { ...sc, path: keyPrefix + sc.path }; + } + out.synthesized_callables = synthesized; + } + return out; +} + // ---------------------------------------------------------------------------------------------- // entry point // ---------------------------------------------------------------------------------------------- @@ -95,9 +127,10 @@ export function finalizeAnalysis( ): AnalysisResult { const level = opts.analysisLevel; const appName = (opts.appName ?? (opts.input ? path.basename(opts.input) : "") ?? "").trim() || "app"; + const keyPrefix = workspaceKeyPrefix(opts); // L1 — stamp ids, derive body{}, project heritage (all overwrite-idempotent per-run passes). - const { appId, idBySig, typeIdBySig, callableBySig, collisions } = assignIds(app, appName); + const { appId, idBySig, typeIdBySig, callableBySig, collisions } = assignIds(app, appName, keyPrefix); populateL1Body(app); // `extends`/`implements` name TYPES: on a merged name (#177) the type facet's id wins over the // value facet's, which is what `idBySig` holds for every other pass. @@ -169,8 +202,8 @@ export function finalizeAnalysis( // strip and shared by reference. A stringify roundtrip is out for the same reason: the string // form OOMs at vscode-L4 scale. const symbol_table: TSApplication["symbol_table"] = {}; - for (const [key, mod] of Object.entries(root.symbol_table)) symbol_table[key] = structuredClone(mod); - const application: TSAnalysis = { ...envelope, application: { ...root, symbol_table } }; + for (const [key, mod] of Object.entries(root.symbol_table)) symbol_table[keyPrefix + key] = structuredClone(mod); + const application: TSAnalysis = { ...envelope, application: { ...root, symbol_table, ...rekeyRepoPaths(root, keyPrefix) } }; stripInternal(application.application); return { application, internal: app, ...(pg ? { program_graphs: pg } : {}), idBySig, collisions, dangling }; } diff --git a/test/workspace-root.test.ts b/test/workspace-root.test.ts new file mode 100644 index 0000000..f6166f9 --- /dev/null +++ b/test/workspace-root.test.ts @@ -0,0 +1,170 @@ +/** + * `--workspace-root`: published file keys and the ids built from them are repo-relative. + * + * Re-keying happens at emission only: signatures, entrypoint path rules, test-file detection and + * the cache keep the --input-relative key, so a workspace root changes WHAT a file is called, never + * WHETHER it is analysed or how it matches. Same semantics as codeanalyzer-java, so a + * language-neutral artifact id names the same node whichever analyzer emitted it. + */ +import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { InputError, analyze } from "../src/core"; +import type { AnalysisOptions } from "../src/options"; +import { forEachCallable, type TSApplication } from "../src/schema"; +import { loadCache } from "../src/utils"; + +const ROOT = path.resolve(import.meta.dir, ".."); +const FIXTURES = path.resolve(import.meta.dir, "fixtures"); + +/** + * A repository whose path ABOVE --input holds a `test` segment: discovery's test-tree skip must + * judge the --input-relative key, or every file here would read as a test and vanish. + */ +function repoWith(fixture: string): { repo: string; input: string; prefix: string } { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "cants-219-")); + const rel = "test/services/app"; + const input = path.join(repo, rel); + fs.cpSync(path.join(FIXTURES, fixture), input, { recursive: true }); + return { repo, input, prefix: `${rel}/` }; +} + +function options(input: string, over: Partial = {}): AnalysisOptions { + return { + input, output: null, emit: "json", appName: "app", neo4jUri: null, neo4jUser: "neo4j", + neo4jPassword: "", neo4jDatabase: null, analysisLevel: 1, graphs: ["cfg", "dfg", "pdg", "sdg"], graphFieldDepth: 3, jobs: 1, + targetFiles: null, skipTests: true, eager: true, noBuild: true, phantoms: true, + cacheDir: fs.mkdtempSync(path.join(os.tmpdir(), "cants-219-cache-")), verbosity: 0, + workspaceRoot: null, ...over, + } as AnalysisOptions; +} + +/** Every `can://app/...` id in a payload, wherever it sits. */ +function idsIn(json: string): string[] { + return [...json.matchAll(/"(can:\/\/app\/[^"]*)"/g)].map((m) => m[1]!); +} + +function cli(args: string[]): { code: number; stderr: string } { + const p = Bun.spawnSync(["bun", "run", path.join(ROOT, "src/index.ts"), ...args], { cwd: ROOT, stdout: "pipe", stderr: "pipe" }); + return { code: p.exitCode, stderr: p.stderr.toString() }; +} + +describe("--workspace-root", () => { + test("symbol_table keys, module ids and every code id below them are repo-relative, through L3", async () => { + const { repo, input, prefix } = repoWith("dataflow-app"); + const without = (await analyze(options(input, { analysisLevel: 3 }))).application.application; + const r = await analyze(options(input, { analysisLevel: 3, workspaceRoot: repo })); + const app = r.application.application; + + expect(app.id).toBe("can://app"); // the app segment does not move + const keys = Object.keys(app.symbol_table); + expect(keys.length).toBe(Object.keys(without.symbol_table).length); // nothing lost to the test-dir skip + for (const k of keys) { + expect(k.startsWith(prefix)).toBe(true); + expect(k.startsWith("/") || k.includes("..")).toBe(false); + expect(app.symbol_table[k]!.id).toBe(`can://app/typescript/${k}`); + } + for (const k of Object.keys(without.symbol_table)) expect(app.symbol_table[prefix + k]).toBeDefined(); + + const json = JSON.stringify(app); + const ids = idsIn(json).filter((id) => /^can:\/\/app\/(typescript|javascript)\//.test(id)); + expect(ids.length).toBeGreaterThan(0); + for (const id of ids) expect(id).toMatch(new RegExp(`^can://app/(typescript|javascript)/${prefix}`)); + expect(json).toContain('"cfg"'); // L3 overlays rode along + + expect(app.call_graph.length).toBe(without.call_graph.length); + for (const e of app.call_graph) expect(e.src.includes(`/${prefix}`)).toBe(true); + }); + + test("signatures stay --input-relative: the re-key is the published name, not the analysis", async () => { + const { repo, input } = repoWith("sample-app"); + const a = (await analyze(options(input))).application.application; + const b = (await analyze(options(input, { workspaceRoot: repo }))).application.application; + const sigs = (app: typeof a) => + Object.values(app.symbol_table).flatMap((m) => Object.values(m.functions ?? {}).map((f) => f.signature)).sort(); + expect(sigs(b)).toEqual(sigs(a)); + }); + + test("entrypoint path rules still match against the --input-relative key", async () => { + // A Next.js route file: the shipped rule `app/**/route.{ts,...}` is matched against the + // --input-relative key, so the prefix above it must not stop it matching. + const { repo, input, prefix } = repoWith("sample-app"); + // The Next.js rules are gated on `next` being a declared dependency. + const pkg = JSON.parse(fs.readFileSync(path.join(input, "package.json"), "utf8")); + fs.writeFileSync(path.join(input, "package.json"), JSON.stringify({ ...pkg, dependencies: { next: "14.0.0" } })); + const route = path.join(input, "app/api/hello/route.ts"); + fs.mkdirSync(path.dirname(route), { recursive: true }); + fs.writeFileSync(route, "export async function GET() { return new Response('hi'); }\n"); + const get = (app: TSApplication, key: string) => { + let found: unknown[] | undefined; + forEachCallable(app.symbol_table[key]!, (c) => { if (c.name === "GET") found = c.entrypoints ?? []; }); + return found; + }; + const a = (await analyze(options(input))).application.application; + const b = (await analyze(options(input, { workspaceRoot: repo }))).application.application; + const expected = { rule: "nextjs.app-route", evidence: "app/api/hello/route.ts", route: "/api/hello" }; + expect(get(a, "app/api/hello/route.ts")?.[0]).toMatchObject(expected); // precondition: the rule fires + expect(get(b, `${prefix}app/api/hello/route.ts`)?.[0]).toMatchObject(expected); + }); + + test("artifact keys, paths and ids are prefixed; classification and extraction are unchanged", async () => { + const { repo, input, prefix } = repoWith("artifacts-app"); + const a = (await analyze(options(input))).application.application; + const b = (await analyze(options(input, { workspaceRoot: repo }))).application.application; + + expect(Object.keys(b.artifacts).sort()).toEqual(Object.keys(a.artifacts).map((k) => prefix + k).sort()); + for (const [k, art] of Object.entries(a.artifacts)) { + const moved = b.artifacts[prefix + k]!; + expect(moved.path).toBe(prefix + k); + expect(moved.id).toBe(`can://app/artifact/${prefix}${k}`); + expect(moved.format).toBe(art.format); + expect(moved.roles).toEqual(art.roles); + expect(moved.config_keys.length).toBe(art.config_keys.length); + for (const ck of moved.config_keys) expect(ck.id.startsWith(moved.id)).toBe(true); + } + expect(b.dependencies.length).toBe(a.dependencies.length); + for (const d of b.dependencies) expect(d.declared_in.startsWith(`can://app/artifact/${prefix}`)).toBe(true); + }); + + test("the cache stays --input-relative, so toggling the flag reuses it", async () => { + const { repo, input, prefix } = repoWith("sample-app"); + const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-219-shared-")); + const plain = (await analyze(options(input, { cacheDir }))).application.application; + const cachedKeys = () => Object.keys(loadCache(cacheDir)!.symbol_table).sort(); + const before = cachedKeys(); + const moved = (await analyze(options(input, { cacheDir, eager: false, workspaceRoot: repo }))).application.application; + expect(cachedKeys()).toEqual(before); // no prefixed key written into the cache + for (const k of before) expect(k.startsWith(prefix)).toBe(false); + expect(Object.keys(moved.symbol_table).sort()).toEqual(Object.keys(plain.symbol_table).map((k) => prefix + k).sort()); + }); + + test("a workspace root equal to --input changes nothing", async () => { + const { input } = repoWith("artifacts-app"); + const a = (await analyze(options(input))).application; + const b = (await analyze(options(input, { workspaceRoot: input }))).application; + expect(JSON.stringify(b)).toBe(JSON.stringify(a)); + }); + + test("an --input outside the workspace root is an InputError naming both", async () => { + const { input } = repoWith("sample-app"); + const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), "cants-219-elsewhere-")); + const run = analyze(options(input, { workspaceRoot: elsewhere })); + await expect(run).rejects.toBeInstanceOf(InputError); + await expect(analyze(options(input, { workspaceRoot: elsewhere }))).rejects.toThrow(elsewhere); + }); + + test("CLI: the flag reaches the analysis, and an outside input exits 1 writing nothing", () => { + const { repo, input, prefix } = repoWith("sample-app"); + const out = path.join(repo, "out"); + expect(cli(["-i", input, "-o", out, "--no-build", "--app-name", "app", "--workspace-root", repo]).code).toBe(0); + const j = JSON.parse(fs.readFileSync(path.join(out, "analysis.json"), "utf8")); + for (const k of Object.keys(j.application.symbol_table)) expect(k.startsWith(prefix)).toBe(true); + + const bad = path.join(repo, "bad-out"); + const r = cli(["-i", input, "-o", bad, "--no-build", "--workspace-root", path.join(repo, "nope")]); + expect(r.code).toBe(1); + expect(r.stderr).toContain("--workspace-root"); + expect(fs.existsSync(path.join(bad, "analysis.json"))).toBe(false); + }); +}); From b41215f05f54eefe241724009a437b35e490b615 Mon Sep 17 00:00:00 2001 From: George Safta Date: Thu, 29 Oct 2026 20:40:25 +0200 Subject: [PATCH 2/2] add artifact reconcile strategy and update resolved modules --- .github/workflows/ci.yml | 2 +- README.md | 14 ++ package.json | 2 +- src/build/neo4j/bolt.ts | 70 ++++++- src/schema/emit.ts | 11 +- test/artifacts.test.ts | 17 +- test/neo4j-artifact-reconcile.test.ts | 261 ++++++++++++++++++++++++++ test/workspace-root.test.ts | 22 ++- 8 files changed, 378 insertions(+), 21 deletions(-) create mode 100644 test/neo4j-artifact-reconcile.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ef0072..9c7085b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 78d410b..6592a3f 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,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 a deleted or renamed file, a removed dependency, or re-keying under + `--workspace-root` leaves nothing stale. 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 diff --git a/package.json b/package.json index ab9a590..09fbb3c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/build/neo4j/bolt.ts b/src/build/neo4j/bolt.ts index a17f8cb..9e43030 100644 --- a/src/build/neo4j/bolt.ts +++ b/src/build/neo4j/bolt.ts @@ -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. @@ -58,7 +60,50 @@ 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. Pre-1.5.1 artifacts carry no marker, + * so under the legacy prefix an unmarked node counts as ours. + */ +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} OR (pre = $legacy AND none(l IN labels(n) WHERE l ENDS WITH 'CanNode'))) ` + + `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"; + +/** `can:///artifact/`, and `can://artifact//` for pre-1.5.1 pushes; both `/`-terminated. */ +export function artifactPrefixes(prefix: string): [string, string] { + return [`${prefix}artifact/`, `can://artifact/${prefix.slice("can://".length)}`]; +} + +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`; @@ -143,6 +188,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, legacy: pres[1], 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(); await withSession(session, async (s) => { @@ -196,10 +259,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); diff --git a/src/schema/emit.ts b/src/schema/emit.ts index dc67047..dfa1340 100644 --- a/src/schema/emit.ts +++ b/src/schema/emit.ts @@ -202,7 +202,16 @@ export function finalizeAnalysis( // strip and shared by reference. A stringify roundtrip is out for the same reason: the string // form OOMs at vscode-L4 scale. const symbol_table: TSApplication["symbol_table"] = {}; - for (const [key, mod] of Object.entries(root.symbol_table)) symbol_table[keyPrefix + key] = structuredClone(mod); + for (const [key, mod] of Object.entries(root.symbol_table)) { + const wire = structuredClone(mod); + // `resolved_module` names a symbol_table key, so it takes the same prefix (on the clone only). + if (keyPrefix) { + for (const b of [...(wire.imports ?? []), ...(wire.exports ?? [])]) { + if (b.resolved_module !== undefined) b.resolved_module = keyPrefix + b.resolved_module; + } + } + symbol_table[keyPrefix + key] = wire; + } const application: TSAnalysis = { ...envelope, application: { ...root, symbol_table, ...rekeyRepoPaths(root, keyPrefix) } }; stripInternal(application.application); return { application, internal: app, ...(pg ? { program_graphs: pg } : {}), idBySig, collisions, dangling }; diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index c66f88a..2257620 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -191,20 +191,9 @@ describe("Neo4j projection — neutral :Artifact/:Package (#101)", () => { const rows = project(r1.application); /** - * Artifacts are inside the destructive scope: they carry the marker, so the prefix-scoped wipe - * reaches them and stale ones are reclaimed instead of accumulating forever. This test pins the - * BOTH halves of that trade, because the second half is a real cost paid by polyglot databases. - * - * What makes it affordable is that this analyzer's artifact walk covers the WHOLE repository, - * not only files it can parse: it deletes and then re-creates every :Artifact node, a sibling - * analyzer's manifests included. What it does not re-create are the config keys and dependency - * edges only that sibling can produce. - * - * Measured against a live store (neo4j:5, throwaway container): on a second push, python's - * :ConfigKey rows under `pyproject.toml` SURVIVE — they never acquired `TSCanNode`, because the - * marker is only ever attached by the analyzer that MERGEs the row — while python's - * DECLARES_DEPENDENCY edge into `pkg:pypi/flask` is dropped by the DETACH and comes back only on - * python's next push. The `:Package` node itself is purl-keyed, never marked, never in scope. + * Artifacts carry the marker: it is this analyzer's claim, which a full `--eager` Bolt run + * releases when the artifact vanishes (see neo4j-artifact-reconcile.test.ts). The `:Package` + * node is purl-keyed, never marked, never in scope. */ test("the wipe reaches artifacts, and the same push restores every one it deletes", () => { const canRows = rows.nodes.filter((n) => n.keyProp === "id" && n.value.startsWith("can://")); diff --git a/test/neo4j-artifact-reconcile.test.ts b/test/neo4j-artifact-reconcile.test.ts new file mode 100644 index 0000000..6521ad8 --- /dev/null +++ b/test/neo4j-artifact-reconcile.test.ts @@ -0,0 +1,261 @@ +/** + * Bolt artifact reconciliation on a full `--eager` run: stale artifacts, config keys and + * TS-owned artifact edges go; anything another analyzer still claims stays. Same contract as + * codeanalyzer-java's and codeanalyzer-python's Bolt writers. Opt-in, like neo4j-bolt.test.ts. + */ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Neo4jContainer, type StartedNeo4jContainer } from "@testcontainers/neo4j"; +import neo4j, { type Driver } from "neo4j-driver"; +import { type BoltConfig, boltWriter, project } from "../src/build/neo4j"; +import type { GraphRows } from "../src/build/neo4j/rows"; +import { analyze } from "../src/core"; +import type { AnalysisOptions } from "../src/options"; +import { Logger } from "../src/utils"; + +const FIXTURES = path.resolve(import.meta.dir, "fixtures"); +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cants-reconcile-")); +const log = new Logger(0); +const APP = "can://app"; +const ART = `${APP}/artifact/`; +const containerSuite = process.env.RUN_CONTAINER_TESTS ? describe : describe.skip; + +/** artifacts-app copied to `/services/app`, so it can be pushed with or without a workspace root. */ +function repo(): { repo: string; input: string } { + const root = fs.mkdtempSync(path.join(TMP, "repo-")); + const input = path.join(root, "services/app"); + fs.cpSync(path.join(FIXTURES, "artifacts-app"), input, { recursive: true }); + return { repo: root, input }; +} + +async function rows(input: string, over: Partial = {}): Promise { + const opts = { + input, output: null, emit: "json", appName: "app", neo4jUri: null, neo4jUser: "neo4j", + neo4jPassword: "", neo4jDatabase: null, analysisLevel: 2, graphs: [], graphFieldDepth: 3, jobs: 1, + targetFiles: null, skipTests: true, eager: true, noBuild: true, phantoms: true, verbosity: 0, + cacheDir: fs.mkdtempSync(path.join(TMP, "cache-")), workspaceRoot: null, ...over, + } as AnalysisOptions; + return project((await analyze(opts)).application); +} + +containerSuite("neo4j bolt artifact reconciliation", () => { + let container: StartedNeo4jContainer; + let driver: Driver; + let cfg: BoltConfig; + + beforeAll(async () => { + container = await new Neo4jContainer("neo4j:5").withPassword("testpassword123").start(); + cfg = { uri: container.getBoltUri(), user: container.getUsername(), password: container.getPassword(), database: null }; + driver = neo4j.driver(cfg.uri, neo4j.auth.basic(cfg.user, cfg.password)); + }, 240_000); + + afterAll(async () => { + await driver?.close(); + await container?.stop(); + fs.rmSync(TMP, { recursive: true, force: true }); + }); + + beforeEach(async () => { + await run("MATCH (n) DETACH DELETE n"); + }); + + async function run(cypher: string, params: Record = {}): Promise { + const s = driver.session(); + try { await s.run(cypher, params); } finally { await s.close(); } + } + + async function num(cypher: string, params: Record = {}): Promise { + const s = driver.session(); + try { + const v = (await s.run(cypher, params)).records[0]?.get(0); + return typeof v?.toNumber === "function" ? v.toNumber() : Number(v ?? 0); + } finally { + await s.close(); + } + } + + const stale = (rooted: string) => + num("MATCH (n) WHERE (n:Artifact OR n:ConfigKey) AND n.id STARTS WITH $p AND NOT n.id STARTS WITH $r RETURN count(n)", + { p: ART, r: ART + rooted }); + + test("toggling --workspace-root leaves no old-keyed artifact, config key or edge", async () => { + const { repo: root, input } = repo(); + await boltWriter(await rows(input), cfg, log, true, true); + expect(await num("MATCH (a:Artifact {id: $id}) RETURN count(a)", { id: ART + "package.json" })).toBe(1); + + const rooted = await rows(input, { workspaceRoot: root }); + await boltWriter(rooted, cfg, log, true, true); + + expect(await stale("services/app/")).toBe(0); + const arts = rooted.nodes.filter((n) => n.labels[0] === "Artifact").length; + expect(await num("MATCH (:TSApplication)-[r:HAS_ARTIFACT]->() RETURN count(r)")).toBe(arts); + expect(await num("MATCH ()-[r:DECLARES_DEPENDENCY]->(:Package {id: 'pkg:npm/react'}) RETURN count(r)")).toBe(1); + const uses = rooted.edges.filter((e) => e.type === "TS_USES_CONFIG").length; + expect(uses).toBeGreaterThan(0); + expect(await num("MATCH ()-[r:TS_USES_CONFIG]->() RETURN count(r)")).toBe(uses); + }, 180_000); + + test("a targeted or non-eager push reconciles no artifacts", async () => { + const { repo: root, input } = repo(); + await boltWriter(await rows(input), cfg, log, true, true); + const rooted = await rows(input, { workspaceRoot: root }); + await boltWriter(rooted, cfg, log, false, true); + await boltWriter(rooted, cfg, log, true, false); + expect(await num("MATCH (a:Artifact {id: $id}) RETURN count(a)", { id: ART + "package.json" })).toBe(1); + }, 180_000); + + test("a changed manifest drops its removed dependency, config key and their edges", async () => { + const { input } = repo(); + await boltWriter(await rows(input), cfg, log, true, true); + expect(await num("MATCH ()-[r:DECLARES_DEPENDENCY]->(:Package {id: 'pkg:npm/react'}) RETURN count(r)")).toBe(1); + const envKey = `${ART}.env@key/PAYMENT_HOST`; + expect(await num("MATCH ()-[r:TS_USES_CONFIG]->(:ConfigKey {id: $k}) RETURN count(r)", { k: envKey })).toBeGreaterThan(0); + + const pkg = JSON.parse(fs.readFileSync(path.join(input, "package.json"), "utf8")); + delete pkg.peerDependencies; + fs.writeFileSync(path.join(input, "package.json"), JSON.stringify(pkg)); + const env = fs.readFileSync(path.join(input, ".env"), "utf8"); + fs.writeFileSync(path.join(input, ".env"), env.split("\n").filter((l) => !l.startsWith("PAYMENT_HOST")).join("\n")); + await boltWriter(await rows(input), cfg, log, true, true); + + expect(await num("MATCH ()-[r:DECLARES_DEPENDENCY]->(:Package {id: 'pkg:npm/react'}) RETURN count(r)")).toBe(0); + expect(await num("MATCH (k:ConfigKey {id: $k}) RETURN count(k)", { k: envKey })).toBe(0); + expect(await num("MATCH ()-[r:TS_USES_CONFIG]->(:ConfigKey {id: $k}) RETURN count(r)", { k: envKey })).toBe(0); + }, 180_000); + + test("an unchanged eager re-push is idempotent", async () => { + const { input } = repo(); + const r = await rows(input); + await boltWriter(r, cfg, log, true, true); + const shape = "MATCH (n) OPTIONAL MATCH (n)-[r]-() RETURN count(DISTINCT n) * 1000000 + count(DISTINCT r)"; + const before = await num(shape); + await boltWriter(r, cfg, log, true, true); + expect(await num(shape)).toBe(before); + }, 180_000); + + test("a sibling's claims survive: shared nodes, its edges, and nodes only it wrote", async () => { + const { input } = repo(); + await boltWriter(await rows(input), cfg, log, true, true); + // What codeanalyzer-python writes: its own inventory of pyproject.toml with a pypi dependency and + // a config key, plus an artifact TypeScript never emits. + await run( + "MATCH (a:Artifact {id: $pp}) SET a:PyCanNode " + + "MERGE (app:PyApplication {id: 'can://app'}) MERGE (app)-[:HAS_ARTIFACT]->(a) " + + "MERGE (p:Package {id: 'pkg:pypi/flask'}) MERGE (a)-[:DECLARES_DEPENDENCY {_k: 'runtime'}]->(p) " + + "MERGE (k:ConfigKey:PyCanNode {id: $pp + '@key/tool.name'}) MERGE (a)-[:DEFINES_CONFIG]->(k) " + + "MERGE (o:Artifact:PyCanNode {id: $py}) MERGE (app)-[:HAS_ARTIFACT]->(o)", + { pp: ART + "pyproject.toml", py: ART + "setup.cfg" }, + ); + // Stop emitting pyproject.toml: TypeScript releases its claim but must not delete python's node. + fs.rmSync(path.join(input, "pyproject.toml")); + await boltWriter(await rows(input), cfg, log, true, true); + + const pp = ART + "pyproject.toml"; + expect(await num("MATCH (a:Artifact:PyCanNode {id: $id}) WHERE NOT a:TSCanNode RETURN count(a)", { id: pp })).toBe(1); + expect(await num("MATCH (:TSApplication)-[r:HAS_ARTIFACT]->(:Artifact {id: $id}) RETURN count(r)", { id: pp })).toBe(0); + expect(await num("MATCH (:Artifact {id: $id})-[r:DECLARES_DEPENDENCY]->(:Package {id: 'pkg:pypi/flask'}) RETURN count(r)", { id: pp })).toBe(1); + expect(await num("MATCH (:Artifact {id: $id})-[:DEFINES_CONFIG]->(:ConfigKey:PyCanNode) RETURN count(*)", { id: pp })).toBe(1); + expect(await num("MATCH (a:Artifact {id: $id}) RETURN count(a)", { id: ART + "setup.cfg" })).toBe(1); + }, 180_000); + + test("a config key another analyzer claims survives TypeScript dropping it", async () => { + const { input } = repo(); + await boltWriter(await rows(input), cfg, log, true, true); + const key = `${ART}.env@key/PAYMENT_HOST`; + await run("MATCH (k:ConfigKey {id: $k}) SET k:PyCanNode", { k: key }); + const env = fs.readFileSync(path.join(input, ".env"), "utf8"); + fs.writeFileSync(path.join(input, ".env"), env.split("\n").filter((l) => !l.startsWith("PAYMENT_HOST")).join("\n")); + await boltWriter(await rows(input), cfg, log, true, true); + expect(await num("MATCH (:Artifact)-[:DEFINES_CONFIG]->(k:ConfigKey:PyCanNode {id: $k}) WHERE NOT k:TSCanNode RETURN count(k)", { k: key })).toBe(1); + }, 180_000); + + test("an app whose name extends another's is not reconciled by it", async () => { + const a = repo(); + const b = repo(); + await boltWriter(await rows(b.input, { appName: "apps" }), cfg, log, true, true); + const before = await num("MATCH (n) WHERE (n:Artifact OR n:ConfigKey) AND n.id STARTS WITH 'can://apps/' RETURN count(n)"); + expect(before).toBeGreaterThan(0); + for (const f of fs.readdirSync(a.input)) if (f !== "src") fs.rmSync(path.join(a.input, f), { recursive: true }); + await boltWriter(await rows(a.input), cfg, log, true, true); + expect(await num("MATCH (n:TSCanNode) WHERE (n:Artifact OR n:ConfigKey) AND n.id STARTS WITH 'can://apps/' RETURN count(n)")).toBe(before); + }, 180_000); + + test("pre-re-key artifacts are released; a sibling's are left alone", async () => { + await run( + "MERGE (a:Artifact {id: 'can://artifact/app/old.json'}) MERGE (k:ConfigKey {id: 'can://artifact/app/old.json@key/x'}) " + + "MERGE (a)-[:DEFINES_CONFIG]->(k) MERGE (j:Artifact:JCanNode {id: 'can://artifact/app/pom.xml'})", + ); + const { input } = repo(); + await boltWriter(await rows(input), cfg, log, true, true); + expect(await num("MATCH (n) WHERE n.id STARTS WITH 'can://artifact/app/old.json' RETURN count(n)")).toBe(0); + expect(await num("MATCH (n:JCanNode {id: 'can://artifact/app/pom.xml'}) RETURN count(n)")).toBe(1); + }, 180_000); + + test("--eager --target-files purges no artifact and drops none of their edges", async () => { + const { input } = repo(); + const full = await rows(input); + await boltWriter(full, cfg, log, true, true); + // A sibling's claim on the shared package.json: the old purge DETACH-deleted it on every eager run. + await run("MATCH (a:Artifact {id: $id}) MERGE (p:PyApplication {id: 'can://app'}) MERGE (p)-[:HAS_ARTIFACT]->(a) " + + "MERGE (k:Package {id: 'pkg:pypi/flask'}) MERGE (a)-[:DECLARES_DEPENDENCY {_k: 'runtime'}]->(k)", { id: ART + "package.json" }); + const shape = "MATCH (a) WHERE a:Artifact OR a:ConfigKey OPTIONAL MATCH (a)-[r]-() " + + "RETURN count(DISTINCT a) * 1000000 + count(DISTINCT r)"; + const before = await num(shape); + await boltWriter(full, cfg, log, false, true); + expect(await num(shape)).toBe(before); + expect(await num("MATCH (:PyApplication)-[:HAS_ARTIFACT]->(:Artifact {id: $id})-[:DECLARES_DEPENDENCY]->(:Package {id: 'pkg:pypi/flask'}) " + + "RETURN count(*)", { id: ART + "package.json" })).toBe(1); + }, 180_000); + + test("a pre-1.5.1 unmarked artifact a sibling still inventories survives", async () => { + await run("MERGE (a:Artifact {id: 'can://artifact/app/requirements.txt'}) " + + "MERGE (p:PyApplication {id: 'can://app'}) MERGE (p)-[:HAS_ARTIFACT]->(a)"); + const { input } = repo(); + await boltWriter(await rows(input), cfg, log, true, true); + expect(await num("MATCH (:PyApplication)-[:HAS_ARTIFACT]->(a:Artifact {id: 'can://artifact/app/requirements.txt'}) " + + "RETURN count(a)")).toBe(1); + }, 180_000); + + test("a removed dependency and a now-declared import drop their import edges", async () => { + const { input } = repo(); + await boltWriter(await rows(input), cfg, log, true, true); + const provides = "MATCH (:Package {id: 'pkg:npm/express'})-[r:TS_PROVIDES]->() RETURN count(r)"; + const unresolved = "MATCH (:TSApplication)-[r:TS_UNRESOLVED_IMPORT]->(g) WHERE g.id ENDS WITH '/@external/left-pad' RETURN count(r)"; + expect(await num(provides)).toBe(1); + expect(await num(unresolved)).toBe(1); + + const pkg = JSON.parse(fs.readFileSync(path.join(input, "package.json"), "utf8")); + delete pkg.dependencies.express; + pkg.dependencies["left-pad"] = "^1.3.0"; + fs.writeFileSync(path.join(input, "package.json"), JSON.stringify(pkg)); + for (const lock of ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"]) fs.rmSync(path.join(input, lock), { force: true }); + await boltWriter(await rows(input), cfg, log, true, true); + + expect(await num(provides)).toBe(0); + expect(await num(unresolved)).toBe(0); + }, 180_000); + + test("javascript-namespace code edges into config keys are rebuilt, not lost", async () => { + const { input } = repo(); + const ts = path.join(input, "src/config.ts"); + fs.writeFileSync(path.join(input, "src/config.js"), + "export function readHost() { return process.env.PAYMENT_HOST; }\n"); + fs.rmSync(ts); + fs.writeFileSync(path.join(input, "tsconfig.json"), JSON.stringify({ compilerOptions: { allowJs: true, target: "ES2022" }, include: ["src"] })); + const r = await rows(input); + const jsUses = r.edges.filter((e) => e.type === "TS_USES_CONFIG" && e.from.value.includes("/javascript/")).length; + expect(jsUses).toBeGreaterThan(0); + await boltWriter(r, cfg, log, true, true); + await boltWriter(r, cfg, log, true, true); // second push: every module unchanged, edges must come back + expect(await num("MATCH (s:JSCanNode)-[r:TS_USES_CONFIG]->(:ConfigKey) RETURN count(r)")).toBe(jsUses); + }, 180_000); + + test("reconciliation spans several batches", async () => { + await run("UNWIND range(1, 2500) AS i CREATE (:Artifact:TSCanNode {id: $p + 'stale/' + toString(i)})", { p: ART }); + const { input } = repo(); + await boltWriter(await rows(input), cfg, log, true, true); + expect(await num("MATCH (a:Artifact) WHERE a.id STARTS WITH $p RETURN count(a)", { p: ART + "stale/" })).toBe(0); + }, 180_000); +}); diff --git a/test/workspace-root.test.ts b/test/workspace-root.test.ts index f6166f9..25047b6 100644 --- a/test/workspace-root.test.ts +++ b/test/workspace-root.test.ts @@ -12,7 +12,8 @@ import * as os from "node:os"; import * as path from "node:path"; import { InputError, analyze } from "../src/core"; import type { AnalysisOptions } from "../src/options"; -import { forEachCallable, type TSApplication } from "../src/schema"; +import { project } from "../src/build/neo4j"; +import { forEachCallable, type TSAnalysis, type TSApplication } from "../src/schema"; import { loadCache } from "../src/utils"; const ROOT = path.resolve(import.meta.dir, ".."); @@ -77,6 +78,25 @@ describe("--workspace-root", () => { for (const e of app.call_graph) expect(e.src.includes(`/${prefix}`)).toBe(true); }); + test("resolved_module names a published symbol_table key, so internal import edges survive", async () => { + const { repo, input, prefix } = repoWith("sample-app"); + const bindings = (a: TSApplication) => + Object.values(a.symbol_table).flatMap((m) => [...(m.imports ?? []), ...(m.exports ?? [])]); + const internalImports = (a: TSAnalysis) => + project(a).edges.filter((e) => (e.type === "TS_IMPORTS" || e.type === "TS_RE_EXPORTS") && e.to.label !== "TSExternal").length; + const without = (await analyze(options(input))).application; + const rooted = (await analyze(options(input, { workspaceRoot: repo }))).application; + + const resolved = bindings(rooted.application).map((b) => b.resolved_module).filter((k) => k !== undefined); + expect(resolved.length).toBeGreaterThan(0); + for (const k of resolved) { + expect(k.startsWith(prefix)).toBe(true); + expect(rooted.application.symbol_table[k]).toBeDefined(); + } + expect(internalImports(without)).toBeGreaterThan(0); + expect(internalImports(rooted)).toBe(internalImports(without)); + }); + test("signatures stay --input-relative: the re-key is the published name, not the analysis", async () => { const { repo, input } = repoWith("sample-app"); const a = (await analyze(options(input))).application.application;