From e7ba73a31f887b30133350c56f3c361ef6d37296 Mon Sep 17 00:00:00 2001 From: George Safta Date: Sun, 25 Oct 2026 00:11:28 +0300 Subject: [PATCH 1/7] 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/7] 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; From dbb5ccdcad46f5ce9c39e279297cf535579907d0 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 30 Oct 2026 13:41:08 -0400 Subject: [PATCH 3/7] test: add namespace identity contract fixtures --- test/fixtures/identity/namespaces.json | 139 +++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 test/fixtures/identity/namespaces.json diff --git a/test/fixtures/identity/namespaces.json b/test/fixtures/identity/namespaces.json new file mode 100644 index 0000000..8a89219 --- /dev/null +++ b/test/fixtures/identity/namespaces.json @@ -0,0 +1,139 @@ +{ + "identity_version": 2, + "roots": [ + { + "application_name": "inventory", + "namespace": null, + "expected_root": "can://inventory/inventory" + }, + { + "application_name": "currencyservice", + "namespace": "online-boutique", + "expected_root": "can://online-boutique/currencyservice" + }, + { + "application_name": "a%b", + "namespace": "team space", + "expected_root": "can://team%20space/a%25b" + }, + { + "application_name": "caf\u00e9", + "namespace": "\u56e2\u961f", + "expected_root": "can://%E5%9B%A2%E9%98%9F/caf%C3%A9" + }, + { + "application_name": "Inventory", + "namespace": "Team", + "expected_root": "can://Team/Inventory" + }, + { + "application_name": "inventory", + "namespace": "team", + "expected_root": "can://team/inventory" + }, + { + "application_name": " cafe\u0301 ", + "namespace": "team", + "expected_root": "can://team/%20cafe%CC%81%20" + } + ], + "invalid": [ + { + "value": "", + "reason": "empty" + }, + { + "value": " ", + "reason": "whitespace-only" + }, + { + "value": "a/b", + "reason": "slash" + }, + { + "value": "a\\b", + "reason": "backslash" + }, + { + "value": ".", + "reason": "dot" + }, + { + "value": "..", + "reason": "dot-dot" + }, + { + "value": "a\u0001b", + "reason": "control" + }, + { + "value": "\ud800", + "reason": "unpaired-high-surrogate" + }, + { + "value": "\udc00", + "reason": "unpaired-low-surrogate" + } + ], + "modules": [ + { + "application_name": "currencyservice", + "namespace": "online-boutique", + "language": "javascript", + "file_key": "server.js", + "expected_uri": "can://online-boutique/currencyservice/javascript/server.js" + }, + { + "application_name": "paymentservice", + "namespace": "online-boutique", + "language": "javascript", + "file_key": "server.js", + "expected_uri": "can://online-boutique/paymentservice/javascript/server.js" + }, + { + "application_name": "inventory", + "namespace": null, + "language": "python", + "file_key": "src/main.py", + "expected_uri": "can://inventory/inventory/python/src/main.py" + }, + { + "application_name": "inventory", + "namespace": null, + "language": "typescript", + "file_key": "src/main.ts", + "expected_uri": "can://inventory/inventory/typescript/src/main.ts" + }, + { + "application_name": "inventory", + "namespace": null, + "language": "java", + "file_key": "src/Main.java", + "expected_uri": "can://inventory/inventory/java/src/Main.java" + }, + { + "application_name": "inventory", + "namespace": null, + "language": "go", + "file_key": "main.go", + "expected_uri": "can://inventory/inventory/go/main.go" + } + ], + "duplicate_module_rows": { + "rows": [ + { + "module_id": "can://online-boutique/currencyservice/javascript/server.js", + "file_key": "server.js" + }, + { + "module_id": "can://online-boutique/paymentservice/javascript/server.js", + "file_key": "server.js" + }, + { + "module_id": "can://online-boutique/currencyservice/javascript/server.js", + "file_key": "server.js" + } + ], + "expected_unique_id_count": 2 + } +} From f0748dfb52f36ca30b78ad636389058c856bf628 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 30 Oct 2026 14:04:05 -0400 Subject: [PATCH 4/7] feat: qualify TypeScript analysis identities by namespace --- README.md | 32 +++-- schema.neo4j.json | 3 + src/build/neo4j/bolt.ts | 35 +++-- src/build/neo4j/project.ts | 5 + src/build/neo4j/rows.ts | 11 +- src/build/neo4j/schema.ts | 2 +- src/cli.ts | 4 +- src/core.ts | 20 +-- src/options/options.ts | 8 +- src/schema/assignIds.ts | 22 +-- src/schema/emit.ts | 50 ++----- src/schema/ids.ts | 37 ++--- src/schema/schema.ts | 8 +- src/utils/cache.ts | 5 +- test/artifacts.test.ts | 16 +-- test/checker-guard.test.ts | 4 +- test/config-keys.test.ts | 10 +- test/config-use.test.ts | 8 +- test/entrypoints-calls.test.ts | 8 +- test/ids.test.ts | 18 +-- test/namespace.test.ts | 67 +++++++++ test/neo4j-artifact-reconcile.test.ts | 26 ++-- test/neo4j-bolt.test.ts | 2 +- test/neo4j-edge-identity.test.ts | 4 +- test/neo4j-prefix-scope.test.ts | 44 +++--- test/neo4j-schema.test.ts | 2 +- test/schema-v2.test.ts | 29 ++-- test/tagged-templates.test.ts | 2 +- test/workspace-root.test.ts | 190 -------------------------- 29 files changed, 257 insertions(+), 415 deletions(-) create mode 100644 test/namespace.test.ts delete mode 100644 test/workspace-root.test.ts diff --git a/README.md b/README.md index 6592a3f..d9c576b 100644 --- a/README.md +++ b/README.md @@ -153,9 +153,7 @@ 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 + --namespace application identity namespace (default: app name) --neo4j-uri push the graph to a live Neo4j over Bolt (incremental); omit to write graph.cypher (env: NEO4J_URI) @@ -232,14 +230,17 @@ Options: cants --input ./my-ts-project --target-files src/a.ts src/b.ts ``` -4. **Repository-relative paths for a project inside a larger repo:** +4. **Group applications in a namespace:** ```sh - cants --input ./monorepo/packages/web --workspace-root ./monorepo - # symbol_table key: packages/web/src/app.ts + cants --input ./monorepo/packages/web --app-name web --namespace monorepo + # application id: can://monorepo/web + # symbol_table key: src/app.ts (relative to --input) ``` - 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. + 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 @@ -264,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://", "name": "", "kind": "application", + "id": "can:///", "name": "", "kind": "application", + "identity_version": 2, "application_name": "", "identity_namespace": "", "symbol_table": { // L1: the tree, keyed by file path "": { "kind": "module", "source": "…", "types": { /* class | interface | enum | type_alias | namespace nodes */ }, @@ -337,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:///…`, so `can://` is a prefix of every node the application +Every node id is `can:////…`, so `can:///` 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`. @@ -350,8 +352,8 @@ statements scope on, and `:Application` is keyed on the id rather than on `--app `--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`, + 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 @@ -405,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:///` that this +A `--emit neo4j` push is **destructive**. It sweeps everything under `can:////` that this analyzer marked, then rewrites what it found. Since the id grammar puts the application outermost, every analyzer over the same `` shares that prefix — so a push reclaims stale rows belonging to *this* analyzer and, in the shared namespaces, sweeps rows a sibling wrote. diff --git a/schema.neo4j.json b/schema.neo4j.json index f7dbd7c..759798d 100644 --- a/schema.neo4j.json +++ b/schema.neo4j.json @@ -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", diff --git a/src/build/neo4j/bolt.ts b/src/build/neo4j/bolt.ts index 9e43030..f69fa97 100644 --- a/src/build/neo4j/bolt.ts +++ b/src/build/neo4j/bolt.ts @@ -86,21 +86,20 @@ export const PURGE_PROVIDES = /** * 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. + * 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} OR (pre = $legacy AND none(l IN labels(n) WHERE l ENDS WITH 'CanNode'))) ` + + `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"; -/** `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)}`]; +/** 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"; @@ -133,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(); const shared: NodeRow[] = []; @@ -164,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); @@ -194,7 +201,7 @@ export async function boltWriter( 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 }; + const params = { pres, keep, appId, prefix }; let released = 0; await withSession(session, async (s) => { await s.run(PURGE_ARTIFACT_EDGES, params); diff --git a/src/build/neo4j/project.ts b/src/build/neo4j/project.ts index 0400166..7331b26 100644 --- a/src/build/neo4j/project.ts +++ b/src/build/neo4j/project.ts @@ -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 @@ -46,11 +47,15 @@ const KIND_LABEL: Record = { }; export function project(app: TSAnalysis, _appName?: string, builder?: RowBuilder): GraphRows { + if (app.identity_version !== 2 || app.application.identity_version !== 2 || 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, diff --git a/src/build/neo4j/rows.ts b/src/build/neo4j/rows.ts index 0aacde2..eb55d5e 100644 --- a/src/build/neo4j/rows.ts +++ b/src/build/neo4j/rows.ts @@ -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[] { @@ -81,13 +80,13 @@ export function descendantPrefix(canId: string): string { } /** - * The scope of every destructive statement: `can:///`, one prefix spanning both language + * The scope of every destructive statement: `can:////`, 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); @@ -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:///` id, as index anchors. + // never emitted. The marker labels ride every `can:////` 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) : []; diff --git a/src/build/neo4j/schema.ts b/src/build/neo4j/schema.ts index 9b2c07c..889c979 100644 --- a/src/build/neo4j/schema.ts +++ b/src/build/neo4j/schema.ts @@ -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`. diff --git a/src/cli.ts b/src/cli.ts index 6ceddd3..9a7d108 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,7 +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") + .option("--namespace ", "application identity namespace (default: application name)") // 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. @@ -147,7 +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, + namespace: o.namespace === undefined ? undefined : String(o.namespace), 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 694c5f0..0d8db28 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,3 +1,4 @@ +import { applicationIdOf } from "./schema/ids"; import * as fs from "node:fs"; import * as path from "node:path"; import { buildProgramGraphs, startExtraction } from "./dataflow"; @@ -51,22 +52,9 @@ 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 identity = applicationIdOf(opts.appName ?? path.basename(path.resolve(opts.input)), opts.namespace); const log = new Logger(opts.verbosity); log.info(`analyzing ${opts.input} (level ${opts.analysisLevel})`); resetCheckerFailures(); @@ -80,7 +68,7 @@ export async function analyze(opts: AnalysisOptions): Promise { for (const note of mat.notes) log.debug(note); - const cached = opts.eager ? null : loadCache(cacheDir); + const cached = opts.eager ? null : loadCache(cacheDir, identity); const { project, symbol_table, programs } = buildSymbolTable(opts, mat, cached?.symbol_table ?? null, log); collectGarbage(); // the symbol-table build leaves the heap mostly garbage memlog("symbol_table"); @@ -173,7 +161,7 @@ export async function analyze(opts: AnalysisOptions): Promise { // Cache the id-free base (ids/body/heritage are per-run layers stamped by finalizeAnalysis; // the cached tree must stay --app-name-free). - saveCache(cacheDir, { symbol_table }); + saveCache(cacheDir, { symbol_table, identity_version: 2, application_id: identity }); memlog("save_cache"); // Never let "some edges are missing" look like "there were no edges": a node the checker could // not resolve is skipped (see schema/checker.ts), and the count is said out loud. diff --git a/src/options/options.ts b/src/options/options.ts index 941897c..d7504c1 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -13,12 +13,8 @@ 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; + /** Raw application identity namespace; omitted ⇒ resolved application name. */ + namespace?: string; /** 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 a34fc16..5efc288 100644 --- a/src/schema/assignIds.ts +++ b/src/schema/assignIds.ts @@ -8,7 +8,7 @@ * id-uniqueness gate's collision list. */ -import { applicationIdOf, artifactIdOf, configKeyIdOf, idFromSig, memberKey, moduleIdOf, modulePrefixOf } from "./ids"; +import { applicationIdOf, artifactIdOf, artifactReferenceOf, configKeyIdOf, idFromSig, memberKey, moduleIdOf, modulePrefixOf } from "./ids"; import type { AnalysisInternal, TSCallable, TSField, TSType } from "./schema"; export interface AssignedIds { @@ -19,13 +19,8 @@ export interface AssignedIds { collisions: string[]; // signatures that mapped to two distinct ids (L1 id-uniqueness gate) } -/** - * @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); +export function assignIds(app: AnalysisInternal, appName: string, namespace?: string): AssignedIds { + const appId = applicationIdOf(appName, namespace); const idBySig = new Map(); const typeIdBySig = new Map(); const callableBySig = new Map(); @@ -75,7 +70,7 @@ export function assignIds(app: AnalysisInternal, appName: string, keyPrefix = "" }; for (const [fileKey, mod] of Object.entries(app.symbol_table)) { - const moduleId = moduleIdOf(appName, keyPrefix + fileKey); + const moduleId = moduleIdOf(appName, fileKey, namespace); const modulePrefix = modulePrefixOf(fileKey); mod.id = moduleId; // Module-scope execution is a call-graph SOURCE (python #131 parity: a call in module scope @@ -91,7 +86,7 @@ export function assignIds(app: AnalysisInternal, appName: string, keyPrefix = "" // 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, keyPrefix + relPath); + art.id = artifactIdOf(appName, relPath, namespace); // 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 @@ -108,12 +103,7 @@ export function assignIds(app: AnalysisInternal, appName: string, keyPrefix = "" } } for (const dep of app.dependencies ?? []) { - 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 = artPath.startsWith("can://") - ? artifactIdOf(appName, artPath.split("/").slice(4).join("/")) - : artifactIdOf(appName, keyPrefix + artPath); + dep.declared_in = artifactReferenceOf(appName, dep.declared_in, namespace); } return { appId, idBySig, typeIdBySig, callableBySig, collisions }; diff --git a/src/schema/emit.ts b/src/schema/emit.ts index dfa1340..5bfa48a 100644 --- a/src/schema/emit.ts +++ b/src/schema/emit.ts @@ -73,37 +73,6 @@ 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 // ---------------------------------------------------------------------------------------------- @@ -126,11 +95,10 @@ export function finalizeAnalysis( rules?: RuleSet, ): AnalysisResult { const level = opts.analysisLevel; - const appName = (opts.appName ?? (opts.input ? path.basename(opts.input) : "") ?? "").trim() || "app"; - const keyPrefix = workspaceKeyPrefix(opts); + const appName = opts.appName ?? (opts.input ? path.basename(path.resolve(opts.input)) : "app"); // L1 — stamp ids, derive body{}, project heritage (all overwrite-idempotent per-run passes). - const { appId, idBySig, typeIdBySig, callableBySig, collisions } = assignIds(app, appName, keyPrefix); + const { appId, idBySig, typeIdBySig, callableBySig, collisions } = assignIds(app, appName, opts.namespace); 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. @@ -140,6 +108,9 @@ export function finalizeAnalysis( const root: TSApplication = { id: appId, + identity_version: 2, + application_name: appName, + identity_namespace: opts.namespace ?? appName, name: appName, kind: "application", symbol_table: app.symbol_table, @@ -187,6 +158,7 @@ export function finalizeAnalysis( const envelope: TSAnalysis = { schema_version: SCHEMA_VERSION, + identity_version: 2, language: LANGUAGE, max_level: Math.min(level, MAX_IMPLEMENTED), ...(k_limit !== undefined ? { k_limit } : {}), @@ -204,15 +176,9 @@ export function finalizeAnalysis( const symbol_table: TSApplication["symbol_table"] = {}; 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; + symbol_table[key] = wire; } - const application: TSAnalysis = { ...envelope, application: { ...root, symbol_table, ...rekeyRepoPaths(root, keyPrefix) } }; + const application: TSAnalysis = { ...envelope, application: { ...root, symbol_table } }; stripInternal(application.application); return { application, internal: app, ...(pg ? { program_graphs: pg } : {}), idBySig, collisions, dangling }; } diff --git a/src/schema/ids.ts b/src/schema/ids.ts index bbafae9..cfc854f 100644 --- a/src/schema/ids.ts +++ b/src/schema/ids.ts @@ -25,25 +25,30 @@ export function languageOf(fileKey: string): string { return JS_EXTS.test(fileKey) ? "javascript" : LANGUAGE; } -/** - * `can://` — the application root, and the prefix EVERY id below it shares. - * - * The application is the outermost segment and the language sits inside it, so the analyzer's two - * namespaces (`typescript`, `javascript`) are two children of one application rather than two - * top-level namespaces that a consumer has to enumerate. The old anchor had to pick a language for - * a mixed repository and picked the analyzer's own; there is nothing left to pick. - * See docs/design/specs/js-language-namespace.md. - */ -export function applicationIdOf(appName: string): string { - return `${SCHEME}${appName}`; +/** Validate and encode one raw namespace or application name without normalizing it. */ +export function identitySegment(value: string): string { + if (!value.trim() || value === "." || value === ".." || /[/\\\x00-\x1F\x7F-\x9F]/u.test(value)) throw new Error("Invalid application identity segment"); + // encodeURIComponent rejects unpaired surrogates; escape its additional punctuation too. + return encodeURIComponent(value).replace(/[!'()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`); +} +export function applicationIdOf(appName: string, namespace?: string): string { + return `${SCHEME}${identitySegment(namespace ?? appName)}/${identitySegment(appName)}`; +} + +/** Recover only an artifact suffix; ownership is restamped from validated raw names. */ +export function artifactReferenceOf(appName: string, reference: string, namespace?: string): string { + if (!reference.startsWith(SCHEME)) return artifactIdOf(appName, reference, namespace); + const match = /^can:\/\/[^/]+\/[^/]+\/artifact\/(.+)$/.exec(reference); + if (!match) throw new Error("Unsupported artifact identity reference"); + return artifactIdOf(appName, match[1]!, namespace); } /** - * `can:////`. Takes the app NAME, not the app id: a module's namespace is its + * `can://///`. Takes the app NAME, not the app id: a module's namespace is its * own, not the application's. */ -export function moduleIdOf(appName: string, fileKey: string): string { - return `${applicationIdOf(appName)}/${languageOf(fileKey)}/${fileKey}`; +export function moduleIdOf(appName: string, fileKey: string, namespace?: string): string { + return `${applicationIdOf(appName, namespace)}/${languageOf(fileKey)}/${fileKey}`; } /** The module/signature prefix: the file key without its TS/JS extension. */ @@ -61,14 +66,14 @@ export function idFromSig(moduleId: string, modulePrefix: string, sig: string): * Repository-artifact ids. Leading "./" and "/" are dropped as SEPARATORS only — dotfiles * (`.env`, `.github/...`) keep their leading dot (python's rule). */ -export function artifactIdOf(appName: string, relPath: string): string { +export function artifactIdOf(appName: string, relPath: string, namespace?: string): string { let rel = relPath.replace(/\\/g, "/"); while (rel.startsWith("./")) rel = rel.slice(2); rel = rel.replace(/^\/+/, ""); // Language-NEUTRAL pseudo-segment (python PR #160): `artifact` sits where a language would, so // sibling analyzers over the same repo emit the SAME id for the same file — `` agreement is // the precondition, and it always was. - return `${applicationIdOf(appName)}/artifact/${rel}`; + return `${applicationIdOf(appName, namespace)}/artifact/${rel}`; } /** Config-key id: the owning artifact's id, `@key/`, then the dotted path. */ diff --git a/src/schema/schema.ts b/src/schema/schema.ts index eb3cf60..95eaef7 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -550,6 +550,7 @@ export interface AnalysisInternal { // ---------------------------------------------------------------------------------------------- export interface TSAnalysis { + identity_version: 2; schema_version: string; // "2.0.0" language: string; // "typescript" max_level: number; // highest level populated; consumers read this, not key-sniffing @@ -566,8 +567,11 @@ export interface TSAnalyzer { /** The application ROOT node (python's PyApplication): the containment tree + app-scope overlays. */ export interface TSApplication { - id: string; // can:// — the prefix every id below it shares - name: string; // normalized --app-name, or the input directory basename + identity_version: 2; + application_name: string; + identity_namespace: string; + id: string; // can:/// — the prefix every id below it shares + name: string; // raw --app-name, or the input directory basename kind: "application"; symbol_table: Record; // keyed by project-relative POSIX path (with extension) call_graph: TSCallGraphEdge[]; // L2 — callable → callable (empty at L1) diff --git a/src/utils/cache.ts b/src/utils/cache.ts index 452c935..df642a5 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -6,6 +6,8 @@ import { ANALYZER_VERSION } from "./version"; export interface CacheData { analyzer_version?: string; + identity_version?: number; + application_id?: string; symbol_table: Record; } @@ -13,7 +15,7 @@ export function cacheFilePath(cacheDir: string): string { return path.join(cacheDir, "analysis_cache.json"); } -export function loadCache(cacheDir: string): CacheData | null { +export function loadCache(cacheDir: string, applicationId?: string): CacheData | null { try { const p = cacheFilePath(cacheDir); if (!fs.existsSync(p)) return null; @@ -21,6 +23,7 @@ export function loadCache(cacheDir: string): CacheData | null { // Invalidate the whole cache when the analyzer version changed — the per-file source hash // can't detect that the extraction logic itself moved on. if (data.analyzer_version !== ANALYZER_VERSION) return null; + if (data.identity_version !== 2 || (applicationId !== undefined && data.application_id !== applicationId)) return null; return data; } catch { return null; diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index 2257620..635fedb 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -57,8 +57,8 @@ describe("artifact inventory — rules-matched, neutral ids (#101/PR-160)", () = }); test("ids are LANGUAGE-NEUTRAL (can:///artifact/); dotfiles keep the dot", () => { - expect(arts[".env"]?.id).toBe("can://artifacts-app/artifact/.env"); - expect(arts["packages/web/package.json"]?.id).toBe("can://artifacts-app/artifact/packages/web/package.json"); + expect(arts[".env"]?.id).toBe("can://artifacts-app/artifacts-app/artifact/.env"); + expect(arts["packages/web/package.json"]?.id).toBe("can://artifacts-app/artifacts-app/artifact/packages/web/package.json"); }); test("roles and formats from the rules table; roles union across matches", () => { @@ -95,8 +95,8 @@ describe("dependencies — flat, evidence-tagged (#101/PR-160)", () => { }); test("declared_in is the manifest's neutral artifact id (workspace member keeps its own)", () => { - expect(byName.get("express")?.declared_in).toBe("can://artifacts-app/artifact/package.json"); - expect(byName.get("lodash")?.declared_in).toBe("can://artifacts-app/artifact/packages/web/package.json"); + expect(byName.get("express")?.declared_in).toBe("can://artifacts-app/artifacts-app/artifact/package.json"); + expect(byName.get("lodash")?.declared_in).toBe("can://artifacts-app/artifacts-app/artifact/packages/web/package.json"); }); test("locks backfill locked_version on declared records only, prov gains lockfile", () => { @@ -114,7 +114,7 @@ describe("dependencies — flat, evidence-tagged (#101/PR-160)", () => { expect(t?.kind).toBe("runtime"); expect(t?.prov).toEqual(["lockfile"]); expect(t?.locked_version).toBe("1.0.0"); - expect(t?.declared_in).toBe("can://artifacts-app/artifact/package-lock.json"); + expect(t?.declared_in).toBe("can://artifacts-app/artifacts-app/artifact/package-lock.json"); // declared packages stay direct expect(byName.get("express")?.direct).toBe(true); // nested shadow entries are NOT records @@ -209,21 +209,21 @@ describe("Neo4j projection — neutral :Artifact/:Package (#101)", () => { test("a sibling analyzer's manifest is re-created, but only with this analyzer's view of it", () => { // The whole-repo walk inventories pyproject.toml even though nothing here can parse it, so the // node the wipe deletes is restored by the same push — it is never missing. - const py = rows.nodes.find((n) => n.value === "can://artifacts-app/artifact/pyproject.toml"); + const py = rows.nodes.find((n) => n.value === "can://artifacts-app/artifacts-app/artifact/pyproject.toml"); expect(py).toBeDefined(); expect(py?.labels).toEqual(["Artifact", "TSCanNode"]); // ...and this is the cost, stated rather than assumed: a degraded view. Unclassified roles, // no extraction, no config keys, no dependency edge — python restores all four when it pushes. expect(py?.props["roles"]).toEqual(["unknown"]); expect(py?.props["extraction"]).toBe("none"); - expect(rows.nodes.some((n) => n.value.startsWith("can://artifacts-app/artifact/pyproject.toml@key/"))).toBe(false); + expect(rows.nodes.some((n) => n.value.startsWith("can://artifacts-app/artifacts-app/artifact/pyproject.toml@key/"))).toBe(false); expect(rows.edges.some((e) => e.type === "DECLARES_DEPENDENCY" && e.from.value.endsWith("/pyproject.toml"))).toBe(false); // The pypi package this analyzer cannot see is not invented either. expect(rows.nodes.some((n) => n.value.startsWith("pkg:pypi/"))).toBe(false); }); test("neutral nodes with purl ids; TS-prefixed claims into the ghost space", () => { - const art = rows.nodes.find((n) => n.value === "can://artifacts-app/artifact/package.json"); + const art = rows.nodes.find((n) => n.value === "can://artifacts-app/artifacts-app/artifact/package.json"); expect(art?.labels).toEqual(["Artifact", "TSCanNode"]); expect(art?.props["roles"]).toEqual(["dependency-manifest", "tool-config"]); // Artifact text belongs on the graph: python has carried `source` on :Artifact since it diff --git a/test/checker-guard.test.ts b/test/checker-guard.test.ts index b3c00e6..87b951e 100644 --- a/test/checker-guard.test.ts +++ b/test/checker-guard.test.ts @@ -25,8 +25,8 @@ import type { AnalysisOptions } from "../src/options"; const FIXTURE = path.resolve(import.meta.dir, "fixtures/unresolvable-js-app"); // Two namespaces in one run (#114): a .js module is `javascript`, a .ts module is `typescript`. // Both now sit UNDER the one application segment, so they share the `can:///` prefix. -const TS = "can://unresolvable-js-app/typescript"; -const JS = "can://unresolvable-js-app/javascript"; +const TS = "can://unresolvable-js-app/unresolvable-js-app/typescript"; +const JS = "can://unresolvable-js-app/unresolvable-js-app/javascript"; function options(over: Partial = {}): AnalysisOptions { return { diff --git a/test/config-keys.test.ts b/test/config-keys.test.ts index 60a0e2b..583ced0 100644 --- a/test/config-keys.test.ts +++ b/test/config-keys.test.ts @@ -38,7 +38,7 @@ describe("config keys — flat and JSON (#101 unit B)", () => { }); test("key ids chain off the artifact id", () => { - expect(keysOf(".env")["PAYMENT_HOST"]?.id).toBe("can://artifacts-app/artifact/.env@key/PAYMENT_HOST"); + expect(keysOf(".env")["PAYMENT_HOST"]?.id).toBe("can://artifacts-app/artifacts-app/artifact/.env@key/PAYMENT_HOST"); }); test("a dependency manifest is never also a config file — manifests/lockfiles yield zero keys", () => { @@ -295,8 +295,8 @@ describe("deployment-env namespaces (#101 unit D)", () => { const envVersion = keys.find((k) => k.namespace === "env" && k.key === "VERSION"); expect(argVersion?.key).toBe("VERSION"); // key field stays bare — only the id is disambiguated expect(envVersion?.key).toBe("VERSION"); - expect(argVersion?.id).toBe("can://artifacts-app/artifact/Dockerfile@key/arg.VERSION"); - expect(envVersion?.id).toBe("can://artifacts-app/artifact/Dockerfile@key/VERSION"); // ENV: unprefixed + expect(argVersion?.id).toBe("can://artifacts-app/artifacts-app/artifact/Dockerfile@key/arg.VERSION"); + expect(envVersion?.id).toBe("can://artifacts-app/artifacts-app/artifact/Dockerfile@key/VERSION"); // ENV: unprefixed expect(argVersion?.id).not.toBe(envVersion?.id); }); @@ -310,8 +310,8 @@ describe("deployment-env namespaces (#101 unit D)", () => { const deployEnv = keys.find((k) => k.namespace === "env" && k.key === "PAYMENT_HOST"); expect(structural?.value).toBe("https://root-level.example.com"); expect(deployEnv?.value).toBe("https://pay.example.com"); - expect(structural?.id).toBe("can://artifacts-app/artifact/docker-compose.yml@key/PAYMENT_HOST"); - expect(deployEnv?.id).toBe("can://artifacts-app/artifact/docker-compose.yml@key/env.PAYMENT_HOST"); + expect(structural?.id).toBe("can://artifacts-app/artifacts-app/artifact/docker-compose.yml@key/PAYMENT_HOST"); + expect(deployEnv?.id).toBe("can://artifacts-app/artifacts-app/artifact/docker-compose.yml@key/env.PAYMENT_HOST"); expect(structural?.id).not.toBe(deployEnv?.id); }); diff --git a/test/config-use.test.ts b/test/config-use.test.ts index 7074cbe..ec392cf 100644 --- a/test/config-use.test.ts +++ b/test/config-use.test.ts @@ -64,14 +64,14 @@ const useDsts = (fnFragment: string): string[] => describe("config_use literal tier (#101 unit C3)", () => { test("a literal env read joins every declaring ConfigKey", () => { const dsts = useDsts("readHost"); - expect(dsts).toContain("can://artifacts-app/artifact/.env@key/PAYMENT_HOST"); - expect(dsts).toContain("can://artifacts-app/artifact/Dockerfile@key/PAYMENT_HOST"); + expect(dsts).toContain("can://artifacts-app/artifacts-app/artifact/.env@key/PAYMENT_HOST"); + expect(dsts).toContain("can://artifacts-app/artifacts-app/artifact/Dockerfile@key/PAYMENT_HOST"); expect(app2.config_uses.every((u) => u.prov.includes("literal"))).toBe(true); }); test("src is a global ordinal body-node id", () => { const u = app2.config_uses.find((x) => x.src.includes("readHost")); - expect(u?.src).toMatch(/^can:\/\/artifacts-app\/typescript\/src\/config\.ts\/readHost@\d+:\d+$/); + expect(u?.src).toMatch(/^can:\/\/artifacts-app\/artifacts-app\/typescript\/src\/config\.ts\/readHost@\d+:\d+$/); }); test("a literal with no declared key is an undefined-key read, not an edge", () => { @@ -199,7 +199,7 @@ describe("Neo4j projection of the config layer (#101)", () => { const rows = neoProject(r2.application); test("ConfigKey nodes are neutral and hang off their artifact", () => { - const id = "can://artifacts-app/artifact/.env@key/PAYMENT_HOST"; + const id = "can://artifacts-app/artifacts-app/artifact/.env@key/PAYMENT_HOST"; const n = rows.nodes.find((x) => x.value === id); expect(n?.labels).toContain("ConfigKey"); expect(n?.labels).not.toContain("TSConfigKey"); diff --git a/test/entrypoints-calls.test.ts b/test/entrypoints-calls.test.ts index 7dd6a0a..a241d2c 100644 --- a/test/entrypoints-calls.test.ts +++ b/test/entrypoints-calls.test.ts @@ -51,10 +51,10 @@ describe("calls: heuristic tier (Express shape)", () => { expect(byName.arrow?.[0]).toMatchObject({ evidence: "app.put", route: "/c", http_methods: ["PUT"] }); expect(byName["(anonymous)"]?.[0]).toMatchObject({ evidence: "app.post", route: "/b", http_methods: ["POST"] }); // via is the module-scope call node id - expect((byName.named?.[0] as { via?: string }).via).toMatch(/^can:\/\/c\/typescript\/src\/app\.ts@5:\d+$/); + expect((byName.named?.[0] as { via?: string }).via).toMatch(/^can:\/\/c\/c\/typescript\/src\/app\.ts@5:\d+$/); // via for a call site owned by a callable (app.delete inside setup()) names THAT callable, not the module const deleteRecord = byName.named?.find((e) => (e as { evidence?: string }).evidence === "app.delete") as { via?: string } | undefined; - expect(deleteRecord?.via).toMatch(/^can:\/\/c\/typescript\/src\/app\.ts\/setup@10:\d+$/); + expect(deleteRecord?.via).toMatch(/^can:\/\/c\/c\/typescript\/src\/app\.ts\/setup@10:\d+$/); }); test("an unresolvable handler is counted, not fabricated", async () => { @@ -106,8 +106,8 @@ describe("calls: heuristic tier (Express shape)", () => { for (const m of Object.values(root.symbol_table)) forEachCallable(m, (c) => { if (c.name === "named") named = (c.entrypoints ?? []) as typeof named; }); expect(named).toHaveLength(2); const byEvidence = Object.fromEntries(named.map((e) => [e.evidence, e.via])); - expect(byEvidence["router.get('/a',named).get"]).toMatch(/^can:\/\/c\/typescript\/src\/app\.ts\/setup@5:\d+$/); - expect(byEvidence["router.get"]).toMatch(/^can:\/\/c\/typescript\/src\/app\.ts\/setup@5:\d+\/2$/); + expect(byEvidence["router.get('/a',named).get"]).toMatch(/^can:\/\/c\/c\/typescript\/src\/app\.ts\/setup@5:\d+$/); + expect(byEvidence["router.get"]).toMatch(/^can:\/\/c\/c\/typescript\/src\/app\.ts\/setup@5:\d+\/2$/); expect(byEvidence["router.get('/a',named).get"]).not.toBe(byEvidence["router.get"]); }); }); diff --git a/test/ids.test.ts b/test/ids.test.ts index 8adfaa6..01a78d6 100644 --- a/test/ids.test.ts +++ b/test/ids.test.ts @@ -21,7 +21,7 @@ describe("per-module language namespace (#114)", () => { // A declaration file is TypeScript. The suffix must not be read as `.ts` on a file named `a.d`. test("a .d.ts declaration file is typescript", () => { expect(languageOf("types/x.d.ts")).toBe("typescript"); - expect(moduleIdOf("app", "types/x.d.ts")).toBe("can://app/typescript/types/x.d.ts"); + expect(moduleIdOf("app", "types/x.d.ts")).toBe("can://app/app/typescript/types/x.d.ts"); }); // The match is anchored: a DIRECTORY named `foo.js` must not make its .ts children javascript. @@ -30,17 +30,17 @@ describe("per-module language namespace (#114)", () => { }); test("module ids carry their own namespace, nested under the one application", () => { - expect(applicationIdOf("app")).toBe("can://app"); - expect(moduleIdOf("app", "src/bar.ts")).toBe("can://app/typescript/src/bar.ts"); - expect(moduleIdOf("app", "lib/foo.js")).toBe("can://app/javascript/lib/foo.js"); + expect(applicationIdOf("app")).toBe("can://app/app"); + expect(moduleIdOf("app", "src/bar.ts")).toBe("can://app/app/typescript/src/bar.ts"); + expect(moduleIdOf("app", "lib/foo.js")).toBe("can://app/app/javascript/lib/foo.js"); }); }); describe("the application is the outermost segment", () => { test("artifact ids nest under the app instead of forming a parallel scheme", () => { // Was can://artifact// — a third top-level namespace. Now one rule. - expect(artifactIdOf("app", "package.json")).toBe("can://app/artifact/package.json"); - expect(artifactIdOf("app", "./.env")).toBe("can://app/artifact/.env"); + expect(artifactIdOf("app", "package.json")).toBe("can://app/app/artifact/package.json"); + expect(artifactIdOf("app", "./.env")).toBe("can://app/app/artifact/.env"); }); test("every id shares the application prefix — both language namespaces included", () => { @@ -69,8 +69,8 @@ describe("the application is the outermost segment", () => { test("an app named after a language is still addressable (the segment-1 collision)", () => { // can://typescript/typescript/... is legal and unambiguous POSITIONALLY. Anything that reads // the language off the FIRST segment gets it wrong here; segment 2 is the language. - expect(moduleIdOf("typescript", "lib/foo.js")).toBe("can://typescript/javascript/lib/foo.js"); - expect(moduleIdOf("javascript", "src/bar.ts")).toBe("can://javascript/typescript/src/bar.ts"); - expect(applicationIdOf("artifact")).toBe("can://artifact"); + expect(moduleIdOf("typescript", "lib/foo.js")).toBe("can://typescript/typescript/javascript/lib/foo.js"); + expect(moduleIdOf("javascript", "src/bar.ts")).toBe("can://javascript/javascript/typescript/src/bar.ts"); + expect(applicationIdOf("artifact")).toBe("can://artifact/artifact"); }); }); diff --git a/test/namespace.test.ts b/test/namespace.test.ts new file mode 100644 index 0000000..48f72a1 --- /dev/null +++ b/test/namespace.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from 'bun:test'; +import fixture from './fixtures/identity/namespaces.json'; +import { applicationIdOf, moduleIdOf, artifactIdOf } from '../src/schema/ids'; +for (const row of fixture.roots) test(`root ${JSON.stringify(row)}`, () => expect(applicationIdOf(row.application_name, row.namespace ?? undefined)).toBe(row.expected_root)); +for (const row of fixture.invalid) test(`invalid ${row.reason}`, () => { + expect(() => applicationIdOf(row.value)).toThrow(); + expect(() => applicationIdOf('app', row.value)).toThrow(); +}); +for (const row of fixture.modules.filter(r => ['typescript','javascript'].includes(r.language))) test(row.expected_uri, () => expect(moduleIdOf(row.application_name,row.file_key,row.namespace ?? undefined)).toBe(row.expected_uri)); +test('artifact scope', () => expect(artifactIdOf('a%b','package.json','team')).toBe('can://team/a%25b/artifact/package.json')); + +import { analyze } from '../src/core'; +import { finalizeAnalysis } from '../src/schema/emit'; +import { project } from '../src/build/neo4j/project'; +import { loadCache } from '../src/utils/cache'; +import type { AnalysisOptions } from '../src/options'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cants-namespace-')); +const options = { + input: path.join(import.meta.dir,'fixtures/artifacts-app'), appName: 'a%b', namespace: 'first', + analysisLevel: 2, graphs: ['cfg','dfg','pdg','sdg'], graphFieldDepth: 3, jobs: 1, + skipTests: true, eager: true, noBuild: true, phantoms: true, cacheDir, verbosity: 0, +} as AnalysisOptions; +test('repeat finalization restamps every reference and preserves input-relative keys', async () => { + const first = await analyze(options); + expect(first.application.identity_version).toBe(2); + const second = finalizeAnalysis(first.internal,null,{...options,namespace:'second'}).application; + expect(second.application.application_name).toBe('a%b'); + expect(second.application.identity_namespace).toBe('second'); + expect(second.application.identity_version).toBe(2); + expect(Object.keys(second.application.symbol_table)).toEqual(Object.keys(first.application.application.symbol_table)); + expect(JSON.stringify(second)).not.toContain('can://first/'); + expect(second.application.dependencies.length).toBeGreaterThan(0); + for (const dependency of second.application.dependencies) expect(dependency.declared_in).toStartWith('can://second/a%25b/artifact/'); + expect(Object.keys(second.application.external_symbols ?? {}).length).toBeGreaterThan(0); + for (const mod of Object.values(second.application.symbol_table)) expect(mod.id).toStartWith('can://second/a%25b/'); + expect(project(second).nodes.find(n=>n.labels.includes('TSApplication'))?.props.identity_namespace).toBe('second'); + expect(loadCache(cacheDir, 'can://first/a%25b')).not.toBeNull(); + expect(loadCache(cacheDir, 'can://second/a%25b')).toBeNull(); + for (const marker of [undefined,1,'2',2.5]) { + const stale = structuredClone(second); + (stale as any).identity_version=marker; + expect(()=>project(stale)).toThrow(); + } + const mismatch = structuredClone(second); + mismatch.application.identity_namespace='first'; + expect(()=>project(mismatch)).toThrow(); +}); +test('explicit empty namespace fails before analysis', async () => { + await expect(analyze({...options,namespace:''})).rejects.toThrow(); +}); +test('cache rejects legacy and noninteger markers without rewriting the input', () => { + const file = path.join(cacheDir, 'analysis_cache.json'); + const original = fs.readFileSync(file,'utf8'); + try { + for (const marker of [undefined, 1, '2', 2.5]) { + const data = JSON.parse(original); + data.identity_version = marker; + const bytes = JSON.stringify(data); + fs.writeFileSync(file,bytes); + expect(loadCache(cacheDir,'can://first/a%25b')).toBeNull(); + expect(fs.readFileSync(file,'utf8')).toBe(bytes); + } + } finally { fs.writeFileSync(file,original); } +}); diff --git a/test/neo4j-artifact-reconcile.test.ts b/test/neo4j-artifact-reconcile.test.ts index 6521ad8..686d4a5 100644 --- a/test/neo4j-artifact-reconcile.test.ts +++ b/test/neo4j-artifact-reconcile.test.ts @@ -18,7 +18,7 @@ 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 APP = "can://app/app"; const ART = `${APP}/artifact/`; const containerSuite = process.env.RUN_CONTAINER_TESTS ? describe : describe.skip; @@ -35,7 +35,7 @@ async function rows(input: string, over: Partial = {}): Promise 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, + cacheDir: fs.mkdtempSync(path.join(TMP, "cache-")), namespace: undefined, ...over, } as AnalysisOptions; return project((await analyze(opts)).application); } @@ -80,15 +80,15 @@ containerSuite("neo4j bolt artifact reconciliation", () => { 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 () => { + test("an identical re-push keeps artifact keys and edges stable", 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 }); + const rooted = await rows(input); await boltWriter(rooted, cfg, log, true, true); - expect(await stale("services/app/")).toBe(0); + expect(await stale("")).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); @@ -100,7 +100,7 @@ containerSuite("neo4j bolt artifact reconciliation", () => { 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 }); + const rooted = await rows(input, { namespace: "other" }); 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); @@ -142,7 +142,7 @@ containerSuite("neo4j bolt artifact reconciliation", () => { // 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 (app:PyApplication {id: 'can://app/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)", @@ -175,21 +175,21 @@ containerSuite("neo4j bolt artifact reconciliation", () => { 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)"); + const before = await num("MATCH (n) WHERE (n:Artifact OR n:ConfigKey) AND n.id STARTS WITH 'can://apps/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); + expect(await num("MATCH (n:TSCanNode) WHERE (n:Artifact OR n:ConfigKey) AND n.id STARTS WITH 'can://apps/apps/' RETURN count(n)")).toBe(before); }, 180_000); - test("pre-re-key artifacts are released; a sibling's are left alone", async () => { + test("legacy artifacts and sibling claims are outside the selected scope", 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) WHERE n.id STARTS WITH 'can://artifact/app/old.json' RETURN count(n)")).toBe(2); expect(await num("MATCH (n:JCanNode {id: 'can://artifact/app/pom.xml'}) RETURN count(n)")).toBe(1); }, 180_000); @@ -198,7 +198,7 @@ containerSuite("neo4j bolt artifact reconciliation", () => { 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) " + + await run("MATCH (a:Artifact {id: $id}) MERGE (p:PyApplication {id: 'can://app/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)"; @@ -211,7 +211,7 @@ containerSuite("neo4j bolt artifact reconciliation", () => { 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)"); + "MERGE (p:PyApplication {id: 'can://app/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'}) " + diff --git a/test/neo4j-bolt.test.ts b/test/neo4j-bolt.test.ts index e65c075..f320fcd 100644 --- a/test/neo4j-bolt.test.ts +++ b/test/neo4j-bolt.test.ts @@ -235,7 +235,7 @@ containerSuite("neo4j bolt writer", () => { "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://sa` is a string prefix of `can://saX`: the boundary case the trailing `/` handles. + // `can://sa/sa` is a string prefix of `can://saX/saX`: the boundary case the trailing `/` handles. 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://${app}/` }); diff --git a/test/neo4j-edge-identity.test.ts b/test/neo4j-edge-identity.test.ts index 0909d31..d26fccd 100644 --- a/test/neo4j-edge-identity.test.ts +++ b/test/neo4j-edge-identity.test.ts @@ -11,8 +11,8 @@ import { RowBuilder } from "../src/build/neo4j/rows"; function twoVarDdgRows() { const b = new RowBuilder(); - const a = b.node(["CanNode", "TSBodyNode"], "id", "can://ts/app/f.ts/f()@1:0", {}); - const c = b.node(["CanNode", "TSBodyNode"], "id", "can://ts/app/f.ts/f()@2:0", {}); + const a = b.node(["CanNode", "TSBodyNode"], "id", "can://ts/ts/app/f.ts/f()@1:0", {}); + const c = b.node(["CanNode", "TSBodyNode"], "id", "can://ts/ts/app/f.ts/f()@2:0", {}); b.edge("TS_DDG", a, c, { var: "x", prov: ["reaching-defs"] }, "x|reaching-defs"); b.edge("TS_DDG", a, c, { var: "y", prov: ["reaching-defs"] }, "y|reaching-defs"); b.edge("TS_CDG", a, c); // undiscriminated control family stays a plain MERGE diff --git a/test/neo4j-prefix-scope.test.ts b/test/neo4j-prefix-scope.test.ts index f213c5a..9cbbe03 100644 --- a/test/neo4j-prefix-scope.test.ts +++ b/test/neo4j-prefix-scope.test.ts @@ -20,45 +20,45 @@ const opts = { input: FIXTURE, appName: "ps", analysisLevel: 1, eager: true, noB describe("can:// prefix scoping (#140)", () => { test("one /-terminated application prefix; anything that is not a bare app id is refused", () => { - expect(applicationPrefix("can://app")).toBe("can://app/"); - expect(descendantPrefix("can://app/typescript/src/a.ts")).toBe("can://app/typescript/src/a.ts/"); + expect(applicationPrefix("can://app/app")).toBe("can://app/app/"); + expect(descendantPrefix("can://app/app/typescript/src/a.ts")).toBe("can://app/app/typescript/src/a.ts/"); // A deeper id would silently narrow the scope to a subtree, so it is refused too. - for (const bad of [null, undefined, "", "can://", "can://app/typescript", "app"]) { + for (const bad of [null, undefined, "", "can://", "can://app/app/typescript", "app"]) { expect(() => applicationPrefix(bad as never)).toThrow(/refusing a destructive statement/); } }); test("one marker spans every can:// id, artifacts included; JSCanNode rides along", () => { - expect(markersFor("can://app/typescript/src/a.ts/f")).toEqual(["TSCanNode"]); - expect(markersFor("can://app/javascript/src/a.js/f")).toEqual(["TSCanNode", "JSCanNode"]); + expect(markersFor("can://app/app/typescript/src/a.ts/f")).toEqual(["TSCanNode"]); + expect(markersFor("can://app/app/javascript/src/a.js/f")).toEqual(["TSCanNode", "JSCanNode"]); // Artifacts and their config keys are marked, so the wipe reaches them: an unmarked artifact // is unreachable by every destructive statement and accumulates forever. The polyglot cost is // measured in artifacts.test.ts, not assumed here. - expect(markersFor("can://app/artifact/package.json")).toEqual(["TSCanNode"]); - expect(markersFor("can://app/artifact/.env@key/PAYMENT_HOST")).toEqual(["TSCanNode"]); + expect(markersFor("can://app/app/artifact/package.json")).toEqual(["TSCanNode"]); + expect(markersFor("can://app/app/artifact/.env@key/PAYMENT_HOST")).toEqual(["TSCanNode"]); // A node keyed on its own natural identity (:Package purl, :TSDecorator name) is never marked. expect(markersFor("pkg:npm/express")).toEqual([]); expect(markersFor("Get")).toEqual([]); // The language is read POSITIONALLY (segment 2), so an app NAMED after a language still works. - expect(markersFor("can://javascript/typescript/src/a.ts/f")).toEqual(["TSCanNode"]); - expect(markersFor("can://typescript/javascript/src/a.js/f")).toEqual(["TSCanNode", "JSCanNode"]); + expect(markersFor("can://javascript/javascript/typescript/src/a.ts/f")).toEqual(["TSCanNode"]); + expect(markersFor("can://typescript/typescript/javascript/src/a.js/f")).toEqual(["TSCanNode", "JSCanNode"]); }); test("RowBuilder lifts _module off the row and adds the markers for id-keyed can:// nodes", () => { const b = new RowBuilder(); - b.node(["CanNode", "TSCallable"], "id", "can://app/typescript/src/a.ts/f", { id: "x", _module: "src/a.ts", name: "f" }); - b.node(["CanNode", "TSCallable"], "id", "can://app/javascript/src/b.js/g", { id: "y", _module: "src/b.js", name: "g" }); - b.node(["Artifact"], "id", "can://app/artifact/package.json", { id: "z", path: "package.json" }); + b.node(["CanNode", "TSCallable"], "id", "can://app/app/typescript/src/a.ts/f", { id: "x", _module: "src/a.ts", name: "f" }); + b.node(["CanNode", "TSCallable"], "id", "can://app/app/javascript/src/b.js/g", { id: "y", _module: "src/b.js", name: "g" }); + b.node(["Artifact"], "id", "can://app/app/artifact/package.json", { id: "z", path: "package.json" }); b.node(["Package"], "id", "pkg:npm/express", { id: "pkg:npm/express", name: "express" }); 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://app/typescript/src/a.ts/f")!; + const ts = byValue.get("can://app/app/typescript/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://app/javascript/src/b.js/g")!.labels).toEqual(["CanNode", "TSCallable", "TSCanNode", "JSCanNode"]); - expect(byValue.get("can://app/artifact/package.json")!.labels).toEqual(["Artifact", "TSCanNode"]); + expect(byValue.get("can://app/app/javascript/src/b.js/g")!.labels).toEqual(["CanNode", "TSCallable", "TSCanNode", "JSCanNode"]); + expect(byValue.get("can://app/app/artifact/package.json")!.labels).toEqual(["Artifact", "TSCanNode"]); expect(byValue.get("pkg:npm/express")!.labels).toEqual(["Package"]); expect(byValue.get("Get")!.labels).toEqual(["TSDecorator"]); }); @@ -66,7 +66,7 @@ describe("can:// prefix scoping (#140)", () => { test("a real projection: no row carries _module; every node under the app prefix carries TSCanNode", async () => { const rows = project((await analyze(opts)).application); expect(rows.nodes.some((n) => "_module" in n.props)).toBe(false); - const owned = rows.nodes.filter((n) => n.value.startsWith("can://ps/")); + const owned = rows.nodes.filter((n) => n.value.startsWith("can://ps/ps/")); expect(owned.length).toBeGreaterThan(10); // No can:// row is left unmarked, so no row is beyond the reach of the scoped delete. for (const n of owned) expect(n.labels).toContain("TSCanNode"); @@ -80,7 +80,7 @@ describe("can:// prefix scoping (#140)", () => { const can = rows.nodes.filter((n) => n.keyProp === "id" && n.value.startsWith("can://")); expect(can.length).toBeGreaterThan(10); for (const n of can) { - expect(n.value === "can://ps" || n.value.startsWith("can://ps/")).toBe(true); + expect(n.value === "can://ps/ps" || n.value.startsWith("can://ps/ps/")).toBe(true); expect(n.value.startsWith("can://typescript/")).toBe(false); expect(n.value.startsWith("can://javascript/")).toBe(false); expect(n.value.startsWith("can://artifact/")).toBe(false); @@ -95,13 +95,13 @@ describe("can:// prefix scoping (#140)", () => { const b = project((await analyze({ ...opts, appName: "svc-orders" })).application); const rootOf = (rows: ReturnType) => rows.nodes.find((n) => n.labels[0] === "Application")!; expect(rootOf(a).keyProp).toBe("id"); - expect(rootOf(a).value).toBe("can://svc-quotes"); - expect(rootOf(b).value).toBe("can://svc-orders"); + expect(rootOf(a).value).toBe("can://svc-quotes/svc-quotes"); + expect(rootOf(b).value).toBe("can://svc-orders/svc-orders"); expect(rootOf(a).value).not.toBe(rootOf(b).value); // The root carries the index anchor, so the prefix-scoped delete can reach its subtree. expect(rootOf(a).labels).toContain("TSCanNode"); // Neither application's prefix reaches the other's nodes. - for (const n of b.nodes) expect(n.value.startsWith("can://svc-quotes/")).toBe(false); + for (const n of b.nodes) expect(n.value.startsWith("can://svc-quotes/svc-quotes/")).toBe(false); }); test("every destructive statement anchors on a marker and a /-terminated prefix", async () => { @@ -110,8 +110,8 @@ describe("can:// prefix scoping (#140)", () => { const rows = project((await analyze(opts)).application); const cypher = renderCypher(rows, "ps"); // the snapshot wipe: ONE marker-scoped delete on a /-terminated prefix, then the app by equality - expect(cypher).toContain("MATCH (x:TSCanNode) WHERE x.id STARTS WITH 'can://ps/' DETACH DELETE x;"); - expect(cypher).toContain("MATCH (a:Application {id: 'can://ps'}) DETACH DELETE a;"); + expect(cypher).toContain("MATCH (x:TSCanNode) WHERE x.id STARTS WITH 'can://ps/ps/' DETACH DELETE x;"); + expect(cypher).toContain("MATCH (a:Application {id: 'can://ps/ps'}) DETACH DELETE a;"); expect(cypher).not.toContain("JSCanNode) WHERE"); // `_module` is in-memory only (#140). Asserted on the property keys, NOT as a substring of the // rendered cypher: :TSModule now carries whole-file `source` (#201), and any fixture that says diff --git a/test/neo4j-schema.test.ts b/test/neo4j-schema.test.ts index f97854f..c870917 100644 --- a/test/neo4j-schema.test.ts +++ b/test/neo4j-schema.test.ts @@ -82,7 +82,7 @@ describe("neo4j schema conformance", () => { expect(application, "TSApplication row").toBeDefined(); expect(application!.keyProp).toBe("id"); - expect(application!.value).toBe("can://dataflow-app"); + expect(application!.value).toBe("can://dataflow-app/dataflow-app"); expect(application!.props.name).toBe("dataflow-app"); expect(declaration?.key).toBe("id"); expect(declaration?.properties.name).toBe("string"); diff --git a/test/schema-v2.test.ts b/test/schema-v2.test.ts index 1724a0c..e806ed4 100644 --- a/test/schema-v2.test.ts +++ b/test/schema-v2.test.ts @@ -90,6 +90,7 @@ describe("schema v2 — L1 envelope", () => { expect(v2.language).toBe("typescript"); expect(v2.max_level).toBe(1); expect(Object.keys(root).sort()).toEqual([ + "application_name", "artifacts", "call_graph", "config_reads", @@ -97,6 +98,8 @@ describe("schema v2 — L1 envelope", () => { "dependencies", "entrypoint_report", // #72 unit 1: level-free coverage report "id", + "identity_namespace", + "identity_version", "kind", "name", "param_in", @@ -104,23 +107,17 @@ describe("schema v2 — L1 envelope", () => { "symbol_table", "unresolved_imports", ]); - expect(root.id).toBe("can://sample-app"); + expect(root.id).toBe("can://sample-app/sample-app"); expect(root.name).toBe("sample-app"); expect(root.kind).toBe("application"); }); - test("application name and id share the normalized app-name fallback", async () => { - for (const [appName, expected] of [[" logical-name ", "logical-name"], [" ", "app"]]) { - const named = await run({ appName }); - const namedRoot = named.application.application; - const applicationRow = project(named.application).nodes.find((node) => node.labels.includes("TSApplication")); - - expect(namedRoot.name).toBe(expected); - expect(namedRoot.id).toBe(`can://${expected}`); - expect(applicationRow?.keyProp).toBe("id"); - expect(applicationRow?.value).toBe(`can://${expected}`); - expect(applicationRow?.props.name).toBe(expected); - } + test("raw application names are encoded without trimming", async () => { + const named = (await run({appName: " logical-name "})).application; + expect(named.application.name).toBe(" logical-name "); + expect(named.application.id).toBe("can://%20%20logical-name%20%20/%20%20logical-name%20%20"); + expect(project(named).nodes[0]!.props.identity_version).toBe(2); + await expect(run({appName: " "})).rejects.toThrow(); }); test("edge lists are empty at L1 (populated at L2/L4)", () => { @@ -151,8 +148,8 @@ describe("schema v2 — L1 identity", () => { // The grammar gate: application OUTERMOST, language second. Anchored on the app so an id in // the sibling `javascript` namespace would still pass the prefix but be caught by the shape. for (const id of ids) { - expect(id.startsWith("can://sample-app/")).toBe(true); - expect(id).toMatch(/^can:\/\/sample-app\/(typescript|javascript)\/.+/); + expect(id.startsWith("can://sample-app/sample-app/")).toBe(true); + expect(id).toMatch(/^can:\/\/sample-app\/sample-app\/(typescript|javascript)\/.+/); expect(id.startsWith("can://typescript/")).toBe(false); expect(id.startsWith("can://javascript/")).toBe(false); } @@ -160,7 +157,7 @@ describe("schema v2 — L1 identity", () => { test("module ids derive from the file key", () => { for (const [key, m] of Object.entries(st) as [string, TSModule][]) { - expect(m.id).toBe(`can://sample-app/typescript/${key}`); + expect(m.id).toBe(`can://sample-app/sample-app/typescript/${key}`); expect(m.kind).toBe("module"); } }); diff --git a/test/tagged-templates.test.ts b/test/tagged-templates.test.ts index 8b5db6a..3b66a6a 100644 --- a/test/tagged-templates.test.ts +++ b/test/tagged-templates.test.ts @@ -51,6 +51,6 @@ describe("tagged template calls (#98)", () => { const fn = result.application.application.symbol_table["src/x.ts"]?.functions["asCSSUrl"]; const calls = Object.values(fn?.body ?? {}).filter((b) => b.kind === "call"); expect(calls.length).toBe(1); - expect(calls[0]?.callee).toBe("can://tagged/typescript/src/x.ts/inline"); + expect(calls[0]?.callee).toBe("can://tagged/tagged/typescript/src/x.ts/inline"); }); }); diff --git a/test/workspace-root.test.ts b/test/workspace-root.test.ts deleted file mode 100644 index 25047b6..0000000 --- a/test/workspace-root.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * `--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 { 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, ".."); -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("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; - 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 fcc9529a932bc40a6be426fb85298237e19ffe65 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 30 Oct 2026 14:05:20 -0400 Subject: [PATCH 5/7] fix: require raw namespace metadata before graph projection --- src/build/neo4j/project.ts | 2 +- test/namespace.test.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/build/neo4j/project.ts b/src/build/neo4j/project.ts index 7331b26..e5478ca 100644 --- a/src/build/neo4j/project.ts +++ b/src/build/neo4j/project.ts @@ -47,7 +47,7 @@ const KIND_LABEL: Record = { }; export function project(app: TSAnalysis, _appName?: string, builder?: RowBuilder): GraphRows { - if (app.identity_version !== 2 || app.application.identity_version !== 2 || app.application.id !== applicationIdOf(app.application.application_name, app.application.identity_namespace)) throw new Error("Incompatible application identity"); + 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; diff --git a/test/namespace.test.ts b/test/namespace.test.ts index 48f72a1..86fda18 100644 --- a/test/namespace.test.ts +++ b/test/namespace.test.ts @@ -65,3 +65,8 @@ test('cache rejects legacy and noninteger markers without rewriting the input', } } finally { fs.writeFileSync(file,original); } }); +test('application metadata is required even for the default namespace', async () => { + const wire = (await analyze({...options,appName:'app',namespace:undefined})).application; + delete (wire.application as any).identity_namespace; + expect(()=>project(wire)).toThrow(); +}); From 9658f895e3e75d683a0c00c87069934ddf2966fa Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 30 Oct 2026 14:07:58 -0400 Subject: [PATCH 6/7] fix: reject explicit empty CLI application names --- src/cli.ts | 2 +- test/namespace.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 9a7d108..cbc55ed 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -146,7 +146,7 @@ export function parseArgs(argv: string[]): AnalysisOptions { input: o.input ? path.resolve(String(o.input)) : "", output: o.output ? path.resolve(String(o.output)) : null, emit, - appName: o.appName ? String(o.appName) : null, + appName: o.appName === undefined ? null : String(o.appName), namespace: o.namespace === undefined ? undefined : String(o.namespace), neo4jUri: o.neo4jUri ? String(o.neo4jUri) : null, neo4jUser: String(o.neo4jUser), diff --git a/test/namespace.test.ts b/test/namespace.test.ts index 86fda18..477fdf3 100644 --- a/test/namespace.test.ts +++ b/test/namespace.test.ts @@ -70,3 +70,33 @@ test('application metadata is required even for the default namespace', async () delete (wire.application as any).identity_namespace; expect(()=>project(wire)).toThrow(); }); + +for (const namespaceArgs of [[], ['--namespace', 'team']]) { + test(`CLI rejects explicit empty application name ${namespaceArgs.join(' ')}`, () => { + const input = fs.mkdtempSync(path.join(os.tmpdir(), 'cants-empty-name-')); + const output = path.join(input, 'out'); + try { + const result = Bun.spawnSync(['bun', 'run', path.join(import.meta.dir, '../src/index.ts'), + '--input', input, '--output', output, '--no-build', '--app-name', '', ...namespaceArgs], + { stdout: 'pipe', stderr: 'pipe' }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr.toString()).toContain('Invalid application identity segment'); + expect(fs.existsSync(path.join(output, 'analysis.json'))).toBe(false); + } finally { fs.rmSync(input, {recursive:true,force:true}); } + }); + test(`CLI omitted application name uses input basename ${namespaceArgs.join(' ')}`, () => { + const input = fs.mkdtempSync(path.join(os.tmpdir(), 'cants-default-name-')); + const output = path.join(input, 'out'); + try { + const result = Bun.spawnSync(['bun', 'run', path.join(import.meta.dir, '../src/index.ts'), + '--input', input, '--output', output, '--no-build', ...namespaceArgs], + { stdout: 'pipe', stderr: 'pipe' }); + expect(result.exitCode).toBe(0); + const wire = JSON.parse(fs.readFileSync(path.join(output,'analysis.json'),'utf8')); + const name = path.basename(input); + expect(wire.application.application_name).toBe(name); + expect(wire.application.identity_namespace).toBe(namespaceArgs.length ? 'team' : name); + expect(wire.application.id).toBe(applicationIdOf(name, namespaceArgs.length ? 'team' : undefined)); + } finally { fs.rmSync(input, {recursive:true,force:true}); } + }); +} From 6d8099711049f0c415ec6e1656b273d06bf73f38 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Sat, 31 Oct 2026 02:16:20 -0400 Subject: [PATCH 7/7] fix: preserve module ownership in Neo4j projection --- src/build/neo4j/project.ts | 12 ++---------- test/namespace.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/build/neo4j/project.ts b/src/build/neo4j/project.ts index e5478ca..e26bbbf 100644 --- a/src/build/neo4j/project.ts +++ b/src/build/neo4j/project.ts @@ -73,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 ?? ""); @@ -398,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, }); } @@ -441,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:////; 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 { if (!n.span) return {}; return { diff --git a/test/namespace.test.ts b/test/namespace.test.ts index 477fdf3..699c158 100644 --- a/test/namespace.test.ts +++ b/test/namespace.test.ts @@ -100,3 +100,33 @@ for (const namespaceArgs of [[], ['--namespace', 'team']]) { } finally { fs.rmSync(input, {recursive:true,force:true}); } }); } + +test('Neo4j projection retains relative JS and TS module keys under an encoded owner', async () => { + const input = fs.mkdtempSync(path.join(os.tmpdir(), 'cants-graph-module-keys-')); + try { + fs.mkdirSync(path.join(input, 'nested')); + fs.writeFileSync(path.join(input, 'server.js'), 'function serve() { return 1; }\nserve();\n'); + fs.writeFileSync(path.join(input, 'nested/client.ts'), 'export function client() { return 2; }\n'); + const result = await analyze({...options, input, appName:'a%b', namespace:'team space', cacheDir:path.join(input, '.cache')}); + const rows = project(result.application); + const modules = rows.nodes.filter(node => node.labels.includes('TSModule')); + expect(modules.map(node => node.props.name).sort()).toEqual(['nested/client.ts', 'server.js']); + expect(modules.map(node => node.module).sort()).toEqual(['nested/client.ts', 'server.js']); + expect(modules.map(node => node.props.id).sort()).toEqual([ + 'can://team%20space/a%25b/javascript/server.js', + 'can://team%20space/a%25b/typescript/nested/client.ts', + ]); + } finally { fs.rmSync(input, {recursive:true,force:true}); } +}); + +test('projected classes stay in their module during incremental reconciliation', async () => { + const input = fs.mkdtempSync(path.join(os.tmpdir(), 'cants-graph-class-owner-')); + try { + fs.writeFileSync(path.join(input, 'server.js'), 'class Server { constructor() {} ping() { return 1; } }\n'); + const result = await analyze({...options, input, appName:'svc', namespace:'team', cacheDir:path.join(input, '.cache')}); + const rows = project(result.application); + const declaration = rows.nodes.find(node => node.value === 'can://team/svc/javascript/server.js/Server'); + expect(declaration?.labels).toContain('TSClass'); + expect(declaration?.module).toBe('server.js'); + } finally { fs.rmSync(input, {recursive:true,force:true}); } +});