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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/SCHEMA_DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<app>", "kind": "application",
"id": "can://<app>", "name": "<app>", "kind": "application",
"symbol_table": { // L1: the tree, keyed by file path
"<file>": { "kind": "module", "source": "…",
"types": { /* class | interface | enum | type_alias | namespace nodes */ },
Expand Down Expand Up @@ -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.

Original file line number Diff line number Diff line change
Expand Up @@ -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/<app>/<path>`) | 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/<name>`, scoped `pkg:npm/%40scope/<name>`) | id, ecosystem, name | language-neutral |
| `ConfigKey` | `id` (`<artifactId>@key/<dotted>`) | 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) |
Expand Down
1 change: 1 addition & 0 deletions schema.neo4j.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"key": "id",
"properties": {
"id": "string",
"name": "string",
"schema_version": "string",
"language": "string",
"max_level": "integer",
Expand Down
3 changes: 2 additions & 1 deletion src/build/neo4j/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,15 @@ 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,
k_limit: app.k_limit ?? null,
// 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
Expand Down
4 changes: 2 additions & 2 deletions src/build/neo4j/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/schema/emit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export function finalizeAnalysis(

const root: TSApplication = {
id: appId,
name: appName,
kind: "application",
symbol_table: app.symbol_table,
call_graph: [],
Expand Down
1 change: 1 addition & 0 deletions src/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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://<app> — the prefix every id below it shares
name: string; // normalized --app-name, or the input directory basename
kind: "application";
symbol_table: Record<string, TSModule>; // keyed by project-relative POSIX path (with extension)
call_graph: TSCallGraphEdge[]; // L2 — callable → callable (empty at L1)
Expand Down
12 changes: 12 additions & 0 deletions test/neo4j-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 18 additions & 2 deletions test/schema-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ function options(): AnalysisOptions {
};
}

async function run(): Promise<AnalysisResult> {
async function run(overrides: Partial<AnalysisOptions> = {}): Promise<AnalysisResult> {
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 });
}
Expand Down Expand Up @@ -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([]);
Expand Down
Loading