diff --git a/.claude/skills/agent-eval/corpus.json b/.claude/skills/agent-eval/corpus.json
index 150b4a601..4c80a5bd0 100644
--- a/.claude/skills/agent-eval/corpus.json
+++ b/.claude/skills/agent-eval/corpus.json
@@ -607,5 +607,28 @@
"files": "~3390",
"question": "How does programs.git.enable produce the final git config file in the user's home directory? Trace the flow from the git program module to the home-files machinery that links generated files into place."
}
+ ],
+ "CDS": [
+ {
+ "name": "fiori-elements-feature-showcase",
+ "repo": "https://github.com/SAP-samples/fiori-elements-feature-showcase",
+ "size": "Small",
+ "files": "~110",
+ "question": "How does LROPODataService's RootEntities entity (a projection on persistence.RootEntities in db/schema.cds) reach the Criticality code list entity? Trace from the service projection through db.RootEntities, its rootBasis aspect's criticality association, to the Criticality entity in db/common.cds."
+ },
+ {
+ "name": "btp-cap-multitenant-saas",
+ "repo": "https://github.com/SAP-samples/btp-cap-multitenant-saas",
+ "size": "Medium",
+ "files": "~1076",
+ "question": "How does AdminService's Members entity (code/srv/srv/admin-service.cds) reach the Roles entity? Trace from the projection on db.Members (code/db/data-model.cds, context susaas.db) through Members' association to Users, then Users' association to Roles."
+ },
+ {
+ "name": "bookshop-demo",
+ "repo": "https://github.com/gregorwolf/bookshop-demo",
+ "size": "Large",
+ "files": "~1462",
+ "question": "How does AdminService's Books entity (srv/admin-service.cds) reach the Genres entity? Trace through the projection on db.Books (db/schema.cds) and its genre association to Genres, noting the managed aspect Books includes and the sap.common.CodeList aspect Genres includes."
+ }
]
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1815c4150..d250c98bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### New Features
+- CodeGraph now indexes **CDS (SAP CAP)** (`.cds`): namespaces and `context` scopes, `service` definitions, entities and views with projections, elements with their associations and compositions, `type`/enum aliases, `aspect` includes, annotation type definitions, events, and unbound/bound actions and functions, plus `using` imports and doc comments. Associations and compositions resolve to their target entities, a projection or view resolves to the entities it selects from, an `aspect` include resolves to the aspect it pulls in, a `using` alias resolves to the entity or service it names (including across files), and `extend`/`annotate` blocks link back to what they extend or annotate. Re-index CDS projects after upgrading.
+
- **A busy screen's picture is laid out by the parts of the screen.** A screen is a set of handlers with no order between them, so on a hub screen the old rows-by-distance collapsed into one enormous row — the main screen of one app put 89 boxes side by side on a canvas over 28,000px wide, every line a near-horizontal sweep across all of it. The Steps tab now groups a screen's picture by region — the component that owns each handler, named in a small caption over its boxes — with each region a column where a step sits above what it sets in motion, tiled in the screen's own source order. At rest the picture hides only two things: the screen's own fan-out — one line into each region stands in for it — and lines that point back up; every other line draws where it leads, between two regions included, and selecting a step brings out its whole story in the side panel, link by link. A box nothing points at is the screen's own doing — run on render or mount, or from a binding written inline — the key says so, and selecting it lights its line from the screen with what fires it. The same app's widest screen now lays out under 3,500px with every line local, and the whole picture fits on screen when it opens. Endpoints, handlers and the in-order reading are untouched, and nothing needs a re-index: the regions come from the same walk that draws the steps.
- **Where the code chooses, the picture says so once.** A helper that ends `return (await hasSeenWelcome(id)) ? '/home/' : '/welcome/'` sends the app to one of two screens, but the Steps picture drew that as two separate arrows, each carrying the whole condition with one of them negated and both cut off at the same forty characters — and before you clicked anything, neither arrow was labelled at all, so nothing said it was a choice. Now sibling arrows out of one box that are the arms of one `if`, `switch` or ternary are drawn as the choice they are: the condition is written once under the box that decides it, and each arrow out says only which way it is — `yes`, `no`, or a case's own value. They are the only arrows labelled before you select anything, so the picture reads at a glance without becoming a wall of text. A one-sided guard — an early exit, an `if` with only one side drawn — still carries its condition on the arrow, and an arrow that is reached whether or not the condition holds never claims a side. Nothing needs a re-index: the decision is read from the source at request time.
diff --git a/README.md b/README.md
index a73d3b2bc..421d328ba 100644
--- a/README.md
+++ b/README.md
@@ -187,6 +187,7 @@ Every language below gets the same treatment — full structural extraction and
+
@@ -289,7 +290,7 @@ CodeGraph's parsing engine is a **native Rust kernel**: 20 languages — TypeScr
| **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 |
| **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes |
| **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
-| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
+| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CDS (SAP CAP), CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks |
| **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules |
| **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
@@ -896,6 +897,7 @@ is written):
| COBOL | `.cbl`, `.cob`, `.cpy` | Full support (programs, sections/paragraphs with PERFORM/GO TO call edges, CALL 'literal' cross-program calls, COPY copybook imports — including standalone `.cpy` files — DATA DIVISION records/fields/88-levels, EXEC CICS LINK/XCTL and EXEC SQL INCLUDE targets; fixed and free format) |
| Visual Basic .NET | `.vb` | Full support (classes, Modules, interfaces, structures, enums, properties, events, `Declare` P/Invoke, `Handles`/`WithEvents`, `Inherits`/`Implements` edges, call edges through VB's call/index paren ambiguity, `As New` instantiation, interpolated strings, LINQ, Unicode identifiers) |
| Erlang | `.erl`, `.hrl`, `.escript`, `.app.src`, `.app` | Full support (functions with multi-clause/multi-arity grouping, `-spec` signatures, records with fields, `-type`/`-opaque` aliases, `-define` macros, `-include`/`-include_lib`/`-import` edges, local and `mod:fn` remote call edges, `fun name/arity` references, `spawn`/`apply`/`proc_lib`/`timer`/`rpc` MFA-argument call edges, `gen_server:call/cast(?MODULE)` → own `handle_call`/`handle_cast` links, `-behaviour` links, `-export`-based visibility) |
+| CDS (SAP CAP) | `.cds` | Full support (namespaces and nested `context` scopes, `service` definitions, entities and views incl. projections and `as select from`, elements with associations/compositions/nested structs, `type`/enum aliases, `aspect` definitions with includes, `annotation` type definitions, `event` definitions, unbound and bound `action`/`function` definitions, `using` imports with alias tracking, `extend`/`annotate` directives, doc comments, annotation decorators; resolves associations and compositions to their target entities, aspect includes, projection/view sources across files, `using` aliases to their fully qualified names, `extend`/`annotate` targets, and relative `using ... from` file imports) |
| Solidity | `.sol` | Full support (contracts, libraries, interfaces, structs, enums, modifiers, events, errors, state variables, `import`/`using` directives, `emit`/`revert` calls) |
| Terraform / OpenTofu | `.tf`, `.tfvars`, `.tofu` | Full support (resources, data sources, modules, variables, outputs, providers incl. aliases, `locals`; `var.`/`local.`/`module.`/resource references with Terraform's per-directory scoping enforced; module calls bridged across the boundary — inputs to the child module's variables, `module.M.out` to the child's output, `source` to the module's files; cloudposse/atmos `remote-state` cross-component wiring when the component is statically named; `provider = aws.east` selections resolved up the module tree; `moved`/`import`/`removed`/`check` block references; `.tfvars` assignments linked to the variables they set) |
| Nix | `.nix` | Full support (functions with simple/destructured/curried params, `let`/attrset bindings, `inherit`, `import ./path` file edges — `./dir` resolving through `default.nix` — plus NixOS module `imports = [ ./x.nix ]` lists and `callPackage ./pkg.nix` file edges; call edges; module-system option wiring — a config write like `launchd.user.agents.x = { ... }` links to the module declaring `options.launchd.user.agents`, so option flows trace across modules) |
diff --git a/__tests__/cds-cross-file.test.ts b/__tests__/cds-cross-file.test.ts
new file mode 100644
index 000000000..a74df9805
--- /dev/null
+++ b/__tests__/cds-cross-file.test.ts
@@ -0,0 +1,324 @@
+/**
+ * CDS (SAP CAP) across a whole project.
+ *
+ * A CAP model is deliberately spread over layers: `db/` declares the domain,
+ * `srv/` projects it into services, `app/` annotates and extends what the
+ * service exposes. Every graph fact that matters is therefore cross-file, and
+ * each of extraction, import resolution and name resolution can look right on
+ * its own while the model still comes out disconnected. This indexes a small
+ * but complete project with the real pipeline and pins the connections end to
+ * end: the `using` file edges (including a directory resolving to its
+ * index.cds and one written with its `.cds` extension), the projection and
+ * query sources, the aspect include, the associations and compositions, the
+ * extend and annotate targets, a definition declared with a DOTTED name
+ * (`entity sap.common.Criticality`, what OData import tooling emits), and the
+ * namespace a `using ... as` alias names. What is left unresolved is pinned
+ * too, since the value of the CDS branch is that it stays silent rather than
+ * guessing: only the reuse artifacts from `@sap/cds/common` remain.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import { CodeGraph } from '../src';
+
+interface NodeRow {
+ kind: string;
+ name: string;
+ qn: string;
+ fp: string;
+ exported: number;
+ decorators: string | null;
+ docstring: string | null;
+}
+interface EdgeRow {
+ kind: string;
+ srcQn: string;
+ srcFp: string;
+ tgtQn: string;
+ tgtFp: string;
+ same: number;
+}
+
+const FILES: Record = {
+ 'db/common/index.cds': `namespace sap.capire.common;
+
+/** Everything an auditor needs to see. */
+aspect Auditable {
+ createdBy : String;
+ createdAt : Timestamp;
+}
+`,
+ 'db/codelists.cds': `entity sap.common.Criticality {
+ key code : Integer;
+ name : String;
+}
+`,
+ 'db/schema.cds': `namespace sap.capire.bookshop;
+
+using { sap.capire.common.Auditable } from './common';
+using { sap.common.Criticality } from './codelists.cds';
+using { ZPDCDS_SRV.SEPMRA_I_Product_E } from '../srv/external/ZPDCDS_SRV.csn';
+using { managed, cuid, Currency } from '@sap/cds/common';
+
+/** A book in the catalog. */
+@title: 'Books'
+entity Books : cuid, managed, Auditable {
+ key ID : Integer;
+ title : localized String(111) @mandatory;
+ author : Association to Authors;
+ genre : Association to Genres;
+ currency : Currency;
+ price : Decimal(9,2);
+ criticality : Association to sap.common.Criticality;
+ items : Composition of many OrderItems on items.book = $self;
+ status : String enum { draft; published = 'P'; };
+}
+
+entity Authors : cuid {
+ name : String;
+ books : Association to many Books on books.author = $self;
+}
+
+entity Genres {
+ key ID : Integer;
+ name : String;
+ books : Association to many Books on books.genre = $self;
+}
+
+entity OrderItems {
+ key ID : UUID;
+ book : Association to Books;
+ amount : Integer;
+}
+`,
+ 'srv/cat-service.cds': `using { sap.capire.bookshop as my } from '../db/schema';
+
+service CatalogService @(path: '/browse') {
+
+ @readonly
+ @(UI.HeaderInfo: { TypeName: 'Book' })
+ entity ListOfBooks as projection on my.Books excluding { price };
+
+ entity Sales as select from my.Books as b
+ join my.Authors as a on b.author.ID = a.ID
+ { b.ID as id, a.name as author };
+
+ entity Orders as projection on my.OrderItems actions {
+ action cancel();
+ function total() returns Decimal;
+ }
+
+ action submitOrder(book : my.Books:ID, quantity : Integer) returns { stock : Integer };
+
+ event OrderedBook : { book : UUID; quantity : Integer; }
+}
+`,
+ 'app/annotations.cds': `using { CatalogService } from '../srv/cat-service';
+using { sap.capire.bookshop as my } from '../db/schema';
+
+annotate CatalogService.ListOfBooks with @UI.HeaderInfo: { TypeName: 'Book' } {
+ title @title: 'Title';
+};
+
+extend my.Books with { isbn : String; }
+
+extend service CatalogService with {
+ entity Extra as projection on my.Authors;
+}
+`,
+};
+
+describe('CDS across a CAP project', () => {
+ let dir: string;
+ let nodes: NodeRow[];
+ let edges: EdgeRow[];
+ let unresolved: string[];
+
+ beforeAll(async () => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cds-cross-file-'));
+ for (const [rel, code] of Object.entries(FILES)) {
+ fs.mkdirSync(path.join(dir, path.dirname(rel)), { recursive: true });
+ fs.writeFileSync(path.join(dir, rel), code);
+ }
+ const cg = await CodeGraph.init(dir, { silent: true });
+ await cg.indexAll();
+ const db = (cg as any).db.db;
+ nodes = db
+ .prepare(
+ `SELECT kind, name, qualified_name qn, file_path fp, is_exported exported,
+ decorators, docstring
+ FROM nodes ORDER BY file_path, start_line`
+ )
+ .all() as NodeRow[];
+ edges = db
+ .prepare(
+ `SELECT e.kind, s.qualified_name srcQn, s.file_path srcFp,
+ t.qualified_name tgtQn, t.file_path tgtFp,
+ CASE WHEN e.source = e.target THEN 1 ELSE 0 END same
+ FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
+ ORDER BY e.kind, srcQn, tgtQn`
+ )
+ .all() as EdgeRow[];
+ unresolved = (
+ db.prepare('SELECT DISTINCT reference_name n FROM unresolved_refs ORDER BY n').all() as {
+ n: string;
+ }[]
+ ).map((r) => r.n);
+ cg.destroy();
+ }, 60000);
+
+ afterAll(() => {
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ const nodeOf = (qn: string): NodeRow | undefined => nodes.find((n) => n.qn === qn);
+ const targetsOf = (srcQn: string, kind: string): string[] =>
+ edges.filter((e) => e.kind === kind && e.srcQn === srcQn).map((e) => e.tgtQn).sort();
+
+ it('links every relative `using` to the model file it names', () => {
+ // Specifiers are extensionless by convention, `./common` is a DIRECTORY
+ // that has to land on its index.cds, and `./codelists.cds` spells the
+ // extension out. All three shapes come from a file that opens with a bare
+ // `namespace X;`, where the `using` hangs off the namespace node rather
+ // than off the file node.
+ const fileImports = edges
+ .filter((e) => e.kind === 'imports')
+ .map((e) => `${e.srcFp} -> ${e.tgtFp}`)
+ .sort();
+ expect(fileImports).toEqual([
+ 'app/annotations.cds -> db/schema.cds',
+ 'app/annotations.cds -> srv/cat-service.cds',
+ 'db/schema.cds -> db/codelists.cds',
+ 'db/schema.cds -> db/common/index.cds',
+ 'srv/cat-service.cds -> db/schema.cds',
+ ]);
+ expect(
+ edges.some((e) => e.kind === 'imports' && e.srcQn === 'sap.capire.bookshop')
+ ).toBe(true);
+ });
+
+ it('links a dotted entity declared in another file', () => {
+ // `entity sap.common.Criticality` is NAMED `Criticality` and qualified
+ // `sap.common::Criticality`, which is what makes the association from a
+ // namespaced file find it.
+ const criticality = nodeOf('sap.common::Criticality');
+ expect(criticality?.kind).toBe('class');
+ expect(criticality?.name).toBe('Criticality');
+ expect(criticality?.fp).toBe('db/codelists.cds');
+ expect(nodeOf('sap.common::Criticality::code')?.kind).toBe('field');
+ expect(targetsOf('sap.capire.bookshop::Books::criticality', 'references')).toEqual([
+ 'sap.common::Criticality',
+ ]);
+ });
+
+ it('links a projection and a joined select to the db entities they read', () => {
+ expect(targetsOf('CatalogService::ListOfBooks', 'references')).toEqual([
+ 'sap.capire.bookshop::Books',
+ ]);
+ expect(targetsOf('CatalogService::Sales', 'references')).toEqual([
+ 'sap.capire.bookshop::Authors',
+ 'sap.capire.bookshop::Books',
+ ]);
+ expect(targetsOf('CatalogService::Orders', 'references')).toEqual([
+ 'sap.capire.bookshop::OrderItems',
+ ]);
+ });
+
+ it('links an aspect include to the aspect', () => {
+ // The extractor emits `extends`; resolution promotes it to `implements`
+ // because an aspect is an `interface` node and the entity including it is a
+ // `class` (see createEdges in src/resolution/index.ts).
+ expect(targetsOf('sap.capire.bookshop::Books', 'implements')).toEqual([
+ 'sap.capire.common::Auditable',
+ ]);
+ });
+
+ it('links associations and compositions from the element that declares them', () => {
+ expect(targetsOf('sap.capire.bookshop::Books::author', 'references')).toEqual([
+ 'sap.capire.bookshop::Authors',
+ ]);
+ expect(targetsOf('sap.capire.bookshop::Books::genre', 'references')).toEqual([
+ 'sap.capire.bookshop::Genres',
+ ]);
+ expect(targetsOf('sap.capire.bookshop::Books::items', 'references')).toEqual([
+ 'sap.capire.bookshop::OrderItems',
+ ]);
+ expect(targetsOf('sap.capire.bookshop::Authors::books', 'references')).toEqual([
+ 'sap.capire.bookshop::Books',
+ ]);
+ expect(targetsOf('sap.capire.bookshop::OrderItems::book', 'references')).toEqual([
+ 'sap.capire.bookshop::Books',
+ ]);
+ });
+
+ it('links an action to the entity its parameter type names', () => {
+ expect(targetsOf('CatalogService::submitOrder', 'references')).toEqual([
+ 'sap.capire.bookshop::Books',
+ ]);
+ });
+
+ it('roots an element added by extend at the extended entity', () => {
+ const isbn = nodeOf('sap.capire.bookshop::Books::isbn');
+ expect(isbn?.kind).toBe('field');
+ // The element is declared in the app layer but belongs to the db entity.
+ expect(isbn?.fp).toBe('app/annotations.cds');
+ });
+
+ it('exposes an entity added by extend service and links its source', () => {
+ const extra = nodeOf('CatalogService::Extra');
+ expect(extra?.kind).toBe('class');
+ expect(extra?.fp).toBe('app/annotations.cds');
+ expect(extra?.exported).toBe(1);
+ expect(targetsOf('CatalogService::Extra', 'references')).toEqual([
+ 'sap.capire.bookshop::Authors',
+ ]);
+ });
+
+ it('links an annotate directive to the artifact it annotates', () => {
+ // `annotate` declares no symbol, so the dependency hangs off the file.
+ const fromFile = targetsOf('app/annotations.cds', 'references');
+ expect(fromFile).toContain('CatalogService::ListOfBooks');
+ expect(fromFile).toContain('CatalogService');
+ });
+
+ it('links a `using ... as` namespace alias to the namespace node', () => {
+ // `using { sap.capire.bookshop as my }` names the namespace itself, which
+ // is one dotted node name rather than a `::` scope step.
+ expect(targetsOf('srv/cat-service.cds', 'references')).toContain('sap.capire.bookshop');
+ expect(targetsOf('app/annotations.cds', 'references')).toContain('sap.capire.bookshop');
+ expect(nodeOf('sap.capire.bookshop')?.kind).toBe('namespace');
+ });
+
+ it('keeps annotations, keys, enums and doc comments through the pipeline', () => {
+ expect(nodeOf('CatalogService')?.decorators).toBe(JSON.stringify(['@path']));
+ expect(nodeOf('CatalogService::ListOfBooks')?.decorators).toBe(
+ JSON.stringify(['@readonly', '@UI.HeaderInfo'])
+ );
+ expect(nodeOf('sap.capire.bookshop::Books::ID')?.decorators).toBe(JSON.stringify(['key']));
+ expect(nodeOf('sap.capire.bookshop::Books::status::draft')?.kind).toBe('enum_member');
+ expect(nodeOf('sap.capire.bookshop::Books')?.docstring).toBe('A book in the catalog.');
+ });
+
+ it('never points an edge at its own source', () => {
+ const loops = edges.filter((e) => e.same === 1).map((e) => `${e.kind} ${e.srcQn}`);
+ expect(loops).toEqual([]);
+ });
+
+ it('leaves only the out-of-repo reuse artifacts unresolved', () => {
+ // `@sap/cds/common` is a package, not a project file, so it and the three
+ // aspects it contributes have nothing in the index to point at. A `.csn`
+ // model is a compiled artifact that is not indexed as source, so the
+ // specifier and the artifact it imports stay unresolved rather than
+ // landing on a same-named node elsewhere. Anything else appearing here is
+ // a model fact the pipeline stopped connecting.
+ expect(unresolved, `unresolved: ${unresolved.join(', ')}`).toEqual([
+ '../srv/external/ZPDCDS_SRV.csn',
+ '@sap/cds/common',
+ 'Currency',
+ 'ZPDCDS_SRV::SEPMRA_I_Product_E',
+ 'cuid',
+ 'managed',
+ ]);
+ });
+});
diff --git a/__tests__/cds-resolution.test.ts b/__tests__/cds-resolution.test.ts
new file mode 100644
index 000000000..edc757685
--- /dev/null
+++ b/__tests__/cds-resolution.test.ts
@@ -0,0 +1,463 @@
+/**
+ * CDS (SAP CAP) reference resolution.
+ *
+ * CDS name resolution is lexical and the names are ordinary words (`Books`,
+ * `Currency`, `Status`), so the matcher has to be exact or silent: a service's
+ * own projection shadows a same-named db entity, a namespace-qualified name
+ * means that one artifact and no other, and a projection on a same-named
+ * entity must never resolve to itself. Everything below pins those rules plus
+ * the `using ... from` file lookup, on synthetic nodes so the suite does not
+ * depend on the CDS grammar being loadable.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { Node, UnresolvedReference } from '../src/types';
+import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
+import { matchReference } from '../src/resolution/name-matcher';
+import { resolveImportPath } from '../src/resolution/import-resolver';
+import { matchesSymbol } from '../src/graph/named-symbol-flow';
+import { DatabaseConnection } from '../src/db';
+import { QueryBuilder } from '../src/db/queries';
+import { createResolver } from '../src/resolution';
+
+function node(
+ kind: Node['kind'],
+ name: string,
+ qualifiedName: string,
+ filePath: string,
+ language: Node['language'] = 'cds',
+ startLine = 1
+): Node {
+ return {
+ id: `${kind}:${filePath}:${qualifiedName}:${startLine}`,
+ kind,
+ name,
+ qualifiedName,
+ filePath,
+ language,
+ startLine,
+ endLine: startLine + 3,
+ startColumn: 0,
+ endColumn: 0,
+ updatedAt: Date.now(),
+ };
+}
+
+function contextOf(nodes: Node[]): ResolutionContext {
+ return {
+ getNodesInFile: (filePath) => nodes.filter((n) => n.filePath === filePath),
+ getNodesByName: (name) => nodes.filter((n) => n.name === name),
+ getNodesByQualifiedName: (qn) => nodes.filter((n) => n.qualifiedName === qn),
+ getNodesByKind: (kind) => nodes.filter((n) => n.kind === kind),
+ getNodesByLowerName: (lower) => nodes.filter((n) => n.name.toLowerCase() === lower),
+ getNodeById: (id) => nodes.find((n) => n.id === id) ?? null,
+ fileExists: () => false,
+ readFile: () => null,
+ getProjectRoot: () => '/proj',
+ getAllFiles: () => [...new Set(nodes.map((n) => n.filePath))],
+ getImportMappings: () => [],
+ };
+}
+
+function cdsRef(
+ fromNodeId: string,
+ referenceName: string,
+ filePath: string,
+ referenceKind: UnresolvedRef['referenceKind'] = 'references'
+): UnresolvedRef {
+ return {
+ fromNodeId,
+ referenceName,
+ referenceKind,
+ line: 4,
+ column: 2,
+ filePath,
+ language: 'cds',
+ };
+}
+
+// A two-file CAP model:
+//
+// db/schema.cds namespace sap.capire.bookshop;
+// entity Books { ... } entity Authors { ... }
+// srv/cat-service.cds using { sap.capire.bookshop as my } from '../db/schema';
+// service CatalogService {
+// entity Books as projection on my.Books;
+// entity ListOfBooks as projection on Books;
+// }
+const DB_FILE = 'db/schema.cds';
+const SRV_FILE = 'srv/cat-service.cds';
+const dbNamespace = node('namespace', 'sap.capire.bookshop', 'sap.capire.bookshop', DB_FILE);
+const dbBooks = node('class', 'Books', 'sap.capire.bookshop::Books', DB_FILE, 'cds', 3);
+const dbAuthors = node('class', 'Authors', 'sap.capire.bookshop::Authors', DB_FILE, 'cds', 12);
+const srvService = node('module', 'CatalogService', 'CatalogService', SRV_FILE, 'cds', 3);
+const srvBooks = node('class', 'Books', 'CatalogService::Books', SRV_FILE, 'cds', 4);
+const srvListOfBooks = node('class', 'ListOfBooks', 'CatalogService::ListOfBooks', SRV_FILE, 'cds', 5);
+const MODEL = [dbNamespace, dbBooks, dbAuthors, srvService, srvBooks, srvListOfBooks];
+
+describe('CDS name resolution', () => {
+ it('resolves a bare name to the enclosing service before the db entity', () => {
+ // `entity ListOfBooks as projection on Books` inside CatalogService means
+ // the service's own projection, not the namespaced db entity of the same
+ // name. The scope chain walks CatalogService::ListOfBooks -> CatalogService
+ // and finds CatalogService::Books there.
+ const result = matchReference(
+ cdsRef(srvListOfBooks.id, 'Books', SRV_FILE),
+ contextOf(MODEL)
+ );
+ expect(result?.targetNodeId).toBe(srvBooks.id);
+ expect(result?.confidence).toBe(0.95);
+ expect(result?.resolvedBy).toBe('qualified-name');
+ });
+
+ it('resolves an alias-expanded namespace-qualified name across files', () => {
+ // `projection on my.Books` with `using { sap.capire.bookshop as my }`
+ // reaches the matcher already expanded, as `sap.capire.bookshop::Books`.
+ const result = matchReference(
+ cdsRef(srvBooks.id, 'sap.capire.bookshop::Books', SRV_FILE),
+ contextOf(MODEL)
+ );
+ expect(result?.targetNodeId).toBe(dbBooks.id);
+ expect(result?.confidence).toBe(0.95);
+ });
+
+ it('resolves a namespace-alias using to the namespace it names', () => {
+ // `using { sap.capire.bookshop as my }` references the NAMESPACE, and the
+ // reference format splits the last dot off: `sap.capire::bookshop`. The
+ // namespace node carries the whole path as one name, so the lookup only
+ // finds it by the full dotted spelling.
+ const result = matchReference(
+ cdsRef(srvBooks.id, 'sap.capire::bookshop', SRV_FILE),
+ contextOf(MODEL)
+ );
+ expect(result?.targetNodeId).toBe(dbNamespace.id);
+ expect(result?.confidence).toBe(0.95);
+ expect(result?.resolvedBy).toBe('qualified-name');
+ });
+
+ it('returns null when two other files declare the same namespace', () => {
+ // A namespace split across model files is ordinary CDS; with no signal to
+ // pick one, the using stays unresolved rather than naming an arbitrary half.
+ const twin = node('namespace', 'sap.capire.bookshop', 'sap.capire.bookshop', 'db/more.cds');
+ const result = matchReference(
+ cdsRef(srvBooks.id, 'sap.capire::bookshop', SRV_FILE),
+ contextOf([...MODEL, twin])
+ );
+ expect(result).toBeNull();
+ });
+
+ it('prefers the call-site file when it declares one of the two namespaces', () => {
+ const localHalf = node('namespace', 'sap.capire.bookshop', 'sap.capire.bookshop', SRV_FILE, 'cds', 9);
+ const result = matchReference(
+ cdsRef(srvBooks.id, 'sap.capire::bookshop', SRV_FILE),
+ contextOf([...MODEL, localHalf])
+ );
+ expect(result?.targetNodeId).toBe(localHalf.id);
+ });
+
+ it('never resolves a projection named after its own source to itself', () => {
+ // `entity Books as projection on Books` inside a service: the FROM node is
+ // dropped from the candidate set, so the only Books left is the db one.
+ const result = matchReference(cdsRef(srvBooks.id, 'Books', SRV_FILE), contextOf(MODEL));
+ expect(result?.targetNodeId).toBe(dbBooks.id);
+ expect(result?.confidence).toBe(0.8);
+ });
+
+ it('resolves an annotate target that names an action', () => {
+ // `annotate srv.criticalAction with @(...)` targets an ACTION. An action is
+ // an ordinary CDS definition reference target, so the candidate kinds have
+ // to include the function and method nodes actions become.
+ const service = node('module', 'LROPODataService', 'LROPODataService', SRV_FILE, 'cds', 3);
+ const action = node('function', 'criticalAction', 'LROPODataService::criticalAction', SRV_FILE, 'cds', 20);
+ const annotations = node('file', 'annotations.cds', 'app/annotations.cds', 'app/annotations.cds');
+ const result = matchReference(
+ cdsRef(annotations.id, 'LROPODataService::criticalAction', 'app/annotations.cds'),
+ contextOf([service, action, annotations])
+ );
+ expect(result?.targetNodeId).toBe(action.id);
+ expect(result?.confidence).toBe(0.95);
+ });
+
+ it('prefers an entity in the caller scope over a same-named action elsewhere', () => {
+ // Adding actions to the candidate kinds must not loosen the scope rule: the
+ // service's own entity still shadows an action of the same name elsewhere.
+ const otherAction = node('function', 'Report', 'OtherService::Report', 'srv/other.cds', 'cds', 7);
+ const scopedEntity = node('class', 'Report', 'CatalogService::Report', SRV_FILE, 'cds', 8);
+ const result = matchReference(
+ cdsRef(srvBooks.id, 'Report', SRV_FILE),
+ contextOf([...MODEL, otherAction, scopedEntity])
+ );
+ expect(result?.targetNodeId).toBe(scopedEntity.id);
+ });
+
+ it('returns null when an entity and an action share one qualified name', () => {
+ const entity = node('class', 'Report', 'analytics::Report', 'db/reports.cds');
+ const action = node('function', 'Report', 'analytics::Report', 'srv/reports.cds');
+ const caller = node('class', 'Orders', 'analytics::Orders', 'db/orders.cds');
+ const result = matchReference(
+ cdsRef(caller.id, 'Report', 'db/orders.cds'),
+ contextOf([entity, action, caller])
+ );
+ expect(result).toBeNull();
+ });
+
+ it('resolves an extends ref to an aspect through the same scope chain', () => {
+ const managed = node('interface', 'managed', 'sap.common::managed', 'db/common.cds');
+ const result = matchReference(
+ cdsRef(dbBooks.id, 'sap.common::managed', DB_FILE, 'extends'),
+ contextOf([...MODEL, managed])
+ );
+ expect(result?.targetNodeId).toBe(managed.id);
+ });
+
+ it('returns null when two same-named artifacts are equally plausible', () => {
+ const bookshopCurrency = node('type_alias', 'Currency', 'sap.capire.bookshop::Currency', DB_FILE);
+ const reviewsCurrency = node('type_alias', 'Currency', 'sap.capire.reviews::Currency', 'db/reviews.cds');
+ const caller = node('class', 'Orders', 'sap.capire.orders::Orders', 'db/orders.cds');
+ const result = matchReference(
+ cdsRef(caller.id, 'Currency', 'db/orders.cds'),
+ contextOf([caller, bookshopCurrency, reviewsCurrency])
+ );
+ expect(result).toBeNull();
+ });
+
+ it('prefers the call-site file when several same-named artifacts exist', () => {
+ const localStatus = node('type_alias', 'Status', 'local::Status', 'db/orders.cds');
+ const remoteStatus = node('type_alias', 'Status', 'other::Status', 'db/other.cds');
+ const caller = node('class', 'Orders', 'orders::Orders', 'db/orders.cds', 'cds', 9);
+ const result = matchReference(
+ cdsRef(caller.id, 'Status', 'db/orders.cds'),
+ contextOf([caller, localStatus, remoteStatus])
+ );
+ expect(result?.targetNodeId).toBe(localStatus.id);
+ expect(result?.confidence).toBe(0.85);
+ });
+
+ it('returns null for an unknown name and for a dotted name with no exact FQN', () => {
+ const ctx = contextOf(MODEL);
+ expect(matchReference(cdsRef(srvBooks.id, 'Genres', SRV_FILE), ctx)).toBeNull();
+ // The artifact exists but under another namespace: an FQN either matches
+ // exactly or the model means something out of repo (`sap.common::CodeList`).
+ expect(matchReference(cdsRef(srvBooks.id, 'sap.capire.reviews::Books', SRV_FILE), ctx)).toBeNull();
+ expect(matchReference(cdsRef(srvBooks.id, 'sap.common::CodeList', SRV_FILE), ctx)).toBeNull();
+ });
+
+ it('never resolves a CDS reference to a same-named node in another language', () => {
+ const tsBooks = node('class', 'Books', 'Books', 'src/models/Books.ts', 'typescript');
+ const tsAspect = node('class', 'managed', 'managed', 'src/models/managed.ts', 'typescript');
+ const caller = node('class', 'Orders', 'orders::Orders', 'db/orders.cds');
+ const ctx = contextOf([caller, tsBooks, tsAspect]);
+ expect(matchReference(cdsRef(caller.id, 'Books', 'db/orders.cds'), ctx)).toBeNull();
+ // `extends` is not language-gated by the resolver, so the branch itself
+ // has to hold the line.
+ expect(matchReference(cdsRef(caller.id, 'managed', 'db/orders.cds', 'extends'), ctx)).toBeNull();
+ });
+
+ it('never resolves a CDS reference to an element of a same-named entity', () => {
+ // A `field` node named `Books` (a column called Books) is not an artifact.
+ const field = node('field', 'Books', 'sap.capire.orders::Order::Books', 'db/orders.cds');
+ const caller = node('class', 'Orders', 'sap.capire.orders::Order', 'db/orders.cds');
+ const result = matchReference(cdsRef(caller.id, 'Books', 'db/orders.cds'), contextOf([caller, field]));
+ expect(result).toBeNull();
+ });
+});
+
+describe('CDS using-from path resolution', () => {
+ function pathContext(files: string[]): ResolutionContext {
+ const ctx = contextOf([]);
+ return { ...ctx, fileExists: (f) => files.includes(f), getAllFiles: () => files };
+ }
+
+ it('resolves an extensionless relative specifier to the .cds file', () => {
+ const ctx = pathContext(['db/schema.cds', 'srv/cat-service.cds']);
+ expect(resolveImportPath('../db/schema', SRV_FILE, 'cds', ctx)).toBe('db/schema.cds');
+ });
+
+ it('resolves a directory specifier to its index.cds', () => {
+ const ctx = pathContext(['srv/common/index.cds', SRV_FILE]);
+ expect(resolveImportPath('./common', SRV_FILE, 'cds', ctx)).toBe('srv/common/index.cds');
+ });
+
+ it('resolves a specifier that already carries the .cds extension', () => {
+ const ctx = pathContext(['db/schema.cds', SRV_FILE]);
+ expect(resolveImportPath('../db/schema.cds', SRV_FILE, 'cds', ctx)).toBe('db/schema.cds');
+ });
+
+ it('treats reuse packages as external instead of guessing a project file', () => {
+ // A CAP project has a `common.cds` of its own more often than not; a
+ // `@sap/cds/common` import must not land on it.
+ const ctx = pathContext(['common.cds', 'srv/cat-service.cds', 'db/common/index.cds']);
+ expect(resolveImportPath('@sap/cds/common', SRV_FILE, 'cds', ctx)).toBeNull();
+ expect(resolveImportPath('some-reuse-package', SRV_FILE, 'cds', ctx)).toBeNull();
+ });
+
+ it('resolves only .cds and index.cds, never a .csn or .json model', () => {
+ // A `cds import`ed external service lands as `srv/external/X.csn` next to
+ // the model, and CAP's own `using ... from './external/X'` would find it.
+ // codegraph indexes CDS sources only, so the extension list stops at
+ // `.cds` / `/index.cds` and an extensionless specifier that could only
+ // land on a compiled model resolves to nothing at all.
+ const ctx = pathContext([
+ 'db/schema.cds',
+ 'srv/external/ZPDCDS_SRV.csn',
+ 'srv/external/other.json',
+ ]);
+ expect(resolveImportPath('../srv/external/ZPDCDS_SRV', DB_FILE, 'cds', ctx)).toBeNull();
+ expect(resolveImportPath('../srv/external/other', DB_FILE, 'cds', ctx)).toBeNull();
+ expect(resolveImportPath('./schema', DB_FILE, 'cds', ctx)).toBe('db/schema.cds');
+ });
+
+ it('keeps a bare specifier external even when a project file would absorb it', () => {
+ // The reuse-package rule has to hold BEFORE any probing: here the project
+ // carries a `some-reuse-package.cds` the direct-path probe would hit and a
+ // `paths` alias whose `src/*` rewrite would hit `src/...` for both
+ // specifiers. `using ... from` takes a relative path or a node module
+ // specifier and nothing else, so neither may be resolved by shape alone.
+ const files = [
+ 'app.cds',
+ SRV_FILE,
+ 'some-reuse-package.cds',
+ 'src/some-reuse-package/index.cds',
+ 'src/@sap/cds/common.cds',
+ ];
+ const ctx: ResolutionContext = {
+ ...pathContext(files),
+ getProjectAliases: () => ({
+ baseUrl: '/proj',
+ patterns: [{ prefix: '', suffix: '', hasWildcard: true, replacements: ['src/*'] }],
+ }),
+ };
+ expect(resolveImportPath('some-reuse-package', 'app.cds', 'cds', ctx)).toBeNull();
+ expect(resolveImportPath('@sap/cds/common', 'app.cds', 'cds', ctx)).toBeNull();
+ // The same name written relative IS a model file, and still resolves.
+ expect(resolveImportPath('./some-reuse-package', 'app.cds', 'cds', ctx)).toBe(
+ 'some-reuse-package.cds'
+ );
+ });
+});
+
+describe('CDS refs through the resolver pipeline', () => {
+ let dir: string;
+ let db: DatabaseConnection;
+ let q: QueryBuilder;
+
+ beforeEach(() => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cds-'));
+ db = DatabaseConnection.initialize(path.join(dir, 'test.db'));
+ q = new QueryBuilder(db.getDb());
+ fs.mkdirSync(path.join(dir, 'db'));
+ fs.mkdirSync(path.join(dir, 'srv'));
+ fs.writeFileSync(path.join(dir, DB_FILE), 'namespace sap.capire.bookshop;\nentity Books { key ID : UUID; }\n');
+ fs.writeFileSync(
+ path.join(dir, SRV_FILE),
+ "using { sap.capire.bookshop as my } from '../db/schema';\nservice CatalogService { entity Books as projection on my.Books; }\n"
+ );
+ for (const n of MODEL) q.insertNode(n);
+ q.insertNode(node('file', 'schema.cds', DB_FILE, DB_FILE));
+ q.insertNode(node('file', 'cat-service.cds', SRV_FILE, SRV_FILE));
+ });
+
+ afterEach(() => {
+ db.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ function persist(refs: UnresolvedRef[]): ReturnType['resolveAndPersist']> {
+ const resolver = createResolver(dir, q);
+ return resolver.resolveAndPersist(refs as UnresolvedReference[]);
+ }
+
+ it('carries an A.B::C reference past the existence pre-filter', () => {
+ // The pre-filter keys a `::` name on its last segment; without that,
+ // `sap.capire.bookshop::Books` never reaches the CDS branch at all.
+ const result = persist([cdsRef(srvBooks.id, 'sap.capire.bookshop::Books', SRV_FILE)]);
+ expect(result.resolved).toHaveLength(1);
+ expect(result.resolved[0]?.targetNodeId).toBe(dbBooks.id);
+ });
+
+ it('carries a namespace reference past the existence pre-filter', () => {
+ // `sap.capire::bookshop` has no segment named `bookshop`; only the full
+ // dotted form matches the namespace node's one-segment name, so the
+ // pre-filter has to try that spelling or the ref never reaches the matcher.
+ const result = persist([cdsRef(srvBooks.id, 'sap.capire::bookshop', SRV_FILE)]);
+ expect(result.resolved).toHaveLength(1);
+ expect(result.resolved[0]?.targetNodeId).toBe(dbNamespace.id);
+ });
+
+ it('resolves a using-from specifier to the imported model file', () => {
+ const result = persist([cdsRef(srvBooks.id, '../db/schema', SRV_FILE, 'imports')]);
+ expect(result.resolved).toHaveLength(1);
+ expect(result.resolved[0]?.targetNodeId).toBe(`file:${DB_FILE}:${DB_FILE}:1`);
+ });
+
+ it('leaves a reuse-package using unresolved rather than name-matching it', () => {
+ const result = persist([cdsRef(srvBooks.id, '@sap/cds/common', SRV_FILE, 'imports')]);
+ expect(result.resolved).toHaveLength(0);
+ });
+
+ it('keeps a CDS reference off a same-named node in another language', () => {
+ q.insertNode(node('class', 'Genres', 'Genres', 'src/Genres.ts', 'typescript'));
+ const result = persist([cdsRef(srvBooks.id, 'Genres', SRV_FILE)]);
+ expect(result.resolved).toHaveLength(0);
+ });
+
+ it('keeps a TypeScript call off a same-named CDS action', () => {
+ // The reverse direction of the guard above. A CAP handler reaches an
+ // action through `srv.on('submitOrder', ...)`, a string the graph never
+ // name-matches; a TS function CALLED `submitOrder` is some other function,
+ // so the CDS action must not become its target. Name matching on its own
+ // takes that exact-name match (at 0.5), so the rule has to hold in the
+ // resolver, which is why this goes through the real pipeline.
+ const action = node('function', 'submitOrder', 'CatalogService::submitOrder', SRV_FILE, 'cds', 6);
+ const handler = node('function', 'run', 'run', 'srv/handler.ts', 'typescript', 2);
+ q.insertNode(action);
+ q.insertNode(handler);
+ q.insertNode(node('file', 'handler.ts', 'srv/handler.ts', 'srv/handler.ts', 'typescript'));
+ const tsCall: UnresolvedRef = {
+ fromNodeId: handler.id,
+ referenceName: 'submitOrder',
+ referenceKind: 'calls',
+ line: 3,
+ column: 9,
+ filePath: 'srv/handler.ts',
+ language: 'typescript',
+ };
+ expect(persist([tsCall]).resolved).toHaveLength(0);
+ // Controls: the action IS reachable, from a CDS reference naming it, and
+ // ordinary CDS name resolution is unaffected.
+ const fromCds = persist([cdsRef(srvBooks.id, 'CatalogService::submitOrder', SRV_FILE)]);
+ expect(fromCds.resolved[0]?.targetNodeId).toBe(action.id);
+ const books = persist([cdsRef(srvBooks.id, 'Books', SRV_FILE)]);
+ expect(books.resolved).toHaveLength(1);
+ expect(books.resolved[0]?.targetNodeId).toBe(dbBooks.id);
+ });
+
+ it('leaves a using that names a compiled .csn model unresolved', () => {
+ // `cds import` writes `srv/external/.csn`, so the file really is
+ // on disk next to the model; it is not a CDS SOURCE file, so nothing
+ // indexes it and the using has no file node to point at.
+ fs.mkdirSync(path.join(dir, 'srv', 'external'), { recursive: true });
+ fs.writeFileSync(path.join(dir, 'srv/external/ZPDCDS_SRV.csn'), '{"definitions":{}}\n');
+ const result = persist([
+ cdsRef(srvBooks.id, '../srv/external/ZPDCDS_SRV.csn', DB_FILE, 'imports'),
+ ]);
+ expect(result.resolved).toHaveLength(0);
+ });
+});
+
+describe('named-symbol lookup over CDS qualified names', () => {
+ it('matches a namespace-dotted CDS FQN against its :: qualified name', () => {
+ expect(matchesSymbol(dbBooks, 'sap.capire.bookshop.Books')).toBe(true);
+ expect(matchesSymbol(dbBooks, 'Books')).toBe(true);
+ // A different namespace with the same leaf must not match.
+ expect(matchesSymbol(dbBooks, 'sap.capire.reviews.Books')).toBe(false);
+ });
+
+ it('matches a service-scoped and an element-scoped CDS name', () => {
+ expect(matchesSymbol(srvBooks, 'CatalogService.Books')).toBe(true);
+ const title = node('field', 'title', 'sap.capire.bookshop::Books::title', DB_FILE);
+ expect(matchesSymbol(title, 'Books.title')).toBe(true);
+ });
+});
diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts
index ad0ba2374..1109aecba 100644
--- a/__tests__/extraction.test.ts
+++ b/__tests__/extraction.test.ts
@@ -11,6 +11,7 @@ import * as os from 'os';
import { CodeGraph } from '../src';
import { extractFromSource, scanDirectory, buildDefaultIgnore, discoverEmbeddedRepoRoots, buildScopeIgnore } from '../src/extraction';
import { detectLanguage, isLanguageSupported, getSupportedLanguages, initGrammars, loadAllGrammars, isSourceFile } from '../src/extraction/grammars';
+import { cdsReferenceName, isCdsBuiltinType, CDS_BUILTIN_TYPES } from '../src/extraction/languages/cds';
import { stripCppTemplateArgs, blankCppExportMacros, blankCppInlineMacros, blankMetalAttributes, blankCudaConstructs, blankCppAnnotationMacroCalls, blankCppApiPrefixMacros, blankCppInlineAnnotationMacros, blankCLeadingAttrMacros, recoverMangledCppName } from '../src/extraction/languages/c-cpp';
import { normalizePath } from '../src/utils';
@@ -55,6 +56,11 @@ describe('Language Detection', () => {
expect(detectLanguage('lib.rs')).toBe('rust');
});
+ it('should detect CDS files', () => {
+ expect(detectLanguage('db/schema.cds')).toBe('cds');
+ expect(detectLanguage('srv/cat-service.cds')).toBe('cds');
+ });
+
it('should detect Java files', () => {
expect(detectLanguage('Main.java')).toBe('java');
});
@@ -10939,6 +10945,869 @@ init(_) -> {ok, #{}}.
});
});
+describe('CDS Extraction', () => {
+ // One model file exercised by most of the assertions below: a namespaced db
+ // schema with includes, associations, a composition, types, an aspect, a
+ // context and an annotation definition.
+ const SCHEMA = `namespace sap.capire.bookshop;
+using { Currency, managed, cuid } from '@sap/cds/common';
+using { sap.common.CodeList } from './common';
+
+/** A book in the catalog */
+@title: 'Books'
+entity Books : cuid, managed {
+ key ID : Integer;
+ title : localized String(111) @mandatory;
+ author : Association to Authors;
+ price : Decimal;
+ currency : Currency;
+ items : Composition of many OrderItems on items.parent = $self;
+ virtual stock : Integer;
+ address : { street : String; city : String; };
+ status : String enum { open; closed = 'C'; };
+}
+
+entity Authors : cuid {
+ name : String;
+}
+
+type Amount : Decimal(10,2);
+type Kind : String enum { hard; soft = 'S'; }
+type TitleOf : type of Books:title;
+
+aspect Trackable {
+ changedAt : Timestamp;
+} actions {
+ action touch();
+}
+
+context Nested {
+ entity Inner { key ID : UUID; }
+}
+
+annotation myAnno : String;
+`;
+
+ describe('Language detection', () => {
+ it('should report CDS as supported', () => {
+ expect(isLanguageSupported('cds')).toBe(true);
+ expect(getSupportedLanguages()).toContain('cds');
+ expect(isSourceFile('db/schema.cds')).toBe(true);
+ });
+ });
+
+ describe('Namespace and qualified names', () => {
+ it('should turn the namespace directive into one dotted namespace node', () => {
+ const result = extractFromSource('db/schema.cds', SCHEMA);
+ const ns = result.nodes.find((n) => n.kind === 'namespace' && n.name === 'sap.capire.bookshop');
+ expect(ns).toBeDefined();
+ expect(ns!.language).toBe('cds');
+ });
+
+ it('should scope definitions under the namespace so dotting gives the CDS FQN', () => {
+ const result = extractFromSource('db/schema.cds', SCHEMA);
+ const books = result.nodes.find((n) => n.kind === 'class' && n.name === 'Books');
+ expect(books!.qualifiedName).toBe('sap.capire.bookshop::Books');
+ const title = result.nodes.find((n) => n.kind === 'field' && n.name === 'title');
+ expect(title!.qualifiedName).toBe('sap.capire.bookshop::Books::title');
+ });
+
+ it('should leave definitions unqualified in a file without a namespace', () => {
+ const code = `service CatalogService {
+ entity Books { key ID : UUID; }
+}
+`;
+ const result = extractFromSource('srv/cat-service.cds', code);
+ expect(result.nodes.some((n) => n.kind === 'namespace')).toBe(false);
+ const books = result.nodes.find((n) => n.kind === 'class' && n.name === 'Books');
+ expect(books!.qualifiedName).toBe('CatalogService::Books');
+ });
+
+ it('should nest a context as a namespace scope', () => {
+ const result = extractFromSource('db/schema.cds', SCHEMA);
+ const nested = result.nodes.find((n) => n.name === 'Nested');
+ expect(nested!.kind).toBe('namespace');
+ const inner = result.nodes.find((n) => n.name === 'Inner');
+ expect(inner!.kind).toBe('class');
+ expect(inner!.qualifiedName).toBe('sap.capire.bookshop::Nested::Inner');
+ });
+ });
+
+ describe('Dotted definition names', () => {
+ // A definition may be DECLARED with a dotted name, which is what SAP's
+ // OData-to-CDS import tooling emits. The node is named by the last segment
+ // so a name lookup finds it, and the prefix becomes one qualifiedName
+ // segment so dot-normalizing still spells the CDS fully qualified name.
+ it('should name a dotted top-level entity by its last segment', () => {
+ const code = `entity sap.common.Regions : CodeList {
+ key code : String(3);
+}
+
+type a.b.T : String;
+`;
+ const result = extractFromSource('db/common.cds', code);
+ const regions = result.nodes.find((n) => n.kind === 'class')!;
+ expect(regions.name).toBe('Regions');
+ expect(regions.qualifiedName).toBe('sap.common::Regions');
+ const alias = result.nodes.find((n) => n.kind === 'type_alias')!;
+ expect(alias.name).toBe('T');
+ expect(alias.qualifiedName).toBe('a.b::T');
+ });
+
+ it('should keep a dotted entity and its elements under the file namespace', () => {
+ const code = `namespace sap.fe.showcase;
+
+entity CV_ATTACHMENT_SRV.OriginalContentSet {
+ key ID : UUID;
+ content : LargeBinary;
+}
+`;
+ const result = extractFromSource('db/schema.cds', code);
+ const entity = result.nodes.find((n) => n.kind === 'class')!;
+ expect(entity.name).toBe('OriginalContentSet');
+ expect(entity.qualifiedName).toBe(
+ 'sap.fe.showcase::CV_ATTACHMENT_SRV::OriginalContentSet'
+ );
+ // The element hangs off the entity's REAL qualifiedName, not off a name
+ // the scope stack recomposes from the last segment alone.
+ const content = result.nodes.find((n) => n.name === 'content')!;
+ expect(content.qualifiedName).toBe(
+ 'sap.fe.showcase::CV_ATTACHMENT_SRV::OriginalContentSet::content'
+ );
+ });
+
+ it('should name a dotted action inside a service by its last segment', () => {
+ const code = `service LROPODataService {
+ action a.b.c(x : Integer);
+}
+`;
+ const result = extractFromSource('srv/list-report-srv.cds', code);
+ const action = result.nodes.find((n) => n.kind === 'function')!;
+ expect(action.name).toBe('c');
+ expect(action.qualifiedName).toBe('LROPODataService::a.b::c');
+ });
+
+ it('should never split a delimited identifier that contains a dot', () => {
+ const code = `entity ![My.Odd Name] {
+ key ID : UUID;
+}
+`;
+ const result = extractFromSource('db/delimited.cds', code);
+ const entity = result.nodes.find((n) => n.kind === 'class')!;
+ expect(entity.name).toBe('My.Odd Name');
+ expect(entity.qualifiedName).toBe('My.Odd Name');
+ expect(result.nodes.find((n) => n.name === 'ID')!.qualifiedName).toBe('My.Odd Name::ID');
+ });
+ });
+
+ describe('Artifact kinds', () => {
+ it('should map each CDS artifact to its node kind', () => {
+ const result = extractFromSource('db/schema.cds', SCHEMA);
+ const kindOf = (name: string): string | undefined =>
+ result.nodes.find((n) => n.name === name && n.kind !== 'import')?.kind;
+ expect(kindOf('Books')).toBe('class');
+ expect(kindOf('Amount')).toBe('type_alias');
+ expect(kindOf('Kind')).toBe('enum');
+ expect(kindOf('TitleOf')).toBe('type_alias');
+ expect(kindOf('Trackable')).toBe('interface');
+ expect(kindOf('myAnno')).toBe('type_alias');
+ });
+
+ it('should mark an annotation definition with the annotation decorator', () => {
+ const result = extractFromSource('db/schema.cds', SCHEMA);
+ const anno = result.nodes.find((n) => n.name === 'myAnno');
+ expect(anno!.decorators).toContain('annotation');
+ expect(anno!.signature).toBe('annotation myAnno : String');
+ });
+
+ it('should extract a view as a class', () => {
+ const code = `view Cheap as select from Books { ID } where price < 10;
+`;
+ const result = extractFromSource('db/views.cds', code);
+ const view = result.nodes.find((n) => n.name === 'Cheap');
+ expect(view!.kind).toBe('class');
+ expect(view!.signature).toBe('view Cheap as select from Books');
+ });
+
+ it('should extract an event as a struct with its elements as fields', () => {
+ const code = `service S {
+ event OrderedBook : { book : UUID; quantity : Integer; }
+}
+`;
+ const result = extractFromSource('srv/cat-service.cds', code);
+ const event = result.nodes.find((n) => n.name === 'OrderedBook');
+ expect(event!.kind).toBe('struct');
+ expect(event!.qualifiedName).toBe('S::OrderedBook');
+ expect(result.nodes.filter((n) => n.kind === 'field').map((n) => n.name)).toEqual([
+ 'book',
+ 'quantity',
+ ]);
+ });
+
+ it('should keep the header, not the body, as an artifact signature', () => {
+ const result = extractFromSource('db/schema.cds', SCHEMA);
+ const books = result.nodes.find((n) => n.kind === 'class' && n.name === 'Books');
+ expect(books!.signature).toBe('entity Books : cuid, managed');
+ const aspect = result.nodes.find((n) => n.name === 'Trackable');
+ expect(aspect!.signature).toBe('aspect Trackable');
+ });
+ });
+
+ describe('Elements', () => {
+ it('should extract elements as fields with their source line as signature', () => {
+ const result = extractFromSource('db/schema.cds', SCHEMA);
+ const title = result.nodes.find((n) => n.kind === 'field' && n.name === 'title');
+ expect(title!.signature).toBe('title : localized String(111) @mandatory');
+ });
+
+ it('should record key and virtual as decorators', () => {
+ const result = extractFromSource('db/schema.cds', SCHEMA);
+ const id = result.nodes.find((n) => n.kind === 'field' && n.name === 'ID');
+ expect(id!.decorators).toContain('key');
+ const stock = result.nodes.find((n) => n.kind === 'field' && n.name === 'stock');
+ expect(stock!.decorators).toContain('virtual');
+ });
+
+ it('should nest a structured element under its parent element', () => {
+ const result = extractFromSource('db/schema.cds', SCHEMA);
+ const street = result.nodes.find((n) => n.kind === 'field' && n.name === 'street');
+ expect(street!.qualifiedName).toBe('sap.capire.bookshop::Books::address::street');
+ });
+
+ it('should extract enum symbols of a type and of an element', () => {
+ const result = extractFromSource('db/schema.cds', SCHEMA);
+ const members = result.nodes.filter((n) => n.kind === 'enum_member');
+ expect(members.map((n) => n.qualifiedName).sort()).toEqual([
+ 'sap.capire.bookshop::Books::status::closed',
+ 'sap.capire.bookshop::Books::status::open',
+ 'sap.capire.bookshop::Kind::hard',
+ 'sap.capire.bookshop::Kind::soft',
+ ]);
+ expect(members.find((n) => n.name === 'soft')!.signature).toBe("soft = 'S'");
+ });
+
+ it('should attach elements to their artifact with contains edges', () => {
+ const result = extractFromSource('db/schema.cds', SCHEMA);
+ const books = result.nodes.find((n) => n.kind === 'class' && n.name === 'Books')!;
+ const title = result.nodes.find((n) => n.kind === 'field' && n.name === 'title')!;
+ expect(result.edges).toContainEqual({ source: books.id, target: title.id, kind: 'contains' });
+ });
+
+ // `Composition of many { ... }` declares an anonymous aspect INLINE as the
+ // target: the elements between those braces belong to the composed entity,
+ // and the wrapper the grammar puts them under is not a member itself. It
+ // has to be walked through, or the nested elements are never visited.
+ it('should extract the anonymous aspect of an inline composition as nested fields', () => {
+ const code = `entity Orders {
+ key ID : UUID;
+ items : Composition of many {
+ key pos : Integer;
+ qty : Integer;
+ }
+}
+`;
+ const result = extractFromSource('db/inline-composition.cds', code);
+ expect(result.errors).toEqual([]);
+ expect(result.nodes.filter((n) => n.kind === 'field').map((n) => n.qualifiedName)).toEqual([
+ 'Orders::ID',
+ 'Orders::items',
+ 'Orders::items::pos',
+ 'Orders::items::qty',
+ ]);
+ expect(result.nodes.find((n) => n.name === 'pos')!.decorators).toContain('key');
+ });
+
+ it('should extract the anonymous aspect of an inline association as nested fields', () => {
+ const code = `entity Orders {
+ key ID : UUID;
+ items : Association to many {
+ key pos : Integer;
+ qty : Integer;
+ }
+}
+`;
+ const result = extractFromSource('db/inline-association.cds', code);
+ expect(result.errors).toEqual([]);
+ expect(result.nodes.filter((n) => n.kind === 'field').map((n) => n.qualifiedName)).toEqual([
+ 'Orders::ID',
+ 'Orders::items',
+ 'Orders::items::pos',
+ 'Orders::items::qty',
+ ]);
+ expect(result.nodes.find((n) => n.name === 'pos')!.decorators).toContain('key');
+ });
+ });
+
+ describe('Services, actions and functions', () => {
+ const SERVICE = `using sap.capire.bookshop as my from '../db/schema';
+
+service CatalogService @(path: '/browse') {
+ @readonly entity ListOfBooks as projection on my.Books { *, author.name as authorName };
+ entity BooksView as select from my.Books { ID, title } where price > 10;
+ action submitOrder(book : my.Books:ID, quantity : Integer) returns { stock : Integer };
+ function getStock(id : UUID) returns my.Books;
+ entity Orders { key ID : UUID; } actions {
+ action cancel();
+ function status() returns String;
+ }
+}
+`;
+
+ it('should extract a service as a module', () => {
+ const result = extractFromSource('srv/cat-service.cds', SERVICE);
+ const service = result.nodes.find((n) => n.name === 'CatalogService');
+ expect(service!.kind).toBe('module');
+ expect(service!.signature).toBe("service CatalogService @(path: '/browse')");
+ });
+
+ it('should mark service members as exported and top-level artifacts as not', () => {
+ const result = extractFromSource('srv/cat-service.cds', SERVICE);
+ expect(result.nodes.find((n) => n.name === 'ListOfBooks')!.isExported).toBe(true);
+ expect(result.nodes.find((n) => n.name === 'submitOrder')!.isExported).toBe(true);
+ const schema = extractFromSource('db/schema.cds', SCHEMA);
+ expect(schema.nodes.find((n) => n.name === 'Books')!.isExported).toBe(false);
+ });
+
+ it('should extract an unbound action as a function and a bound one as a method', () => {
+ const result = extractFromSource('srv/cat-service.cds', SERVICE);
+ const submit = result.nodes.find((n) => n.name === 'submitOrder');
+ expect(submit!.kind).toBe('function');
+ expect(submit!.qualifiedName).toBe('CatalogService::submitOrder');
+ const cancel = result.nodes.find((n) => n.name === 'cancel');
+ expect(cancel!.kind).toBe('method');
+ expect(cancel!.qualifiedName).toBe('CatalogService::Orders::cancel');
+ expect(result.nodes.find((n) => n.name === 'status')!.kind).toBe('method');
+ });
+
+ it('should keep the whole declaration as an action signature', () => {
+ const result = extractFromSource('srv/cat-service.cds', SERVICE);
+ const getStock = result.nodes.find((n) => n.name === 'getStock');
+ expect(getStock!.signature).toBe('function getStock(id : UUID) returns my.Books');
+ });
+
+ it('should export the service itself and whatever an extend adds to it', () => {
+ // A service IS the exposed API, so the module node is exported even
+ // though nothing encloses it, while a db entity next to it is not. The
+ // same split reaches members added from outside: `extend
+ // AdminService.Exposed` adds to a service, `extend Books` does not.
+ const code = `namespace srv;
+
+service AdminService { entity Exposed { key ID : Integer; } }
+
+entity Books { key ID : UUID; }
+
+extend AdminService.Exposed with { extra : String; }
+extend Books with { isbn : String; }
+`;
+ const result = extractFromSource('srv/extend-exported.cds', code);
+ const nodeOf = (qualifiedName: string): (typeof result.nodes)[number] =>
+ result.nodes.find((n) => n.qualifiedName === qualifiedName)!;
+ expect(nodeOf('srv::AdminService').kind).toBe('module');
+ expect(nodeOf('srv::AdminService').isExported).toBe(true);
+ expect(nodeOf('srv::Books').isExported).toBe(false);
+ expect(nodeOf('srv::AdminService::Exposed::extra').isExported).toBe(true);
+ expect(nodeOf('srv::Books::isbn').isExported).toBe(false);
+ });
+ });
+
+ describe('Annotations and doc comments', () => {
+ it('should collect prefix and inline annotations as decorators', () => {
+ const code = `@cds.persistence.skip
+@(UI.HeaderInfo: { TypeName: 'Book' }, Common.Label: 'Books')
+entity Books {
+ title : String @mandatory @title: 'Title';
+}
+`;
+ const result = extractFromSource('db/schema.cds', code);
+ const books = result.nodes.find((n) => n.kind === 'class' && n.name === 'Books');
+ expect(books!.decorators).toEqual([
+ '@cds.persistence.skip',
+ '@UI.HeaderInfo',
+ '@Common.Label',
+ ]);
+ const title = result.nodes.find((n) => n.kind === 'field' && n.name === 'title');
+ expect(title!.decorators).toEqual(['@mandatory', '@title']);
+ });
+
+ it('should not read a member prefix annotation as the enclosing service inline one', () => {
+ const code = `service S @(requires: 'authenticated-user') {
+ @readonly entity Books { key ID : UUID; }
+}
+`;
+ const result = extractFromSource('srv/s.cds', code);
+ expect(result.nodes.find((n) => n.name === 'S')!.decorators).toEqual(['@requires']);
+ expect(result.nodes.find((n) => n.name === 'Books')!.decorators).toEqual(['@readonly']);
+ });
+
+ it('should read the doc comment above a definition, annotations included', () => {
+ const result = extractFromSource('db/schema.cds', SCHEMA);
+ const books = result.nodes.find((n) => n.kind === 'class' && n.name === 'Books');
+ expect(books!.docstring).toBe('A book in the catalog');
+ expect(books!.decorators).toContain('@title');
+ });
+
+ it('should read a line-comment doc block', () => {
+ const code = `// Everything a reader can borrow.
+entity Books { key ID : UUID; }
+`;
+ const result = extractFromSource('db/schema.cds', code);
+ const books = result.nodes.find((n) => n.kind === 'class' && n.name === 'Books');
+ expect(books!.docstring).toBe('Everything a reader can borrow.');
+ });
+
+ it('should keep both annotations when a comment sits between them', () => {
+ // The prefix-annotation scan walks back over comments, so a note written
+ // between two annotations does not cut the run in half.
+ const code = `@readonly
+// note
+@title: 'x'
+entity E { key ID : UUID; }
+`;
+ const result = extractFromSource('db/comment-between-annotations.cds', code);
+ expect(result.nodes.find((n) => n.name === 'E')!.decorators).toEqual(['@readonly', '@title']);
+ });
+
+ it('should not let an annotation value brace cut a service signature short', () => {
+ // `@(UI.HeaderInfo: { TypeName: 'Book' })` carries braces of its own. The
+ // signature stops at the SERVICE body, so the annotation stays whole and
+ // the members stay out.
+ const code = `service S @(UI.HeaderInfo: { TypeName: 'Book' }) {
+ entity E { key ID : UUID; }
+}
+`;
+ const result = extractFromSource('srv/header-annotation.cds', code);
+ const service = result.nodes.find((n) => n.name === 'S')!;
+ expect(service.signature).toBe("service S @(UI.HeaderInfo: { TypeName: 'Book' })");
+ expect(service.signature).not.toContain('entity E');
+ expect(service.decorators).toEqual(['@UI.HeaderInfo']);
+ });
+
+ it('should keep a prefix annotation out of an entity signature', () => {
+ // A prefix annotation is a SIBLING of the definition, so an entity's
+ // header starts at `entity` however much annotation sits above it.
+ const code = `@UI.HeaderInfo: { TypeName: 'Book' }
+entity Books { key ID : UUID; }
+`;
+ const result = extractFromSource('db/header-prefix-annotation.cds', code);
+ const books = result.nodes.find((n) => n.kind === 'class' && n.name === 'Books')!;
+ expect(books.signature).toBe('entity Books');
+ expect(books.decorators).toEqual(['@UI.HeaderInfo']);
+ });
+ });
+
+ describe('Imports and using aliases', () => {
+ it('should emit one import node and one imports ref per using file', () => {
+ const result = extractFromSource('db/schema.cds', SCHEMA);
+ const imports = result.nodes.filter((n) => n.kind === 'import');
+ expect(imports.map((n) => n.name)).toEqual(['@sap/cds/common', './common']);
+ const importRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'imports');
+ expect(importRefs.map((r) => r.referenceName)).toEqual(['@sap/cds/common', './common']);
+ });
+
+ it('should keep a relative specifier verbatim for the import resolver', () => {
+ const code = `using from '../db/schema';
+`;
+ const result = extractFromSource('srv/cat-service.cds', code);
+ const ref = result.unresolvedReferences.find((r) => r.referenceKind === 'imports');
+ expect(ref!.referenceName).toBe('../db/schema');
+ });
+
+ it('should reference every imported artifact even when the file never names it', () => {
+ const result = extractFromSource('db/schema.cds', SCHEMA);
+ const names = result.unresolvedReferences
+ .filter((r) => r.referenceKind === 'references')
+ .map((r) => r.referenceName);
+ expect(names).toContain('managed');
+ expect(names).toContain('cuid');
+ expect(names).toContain('sap.common::CodeList');
+ });
+
+ it('should expand an alias bound by `using ... as` into the full artifact path', () => {
+ const code = `using sap.capire.bookshop as my from '../db/schema';
+
+service CatalogService {
+ entity ListOfBooks as projection on my.Books;
+}
+`;
+ const result = extractFromSource('srv/cat-service.cds', code);
+ const list = result.nodes.find((n) => n.name === 'ListOfBooks')!;
+ const ref = result.unresolvedReferences.find(
+ (r) => r.fromNodeId === list.id && r.referenceKind === 'references'
+ );
+ expect(ref!.referenceName).toBe('sap.capire.bookshop::Books');
+ });
+
+ it('should expand an unaliased using through its last segment', () => {
+ const code = `using sap.capire.bookshop.Books from '../db/schema';
+
+entity Loans { book : Association to Books; }
+`;
+ const result = extractFromSource('db/loans.cds', code);
+ const book = result.nodes.find((n) => n.kind === 'field' && n.name === 'book')!;
+ const ref = result.unresolvedReferences.find((r) => r.fromNodeId === book.id);
+ expect(ref!.referenceName).toBe('sap.capire.bookshop::Books');
+ });
+
+ it('should expose the reference-name formatter and the builtin set', () => {
+ const aliases = new Map([['my', 'sap.capire.bookshop']]);
+ expect(cdsReferenceName('my.Books', aliases)).toBe('sap.capire.bookshop::Books');
+ expect(cdsReferenceName('Authors')).toBe('Authors');
+ expect(cdsReferenceName('sap.common.CodeList')).toBe('sap.common::CodeList');
+ expect(isCdsBuiltinType('String')).toBe(true);
+ expect(isCdsBuiltinType('cds.Integer')).toBe(true);
+ expect(isCdsBuiltinType('hana.SMALLINT')).toBe(true);
+ expect(isCdsBuiltinType('Books')).toBe(false);
+ expect(CDS_BUILTIN_TYPES.has('UUID')).toBe(true);
+ });
+ });
+
+ describe('References', () => {
+ const refsFrom = (
+ result: ReturnType,
+ name: string,
+ kind = 'references'
+ ): string[] => {
+ const owner = result.nodes.find((n) => n.name === name && n.kind !== 'import')!;
+ return result.unresolvedReferences
+ .filter((r) => r.fromNodeId === owner.id && r.referenceKind === kind)
+ .map((r) => r.referenceName);
+ };
+
+ it('should emit an extends ref per include', () => {
+ const result = extractFromSource('db/schema.cds', SCHEMA);
+ expect(refsFrom(result, 'Books', 'extends')).toEqual(['cuid', 'managed']);
+ });
+
+ it('should reference association and composition targets from the element', () => {
+ const result = extractFromSource('db/schema.cds', SCHEMA);
+ expect(refsFrom(result, 'author')).toEqual(['Authors']);
+ expect(refsFrom(result, 'items')).toEqual(['OrderItems']);
+ });
+
+ it('should reference a non-builtin element type and skip the builtins', () => {
+ const result = extractFromSource('db/schema.cds', SCHEMA);
+ expect(refsFrom(result, 'currency')).toEqual(['Currency']);
+ expect(refsFrom(result, 'price')).toEqual([]);
+ expect(refsFrom(result, 'title')).toEqual([]);
+ const all = result.unresolvedReferences.map((r) => r.referenceName);
+ expect(all).not.toContain('String');
+ expect(all).not.toContain('Timestamp');
+ });
+
+ it('should reference the entity behind a `type of` element type', () => {
+ const result = extractFromSource('db/schema.cds', SCHEMA);
+ expect(refsFrom(result, 'TitleOf')).toEqual(['Books']);
+ });
+
+ it('should see through `type of`, `array of` and `many` on an element', () => {
+ const code = `entity Books {
+ borrowed : type of Loans:since;
+ tags : array of Tag;
+ copies : many Copy;
+}
+`;
+ const result = extractFromSource('db/schema.cds', code);
+ expect(refsFrom(result, 'borrowed')).toEqual(['Loans']);
+ expect(refsFrom(result, 'tags')).toEqual(['Tag']);
+ expect(refsFrom(result, 'copies')).toEqual(['Copy']);
+ });
+
+ it('should reference every source of a projection, a select and a join', () => {
+ const code = `using sap.capire.bookshop as my from '../db/schema';
+
+service CatalogService {
+ entity ListOfBooks as projection on my.Books;
+ entity Sales as select from my.Books as b
+ join my.Authors as a on b.author.ID = a.ID
+ { b.ID as id, a.name as author };
+}
+`;
+ const result = extractFromSource('srv/cat-service.cds', code);
+ expect(refsFrom(result, 'ListOfBooks')).toEqual(['sap.capire.bookshop::Books']);
+ expect(refsFrom(result, 'Sales')).toEqual([
+ 'sap.capire.bookshop::Books',
+ 'sap.capire.bookshop::Authors',
+ ]);
+ });
+
+ it('should reference a redirected-to target from the projecting entity', () => {
+ const code = `service S {
+ entity Books as projection on db.Books { *, author : redirected to Authors };
+ entity Authors as projection on db.Authors;
+}
+`;
+ const result = extractFromSource('srv/s.cds', code);
+ expect(refsFrom(result, 'Books')).toEqual(['db::Books', 'Authors']);
+ });
+
+ it('should reference parameter and return types from the operation node', () => {
+ const code = `using sap.capire.bookshop as my from '../db/schema';
+
+service CatalogService {
+ action submitOrder(book : my.Books:ID, quantity : Integer) returns my.Orders;
+ function ping() returns String;
+}
+`;
+ const result = extractFromSource('srv/cat-service.cds', code);
+ expect(refsFrom(result, 'submitOrder')).toEqual([
+ 'sap.capire.bookshop::Books',
+ 'sap.capire.bookshop::Orders',
+ ]);
+ expect(refsFrom(result, 'ping')).toEqual([]);
+ });
+
+ it('should emit the projection source from the projecting entity, not its service', () => {
+ const code = `service CatalogService {
+ entity ListOfBooks as projection on Books;
+}
+`;
+ const result = extractFromSource('srv/cat-service.cds', code);
+ const list = result.nodes.find((n) => n.name === 'ListOfBooks')!;
+ const ref = result.unresolvedReferences.find((r) => r.referenceName === 'Books')!;
+ expect(ref.fromNodeId).toBe(list.id);
+ });
+
+ it('should emit one reference per artifact however many directives name it', () => {
+ // `using`, `extend service` and `annotate` each name CatalogService from
+ // the same file scope, so three refs would be emitted with the same
+ // from/kind/name. They collapse to one, and the graph carries one edge
+ // instead of three copies of it.
+ const code = `using { CatalogService } from './srv';
+
+extend service CatalogService with { entity Extra { key ID : UUID; } }
+
+annotate CatalogService with @requires: 'admin';
+`;
+ const result = extractFromSource('app/duplicate-directives.cds', code);
+ const fileNode = result.nodes.find((n) => n.kind === 'file')!;
+ const refs = result.unresolvedReferences.filter(
+ (r) => r.referenceKind === 'references' && r.referenceName === 'CatalogService'
+ );
+ expect(refs).toHaveLength(1);
+ expect(refs[0]!.fromNodeId).toBe(fileNode.id);
+ // Deduping the reference must not drop the member the extend adds.
+ expect(result.nodes.find((n) => n.name === 'Extra')!.qualifiedName).toBe(
+ 'CatalogService::Extra'
+ );
+ });
+ });
+
+ describe('Extend and annotate', () => {
+ const EXTENSIONS = `using sap.capire.bookshop as my from '../db/schema';
+
+extend my.Books with { isbn : String; }
+extend service CatalogService with { entity Extra as projection on my.Authors; }
+extend my.Books with actions { action reprint(); }
+annotate CatalogService.ListOfBooks with @UI.HeaderInfo: { TypeName: 'Book' } {
+ title @title: 'Title';
+};
+`;
+
+ it('should create no node for the extend or annotate directive itself', () => {
+ const result = extractFromSource('app/annotations.cds', EXTENSIONS);
+ const names = result.nodes.map((n) => n.name);
+ expect(names).not.toContain('my.Books');
+ expect(names).not.toContain('CatalogService.ListOfBooks');
+ });
+
+ it('should reference the extend and annotate targets from the enclosing scope', () => {
+ const result = extractFromSource('app/annotations.cds', EXTENSIONS);
+ const fileNode = result.nodes.find((n) => n.kind === 'file')!;
+ const names = result.unresolvedReferences
+ .filter((r) => r.fromNodeId === fileNode.id && r.referenceKind === 'references')
+ .map((r) => r.referenceName);
+ expect(names).toContain('sap.capire.bookshop::Books');
+ expect(names).toContain('CatalogService');
+ expect(names).toContain('CatalogService::ListOfBooks');
+ });
+
+ it('should root members added by extend at the extended artifact', () => {
+ const result = extractFromSource('app/annotations.cds', EXTENSIONS);
+ const isbn = result.nodes.find((n) => n.name === 'isbn');
+ expect(isbn!.kind).toBe('field');
+ expect(isbn!.qualifiedName).toBe('sap.capire.bookshop::Books::isbn');
+ const reprint = result.nodes.find((n) => n.name === 'reprint');
+ expect(reprint!.kind).toBe('method');
+ expect(reprint!.qualifiedName).toBe('sap.capire.bookshop::Books::reprint');
+ const extra = result.nodes.find((n) => n.name === 'Extra');
+ expect(extra!.kind).toBe('class');
+ expect(extra!.qualifiedName).toBe('CatalogService::Extra');
+ expect(extra!.isExported).toBe(true);
+ });
+
+ it('should root a nested definition added by extend context at the context', () => {
+ const code = `extend context Nested with { entity Later { key ID : UUID; } }
+`;
+ const result = extractFromSource('db/more.cds', code);
+ expect(result.nodes.find((n) => n.name === 'Later')!.qualifiedName).toBe('Nested::Later');
+ expect(result.nodes.find((n) => n.name === 'ID')!.qualifiedName).toBe('Nested::Later::ID');
+ });
+
+ it('should root a bare extend or annotate target in the file namespace', () => {
+ // CDS looks a bare target up in the current namespace first, so `Books`
+ // here is `sap.capire.bookshop.Books` and the added element belongs to
+ // that entity rather than to a new top-level artifact.
+ const code = `namespace sap.capire.bookshop;
+
+extend Books with { isbn : String; }
+annotate Authors with @readonly;
+`;
+ const result = extractFromSource('db/extensions.cds', code);
+ expect(result.nodes.find((n) => n.name === 'isbn')!.qualifiedName).toBe(
+ 'sap.capire.bookshop::Books::isbn'
+ );
+ const names = result.unresolvedReferences
+ .filter((r) => r.referenceKind === 'references')
+ .map((r) => r.referenceName);
+ expect(names).toContain('sap.capire.bookshop::Books');
+ expect(names).toContain('sap.capire.bookshop::Authors');
+ });
+
+ it('should leave a bare extend target bound by `using` outside the namespace', () => {
+ // `managed` names a reuse aspect from another model, so the file's own
+ // namespace says nothing about it and the target stays as written.
+ const code = `namespace sap.capire.bookshop;
+using { managed } from '@sap/cds/common';
+
+extend managed with { changedBy : String; }
+`;
+ const result = extractFromSource('db/extensions.cds', code);
+ expect(result.nodes.find((n) => n.name === 'changedBy')!.qualifiedName).toBe(
+ 'managed::changedBy'
+ );
+ const names = result.unresolvedReferences
+ .filter((r) => r.referenceKind === 'references')
+ .map((r) => r.referenceName);
+ expect(names).toContain('managed');
+ expect(names).not.toContain('sap.capire.bookshop::managed');
+ });
+
+ it('should add no member node for `extend ... with columns`', () => {
+ const code = `extend Books with columns { extra, more };
+`;
+ const result = extractFromSource('db/more.cds', code);
+ expect(result.nodes.filter((n) => n.kind === 'field')).toHaveLength(0);
+ expect(result.unresolvedReferences.map((r) => r.referenceName)).toEqual(['Books']);
+ });
+
+ it('should spell a dotted extend target by where the target lives', () => {
+ // Three dotted-or-bare targets in one namespaced file, each resolving a
+ // different way: `AdminService.Exposed` starts at an artifact THIS file
+ // defines, so it is namespace-local and spelled as the nested scopes the
+ // definition itself got; `sap.common.Regions` is a global name written
+ // out in full; `Countries` is bare but bound by `using`, so the file's
+ // namespace says nothing about it and the alias expansion stands.
+ const code = `namespace srv;
+using { sap.common.Countries } from '@sap/cds/common';
+
+service AdminService { entity Exposed { key ID : Integer; } }
+
+extend AdminService.Exposed with { extra : String; }
+extend sap.common.Regions with { flag : Boolean; }
+extend Countries with { code2 : String; }
+`;
+ const result = extractFromSource('srv/extend-targets.cds', code);
+ const qnOf = (name: string): string | undefined =>
+ result.nodes.find((n) => n.name === name)?.qualifiedName;
+ expect(qnOf('extra')).toBe('srv::AdminService::Exposed::extra');
+ expect(qnOf('flag')).toBe('sap.common::Regions::flag');
+ expect(qnOf('code2')).toBe('sap.common::Countries::code2');
+ const names = result.unresolvedReferences
+ .filter((r) => r.referenceKind === 'references')
+ .map((r) => r.referenceName);
+ expect(names).toContain('srv::AdminService::Exposed');
+ expect(names).toContain('sap.common::Regions');
+ expect(names).toContain('sap.common::Countries');
+ });
+ });
+
+ describe('Per-file state', () => {
+ // A file's `using` aliases, its namespace and the references already
+ // emitted for it live in state reset when the file path changes. Each case
+ // below uses a path of its own so a stale reset cannot be masked by a
+ // neighbouring test having left the right state behind.
+ it('should not leak a using alias into the next file extracted', () => {
+ const owner = `using { sap.capire.bookshop as my } from './schema';
+
+entity X { b : Association to my.Books; }
+`;
+ const first = extractFromSource('db/alias-owner.cds', owner);
+ const x = first.nodes.find((n) => n.kind === 'field' && n.name === 'b')!;
+ expect(first.unresolvedReferences.find((r) => r.fromNodeId === x.id)!.referenceName).toBe(
+ 'sap.capire.bookshop::Books'
+ );
+
+ // No `using` here, so `my` is an ordinary first path segment.
+ const second = extractFromSource(
+ 'db/alias-free.cds',
+ `entity Y { b : Association to my.Books; }\n`
+ );
+ const y = second.nodes.find((n) => n.kind === 'field' && n.name === 'b')!;
+ expect(second.unresolvedReferences.find((r) => r.fromNodeId === y.id)!.referenceName).toBe(
+ 'my::Books'
+ );
+ });
+
+ it('should extract the same file twice with identical nodes and references', () => {
+ const code = `namespace sap.capire.bookshop;
+using { sap.common.CodeList } from './common';
+
+entity Books : CodeList { key ID : UUID; author : Association to Authors; }
+`;
+ const first = extractFromSource('db/repeat-extraction.cds', code);
+ const second = extractFromSource('db/repeat-extraction.cds', code);
+ expect(first.unresolvedReferences.length).toBeGreaterThan(0);
+ expect(second.nodes.map((n) => `${n.kind}:${n.qualifiedName}`)).toEqual(
+ first.nodes.map((n) => `${n.kind}:${n.qualifiedName}`)
+ );
+ expect(
+ second.unresolvedReferences.map((r) => `${r.referenceKind}:${r.referenceName}`)
+ ).toEqual(first.unresolvedReferences.map((r) => `${r.referenceKind}:${r.referenceName}`));
+ });
+
+ it('should skip a definition with no name and still extract its neighbour', () => {
+ const code = `entity { key ID : UUID; }
+
+entity Ok { key ID : UUID; }
+`;
+ const result = extractFromSource('db/nameless-entity.cds', code);
+ expect(result.nodes.find((n) => n.name === 'Ok')!.qualifiedName).toBe('Ok');
+ expect(result.nodes.every((n) => n.name.length > 0)).toBe(true);
+ // The unnamed entity has no qualifiedName to hang its elements off, so
+ // they are dropped rather than rooted at an empty prefix.
+ expect(result.nodes.filter((n) => n.kind === 'field').map((n) => n.qualifiedName)).toEqual([
+ 'Ok::ID',
+ ]);
+ });
+ });
+
+ describe('Robustness', () => {
+ it('should still extract the parseable definitions of a file the grammar trips on', () => {
+ // `event X : SomeType;` (an event typed by an artifact rather than an
+ // inline structure) is a grammar gap: it parses to an ERROR node. The
+ // error stays contained, so everything around it must still be indexed.
+ const code = `namespace sap.capire.bookshop;
+
+entity Books { key ID : UUID; }
+
+service CatalogService {
+ entity ListOfBooks as projection on Books;
+ event Ping : Books;
+}
+
+entity Authors { key ID : UUID; }
+`;
+ const result = extractFromSource('srv/cat-service.cds', code);
+ expect(result.errors).toEqual([]);
+ const names = result.nodes.filter((n) => n.kind === 'class').map((n) => n.name);
+ expect(names).toContain('Books');
+ expect(names).toContain('ListOfBooks');
+ expect(names).toContain('Authors');
+ expect(result.nodes.find((n) => n.name === 'CatalogService')!.kind).toBe('module');
+ });
+ });
+});
+
describe('Terraform Extraction', () => {
describe('Language detection', () => {
it('should detect Terraform files', () => {
diff --git a/assets/languages/cds.svg b/assets/languages/cds.svg
new file mode 100644
index 000000000..36391c9d9
--- /dev/null
+++ b/assets/languages/cds.svg
@@ -0,0 +1,13 @@
+
diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md
index abf6a2346..6532c0969 100644
--- a/docs/design/framework-coverage.md
+++ b/docs/design/framework-coverage.md
@@ -136,8 +136,8 @@ Objective-C.
the first two inherit the C rules and the third has none.)
**No rules** — boxes draw, arrows carry no condition, and no arguments or
-trigger labels are read: PHP, Ruby, Rust, Scala, Dart, Erlang, Lua, Luau, R,
-Solidity, COBOL, CFML, VB.NET, Nix, Terraform, Pascal/Delphi, Liquid, Razor,
+trigger labels are read: PHP, Ruby, Rust, Scala, Dart, Erlang, CDS, Lua, Luau,
+R, Solidity, COBOL, CFML, VB.NET, Nix, Terraform, Pascal/Delphi, Liquid, Razor,
Twig, ArkTS, and the `.svelte` / `.vue` / `.astro` template languages.
A language with no rules yields **nothing**, never a wrong label — that is the
diff --git a/docs/design/rust-kernel-migration-plan.md b/docs/design/rust-kernel-migration-plan.md
index 14ed2673e..49ac3d794 100644
--- a/docs/design/rust-kernel-migration-plan.md
+++ b/docs/design/rust-kernel-migration-plan.md
@@ -644,6 +644,7 @@ parity before porting the language.
| vbnet | `languages/vbnet.ts` | T2 | **vendored, patched + external scanner** | Our wasm is a patched grammar WITH a C external scanner — the kernel must build that scanner; ts-cli 0.24 dropped `\p{...}` classes during the original build (#1164). Highest grammar-build risk of any language. | ☐ |
| cobol | `languages/cobol.ts` | T2 | **vendored fork** | Paragraph-extent reconstruction + copybook resolution are extraction logic (#1161, CardDemo 43/44). Port carefully or keep TS post-pass. | ☐ |
| erlang | `languages/erlang.ts` | T2 | **vendored (WhatsApp/ELP)** | npm `tree-sitter-erlang` is HIJACKED — never source from it (#1165). gen_server dispatch is synthesis-side (fine). | ☐ |
+| cds | `languages/cds.ts` | T2 | **vendored, patched (cap-js-community, archived)** | Grammar frozen at one commit plus `docs/grammars/tree-sitter-cds.patch` (annotation values and trailing element annotations); upstream is archived, so further fixes ride the same patch like cobol/vbnet. The kernel must regenerate from grammar.js + patch, not from upstream's checked-in parser.c. Every symbol-bearing construct (entities, elements, services, actions) is walked via visitNode, since CDS definitions have no generic body shape the walker otherwise understands; no `calls` edges (declarative language). | ☐ |
| nix | `languages/nix.ts` | T2 | **vendored (ABI-15 rebuild)** | Option-path synthesizer is synthesis-side; the `===`-always-false → `.equals()` lesson (#1190) is wasm-binding-specific and disappears natively — still gate on nixpkgs (44k files). | ☐ |
| solidity | `languages/solidity.ts` | T2 | **vendored** | `modifier_invocation` outside body walk (#1170) is extraction-side; port it. | ☐ |
| terraform | `languages/terraform.ts` | T2 | **vendored** | `:`-scoped refs for module-boundary bridging (#1173); metadata does NOT persist — re-read source (#1174). | ☐ |
diff --git a/docs/grammars/tree-sitter-cds.md b/docs/grammars/tree-sitter-cds.md
new file mode 100644
index 000000000..d87174632
--- /dev/null
+++ b/docs/grammars/tree-sitter-cds.md
@@ -0,0 +1,186 @@
+# tree-sitter-cds.wasm: provenance & rebuild
+
+`src/extraction/wasm/tree-sitter-cds.wasm` is built from
+[cap-js-community/tree-sitter-cds](https://github.com/cap-js-community/tree-sitter-cds)
+(Apache-2.0) at commit `66bc383c134ae76d25abac7b952985e8d0a52b03` (package
+version `2.0.0`) with the patch in `tree-sitter-cds.patch` applied
+(`grammar.js` plus two `test/corpus` entries; `src/parser.c` is regenerated by
+`tree-sitter generate`, `src/scanner.c` is upstream's, untouched).
+
+## Why vendored, and why archived
+
+CDS is not in the `tree-sitter-wasms` package codegraph rides for stock
+grammars, and cap-js-community/tree-sitter-cds is the only tree-sitter
+grammar for SAP CAP CDS that exists. SAP archived the repository on
+2026-06-12: its README now opens with a "Deprecation and Archival Notice" and
+a `STATUS: NOT CURRENTLY MAINTAINED` badge, so codegraph vendors the last
+commit as a frozen artifact rather than tracking an npm release that will
+never come.
+
+Upstream is explicit about the grammar's own scope:
+
+> The main goal of this project is to provide syntax highlighting, e.g. for
+> Neovim. It is _not_ a 100% CAP CDS compatible parser. There are places that
+> this grammar can't parse properly, yet.
+
+Since upstream is archived, the gaps that a real CDS corpus hits are fixed
+here instead. See "What the patch adds" below and "Known grammar gaps" for
+what is left.
+
+## What's in the vendored source
+
+- `src/parser.c`: the parse table regenerated from the patched `grammar.js`
+ with tree-sitter-cli 0.25.10, `LANGUAGE_VERSION 15` (ABI 15, which the
+ repo's web-tree-sitter 0.25.3 supports). Upstream's checked-in table was
+ ABI 14.
+- `src/scanner.c`: a tiny external scanner (37 lines, one token,
+ `AUTOMATIC_SEMICOLON`): it lets a statement's trailing `;` be omitted when
+ the next non-whitespace character is `}` (or EOF). No other state;
+ `serialize`/`deserialize` are no-ops. Unchanged from upstream.
+
+## What the patch adds
+
+Measured on 247 `.cds` files from 11 public CAP repositories, upstream leaves
+9 files with ERROR nodes. Two constructs account for all nine:
+
+1. **Empty annotation records and arrays.** `annotation_value` required at
+ least one element inside `{ }` and `[ ]`, so `@requires: []`,
+ `@UI.LineItem: []`, `@UI.Facets: []` and nested forms such as
+ `@(UI : { SelectionFields : [], LineItem : [ ... ] })` inserted a MISSING
+ identifier and poisoned the rest of the file. The patch swaps
+ `list_of_trailing` for `optional_list_of_trailing` in both branches. Empty
+ values now produce an `annotation_value` node with no children; every
+ non-empty value keeps the tree it had. 5 of the 9 files.
+2. **Annotations after an element's `default` / `not null` properties.**
+ `_element_properties` (`default_and_nullability` or
+ `calc_element_assignment`) was followed straight by the statement
+ semicolon, so real models broke on
+ `TravelID : Integer default 0 @readonly;`,
+ `filename : String not null @title: '{i18n>filename}';`,
+ `![PRICE] : Decimal(15, 2) not null @title : 'PRICE';` and on several
+ annotations chained without separators
+ (`key code : Integer default 0 @Common.Text: name @Common.TextArrangement: #TextFirst;`).
+ The patch appends `repeat($.annotation)` to `_element_properties`, which
+ covers every element branch at once (plain type, type with arguments,
+ `type of`, `array of`, `localized`, and the enum variants). The
+ `localized` branch's own trailing `repeat($.annotation)` becomes a
+ `choice` so the two repeats cannot overlap. `_element_properties` is a
+ hidden rule, so the annotations surface as `annotation` siblings of
+ `default_and_nullability` inside the same `element_definition`, which is
+ where the extractor already looks for them. 4 of the 9 files.
+
+The patch also adds two `test/corpus` entries (`Annotation Values: Empty
+Structures and Arrays` in `annotations.txt`, `Entity With Annotations After
+Element Properties` in `entity.txt`) covering both constructs.
+
+No rule names or node types change, so `src/extraction/languages/cds.ts`
+(which reads `annotation`, `annotation_group`, `annotation_group_item`,
+`annotation_path`, `annotation_value`, `annotate_artifact`,
+`definition_reference`, `element_definition` and friends) needs no change.
+
+Two constructs that looked like gaps turned out to be supported by upstream
+already and are left alone: `annotate X @(...)` without the `with` keyword
+(upstream's `annotate_artifact` has `optional_kw('with')`, and
+`test/corpus/extensions.txt` covers it), and several annotations chained
+ahead of an element name inside an `annotate` block
+(`annotate OrderStatus { @title: '...' @description: '...' name; }`).
+
+## Rebuild
+
+```bash
+git clone https://github.com/cap-js-community/tree-sitter-cds
+cd tree-sitter-cds
+git checkout 66bc383c134ae76d25abac7b952985e8d0a52b03
+git apply path/to/tree-sitter-cds.patch
+npm i -D --ignore-scripts tree-sitter-cli@0.25.10
+node node_modules/tree-sitter-cli/install.js # fetches the CLI binary
+npx tree-sitter generate # rewrites src/parser.c at ABI 15
+npx tree-sitter test # 62 tests, all green
+npx tree-sitter build --wasm --docker -o tree-sitter-cds.wasm .
+```
+
+`--docker` builds inside the `emscripten/emsdk:4.0.4` image, so no local
+emscripten toolchain is needed. `--ignore-scripts` on the install keeps npm
+from trying to compile the grammar's own node-gyp binding, which is not
+needed for `generate`, `test` or the wasm build; the follow-up `install.js`
+call is what downloads the CLI binary itself.
+
+Unlike the previous vendoring, this is a `generate` plus a `build`: the
+checked-in upstream `src/parser.c` is replaced by the table generated from
+the patched `grammar.js`. `git apply` on `66bc383` followed by `generate`
+reproduces the exact `src/parser.c` this wasm was built from.
+
+## Output
+
+| Field | Value |
+|---|---|
+| File | `src/extraction/wasm/tree-sitter-cds.wasm` |
+| Size | 443,989 bytes |
+| SHA-256 | `06e6a485e987816cd4928e23aa34c793719f68feb21eb3b60164cfff8d49a1f0` |
+| ABI | 15 |
+| Runtime | loads under the repo's web-tree-sitter 0.25.3; passes `node scripts/add-lang/check-grammar.mjs src/extraction/wasm/tree-sitter-cds.wasm .cds` (20 clean parses of 20) |
+
+## Measured parse health
+
+Every `.cds` file tracked by `git ls-files` in 11 public CAP repositories
+(the three under `CDS` in `.claude/skills/agent-eval/corpus.json` plus eight
+more), counting files whose parse tree contains an ERROR or MISSING node.
+"Before" is the unpatched upstream wasm (ABI 14), "after" is the wasm above.
+
+| Corpus repository | `.cds` files | Files with errors, before | after |
+|---|---|---|---|
+| [bookshop-demo](https://github.com/gregorwolf/bookshop-demo) | 74 | 3 | 0 |
+| [bookstore](https://github.com/capire/bookstore) | 16 | 0 | 0 |
+| [btp-cap-multitenant-saas](https://github.com/SAP-samples/btp-cap-multitenant-saas) | 27 | 0 | 0 |
+| [cap-sflight](https://github.com/SAP-samples/cap-sflight) | 13 | 2 | 0 |
+| [cds-caching](https://github.com/mikezaschka/cds-caching) | 5 | 0 | 0 |
+| [cloud-cap-samples](https://github.com/SAP-samples/cloud-cap-samples) | 36 | 1 | 0 |
+| [cloud-cap-samples-java](https://github.com/SAP-samples/cloud-cap-samples-java) | 24 | 0 | 0 |
+| [cloud-extension-s4hana-business-process](https://github.com/SAP-samples/cloud-extension-s4hana-business-process) | 3 | 0 | 0 |
+| [event-queue](https://github.com/cap-js-community/event-queue) | 18 | 0 | 0 |
+| [fiori-elements-feature-showcase](https://github.com/SAP-samples/fiori-elements-feature-showcase) | 19 | 2 | 0 |
+| [hana-opensap-cloud-2020](https://github.com/SAP-samples/hana-opensap-cloud-2020) | 12 | 1 | 0 |
+| **Total** | **247** | **9** | **0** |
+
+Upstream corpus tests: 62 of 62 pass (60 upstream tests unchanged, 2 added by
+the patch).
+
+The change is confined to the constructs above. Dumping the s-expression of
+all 247 corpus files under both wasms, exactly the 9 previously failing files
+differ, and in each the only difference is the removed ERROR wrapper or the
+removed MISSING identifier; the other 238 trees are byte-identical. The same
+holds for `scripts/add-lang/` sample models and every CDS snippet in the
+`CDS Extraction` block of `__tests__/extraction.test.ts` (25 inputs, zero
+difference).
+
+## Known grammar gaps
+
+Upstream's disclaimer still applies to constructs outside the measured
+corpus. These were probed by hand and still produce ERROR nodes; all of them
+failed the same way before the patch, and none occur in the 247-file corpus:
+
+- **`technical configuration { ... }`**, the HANA-specific CDL block after an
+ entity body.
+- **`extend ... with columns { ... }`** (including
+ `extend projection E with columns { ... }`).
+- **An annotation written before `not null` on an association**
+ (`x : Association to Y @a not null;`). The reverse order,
+ `x : Association to Y not null @a;`, parses.
+- **An annotation written before `default` on a `localized` element**
+ (`title : localized String(111) @mandatory default 'x';`). The reverse
+ order, `localized String(111) default 'x' @mandatory`, parses.
+
+The two ordering gaps are the mirror image of what the patch fixes:
+annotations are accepted after element properties, and before them for the
+common branches, but not interleaved in every combination.
+
+## Upstreaming
+
+Not applicable: the upstream repository is archived and no longer accepts
+contributions (`ospo@sap.com` is listed for inquiries about the repository's
+status only, not for patches). If SAP or the community forks the grammar
+later, this patch can be offered there the way the COBOL and VB.NET patches
+are offered to their live upstreams. Until then,
+`git checkout 66bc383c134ae76d25abac7b952985e8d0a52b03` on a clone of the
+archived repository, `git apply tree-sitter-cds.patch` and the rebuild steps
+above reproduce the vendored wasm exactly.
diff --git a/docs/grammars/tree-sitter-cds.patch b/docs/grammars/tree-sitter-cds.patch
new file mode 100644
index 000000000..9846d7465
--- /dev/null
+++ b/docs/grammars/tree-sitter-cds.patch
@@ -0,0 +1,189 @@
+diff --git a/grammar.js b/grammar.js
+index 075b064..6a53a38 100644
+--- a/grammar.js
++++ b/grammar.js
+@@ -560,9 +560,15 @@ module.exports = grammar({
+ ),
+ ),
+
+- _element_properties: $ => choice(
+- $.default_and_nullability,
+- $.calc_element_assignment,
++ _element_properties: $ => seq(
++ choice(
++ $.default_and_nullability,
++ $.calc_element_assignment,
++ ),
++ // Annotations may follow `default`/`not null`, e.g.
++ // TravelID : Integer default 0 @readonly;
++ // PRICE : Decimal(15, 2) not null @title : 'PRICE';
++ repeat($.annotation),
+ ),
+
+ element_enum_definition: $ => seq(
+@@ -724,8 +730,11 @@ module.exports = grammar({
+ seq(
+ kw('localized'),
+ $.type_reference,
+- optional($._element_properties),
+- repeat($.annotation),
++ // `_element_properties` already allows trailing annotations.
++ choice(
++ $._element_properties,
++ repeat($.annotation),
++ ),
+ $._required_semicolon,
+ ),
+ seq(
+@@ -1325,9 +1334,11 @@ module.exports = grammar({
+ ),
+
+ annotation_value: $ => choice(
+- seq('{', list_of_trailing($.annotation_named_value), '}'),
++ // Records and arrays may be empty, e.g. `@requires: []`,
++ // `@UI: { SelectionFields: [], LineItem: [ ... ] }`.
++ seq('{', optional_list_of_trailing($.annotation_named_value), '}'),
+ seq('[',
+- list_of_trailing(choice($.annotation_ellipsis_up_to, $.annotation_value)),
++ optional_list_of_trailing(choice($.annotation_ellipsis_up_to, $.annotation_value)),
+ ']',
+ ),
+ $._literal,
+diff --git a/test/corpus/annotations.txt b/test/corpus/annotations.txt
+index 9eabaa9..884584e 100644
+--- a/test/corpus/annotations.txt
++++ b/test/corpus/annotations.txt
+@@ -165,3 +165,46 @@ type T;
+ (type_definition
+ (name
+ (identifier))))
++
++================================================================================
++Annotation Values: Empty Structures and Arrays
++================================================================================
++
++@anno: []
++@(anno1: {}, anno2: { val: [], sub: { val: [ 1 ] } })
++type T;
++
++--------------------------------------------------------------------------------
++
++(cds
++ (annotation
++ (annotation_path
++ (identifier))
++ (annotation_value))
++ (annotation
++ (annotation_group
++ (annotation_group_item
++ (annotation_path
++ (identifier))
++ (annotation_value))
++ (annotation_group_item
++ (annotation_path
++ (identifier))
++ (annotation_value
++ (annotation_named_value
++ (annotation_path
++ (identifier))
++ (annotation_value))
++ (annotation_named_value
++ (annotation_path
++ (identifier))
++ (annotation_value
++ (annotation_named_value
++ (annotation_path
++ (identifier))
++ (annotation_value
++ (annotation_value
++ (number))))))))))
++ (type_definition
++ (name
++ (identifier))))
+diff --git a/test/corpus/entity.txt b/test/corpus/entity.txt
+index 63c64f1..0a9fd4b 100644
+--- a/test/corpus/entity.txt
++++ b/test/corpus/entity.txt
+@@ -75,3 +75,82 @@ entity E(P : String(length: 100), P2: Integer) {
+ (name)
+ (simple_path
+ (identifier))))))
++
++================================================================================
++Entity With Annotations After Element Properties
++================================================================================
++
++entity E {
++ key code : Integer default 0 @Common.Text: name @Common.TextArrangement: #TextFirst;
++ ![price] : Decimal(15, 2) not null @title: 'PRICE';
++ descr : localized String not null @title: 'DESCR';
++ calculated : Integer = 1 + 2 @readonly;
++};
++
++--------------------------------------------------------------------------------
++
++(cds
++ (entity_definition
++ (name
++ (identifier))
++ (element_definitions
++ (element_definition
++ (name)
++ (simple_path
++ (identifier))
++ (default_and_nullability
++ (default_value
++ (number)))
++ (annotation
++ (annotation_path
++ (identifier)
++ (identifier))
++ (annotation_value
++ (annotation_path
++ (identifier))))
++ (annotation
++ (annotation_path
++ (identifier)
++ (identifier))
++ (annotation_value
++ (identifier))))
++ (element_definition
++ (name)
++ (simple_path
++ (identifier))
++ (type_argument
++ (number))
++ (type_argument
++ (number))
++ (default_and_nullability
++ (nullability
++ (null)))
++ (annotation
++ (annotation_path
++ (identifier))
++ (annotation_value
++ (single_quote_string))))
++ (element_definition
++ (name)
++ (type_reference
++ (simple_path
++ (identifier)))
++ (default_and_nullability
++ (nullability
++ (null)))
++ (annotation
++ (annotation_path
++ (identifier))
++ (annotation_value
++ (single_quote_string))))
++ (element_definition
++ (name)
++ (simple_path
++ (identifier))
++ (calc_element_assignment
++ (binary_expression
++ (number)
++ (number)))
++ (annotation
++ (annotation_path
++ (identifier)))))))
diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts
index c7710f200..203e95dc9 100644
--- a/src/extraction/grammars.ts
+++ b/src/extraction/grammars.ts
@@ -50,6 +50,7 @@ const WASM_GRAMMAR_FILES: Record = {
terraform: 'tree-sitter-terraform.wasm',
arkts: 'tree-sitter-arkts.wasm',
nix: 'tree-sitter-nix.wasm',
+ cds: 'tree-sitter-cds.wasm',
};
/**
@@ -170,6 +171,9 @@ export const EXTENSION_MAP: Record = {
'.tf': 'terraform',
'.tfvars': 'terraform',
'.tofu': 'terraform',
+ // SAP CAP CDS models (db schemas, service definitions, annotation files).
+ // Vendored cap-js-community/tree-sitter-cds grammar.
+ '.cds': 'cds',
};
/**
@@ -271,6 +275,16 @@ export async function initGrammars(): Promise {
* nix-community/tree-sitter-nix @ 3d0173d (MIT) with tree-sitter-cli 0.25.10
* (`generate` + `build --wasm`, ABI 15 — upstream's checked-in parser.c is
* still ABI 13; all 54 upstream corpus tests pass on the regenerated parser).
+ * CDS (SAP CAP): not in tree-sitter-wasms; we vendor a wasm built from
+ * cap-js-community/tree-sitter-cds @ 66bc383 (v2.0.0, Apache-2.0) with the
+ * patch in docs/grammars/tree-sitter-cds.patch (empty `[]` / `{}` annotation
+ * values and annotations after `default` / `not null`, which broke 9 of 247
+ * real-repo files). SAP archived that repo on 2026-06-12, so the grammar is
+ * frozen at that commit plus our patch. Built with tree-sitter-cli 0.25.10
+ * (`generate` + `build --wasm --docker`, emscripten/emsdk 4.0.4, ABI 15);
+ * upstream's checked-in parser.c is ABI 14 and all 60 upstream corpus tests
+ * pass on the regenerated parser. The external scanner (unchanged) only
+ * supplies automatic semicolons. Rebuild steps: docs/grammars/tree-sitter-cds.md.
*
* TypeScript/TSX/JavaScript (+jsx, which shares the javascript grammar): the
* tree-sitter-wasms builds are 2023-era (^0.20.x); we vendor wasm built from
@@ -290,7 +304,7 @@ export async function initGrammars(): Promise {
*/
const VENDORED_WASM_LANGS: ReadonlySet = new Set([
'pascal', 'scala', 'lua', 'luau', 'csharp', 'r', 'cfml', 'cfscript', 'cfquery',
- 'cobol', 'vbnet', 'erlang', 'terraform', 'arkts', 'nix',
+ 'cobol', 'vbnet', 'erlang', 'terraform', 'arkts', 'nix', 'cds',
'typescript', 'tsx', 'javascript', 'jsx', 'java', 'python', 'go',
// R7a (C/C++ kernel port prep): tree-sitter-c v0.24.2 (b780e47) +
// tree-sitter-cpp v0.23.4 (f41e1a0), parser.c/scanner.c sha-matched against
@@ -707,6 +721,7 @@ export function getLanguageDisplayName(language: Language): string {
erlang: 'Erlang',
terraform: 'Terraform',
arkts: 'ArkTS',
+ cds: 'CDS',
unknown: 'Unknown',
};
return names[language] || language;
diff --git a/src/extraction/languages/cds.ts b/src/extraction/languages/cds.ts
new file mode 100644
index 000000000..2ab920014
--- /dev/null
+++ b/src/extraction/languages/cds.ts
@@ -0,0 +1,817 @@
+import type { Node as SyntaxNode } from 'web-tree-sitter';
+import { getNodeText, getChildByField, getPrecedingDocstring } from '../tree-sitter-helpers';
+import type { LanguageExtractor, ExtractorContext } from '../tree-sitter-types';
+import type { NodeKind, ReferenceKind } from '../../types';
+
+// Node names follow the vendored cap-js-community/tree-sitter-cds grammar
+// (2.0.0, ABI 14) for SAP CAP's Core Data Services.
+//
+// CDS is a declarative modelling language: a file holds artifacts (entities,
+// services, types, aspects, events, actions) and nothing else. There are no
+// call sites, so the whole graph comes from DECLARED structure, and three
+// shapes of that structure defeat the generic extractor, which is why every
+// symbol-bearing node is dispatched through the visitNode hook below:
+// - an artifact carries its members as direct children under a wrapper the
+// generic walker has no field for (`element_definitions`, `bound_actions`),
+// so a bodiless-looking `entity_definition` would be skipped and its
+// elements never seen;
+// - `extend X with { ... }` and `annotate X with ...` declare no symbol of
+// their own: the directive is a dependency on X plus, for `extend`, a set
+// of members that belong to X, so the members are created with their
+// qualifiedName rooted at X rather than at the file that adds them;
+// - annotations (`@readonly entity E`) sit BEFORE the definition as siblings
+// while inline ones (`service S @(path:'/x') {`) sit inside it, so
+// decorators cannot be read off a single child field.
+//
+// qualifiedName scheme: `namespace a.b.c;` becomes one `namespace` node named
+// `a.b.c` (dotted, a single scope segment) through packageTypes/extractPackage,
+// so definitions read `a.b.c::Books`, `a.b.c::Books::title`,
+// `a.b.c::CatalogService::Books`. Replacing `::` with `.` in a qualifiedName
+// therefore yields exactly the CDS fully qualified name, which is what the
+// resolver compares against. A definition DECLARED with a dotted name
+// (`entity sap.common.Regions`, what OData import tooling emits) keeps that
+// property: it is named by its last segment and its prefix becomes one more
+// qualifiedName segment (see definitionName).
+//
+// Reference names use the same spelling (see cdsReferenceName): the artifact
+// path with its first segment expanded through the file's `using ... as`
+// aliases, written with `::` before the last segment
+// (`sap.capire.bookshop.Books` -> `sap.capire.bookshop::Books`). Built-in
+// scalar types are dropped: they have no definition to point at.
+
+/**
+ * CDS built-in scalar types (case sensitive, as CDS is). A reference to one of
+ * these can never resolve to a node in the repo, so emitting it would leave a
+ * permanently unresolved reference on nearly every element in the model.
+ * `cds.` / `hana.` prefixed names are the qualified spelling of the same set
+ * plus the HANA-native types, handled by isCdsBuiltinType.
+ */
+export const CDS_BUILTIN_TYPES: ReadonlySet = new Set([
+ 'String', 'LargeString', 'Binary', 'LargeBinary', 'Boolean',
+ 'Integer', 'Integer16', 'Integer32', 'Integer64',
+ 'Int16', 'Int32', 'Int64', 'UInt8',
+ 'Decimal', 'DecimalFloat', 'Double',
+ 'Date', 'Time', 'DateTime', 'Timestamp',
+ 'UUID', 'Vector', 'Map',
+]);
+
+/** Whether a type path names a CDS built-in rather than a modelled artifact. */
+export function isCdsBuiltinType(name: string): boolean {
+ return CDS_BUILTIN_TYPES.has(name) || name.startsWith('cds.') || name.startsWith('hana.');
+}
+
+/**
+ * The reference name codegraph stores for a CDS artifact path.
+ *
+ * A `using` alias only ever binds the FIRST segment of a path, so expansion is
+ * a single map lookup and the rest of the path is kept as written. The result
+ * is spelled the way qualifiedNames are built: the enclosing namespace stays
+ * dotted and a `::` separates it from the artifact, so
+ * `sap.capire.bookshop.Books` becomes `sap.capire.bookshop::Books` and matches
+ * the qualifiedName that definition got from `namespace` + name. A bare name
+ * (`Authors`) has no namespace part and stays bare.
+ */
+export function cdsReferenceName(
+ dottedPath: string,
+ aliases?: ReadonlyMap
+): string {
+ const segments = dottedPath.split('.').filter((s) => s.length > 0);
+ if (segments.length === 0) return '';
+ const head = aliases?.get(segments[0]!);
+ const expanded = head ? [head, ...segments.slice(1)].join('.') : segments.join('.');
+ const lastDot = expanded.lastIndexOf('.');
+ return lastDot === -1 ? expanded : `${expanded.slice(0, lastDot)}::${expanded.slice(lastDot + 1)}`;
+}
+
+function collapseWs(text: string): string {
+ return text.replace(/\s+/g, ' ').trim();
+}
+
+/** `![Some Name]` is CDS's quoting for identifiers that aren't plain words. */
+function stripDelimiters(text: string): string {
+ const t = text.trim();
+ return t.startsWith('![') && t.endsWith(']') ? t.slice(2, -1) : t;
+}
+
+function stringLiteral(text: string): string {
+ return text.replace(/^[`'"]/, '').replace(/[`'"]$/, '');
+}
+
+/**
+ * The dotted path a `simple_path` / `from_path` / `definition_reference` /
+ * `annotation_path` node spells. Only DIRECT identifier children are joined, so
+ * a path filter or argument hanging off the path (`Books[stock > 0]`) drops out
+ * instead of contributing a phantom segment.
+ */
+function dottedPath(node: SyntaxNode, source: string): string {
+ const parts: string[] = [];
+ for (const child of node.namedChildren) {
+ if (child.type === 'identifier') parts.push(stripDelimiters(getNodeText(child, source)));
+ }
+ return parts.join('.');
+}
+
+// --- Per-file state. Extraction is file-sequential within a worker, so a
+// single set of module-level maps keyed by filePath is safe (and resets
+// naturally when the next file starts). ---
+
+let stateFile = '';
+/** Local name -> full artifact path, from this file's `using` directives. */
+let aliases = new Map();
+/** This file's `namespace a.b.c;` path, or '' when it declares none. */
+let fileNamespace = '';
+/** `fromNodeId|kind|name` of references already emitted for this file. */
+let emittedRefs = new Set();
+/**
+ * First name segment -> kind of every artifact this file defines at its top
+ * level (`service AdminService` records `AdminService` -> module). Used to tell
+ * a dotted directive target that lives in THIS file's namespace
+ * (`extend AdminService.Exposed`) from a global one (`extend sap.common.Countries`).
+ */
+let fileDefinitions = new Map();
+
+const TOP_LEVEL_KINDS: Readonly> = {
+ entity_definition: 'class',
+ view_definition: 'class',
+ service_definition: 'module',
+ context_definition: 'namespace',
+ aspect_definition: 'interface',
+ type_definition: 'type_alias',
+ event_definition: 'struct',
+ annotation_definition: 'type_alias',
+ action_definition: 'function',
+ function_definition: 'function',
+};
+
+/**
+ * `using` directives are collected up front rather than as the walk reaches
+ * them: CDS allows them anywhere at the top level, and an annotation-only file
+ * commonly names an imported artifact in the same statement order it imports
+ * it, so a lazily built map would expand some paths and not others.
+ */
+function resetFileState(root: SyntaxNode, source: string, filePath: string): void {
+ stateFile = filePath;
+ aliases = new Map();
+ fileNamespace = '';
+ emittedRefs = new Set();
+ fileDefinitions = new Map();
+ for (const child of root.namedChildren) {
+ if (child.type === 'namespace') {
+ const path = getChildByField(child, 'path');
+ if (path) fileNamespace = dottedPath(path, source);
+ continue;
+ }
+ const kind = TOP_LEVEL_KINDS[child.type];
+ if (kind) {
+ const nameNode = getChildByField(child, 'name');
+ const first = nameNode ? stripDelimiters(getNodeText(nameNode, source)).split('.')[0] : '';
+ if (first) fileDefinitions.set(first, kind);
+ continue;
+ }
+ if (child.type !== 'using') continue;
+ for (const imported of child.namedChildren) {
+ if (imported.type !== 'artifact_import') continue;
+ const target = imported.namedChildren.find((c) => c.type === 'definition_reference');
+ if (!target) continue;
+ const path = dottedPath(target, source);
+ if (!path) continue;
+ const aliasNode = getChildByField(imported, 'alias');
+ // Without `as`, the local name is the path's last segment:
+ // `using sap.capire.bookshop.Books` binds `Books`.
+ const local = aliasNode ? getNodeText(aliasNode, source) : path.split('.').pop()!;
+ aliases.set(stripDelimiters(local), path);
+ }
+ }
+}
+
+function fileAliases(node: SyntaxNode, ctx: ExtractorContext): ReadonlyMap {
+ if (ctx.filePath !== stateFile) {
+ let root = node;
+ while (root.parent) root = root.parent;
+ resetFileState(root, ctx.source, ctx.filePath);
+ }
+ return aliases;
+}
+
+// --- Annotations, doc comments, signatures ---
+
+/**
+ * Node types that a leading annotation run can introduce. Used to tell a
+ * definition's own inline annotations from the prefix annotations of the
+ * member that follows it.
+ */
+const DEFINITION_TYPES: ReadonlySet = new Set([
+ 'entity_definition', 'view_definition', 'service_definition', 'context_definition',
+ 'aspect_definition', 'type_definition', 'event_definition', 'annotation_definition',
+ 'action_definition', 'function_definition', 'element_definition', 'mixin_element_definition',
+ 'enum_symbol_definition', 'parameter_definition', 'annotate_artifact', 'annotate_element',
+ 'extend_artifact', 'extend_structure', 'extend_projection', 'extend_service', 'extend_context',
+]);
+
+/**
+ * The annotations written IN FRONT of a definition (`@readonly entity E`).
+ * They are siblings, not children, and a doc comment may sit above them. The
+ * scan walks EVERY sibling, anonymous tokens included: the `{` that opens a
+ * service body is exactly what separates the service's own inline annotations
+ * from the prefix annotations of its first member.
+ */
+function prefixAnnotations(node: SyntaxNode): SyntaxNode[] {
+ const found: SyntaxNode[] = [];
+ let sibling = node.previousSibling;
+ while (sibling) {
+ if (sibling.type === 'annotation') found.unshift(sibling);
+ else if (sibling.type !== 'comment') break;
+ sibling = sibling.previousSibling;
+ }
+ return found;
+}
+
+/** Whether an annotation run starting here introduces a nested definition. */
+function introducesDefinition(annotation: SyntaxNode): boolean {
+ let sibling = annotation.nextSibling;
+ while (sibling && (sibling.type === 'annotation' || sibling.type === 'comment')) {
+ sibling = sibling.nextSibling;
+ }
+ return !!sibling && DEFINITION_TYPES.has(sibling.type);
+}
+
+/**
+ * The annotations written ON a definition itself: `service S @(path:'/x') {`,
+ * `title : String @mandatory;`. A child annotation that introduces a nested
+ * definition belongs to that member instead and is picked up there.
+ */
+function inlineAnnotations(node: SyntaxNode): SyntaxNode[] {
+ return node.children.filter((c) => c.type === 'annotation' && !introducesDefinition(c));
+}
+
+/**
+ * `@readonly`, `@UI.HeaderInfo`, `@cds.persistence.skip` as written. A grouped
+ * annotation (`@(a: 1, b.c: 2)`) contributes one entry per group item, which is
+ * how CAP itself reads them.
+ */
+function annotationNames(annotation: SyntaxNode, source: string, out: string[]): void {
+ const group = annotation.namedChildren.find((c) => c.type === 'annotation_group');
+ const paths = group
+ ? group.namedChildren
+ .filter((item) => item.type === 'annotation_group_item')
+ .map((item) => item.namedChildren.find((c) => c.type === 'annotation_path'))
+ : [annotation.namedChildren.find((c) => c.type === 'annotation_path')];
+ for (const path of paths) {
+ if (!path) continue;
+ const name = dottedPath(path, source);
+ if (name) out.push(`@${name}`);
+ }
+}
+
+function decoratorsOf(node: SyntaxNode, source: string, extra: string[] = []): string[] | undefined {
+ const names = [...extra];
+ for (const annotation of prefixAnnotations(node)) annotationNames(annotation, source, names);
+ for (const annotation of inlineAnnotations(node)) annotationNames(annotation, source, names);
+ const unique = [...new Set(names)];
+ return unique.length > 0 ? unique : undefined;
+}
+
+function docstringOf(node: SyntaxNode, source: string): string | undefined {
+ // A doc comment sits above the ANNOTATIONS, not above the definition, so the
+ // lookup is anchored at the first prefix annotation whenever there is one.
+ const anchor = prefixAnnotations(node)[0] ?? node;
+ return getPrecedingDocstring(anchor, source);
+}
+
+/** Node types that open a definition's body, where its signature stops. */
+const BODY_TYPES: ReadonlySet = new Set([
+ 'element_definitions', 'element_enum_definition', 'select_item_list',
+ 'bound_actions', 'mixin_definition_list', 'excluding_clause', 'where_clause',
+]);
+
+/**
+ * An artifact's header: everything up to its body. `entity Books : cuid,
+ * managed`, `entity ListOfBooks as projection on Books`. Annotation subtrees
+ * are skipped because an annotation VALUE carries braces of its own
+ * (`@UI.HeaderInfo: { ... }`) that must not be mistaken for the body.
+ */
+function headerText(node: SyntaxNode, source: string): string {
+ let end = node.endIndex;
+ const scan = (n: SyntaxNode): void => {
+ for (const child of n.children) {
+ if (child.type === 'annotation') continue;
+ if (BODY_TYPES.has(child.type) || (!child.isNamed && child.type === '{')) {
+ if (child.startIndex < end) end = child.startIndex;
+ continue;
+ }
+ scan(child);
+ }
+ };
+ scan(node);
+ return collapseWs(source.substring(node.startIndex, end)).replace(/[:,;]$/, '').trim();
+}
+
+/** A member's source line without its terminating semicolon. */
+function statementText(node: SyntaxNode, source: string): string {
+ return collapseWs(getNodeText(node, source)).replace(/;$/, '').trim();
+}
+
+// --- References ---
+
+function emitRef(
+ ctx: ExtractorContext,
+ fromNodeId: string,
+ referenceName: string,
+ referenceKind: ReferenceKind,
+ at: SyntaxNode
+): void {
+ const key = `${fromNodeId}|${referenceKind}|${referenceName}`;
+ if (emittedRefs.has(key)) return;
+ emittedRefs.add(key);
+ ctx.addUnresolvedReference({
+ fromNodeId,
+ referenceName,
+ referenceKind,
+ line: at.startPosition.row + 1,
+ column: at.startPosition.column,
+ });
+}
+
+/**
+ * Emit a reference to the artifact a path node names. Built-ins and the CDS
+ * pseudo-variables (`$self`, `$user`, `$now`, `$at`, `$projection`) name
+ * nothing that can be indexed, so they are dropped rather than left to sit
+ * unresolved forever.
+ */
+function emitPathRef(
+ ctx: ExtractorContext,
+ fromNodeId: string,
+ pathNode: SyntaxNode,
+ kind: ReferenceKind,
+ aliasMap: ReadonlyMap
+): void {
+ const raw = dottedPath(pathNode, ctx.source);
+ if (!raw || raw.startsWith('$') || isCdsBuiltinType(raw)) return;
+ const name = cdsReferenceName(raw, aliasMap);
+ if (name) emitRef(ctx, fromNodeId, name, kind, pathNode);
+}
+
+/**
+ * The artifact an `extend` / `annotate` directive names.
+ *
+ * CDS looks a bare target up in the CURRENT namespace first, so inside
+ * `namespace sap.capire.bookshop;` the directive `extend Books with { isbn :
+ * String; }` extends `sap.capire.bookshop.Books` and the new element belongs
+ * there. The namespace is one dotted qualifiedName segment, which is why the
+ * result reads `sap.capire.bookshop::Books` and lines up with the entity's own
+ * qualifiedName. Two bare targets are NOT namespace-local: an alias bound by
+ * `using` (`extend managed with ...`) already names an artifact elsewhere, and
+ * a dotted target is written out in full, so both keep the plain reference
+ * spelling.
+ */
+function directiveTargetName(target: SyntaxNode, ctx: ExtractorContext): string {
+ // Refreshes the per-file state, so fileNamespace below is this file's.
+ const aliasMap = fileAliases(target, ctx);
+ const raw = dottedPath(target, ctx.source);
+ if (!raw || raw.startsWith('$') || isCdsBuiltinType(raw)) return '';
+ const segments = raw.split('.');
+ const first = segments[0]!;
+ if (fileNamespace && !aliasMap.has(first)) {
+ // A bare target is namespace-local. A dotted one is too when its first
+ // segment is an artifact this file defines (`extend AdminService.Exposed`
+ // next to `service AdminService`), and is then spelled as the nested
+ // scopes the definition itself got. Any other dotted target is a global
+ // name (`extend sap.common.Countries`) and keeps its plain spelling.
+ if (segments.length === 1) return `${fileNamespace}::${raw}`;
+ if (fileDefinitions.has(first)) return `${fileNamespace}::${segments.join('::')}`;
+ }
+ return cdsReferenceName(raw, aliasMap);
+}
+
+/**
+ * Whether a directive target sits inside a service this file defines, which
+ * makes the members an `extend` adds part of that service's exposed API.
+ */
+function targetsLocalService(target: SyntaxNode, ctx: ExtractorContext): boolean {
+ const aliasMap = fileAliases(target, ctx);
+ const first = dottedPath(target, ctx.source).split('.')[0] ?? '';
+ return !!first && !aliasMap.has(first) && fileDefinitions.get(first) === 'module';
+}
+
+/**
+ * Every source a projection or query reads from: the `on` target of a
+ * projection, each `from` of a select (comma-separated sources and joins
+ * alike), and each `redirected to` target in the select list.
+ */
+function collectQuerySources(node: SyntaxNode, out: SyntaxNode[]): void {
+ for (const child of node.namedChildren) {
+ if (child.type === 'from_path') {
+ out.push(child);
+ } else if (child.type === 'redirected_to') {
+ const target = child.namedChildren.find((c) => c.type === 'simple_path');
+ if (target) out.push(target);
+ } else {
+ collectQuerySources(child, out);
+ }
+ }
+}
+
+/**
+ * The artifacts a TYPE position names, read off one node's direct children: an
+ * association/composition target, a parameterized or plain named type, or the
+ * entity behind `type of Books:title`.
+ */
+function emitTypeRefs(
+ node: SyntaxNode,
+ fromNodeId: string,
+ ctx: ExtractorContext,
+ aliasMap: ReadonlyMap
+): void {
+ for (const child of node.namedChildren) {
+ switch (child.type) {
+ case 'association_to':
+ case 'composition_of':
+ case 'type_type_of': {
+ // The first simple_path is the target; anything after it is the
+ // cardinality, the `on` condition or a foreign-key list.
+ const target = child.namedChildren.find((c) => c.type === 'simple_path');
+ if (target) emitPathRef(ctx, fromNodeId, target, 'references', aliasMap);
+ break;
+ }
+ case 'type_reference': {
+ // `Books:ID` borrows an element's type: the artifact is `Books`.
+ const target = getChildByField(child, 'name');
+ if (target) emitPathRef(ctx, fromNodeId, target, 'references', aliasMap);
+ break;
+ }
+ case 'simple_path':
+ // The `type:` field of `title : String` / `currency : Currency`.
+ emitPathRef(ctx, fromNodeId, child, 'references', aliasMap);
+ break;
+ default:
+ break;
+ }
+ }
+}
+
+/**
+ * Type references anywhere under a parameter list or a return type. Parameters
+ * are not symbols of their own, but the artifacts they name are real
+ * dependencies of the operation, including the elements of an anonymous
+ * `returns { ... }` structure.
+ */
+function emitNestedTypeRefs(
+ node: SyntaxNode,
+ fromNodeId: string,
+ ctx: ExtractorContext,
+ aliasMap: ReadonlyMap,
+ depth = 0
+): void {
+ if (depth > 8) return;
+ emitTypeRefs(node, fromNodeId, ctx, aliasMap);
+ for (const child of node.namedChildren) {
+ // A type_reference's second path is an ELEMENT of the first, never an
+ // artifact, so its subtree is already fully consumed above.
+ if (child.type === 'type_reference' || child.type === 'type_type_of') continue;
+ emitNestedTypeRefs(child, fromNodeId, ctx, aliasMap, depth + 1);
+ }
+}
+
+// --- Definitions ---
+
+interface DefOptions {
+ /**
+ * The qualifiedName every definition visited under these options hangs off:
+ * the enclosing artifact's own qualifiedName, or the target of an `extend X
+ * with ...` (whose members belong to X, not to the file that adds them).
+ */
+ qnPrefix?: string;
+ /** Members of a service are its exposed API surface. */
+ exported?: boolean;
+}
+
+/**
+ * The qualifiedName the definitions in the current scope hang off.
+ *
+ * The core composes a qualifiedName by joining the NAMES on the scope stack,
+ * which comes out one segment short as soon as a name is dotted (`entity
+ * sap.common.Regions` is NAMED `Regions` but qualified `sap.common::Regions`,
+ * so its elements would land under `Regions::code`). Every definition
+ * therefore passes its own qualifiedName down as qnPrefix instead of letting
+ * the stack recompose one. For an undotted name the two agree exactly.
+ */
+function scopeQualifiedName(ctx: ExtractorContext, opts: DefOptions): string {
+ if (opts.qnPrefix !== undefined) return opts.qnPrefix;
+ const parentId = ctx.nodeStack[ctx.nodeStack.length - 1];
+ if (!parentId) return '';
+ const parent = ctx.nodes.find((n) => n.id === parentId);
+ // A file node contributes nothing to a qualifiedName, matching the core.
+ return parent && parent.kind !== 'file' ? parent.qualifiedName : '';
+}
+
+/**
+ * A definition's node name and qualifiedName.
+ *
+ * CDS allows a DOTTED name wherever a definition is declared (`entity
+ * sap.common.Regions : CodeList`, `type a.b.T : String`, `action a.b.c()`),
+ * and that is what SAP's OData-to-CDS import tooling emits. The node is NAMED
+ * by the last segment so a lookup by name finds it, and the dotted prefix
+ * becomes ONE qualifiedName segment, so dot-normalizing the qualifiedName
+ * still spells the CDS fully qualified name: `sap.common::Regions` at the top
+ * level, `X::sap.common::Regions` under `namespace X;`, `S::a.b::C` inside a
+ * service. A delimited identifier is a single name even when it contains a
+ * dot, so it is never split.
+ */
+function definitionName(
+ node: SyntaxNode,
+ ctx: ExtractorContext,
+ opts: DefOptions
+): { name: string; qualifiedName: string } | null {
+ const nameNode = getChildByField(node, 'name');
+ if (!nameNode) return null;
+ const raw = getNodeText(nameNode, ctx.source).trim();
+ const text = stripDelimiters(raw);
+ const lastDot = raw.startsWith('![') ? -1 : text.lastIndexOf('.');
+ const name = lastDot === -1 ? text : text.slice(lastDot + 1);
+ if (!name) return null;
+ const prefix = lastDot === -1 ? '' : text.slice(0, lastDot);
+ const parts = [scopeQualifiedName(ctx, opts), prefix, name].filter((p) => p.length > 0);
+ return { name, qualifiedName: parts.join('::') };
+}
+
+/** Member node types dispatched back through visitDefinition. */
+const MEMBER_TYPES: ReadonlySet = new Set([
+ 'element_definition', 'action_definition', 'function_definition',
+ 'entity_definition', 'view_definition', 'service_definition', 'context_definition',
+ 'aspect_definition', 'type_definition', 'event_definition', 'annotation_definition',
+]);
+
+/** Wrappers that hold members instead of being one. */
+const MEMBER_WRAPPERS: ReadonlySet = new Set([
+ 'element_definitions', 'element_enum_definition', 'bound_actions',
+ // `Composition of many { ... }` inlines an anonymous aspect as the target.
+ 'association_to', 'composition_of',
+]);
+
+function visitMembers(node: SyntaxNode, ctx: ExtractorContext, opts: DefOptions): void {
+ for (const child of node.namedChildren) {
+ if (MEMBER_WRAPPERS.has(child.type)) visitMembers(child, ctx, opts);
+ else if (child.type === 'enum_symbol_definition') handleEnumSymbol(child, ctx, opts);
+ else if (MEMBER_TYPES.has(child.type)) visitDefinition(child, ctx, opts);
+ }
+}
+
+function handleArtifact(
+ node: SyntaxNode,
+ ctx: ExtractorContext,
+ opts: DefOptions,
+ kind: NodeKind,
+ extraDecorators: string[] = []
+): boolean {
+ const named = definitionName(node, ctx, opts);
+ if (!named) return true;
+ const aliasMap = fileAliases(node, ctx);
+ const artifact = ctx.createNode(kind, named.name, node, {
+ signature: headerText(node, ctx.source).slice(0, 200),
+ docstring: docstringOf(node, ctx.source),
+ decorators: decoratorsOf(node, ctx.source, extraDecorators),
+ // A service is reachable from outside by definition, as are its members.
+ isExported: kind === 'module' || (opts.exported ?? false),
+ qualifiedName: named.qualifiedName,
+ });
+ if (!artifact) return true;
+
+ // `entity Books : cuid, managed` / `aspect A : B` / `event E : Base`
+ for (const child of node.namedChildren) {
+ if (child.type !== 'include_list') continue;
+ for (const include of child.namedChildren) {
+ if (include.type === 'simple_path') {
+ emitPathRef(ctx, artifact.id, include, 'extends', aliasMap);
+ }
+ }
+ }
+
+ const sources: SyntaxNode[] = [];
+ for (const child of node.namedChildren) {
+ if (child.type === 'projection_clause' || child.type === 'query_expression') {
+ collectQuerySources(child, sources);
+ }
+ }
+ for (const source of sources) emitPathRef(ctx, artifact.id, source, 'references', aliasMap);
+
+ // `type Amount : Decimal(10,2)` / `type Ref : type of Books:title`
+ emitTypeRefs(node, artifact.id, ctx, aliasMap);
+
+ ctx.pushScope(artifact.id);
+ visitMembers(node, ctx, {
+ qnPrefix: artifact.qualifiedName,
+ exported: kind === 'module' || (opts.exported ?? false),
+ });
+ ctx.popScope();
+ return true;
+}
+
+function handleElement(node: SyntaxNode, ctx: ExtractorContext, opts: DefOptions): boolean {
+ const named = definitionName(node, ctx, opts);
+ if (!named) return true;
+ const aliasMap = fileAliases(node, ctx);
+ // `key` and `virtual` are bare keywords, not annotations, but they change what
+ // the element IS, so they ride along on the decorators list.
+ const modifiers = node.children
+ .filter((c) => !c.isNamed && (c.type === 'key' || c.type === 'virtual'))
+ .map((c) => c.type);
+ const field = ctx.createNode('field', named.name, node, {
+ signature: statementText(node, ctx.source).slice(0, 200),
+ docstring: docstringOf(node, ctx.source),
+ decorators: decoratorsOf(node, ctx.source, modifiers),
+ isExported: opts.exported ?? false,
+ qualifiedName: named.qualifiedName,
+ });
+ if (!field) return true;
+ emitTypeRefs(node, field.id, ctx, aliasMap);
+ ctx.pushScope(field.id);
+ visitMembers(node, ctx, {
+ qnPrefix: field.qualifiedName,
+ exported: opts.exported ?? false,
+ });
+ ctx.popScope();
+ return true;
+}
+
+function handleEnumSymbol(node: SyntaxNode, ctx: ExtractorContext, opts: DefOptions): void {
+ const named = definitionName(node, ctx, opts);
+ if (!named) return;
+ ctx.createNode('enum_member', named.name, node, {
+ signature: statementText(node, ctx.source).slice(0, 120),
+ decorators: decoratorsOf(node, ctx.source),
+ isExported: opts.exported ?? false,
+ qualifiedName: named.qualifiedName,
+ });
+}
+
+function handleAction(
+ node: SyntaxNode,
+ ctx: ExtractorContext,
+ opts: DefOptions,
+ kind: 'function' | 'method'
+): boolean {
+ const named = definitionName(node, ctx, opts);
+ if (!named) return true;
+ const aliasMap = fileAliases(node, ctx);
+ const action = ctx.createNode(kind, named.name, node, {
+ signature: statementText(node, ctx.source).slice(0, 200),
+ docstring: docstringOf(node, ctx.source),
+ decorators: decoratorsOf(node, ctx.source),
+ isExported: opts.exported ?? false,
+ qualifiedName: named.qualifiedName,
+ });
+ if (!action) return true;
+ for (const child of node.namedChildren) {
+ if (child.type === 'parameter_list' || child.type === 'return_type') {
+ emitNestedTypeRefs(child, action.id, ctx, aliasMap);
+ }
+ }
+ return true;
+}
+
+/**
+ * `extend X with { ... }` and its projection / service / context / actions
+ * variants. The directive declares no symbol: it is a dependency of the
+ * enclosing scope on X plus a set of members that belong to X. Select items
+ * (`extend X with columns { a, b }`) name existing elements and add nothing.
+ */
+function handleExtend(node: SyntaxNode, ctx: ExtractorContext, opts: DefOptions): boolean {
+ const target = node.namedChildren.find((c) => c.type === 'definition_reference');
+ if (!target) return true;
+ const root = directiveTargetName(target, ctx);
+ const scopeId = ctx.nodeStack[ctx.nodeStack.length - 1];
+ if (scopeId && root) emitRef(ctx, scopeId, root, 'references', target);
+ visitMembers(node, ctx, {
+ // An unnamed target (nothing indexable to extend) leaves the members where
+ // the scope stack puts them rather than rooting them at an empty prefix.
+ ...(root ? { qnPrefix: root } : {}),
+ exported: node.type === 'extend_service' || targetsLocalService(target, ctx) || (opts.exported ?? false),
+ });
+ return true;
+}
+
+/**
+ * `annotate X with ...`. Pure metadata on an artifact defined elsewhere, so the
+ * only graph fact is that this file depends on X.
+ */
+function handleAnnotate(node: SyntaxNode, ctx: ExtractorContext): boolean {
+ const target = node.namedChildren.find((c) => c.type === 'definition_reference');
+ const scopeId = ctx.nodeStack[ctx.nodeStack.length - 1];
+ if (target && scopeId) {
+ const name = directiveTargetName(target, ctx);
+ if (name) emitRef(ctx, scopeId, name, 'references', target);
+ }
+ return true;
+}
+
+/**
+ * One `references` ref per imported artifact, so a definition pulled in by a
+ * `using` records a dependency even when the file never names it again (the
+ * common shape for `using { Currency, managed } from '@sap/cds/common'`). The
+ * `imports` ref for the FILE is emitted by the core from extractImport.
+ */
+function handleUsing(node: SyntaxNode, ctx: ExtractorContext): void {
+ const scopeId = ctx.nodeStack[ctx.nodeStack.length - 1];
+ if (!scopeId) return;
+ const aliasMap = fileAliases(node, ctx);
+ for (const imported of node.namedChildren) {
+ if (imported.type !== 'artifact_import') continue;
+ const target = imported.namedChildren.find((c) => c.type === 'definition_reference');
+ if (target) emitPathRef(ctx, scopeId, target, 'references', aliasMap);
+ }
+}
+
+function hasEnumBody(node: SyntaxNode): boolean {
+ return node.namedChildren.some((c) => c.type === 'element_enum_definition');
+}
+
+function visitDefinition(node: SyntaxNode, ctx: ExtractorContext, opts: DefOptions): boolean {
+ switch (node.type) {
+ case 'cds':
+ resetFileState(node, ctx.source, ctx.filePath);
+ return false; // the root's children are dispatched by the core walker
+ case 'namespace':
+ return true; // the namespace node itself is created by extractFilePackage
+ case 'using':
+ handleUsing(node, ctx);
+ return false; // extractImport (core) creates the import node and its ref
+ case 'entity_definition':
+ case 'view_definition':
+ return handleArtifact(node, ctx, opts, 'class');
+ case 'service_definition':
+ return handleArtifact(node, ctx, opts, 'module');
+ case 'context_definition':
+ return handleArtifact(node, ctx, opts, 'namespace');
+ case 'aspect_definition':
+ return handleArtifact(node, ctx, opts, 'interface');
+ case 'event_definition':
+ return handleArtifact(node, ctx, opts, 'struct');
+ case 'annotation_definition':
+ return handleArtifact(node, ctx, opts, 'type_alias', ['annotation']);
+ case 'type_definition':
+ return handleArtifact(node, ctx, opts, hasEnumBody(node) ? 'enum' : 'type_alias');
+ case 'action_definition':
+ case 'function_definition':
+ // `... } actions { action cancel(); }` binds the operation to the entity.
+ return handleAction(node, ctx, opts, node.parent?.type === 'bound_actions' ? 'method' : 'function');
+ case 'element_definition':
+ return handleElement(node, ctx, opts);
+ case 'bound_actions':
+ visitMembers(node, ctx, opts);
+ return true;
+ case 'extend_artifact':
+ case 'extend_structure':
+ case 'extend_projection':
+ case 'extend_service':
+ case 'extend_context':
+ return handleExtend(node, ctx, opts);
+ case 'annotate_artifact':
+ return handleAnnotate(node, ctx);
+ case 'annotation':
+ case 'comment':
+ return true; // consumed by decoratorsOf / docstringOf on the definition
+ default:
+ return false;
+ }
+}
+
+export const cdsExtractor: LanguageExtractor = {
+ // Every symbol-bearing node is dispatched through visitNode; the type lists
+ // record the same mapping for the core (and for tooling that reads them).
+ functionTypes: ['action_definition', 'function_definition'],
+ classTypes: ['entity_definition', 'view_definition'],
+ methodTypes: [],
+ interfaceTypes: ['aspect_definition'],
+ structTypes: ['event_definition'],
+ enumTypes: [],
+ enumMemberTypes: ['enum_symbol_definition'],
+ typeAliasTypes: ['type_definition', 'annotation_definition'],
+ importTypes: ['using'],
+ callTypes: [], // CDS is declarative: a model has no call sites
+ variableTypes: [],
+ fieldTypes: ['element_definition'],
+ nameField: 'name',
+ bodyField: 'element_definitions',
+ paramsField: 'parameter_list',
+ returnField: 'return_type',
+
+ // `namespace a.b.c;` wraps the file's definitions in one namespace node named
+ // `a.b.c`, so an entity's qualifiedName is `a.b.c::Books`, the same spelling
+ // cdsReferenceName produces for `a.b.c.Books`, which is what makes
+ // cross-file resolution a plain qualified-name match.
+ packageTypes: ['namespace'],
+ extractPackage: (node, source) => {
+ const path = getChildByField(node, 'path');
+ return path ? dottedPath(path, source) || null : null;
+ },
+
+ extractImport: (node, source) => {
+ const file = getChildByField(node, 'file');
+ if (!file) return null;
+ const moduleName = stringLiteral(getNodeText(file, source));
+ if (!moduleName) return null;
+ return { moduleName, signature: collapseWs(getNodeText(node, source)).slice(0, 200) };
+ },
+
+ visitNode: (node, ctx) => visitDefinition(node, ctx, {}),
+};
diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts
index 6b760b01d..cdffbb37f 100644
--- a/src/extraction/languages/index.ts
+++ b/src/extraction/languages/index.ts
@@ -36,6 +36,7 @@ import { solidityExtractor } from './solidity';
import { terraformExtractor } from './terraform';
import { arktsExtractor } from './arkts';
import { nixExtractor } from './nix';
+import { cdsExtractor } from './cds';
export const EXTRACTORS: Partial> = {
typescript: typescriptExtractor,
@@ -69,4 +70,5 @@ export const EXTRACTORS: Partial> = {
terraform: terraformExtractor,
arkts: arktsExtractor,
nix: nixExtractor,
+ cds: cdsExtractor,
};
diff --git a/src/extraction/wasm/tree-sitter-cds.wasm b/src/extraction/wasm/tree-sitter-cds.wasm
new file mode 100644
index 000000000..dfd24f554
Binary files /dev/null and b/src/extraction/wasm/tree-sitter-cds.wasm differ
diff --git a/src/graph/named-symbol-flow.ts b/src/graph/named-symbol-flow.ts
index 3fc095c79..52ffa858e 100644
--- a/src/graph/named-symbol-flow.ts
+++ b/src/graph/named-symbol-flow.ts
@@ -108,6 +108,15 @@ export function matchesSymbol(node: Node, symbol: string): boolean {
// `Session::request` both become `Session::request` here.
const colonSuffix = parts.join('::');
if (node.qualifiedName.includes(colonSuffix)) return true;
+ // A CDS namespace is ONE dotted qualifiedName segment
+ // (`sap.capire.bookshop::Books`), so the fully qualified name a model writes
+ // (`sap.capire.bookshop.Books`) splits into four parts whose `::` join is
+ // `sap::capire::bookshop::Books` and misses. Re-join everything before the
+ // last part with dots and try that spelling too.
+ if (parts.length >= 3) {
+ const dottedContainer = `${parts.slice(0, -1).join('.')}::${lastPart}`;
+ if (node.qualifiedName.includes(dottedContainer)) return true;
+ }
// Stage 2: file-path containment. Rust modules and Python packages
// are not in `qualifiedName` — they're encoded in the file path. So
diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts
index 58d957475..435669206 100644
--- a/src/resolution/import-resolver.ts
+++ b/src/resolution/import-resolver.ts
@@ -47,8 +47,23 @@ const EXTENSION_RESOLUTION: Record = {
ruby: ['.rb'],
objc: ['.h', '.m', '.mm'],
nix: ['.nix', '/default.nix'],
+ // CDS `using ... from './db/schema'` names a model file WITHOUT an
+ // extension, and a directory specifier (`./common`) means that
+ // directory's `index.cds`. Pre-compiled `.csn`/`.json` models are not
+ // indexed as source, so a specifier naming one stays unresolved.
+ cds: ['.cds', '/index.cds'],
};
+/**
+ * A CDS `using ... from ''` reference. The specifier names a model FILE
+ * (relative path) or a reuse package (`@sap/cds/common`), never a symbol, so
+ * these resolve to a file node or to nothing: falling through to the symbol
+ * name-matcher would let `./common` land on any same-named node in the tree.
+ */
+export function isCdsUsingRef(ref: UnresolvedRef): boolean {
+ return ref.language === 'cds' && ref.referenceKind === 'imports';
+}
+
export function isNixPathImportRef(ref: UnresolvedRef): boolean {
return (
ref.language === 'nix' &&
@@ -343,6 +358,14 @@ function isExternalImport(
return false;
}
+ // CDS: `using ... from` takes either a relative path (handled above) or a
+ // node module specifier (`@sap/cds/common`, a reuse package). Anything not
+ // relative is therefore outside the project. Ordered after the workspace
+ // check so a monorepo member (`@capire/bookshop`) stays local.
+ if (language === 'cds') {
+ return true;
+ }
+
// Common external patterns
if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx' || language === 'arkts') {
// Node built-ins
@@ -1431,6 +1454,31 @@ export function resolveViaImport(
return null;
}
+ // CDS `using ... from ''` resolves to the imported MODEL FILE, mirroring
+ // the C/C++ include branch above: a file-to-file edge, no symbol lookup. The
+ // specifier is extensionless by convention (`../db/schema` -> `db/schema.cds`,
+ // `./common` -> `common/index.cds`), so it only ever resolves through
+ // EXTENSION_RESOLUTION. A reuse package (`@sap/cds/common`) resolves to
+ // nothing and stays unresolved rather than falling through to the symbol
+ // name-matcher, which would connect it to an unrelated same-named node.
+ if (isCdsUsingRef(ref)) {
+ const resolvedPath = resolveImportPath(ref.referenceName, ref.filePath, ref.language, context);
+ if (!resolvedPath) return null;
+ const basename = resolvedPath.split('/').pop()!;
+ const fileNode = context
+ .getNodesByName(basename)
+ .find((n) => n.kind === 'file' && n.filePath === resolvedPath);
+ if (fileNode) {
+ return {
+ original: ref,
+ targetNodeId: fileNode.id,
+ confidence: 0.9,
+ resolvedBy: 'import',
+ };
+ }
+ return null;
+ }
+
// Nix static project-path imports (`import ./x.nix`, `builtins.import ./dir`,
// `import ./x.nix {}`) resolve to file nodes only. Do not resolve
// angle-bracket channels, attribute expressions, variables, or other dynamic
diff --git a/src/resolution/index.ts b/src/resolution/index.ts
index 7988c1bcf..acd6f9339 100644
--- a/src/resolution/index.ts
+++ b/src/resolution/index.ts
@@ -17,7 +17,7 @@ import {
ImportMapping,
} from './types';
import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher';
-import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, clearImportResolverMemos } from './import-resolver';
+import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isCdsUsingRef, clearImportResolverMemos } from './import-resolver';
import { ResolverPool, minRefsForPool } from './resolver-pool';
import { detectFrameworks } from './frameworks';
import { synthesizeCallbackEdges } from './callback-synthesizer';
@@ -886,6 +886,9 @@ export class ReferenceResolver {
// indexed under the bare name, so the existence check strips the dot.
// Nix static path imports (`import ./x.nix`) name a FILE, not a symbol —
// they bypass the symbol-existence check and resolve via resolveViaImport.
+ // CDS `using ... from './db/schema'` names a file the same way; its
+ // extensionless specifier matches no indexed symbol name, so without the
+ // bypass the ref dies here and no model file ever gets its import edge.
let existenceName =
ref.language === 'arkts' && ref.referenceName.startsWith('.')
? ref.referenceName.slice(1)
@@ -893,9 +896,15 @@ export class ReferenceResolver {
// Erlang refs carry the call-site arity (`f/1`, `mod::f/2` — #1610); the
// name index stores bare names, so existence is checked arity-less.
if (ref.language === 'erlang') existenceName = existenceName.replace(/\/\d{1,3}$/, '');
+ // A CDS ref is `A.B::C`, and its target is indexed either under the leaf
+ // (`C`, an artifact) or under the whole dotted path (`A.B.C`, a namespace,
+ // which is ONE node name). The dotted spelling covers both: the leaf check
+ // inside hasAnyPossibleMatch still finds `C`.
+ if (ref.language === 'cds') existenceName = existenceName.replace(/::/g, '.');
const tPre = this.profileStages ? process.hrtime.bigint() : 0n;
const preFilterPass =
isNixPathImportRef(ref) ||
+ isCdsUsingRef(ref) ||
this.hasAnyPossibleMatch(existenceName) ||
this.matchesAnyImport(ref) ||
this.frameworks.some((f) => f.claimsReference?.(ref.referenceName));
@@ -992,7 +1001,9 @@ export class ReferenceResolver {
// qualified-name fallback would only ever add wrong cross-module edges.
// Nix static path imports are file references for the same reason —
// falling through would let "./x.nix" name-match an unrelated node.
- if (isPhpIncludePathRef(ref) || isCobolCopybookRef(ref) || isNixPathImportRef(ref) || ref.language === 'terraform') {
+ // A CDS `using ... from` specifier is a model file path (or a reuse
+ // package that is out of repo by definition), so the same rule applies.
+ if (isPhpIncludePathRef(ref) || isCobolCopybookRef(ref) || isNixPathImportRef(ref) || isCdsUsingRef(ref) || ref.language === 'terraform') {
return candidates.length > 0
? candidates.reduce((best, curr) =>
curr.confidence > best.confidence ? curr : best
@@ -1022,6 +1033,13 @@ export class ReferenceResolver {
// linkable symbol) — without this, a Python script's `split()` lands
// on some module's `split = ...` binding as a low-confidence match.
nameResult = null;
+ } else if (target && target.language === 'cds' && ref.language !== 'cds') {
+ // A CDS artifact is a model definition, never a symbol another
+ // language links to by name: a CAP handler reaches its action through
+ // `srv.on('submitOrder', ...)`, a string, and a TS/JS call spelled
+ // like one is some other function. Without this a stray
+ // `submitOrder(x)` call lands on the CDS action at confidence 0.5.
+ nameResult = null;
}
}
if (nameResult) {
diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts
index c74d8f272..639c21d09 100644
--- a/src/resolution/name-matcher.ts
+++ b/src/resolution/name-matcher.ts
@@ -2480,6 +2480,153 @@ export function dumpNameMatcherProfile(label: string): void {
}
}
+/**
+ * Node kinds a CDS type reference can name: entities/views (`class`), events
+ * (`struct`), aspects (`interface`), types and annotation declarations
+ * (`type_alias`), enums, services (`module`), namespaces/contexts
+ * (`namespace`) and actions/functions (`function`, `method`), which
+ * `annotate srv.criticalAction with ...` and `extend` target as readily as an
+ * entity. Elements (`field`) are never the target of a CDS reference name, so
+ * leaving them out keeps `Books` off a same-named column.
+ */
+const CDS_DEFINITION_KINDS = new Set([
+ 'class',
+ 'struct',
+ 'interface',
+ 'type_alias',
+ 'enum',
+ 'module',
+ 'namespace',
+ 'function',
+ 'method',
+]);
+
+/**
+ * Reference kinds the CDS branch owns: exactly the two the extractor emits.
+ * CDS is declarative, so there are no calls, and a `using ... from` specifier
+ * is a file path the import resolver handles rather than a name.
+ *
+ * `implements` is deliberately absent. No CDS reference is ever emitted with
+ * that kind; an `implements` EDGE on a CDS node appears only later, when
+ * createEdges promotes a RESOLVED `extends` whose target turned out to be an
+ * aspect (an `interface` node), long after the matcher has run.
+ */
+const CDS_REFERENCE_KINDS = new Set(['references', 'extends']);
+
+/**
+ * The CDS fully qualified name spelling of a `::`-joined qualifiedName or
+ * reference name: `sap.capire.bookshop::CatalogService::Books` becomes
+ * `sap.capire.bookshop.CatalogService.Books`, which is exactly what a CDS
+ * model writes. Comparison happens in this one normalized form so a namespace
+ * (one dotted segment) and a nested scope (a `::` step) are interchangeable.
+ */
+function cdsDotted(name: string): string {
+ return name.replace(/::/g, '.');
+}
+
+/**
+ * Resolve a CDS type reference (association/composition target, include,
+ * projection source, element type, extend/annotate target, `using` artifact).
+ *
+ * CDS name resolution is lexical: a name is looked up in the enclosing scope
+ * first (a service's own projection shadows a same-named db entity), then
+ * outward to the file's top level, where it must be a fully qualified name.
+ * The extractor already expands `using` aliases, so the reference name is
+ * either bare (`Authors`) or the FQN with its last segment split off
+ * (`sap.capire.bookshop::Books`).
+ *
+ * Returns definitively: a CDS ref that finds no scoped or unique target stays
+ * unresolved instead of falling through to the fuzzy strategies. Entity names
+ * are short and ordinary (`Books`, `Currency`, `Status`), so a partial or
+ * lowercase match would link half a CAP model to the wrong artifact.
+ */
+function matchCdsReference(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+ const sep = ref.referenceName.lastIndexOf('::');
+ const lastSegment = sep >= 0 ? ref.referenceName.slice(sep + 2) : ref.referenceName;
+ const refDotted = cdsDotted(ref.referenceName);
+
+ // A namespace is ONE dotted node name (`sap.capire.bookshop`), so a reference
+ // to a namespace splits in the wrong place: `using { sap.capire.bookshop as
+ // my }` reaches here as `sap.capire::bookshop`, whose last segment names
+ // nothing. Looking the FULL dotted name up as well is what lets a namespace
+ // be a candidate; for every other reference the two lookups coincide or the
+ // second returns nothing.
+ const byLastSegment = context.getNodesByName(lastSegment);
+ const byFullDotted = refDotted === lastSegment ? [] : context.getNodesByName(refDotted);
+ const seen = new Set();
+ const candidates = [...byLastSegment, ...byFullDotted].filter((n) => {
+ if (seen.has(n.id)) return false;
+ seen.add(n.id);
+ return n.language === 'cds' && CDS_DEFINITION_KINDS.has(n.kind) && n.id !== ref.fromNodeId;
+ });
+ if (candidates.length === 0) return null;
+
+ // The caller's scope chain, innermost first: its own qualifiedName, then one
+ // enclosing scope at a time, ending at the file's top level (empty prefix,
+ // where the reference must be an FQN). `entity Books as projection on Books`
+ // inside a service means the db entity, never itself, which is why the
+ // candidate filter above drops the FROM node.
+ const callerQn = context.getNodeById?.(ref.fromNodeId)?.qualifiedName;
+ const scopes: string[] = [];
+ for (let scope = callerQn ?? ''; scope; ) {
+ scopes.push(scope);
+ const cut = scope.lastIndexOf('::');
+ scope = cut > 0 ? scope.slice(0, cut) : '';
+ }
+ scopes.push('');
+
+ for (const scope of scopes) {
+ const want = scope ? `${cdsDotted(scope)}.${refDotted}` : refDotted;
+ const hits = candidates.filter((n) => cdsDotted(n.qualifiedName) === want);
+ if (hits.length === 0) continue;
+ if (hits.length === 1) {
+ return {
+ original: ref,
+ targetNodeId: hits[0]!.id,
+ confidence: 0.95,
+ resolvedBy: 'qualified-name',
+ };
+ }
+ // One FQN, several definitions (the same model compiled into two files, or
+ // a duplicated namespace): the reference's own file is the only signal.
+ const preferred = preferCallSiteFile(hits, ref.filePath);
+ if (preferred[0]!.filePath === ref.filePath) {
+ return {
+ original: ref,
+ targetNodeId: preferred[0]!.id,
+ confidence: 0.95,
+ resolvedBy: 'qualified-name',
+ };
+ }
+ return null;
+ }
+
+ // A dotted reference names an exact FQN. Missing it means the artifact is
+ // out of repo (a reuse model such as `sap.common.CodeList`) or the namespace
+ // differs, so a bare-name fallback would be a guess.
+ if (ref.referenceName.includes('::')) return null;
+
+ // Bare name with no scope hit: resolve only when the project leaves no doubt.
+ if (candidates.length === 1) {
+ return {
+ original: ref,
+ targetNodeId: candidates[0]!.id,
+ confidence: 0.8,
+ resolvedBy: 'exact-match',
+ };
+ }
+ const sameFile = candidates.filter((n) => n.filePath === ref.filePath);
+ if (sameFile.length === 1) {
+ return {
+ original: ref,
+ targetNodeId: sameFile[0]!.id,
+ confidence: 0.85,
+ resolvedBy: 'exact-match',
+ };
+ }
+ return null;
+}
+
export function matchReference(
ref: UnresolvedRef,
context: ResolutionContext
@@ -2491,6 +2638,14 @@ export function matchReference(
return matchFunctionRef(ref, context);
}
+ // CDS type references resolve through the CDS scope chain ONLY (see
+ // matchCdsReference). The branch is terminal in both directions: it never
+ // falls through to the fuzzy strategies, and it only ever returns a CDS
+ // node, so a `Books` entity cannot land on a same-named TypeScript class.
+ if (ref.language === 'cds' && CDS_REFERENCE_KINDS.has(ref.referenceKind)) {
+ return matchCdsReference(ref, context);
+ }
+
// ArkTS chained UI attributes — emitted with a leading dot (`.titleStyle`,
// `.width`) by the extractor — resolve ONLY to decorator-marked attribute
// helpers: `@Extend`/`@Styles`/`@AnimatableExtend` functions (and global
diff --git a/src/types.ts b/src/types.ts
index 44ffaf4e4..a58f5e77b 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -118,6 +118,7 @@ export const LANGUAGES = [
'vbnet',
'erlang',
'terraform',
+ 'cds',
'unknown',
] as const;