diff --git a/.claude/SCHEMA_DECISIONS.md b/.claude/SCHEMA_DECISIONS.md index 1ceae7a..9e18160 100644 --- a/.claude/SCHEMA_DECISIONS.md +++ b/.claude/SCHEMA_DECISIONS.md @@ -6,7 +6,7 @@ analyzers — Java (`python-sdk/cldk/models/java/models.py`, rich-edge legacy) a Every divergence below was decided **with the user**. ## Invariant spine (never drifts) -- Root: `TSApplication { symbol_table: Dict[path, TSModule], call_graph: List[TSCallEdge], +- Root: `TSApplication { id, name, symbol_table: Dict[path, TSModule], call_graph: List[TSCallEdge], entrypoints: Dict[str, List[TSEntrypoint]] }`. - `symbol_table` keyed by **project-relative POSIX path with extension** (e.g. `src/user.ts`). - `Module → Class/Callable` nesting; identity-only edges (`source`/`target` are bare signature diff --git a/README.md b/README.md index 857fbd2..2627655 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ deeply; each level only ever *adds*. { "schema_version": "2.0.0", "language": "typescript", "max_level": 4, "k_limit": 3, "application": { - "id": "can://", "kind": "application", + "id": "can://", "name": "", "kind": "application", "symbol_table": { // L1: the tree, keyed by file path "": { "kind": "module", "source": "…", "types": { /* class | interface | enum | type_alias | namespace nodes */ }, @@ -397,4 +397,3 @@ swept. Nothing here corrupts a graph: what is lost is derived and regenerates. But a partial run leaves a partial answer, and nothing in the data says so. - diff --git a/docs/skills/analyzing-cants-graphs/references/vocabulary.md b/docs/skills/analyzing-cants-graphs/references/vocabulary.md index 1302025..5991157 100644 --- a/docs/skills/analyzing-cants-graphs/references/vocabulary.md +++ b/docs/skills/analyzing-cants-graphs/references/vocabulary.md @@ -9,7 +9,7 @@ assuming the graph is empty. | label | merge key | properties | notes | | --- | --- | --- | --- | -| `TSApplication` | `id` | id, schema_version, language, max_level, k_limit, analyzer_name, analyzer_version | one per run; the `:Application` anchor | +| `TSApplication` | `id` | id, name, schema_version, language, max_level, k_limit, analyzer_name, analyzer_version | one per run; the `:Application` anchor; `name` is the normalized `--app-name` or input-directory basename | | `Artifact` | `id` (`can://artifact//`) | id, kind, path, format, roles[], size_bytes, sha256, source, extraction | **language-neutral, no TS prefix by design** — sibling analyzers MERGE onto the same node. `source` is the WHOLE file (no byte cap, #116) or `""` under `--no-artifact-text`; `config_keys` are separate `ConfigKey` nodes | | `Package` | `id` (purl `pkg:npm/`, scoped `pkg:npm/%40scope/`) | id, ecosystem, name | language-neutral | | `ConfigKey` | `id` (`@key/`) | id, key, namespace, value, references[] | language-neutral; `key` is always the bare dotted name even when `id` carries an internal `arg.`/`env.` disambiguation prefix (see SKILL.md's identity section) | diff --git a/schema.neo4j.json b/schema.neo4j.json index 2b5fe80..f7dbd7c 100644 --- a/schema.neo4j.json +++ b/schema.neo4j.json @@ -12,6 +12,7 @@ "key": "id", "properties": { "id": "string", + "name": "string", "schema_version": "string", "language": "string", "max_level": "integer", diff --git a/src/build/neo4j/project.ts b/src/build/neo4j/project.ts index 15d625c..7327e8f 100644 --- a/src/build/neo4j/project.ts +++ b/src/build/neo4j/project.ts @@ -51,6 +51,7 @@ export function project(app: TSAnalysis, _appName?: string): GraphRows { const appRef = b.node(["Application", "TSApplication"], "id", root.id, prune({ id: root.id, + name: root.name, schema_version: SCHEMA_VERSION, language: app.language, max_level: app.max_level, @@ -58,7 +59,7 @@ export function project(app: TSAnalysis, _appName?: string): GraphRows { // Same analyzer{name,version} the JSON envelope carries (emit.ts) — the two co-primary // projections must never diverge on analyzer identity (issue #43). Namespaced as // analyzer_name/analyzer_version (not bare name/version) to avoid colliding with the - // app-name param (project()'s _appName) and every other CanNode's bare `name`. + // application's display name and every other CanNode's bare `name`. analyzer_name: app.analyzer.name, analyzer_version: app.analyzer.version, // Entrypoint report (#72; python #182 parity): the pass under-approximates by design, so a graph diff --git a/src/build/neo4j/schema.ts b/src/build/neo4j/schema.ts index 9397878..9b2c07c 100644 --- a/src/build/neo4j/schema.ts +++ b/src/build/neo4j/schema.ts @@ -71,9 +71,9 @@ export const NODE_LABELS: NodeLabel[] = [ mergeLabel: "Application", key: "id", properties: { - id: "string", schema_version: "string", language: "string", max_level: "integer", k_limit: "integer", + id: "string", name: "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 app-name param / every + // namespaced (not bare name/version) to avoid colliding with the application name / every // other CanNode's bare `name`. analyzer_name: "string", analyzer_version: "string", // Entrypoint report (#72; python #182 parity) — sorted-key JSON, since Neo4j has no map type. diff --git a/src/schema/emit.ts b/src/schema/emit.ts index 9679b66..193c4f6 100644 --- a/src/schema/emit.ts +++ b/src/schema/emit.ts @@ -107,6 +107,7 @@ export function finalizeAnalysis( const root: TSApplication = { id: appId, + name: appName, kind: "application", symbol_table: app.symbol_table, call_graph: [], diff --git a/src/schema/schema.ts b/src/schema/schema.ts index 22b2345..eb3cf60 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -567,6 +567,7 @@ 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 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/test/neo4j-schema.test.ts b/test/neo4j-schema.test.ts index 76ce168..f97854f 100644 --- a/test/neo4j-schema.test.ts +++ b/test/neo4j-schema.test.ts @@ -76,6 +76,18 @@ describe("neo4j schema conformance", () => { } }); + test("TSApplication retains its display name while merging by id", () => { + const application = rows.nodes.find((node) => node.labels.includes("TSApplication")); + const declaration = byLabel.get("TSApplication"); + + expect(application, "TSApplication row").toBeDefined(); + expect(application!.keyProp).toBe("id"); + expect(application!.value).toBe("can://dataflow-app"); + expect(application!.props.name).toBe("dataflow-app"); + expect(declaration?.key).toBe("id"); + expect(declaration?.properties.name).toBe("string"); + }); + test("every emitted relationship type + property + endpoint is declared", () => { for (const edge of rows.edges) { const decl = relByType.get(edge.type); diff --git a/test/schema-v2.test.ts b/test/schema-v2.test.ts index 54c4778..1724a0c 100644 --- a/test/schema-v2.test.ts +++ b/test/schema-v2.test.ts @@ -45,10 +45,10 @@ function options(): AnalysisOptions { }; } -async function run(): Promise { +async function run(overrides: Partial = {}): Promise { const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-v2-test-")); try { - return await analyze({ ...options(), cacheDir }); + return await analyze({ ...options(), ...overrides, cacheDir }); } finally { fs.rmSync(cacheDir, { recursive: true, force: true }); } @@ -98,15 +98,31 @@ describe("schema v2 — L1 envelope", () => { "entrypoint_report", // #72 unit 1: level-free coverage report "id", "kind", + "name", "param_in", "param_out", "symbol_table", "unresolved_imports", ]); expect(root.id).toBe("can://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("edge lists are empty at L1 (populated at L2/L4)", () => { expect(root.call_graph).toEqual([]); expect(root.param_in).toEqual([]);