@cbs-consulting/cds-linter 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.commitlintrc ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": [
3
+ "@commitlint/config-conventional"
4
+ ]
5
+ }
package/.czrc ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "path": "cz-conventional-changelog"
3
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "cds-linter",
3
+ "image": "mcr.microsoft.com/devcontainers/base:trixie",
4
+ "features": {
5
+ "ghcr.io/devcontainers/features/node:1": {}
6
+ }
7
+ }
package/.gitattributes ADDED
@@ -0,0 +1,3 @@
1
+ * text=auto eol=lf
2
+ *.{cmd,[cC][mM][dD]} text eol=crlf
3
+ *.{bat,[bB][aA][tT]} text eol=crlf
package/.releaserc ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "branches": [
3
+ "+([0-9])?(.{+([0-9]),x}).x",
4
+ "main",
5
+ "next",
6
+ "next-major",
7
+ {
8
+ "name": "beta",
9
+ "prerelease": true
10
+ },
11
+ {
12
+ "name": "alpha",
13
+ "prerelease": true
14
+ }
15
+ ],
16
+ "plugins": [
17
+ "@semantic-release/commit-analyzer",
18
+ "@semantic-release/release-notes-generator",
19
+ [
20
+ "@semantic-release/changelog",
21
+ {
22
+ "changelogFile": "docs/CHANGELOG.md"
23
+ }
24
+ ],
25
+ "@semantic-release/npm",
26
+ [
27
+ "@semantic-release/git",
28
+ {
29
+ "message": "chore(release): ${nextRelease.version} - <%= new Date().toLocaleDateString('de-DE', {year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', timeZone: 'Europe/Berlin' }) %>",
30
+ "assets": [
31
+ "docs/CHANGELOG.md",
32
+ "package.json"
33
+ ]
34
+ }
35
+ ]
36
+ ]
37
+ }
package/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # CDS Linter
2
+
3
+ A lightweight TypeScript-based linter for CDS files.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @cbs-consulting/cds-linter
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Command Line
14
+
15
+ ```bash
16
+ # Lint current directory
17
+ cds-lint
18
+
19
+ # Lint specific files or directories
20
+ cds-lint examples/
21
+ cds-lint src/models/views.cds
22
+
23
+ # JSON output
24
+ cds-lint examples/ --format json
25
+ ```
26
+
27
+ ### Programmatic API
28
+
29
+ ```typescript
30
+ import CDSLinter from '@cbs-consulting/cds-linter';
31
+
32
+ const linter = new CDSLinter();
33
+ const results = await linter.lintDirectory('./db');
34
+
35
+ console.log(`Found ${results.errorCount} errors`);
36
+ ```
37
+
38
+ ## The Rule
39
+
40
+ ### `group-by-matches-keys` (error)
41
+
42
+ Validates that GROUP BY clauses in CDS views match the key structure:
43
+
44
+ 1. **All key columns must be in GROUP BY** - Every column marked as `key` in the SELECT must appear in the GROUP BY clause
45
+ 2. **Only key columns in GROUP BY** - No non-key columns should appear in the GROUP BY clause
46
+
47
+ **Why?** This ensures that aggregated views have a proper, consistent key structure that matches the grouping logic. It prevents common mistakes where:
48
+ - Keys are incomplete, leading to ambiguous aggregation results
49
+ - Non-key fields are grouped, which should instead be aggregated
50
+
51
+ ### Examples
52
+
53
+ ✅ **Good:**
54
+ ```cds
55
+ entity OrderSummary as
56
+ select from Orders as o {
57
+ key o.ID,
58
+ key o.OrderNo,
59
+ sum(o.price) as totalPrice
60
+ }
61
+ group by
62
+ o.ID,
63
+ o.OrderNo; // All keys present, no non-keys
64
+ ```
65
+
66
+ ❌ **Bad:**
67
+ ```cds
68
+ entity OrderSummary as
69
+ select from Orders as o {
70
+ key o.ID,
71
+ key o.OrderNo,
72
+ sum(o.price) as totalPrice
73
+ }
74
+ group by
75
+ o.ID; // ERROR: Missing key o.OrderNo
76
+ ```
77
+
78
+ ❌ **Bad:**
79
+ ```cds
80
+ entity OrderSummary as
81
+ select from Orders as o {
82
+ key o.ID,
83
+ o.quantity,
84
+ sum(o.price) as totalPrice
85
+ }
86
+ group by
87
+ o.ID,
88
+ o.quantity; // ERROR: quantity is not a key
89
+ ```
90
+
91
+ ## Development
92
+
93
+ ### Building
94
+
95
+ ```bash
96
+ npm run build # Compile TypeScript
97
+ npm run watch # Watch mode
98
+ ```
99
+
100
+ ### Testing
101
+
102
+ ```bash
103
+ npm test # Run tests
104
+ npm run test:watch # Watch mode
105
+ ```
106
+
107
+ ## Integration
108
+
109
+ ### npm scripts
110
+ ```json
111
+ {
112
+ "scripts": {
113
+ "lint:cds": "cds-lint db/",
114
+ "ci": "npm run lint:cds && npm test"
115
+ }
116
+ }
117
+ ```
118
+
119
+ ### CI/CD
120
+ ```yaml
121
+ - run: npm run lint:cds
122
+ ```
@@ -0,0 +1,39 @@
1
+ trigger:
2
+ - main
3
+
4
+ pool:
5
+ vmImage: "ubuntu-latest"
6
+
7
+ parameters:
8
+ - name: node_version
9
+ type: string
10
+ default: "22.x"
11
+
12
+ steps:
13
+ - task: UseNode@1
14
+ inputs:
15
+ version: ${{ parameters.node_version }}
16
+ displayName: "⚙️ Use node ${{ parameters.node_version }}"
17
+
18
+ - task: DownloadSecureFile@1
19
+ name: npmrc
20
+ inputs:
21
+ secureFile: ".npmrc"
22
+ displayName: "Download secure file"
23
+
24
+ - script: |
25
+ mv $(npmrc.secureFilePath) ./
26
+ displayName: "Move secure file"
27
+
28
+ - script: |
29
+ npm install
30
+ displayName: "Install dependencies..."
31
+
32
+ - script: |
33
+ AUTH=$(echo -n ":$SYSTEM_ACCESSTOKEN" | openssl base64 | tr -d '\n')
34
+ git config --global http."https://cbsCCCP@dev.azure.com".extraheader "AUTHORIZATION: basic $AUTH"
35
+ npx --no semantic-release
36
+ condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'))
37
+ displayName: "Run Release"
38
+ env:
39
+ SYSTEM_ACCESSTOKEN: $(System.AccessToken)
@@ -0,0 +1,14 @@
1
+ # 1.0.0 (2026-02-08)
2
+
3
+
4
+ ### Features
5
+
6
+ * add azure pipeline config ([bfac30b](https://dev.azure.com/cbsCCCP/CBS_SWE/_git/cds-linter/commit/bfac30bd5bd824cfb1f1d3d0cf9b653b88c0977d))
7
+ * add CDSLinter api ([e0e79a6](https://dev.azure.com/cbsCCCP/CBS_SWE/_git/cds-linter/commit/e0e79a684e84620edc2a35ab2ac0472dde75430a))
8
+ * add cli parser ([136faec](https://dev.azure.com/cbsCCCP/CBS_SWE/_git/cds-linter/commit/136faec4d913c15320e9f41c8eda165c1c8d1001))
9
+ * add devcontainer ([bdf04ea](https://dev.azure.com/cbsCCCP/CBS_SWE/_git/cds-linter/commit/bdf04ea572f83f0634ec617410cd3fa1ef178d34))
10
+ * add group by rule ([149cbff](https://dev.azure.com/cbsCCCP/CBS_SWE/_git/cds-linter/commit/149cbffd6d936f4a9ae2f7ee67b00ddff7522148))
11
+ * add reporter class ([23a1bfe](https://dev.azure.com/cbsCCCP/CBS_SWE/_git/cds-linter/commit/23a1bfe695a3cb6b6f92291d6366ac53fd627095))
12
+ * add test cases ([e634c0d](https://dev.azure.com/cbsCCCP/CBS_SWE/_git/cds-linter/commit/e634c0d6cab1afaa22369aa922a32b3133831899))
13
+ * cds parser ([6cf862e](https://dev.azure.com/cbsCCCP/CBS_SWE/_git/cds-linter/commit/6cf862e7de47e257aea2b94041a2b241509443c2))
14
+ * commit tools ([8f105c3](https://dev.azure.com/cbsCCCP/CBS_SWE/_git/cds-linter/commit/8f105c3e0c03f91b4fe74c2f116d6e6f1ed7fac0))
@@ -0,0 +1,69 @@
1
+ namespace example;
2
+
3
+ // Base entity for examples
4
+ entity Orders {
5
+ key ID : UUID;
6
+ key OrderNo : String(10);
7
+ customerID : UUID;
8
+ productID : UUID;
9
+ quantity : Integer;
10
+ price : Decimal(10, 2);
11
+ orderDate : Date;
12
+ }
13
+
14
+ /**
15
+ * BAD EXAMPLE 1: Missing key in GROUP BY
16
+ * ERROR: Key 'o.OrderNo' is selected but not in GROUP BY
17
+ */
18
+ entity BadExample1 as
19
+ select from Orders as o {
20
+ key o.ID,
21
+ key o.OrderNo,
22
+ sum(o.price) as totalPrice
23
+ }
24
+ group by
25
+ o.ID;
26
+ // Missing: o.OrderNo
27
+
28
+ /**
29
+ * BAD EXAMPLE 2: Non-key column in GROUP BY
30
+ * ERROR: quantity is not a key but is in GROUP BY
31
+ */
32
+ entity BadExample2 as
33
+ select from Orders as o {
34
+ key o.ID,
35
+ o.quantity,
36
+ sum(o.price) as totalPrice
37
+ }
38
+ group by
39
+ o.ID,
40
+ o.quantity; // BAD: quantity should not be in GROUP BY
41
+
42
+ /**
43
+ * BAD EXAMPLE 3: Extra non-key field in GROUP BY
44
+ * ERROR: customerID is not a key but is in GROUP BY
45
+ */
46
+ entity BadExample3 as
47
+ select from Orders as o {
48
+ key o.productID,
49
+ count(*) as orderCount
50
+ }
51
+ group by
52
+ o.productID,
53
+ o.customerID; // BAD: customerID is not a key
54
+
55
+ /**
56
+ * BAD EXAMPLE 4: Multiple violations
57
+ * ERROR: Missing key o.OrderNo in GROUP BY
58
+ * ERROR: Non-key o.customerID in GROUP BY
59
+ */
60
+ entity BadExample4 as
61
+ select from Orders as o {
62
+ key o.ID,
63
+ key o.OrderNo,
64
+ o.customerID,
65
+ sum(o.price) as totalPrice
66
+ }
67
+ group by
68
+ o.ID,
69
+ o.customerID; // BAD: Missing o.OrderNo, and customerID shouldn't be here
@@ -0,0 +1,55 @@
1
+ namespace example;
2
+
3
+ // Base entity for examples
4
+ entity Orders {
5
+ key ID : UUID;
6
+ keyOrderNo : String(10);
7
+ customerID : UUID;
8
+ productID : UUID;
9
+ quantity : Integer;
10
+ price : Decimal(10, 2);
11
+ orderDate : Date;
12
+ }
13
+
14
+ /**
15
+ * GOOD EXAMPLE: Keys match GROUP BY exactly
16
+ * All key columns (ID, OrderNo) are in GROUP BY
17
+ * No non-key columns are in GROUP BY
18
+ */
19
+ entity OrderSummary as
20
+ select from Orders as o {
21
+ key o.ID,
22
+ key o.OrderNo,
23
+ min(o.quantity) as minQuantity,
24
+ sum(o.price) as totalPrice
25
+ }
26
+ group by
27
+ o.ID,
28
+ o.OrderNo;
29
+
30
+ /**
31
+ * GOOD EXAMPLE: Single key matches GROUP BY
32
+ */
33
+ entity CustomerOrders as
34
+ select from Orders as o {
35
+ key o.customerID,
36
+ count(*) as orderCount,
37
+ sum(o.price) as totalSpent
38
+ }
39
+ group by
40
+ o.customerID;
41
+
42
+ /**
43
+ * GOOD EXAMPLE: Composite key with aggregations
44
+ */
45
+ entity ProductDailySales as
46
+ select from Orders as o {
47
+ key o.productID,
48
+ key o.orderDate,
49
+ sum(o.quantity) as totalQuantity,
50
+ avg(o.price) as avgPrice,
51
+ max(o.price) as maxPrice
52
+ }
53
+ group by
54
+ o.productID,
55
+ o.orderDate;
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@cbs-consulting/cds-linter",
3
+ "version": "1.0.0",
4
+ "description": "Lightweight CDS file linter with GROUP BY validation.",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "cds-lint": "dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "watch": "tsc --watch",
12
+ "start": "npm run build && node dist/cli.js",
13
+ "dev": "ts-node src/cli.ts",
14
+ "test": "vitest run",
15
+ "test:watch": "vitest",
16
+ "clean": "rm -rf dist",
17
+ "commit": "git add . && npx cz"
18
+ },
19
+ "dependencies": {
20
+ "@sap/cds": "^7.5.0",
21
+ "chalk": "^4.1.2",
22
+ "commander": "^11.1.0",
23
+ "glob": "^10.3.10"
24
+ },
25
+ "devDependencies": {
26
+ "@commitlint/cli": "20.4.1",
27
+ "@commitlint/config-conventional": "20.4.1",
28
+ "@semantic-release/changelog": "6.0.3",
29
+ "@semantic-release/git": "10.0.1",
30
+ "commitizen": "4.3.1",
31
+ "cz-conventional-changelog": "3.3.0",
32
+ "semantic-release": "25.0.3",
33
+ "@types/node": "^20.10.5",
34
+ "ts-node": "^10.9.2",
35
+ "typescript": "^5.3.3",
36
+ "vitest": "^2.1.8"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "engines": {
42
+ "node": ">=20.0.0"
43
+ }
44
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import CDSLinter from '../index';
3
+ import * as path from 'path';
4
+
5
+ describe('CDSLinter', () => {
6
+ let linter: CDSLinter;
7
+
8
+ beforeEach(() => {
9
+ linter = new CDSLinter();
10
+ });
11
+
12
+ describe('Basic functionality', () => {
13
+ it('should create a linter instance', () => {
14
+ expect(linter).toBeInstanceOf(CDSLinter);
15
+ });
16
+
17
+ it('should lint a CDS file and return results', async () => {
18
+ const testFile = path.join(__dirname, '../../examples/good-example.cds');
19
+ const results = await linter.lintFiles([testFile]);
20
+
21
+ expect(results).toHaveProperty('files');
22
+ expect(results).toHaveProperty('errorCount');
23
+ expect(results).toHaveProperty('warningCount');
24
+ expect(Array.isArray(results.files)).toBe(true);
25
+ });
26
+
27
+ it('should pass validation for correct GROUP BY usage', async () => {
28
+ const testFile = path.join(__dirname, '../../examples/good-example.cds');
29
+ const results = await linter.lintFiles([testFile]);
30
+
31
+ // Good examples should have no errors
32
+ expect(results.errorCount).toBe(0);
33
+ });
34
+
35
+ it('should detect GROUP BY violations in bad examples', async () => {
36
+ const testFile = path.join(__dirname, '../../examples/bad-example.cds');
37
+ const results = await linter.lintFiles([testFile]);
38
+
39
+ // Should have errors for GROUP BY not matching keys
40
+ expect(results.errorCount).toBeGreaterThan(0);
41
+
42
+ // Check that the rule ID is correct
43
+ const hasGroupByError = results.files.some(file =>
44
+ file.messages.some(msg => msg.ruleId === 'group-by-matches-keys')
45
+ );
46
+ expect(hasGroupByError).toBe(true);
47
+ });
48
+
49
+ it('should return null for non-CDS files', async () => {
50
+ const results = await linter.lintFiles(['test.js']);
51
+ expect(results.files.length).toBe(0);
52
+ });
53
+ });
54
+
55
+ describe('Directory linting', () => {
56
+ it('should lint all CDS files in a directory', async () => {
57
+ const examplesDir = path.join(__dirname, '../../examples');
58
+ const results = await linter.lintDirectory(examplesDir);
59
+
60
+ // Should find both example files
61
+ expect(results.files.length).toBeGreaterThan(0);
62
+ });
63
+ });
64
+
65
+ describe('Configuration', () => {
66
+ it('should create a linter instance with default configuration', () => {
67
+ const customLinter = new CDSLinter();
68
+ expect(customLinter).toBeInstanceOf(CDSLinter);
69
+ });
70
+ });
71
+ });
@@ -0,0 +1,110 @@
1
+ import cds from '@sap/cds';
2
+ import * as fs from 'fs';
3
+ import { CSN, LintResult, Rule, Definition } from './types/cds';
4
+
5
+ /**
6
+ * CDS file parser and rule executor
7
+ */
8
+ export default class CDSParser {
9
+ constructor(private rules: Rule[]) {}
10
+
11
+ /**
12
+ * Lint a CDS file
13
+ * @param filePath - Path to CDS file
14
+ * @returns Lint results for the file
15
+ */
16
+ async lintFile(filePath: string): Promise<LintResult | null> {
17
+ if (!filePath.endsWith('.cds')) {
18
+ return null;
19
+ }
20
+
21
+ if (!fs.existsSync(filePath)) {
22
+ throw new Error(`File not found: ${filePath}`);
23
+ }
24
+
25
+ const content = fs.readFileSync(filePath, 'utf-8');
26
+ const results: LintResult = {
27
+ filePath,
28
+ messages: [],
29
+ errorCount: 0,
30
+ warningCount: 0,
31
+ fixableErrorCount: 0,
32
+ fixableWarningCount: 0
33
+ };
34
+
35
+ try {
36
+ // Parse CDS file using @sap/cds compiler
37
+ const csn = await cds.compile.to.csn(content, {
38
+ flavor: 'parsed',
39
+ docs: true
40
+ }) as CSN;
41
+
42
+ // Apply rules to parsed CSN
43
+ for (const rule of this.rules) {
44
+ if (rule.enabled !== false) {
45
+ const violations = await rule.check(csn, content, filePath);
46
+ if (violations && violations.length > 0) {
47
+ results.messages.push(...violations);
48
+
49
+ for (const violation of violations) {
50
+ if (violation.severity === 'error') {
51
+ results.errorCount++;
52
+ if (violation.fixable) results.fixableErrorCount++;
53
+ } else if (violation.severity === 'warning') {
54
+ results.warningCount++;
55
+ if (violation.fixable) results.fixableWarningCount++;
56
+ }
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ } catch (error: any) {
63
+ // Report parsing errors
64
+ results.messages.push({
65
+ ruleId: 'parse-error',
66
+ severity: 'error',
67
+ message: error.message,
68
+ line: error.line || 1,
69
+ column: error.column || 1
70
+ });
71
+ results.errorCount++;
72
+ }
73
+
74
+ return results;
75
+ }
76
+
77
+ /**
78
+ * Extract entities from CSN
79
+ * @param csn - Compiled CSN
80
+ * @returns List of entities
81
+ */
82
+ getEntities(csn: CSN): Array<{ name: string; definition: Definition }> {
83
+ const entities: Array<{ name: string; definition: Definition }> = [];
84
+ if (csn.definitions) {
85
+ for (const [name, definition] of Object.entries(csn.definitions)) {
86
+ if (definition.kind === 'entity' || definition.kind === 'type') {
87
+ entities.push({ name, definition });
88
+ }
89
+ }
90
+ }
91
+ return entities;
92
+ }
93
+
94
+ /**
95
+ * Extract services from CSN
96
+ * @param csn - Compiled CSN
97
+ * @returns List of services
98
+ */
99
+ getServices(csn: CSN): Array<{ name: string; definition: Definition }> {
100
+ const services: Array<{ name: string; definition: Definition }> = [];
101
+ if (csn.definitions) {
102
+ for (const [name, definition] of Object.entries(csn.definitions)) {
103
+ if (definition.kind === 'service') {
104
+ services.push({ name, definition });
105
+ }
106
+ }
107
+ }
108
+ return services;
109
+ }
110
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import CDSLinter from './index';
5
+ import Reporter from './utils/reporter';
6
+ import * as path from 'path';
7
+ import * as fs from 'fs';
8
+
9
+ const program = new Command();
10
+
11
+ program
12
+ .name('cds-lint')
13
+ .description('Linter for CDS files')
14
+ .version('0.1.0')
15
+ .argument('[paths...]', 'Files or directories to lint', ['.'])
16
+ .option('--format <type>', 'Output format (stylish or json)', 'stylish')
17
+ .action(async (paths: string[], options: any) => {
18
+ try {
19
+ // Initialize linter
20
+ const linter = new CDSLinter();
21
+
22
+ // Determine what to lint
23
+ const targets: string[] = [];
24
+ for (const targetPath of paths) {
25
+ const resolved = path.resolve(targetPath);
26
+ if (fs.existsSync(resolved)) {
27
+ const stat = fs.statSync(resolved);
28
+ if (stat.isDirectory()) {
29
+ targets.push(resolved);
30
+ } else if (stat.isFile()) {
31
+ targets.push(resolved);
32
+ }
33
+ } else {
34
+ console.error(`Path not found: ${targetPath}`);
35
+ }
36
+ }
37
+
38
+ // Lint files
39
+ let results;
40
+ if (targets.length === 1 && fs.statSync(targets[0]).isDirectory()) {
41
+ results = await linter.lintDirectory(targets[0]);
42
+ } else {
43
+ results = await linter.lintFiles(targets);
44
+ }
45
+
46
+ // Report results
47
+ const reporter = new Reporter(options.format);
48
+ reporter.report(results);
49
+
50
+ // Exit with appropriate code
51
+ process.exit(results.errorCount > 0 ? 1 : 0);
52
+
53
+ } catch (error: any) {
54
+ console.error('Error:', error.message);
55
+ process.exit(1);
56
+ }
57
+ });
58
+
59
+ program.parse();
package/src/index.ts ADDED
@@ -0,0 +1,59 @@
1
+ import CDSParser from './cds-linter';
2
+ import * as rules from './rules';
3
+ import { LintResults } from './types/cds';
4
+ import { glob } from 'glob';
5
+ import * as path from 'path';
6
+
7
+ /**
8
+ * CDS Linter - Main entry point
9
+ */
10
+ export default class CDSLinter {
11
+ private parser: CDSParser;
12
+
13
+ constructor() {
14
+ this.parser = new CDSParser(rules.getDefaultRules());
15
+ }
16
+
17
+ /**
18
+ * Lint files in given paths
19
+ * @param paths - File paths or glob patterns
20
+ * @returns Lint results
21
+ */
22
+ async lintFiles(paths: string[]): Promise<LintResults> {
23
+ const results: LintResults = {
24
+ files: [],
25
+ errorCount: 0,
26
+ warningCount: 0,
27
+ fixableErrorCount: 0,
28
+ fixableWarningCount: 0
29
+ };
30
+
31
+ // Lint CDS files
32
+ for (const filePath of paths) {
33
+ const fileResults = await this.parser.lintFile(filePath);
34
+ if (fileResults) {
35
+ results.files.push(fileResults);
36
+ results.errorCount += fileResults.errorCount;
37
+ results.warningCount += fileResults.warningCount;
38
+ results.fixableErrorCount += fileResults.fixableErrorCount;
39
+ results.fixableWarningCount += fileResults.fixableWarningCount;
40
+ }
41
+ }
42
+
43
+ return results;
44
+ }
45
+
46
+ /**
47
+ * Lint a directory
48
+ * @param directory - Directory path
49
+ * @returns Lint results
50
+ */
51
+ async lintDirectory(directory: string): Promise<LintResults> {
52
+ const pattern = path.join(directory, '**/*.cds');
53
+ const files = await glob(pattern);
54
+
55
+ return this.lintFiles(files);
56
+ }
57
+ }
58
+
59
+ module.exports = CDSLinter;
@@ -0,0 +1,114 @@
1
+ import { Rule, CSN, LintMessage } from '../types/cds';
2
+
3
+ /**
4
+ * Rule: Group By must match key properties exactly
5
+ * Ensures that in CDS views with GROUP BY:
6
+ * 1. All key properties are present in GROUP BY
7
+ * 2. Only key properties are present in GROUP BY (no non-key fields)
8
+ */
9
+ export const groupByMatchesKeys: Rule = {
10
+ name: 'group-by-matches-keys',
11
+ enabled: true,
12
+ severity: 'error',
13
+
14
+ async check(csn: CSN, content: string, filePath: string): Promise<LintMessage[]> {
15
+ const messages: LintMessage[] = [];
16
+
17
+ if (!csn.definitions) return messages;
18
+
19
+ for (const [entityName, definition] of Object.entries(csn.definitions)) {
20
+ // Check if this is a view/projection with a query
21
+ if (definition.kind === 'entity' && definition.query) {
22
+ const query = definition.query;
23
+
24
+ // Check if there's a GROUP BY clause
25
+ if (query.SELECT?.groupBy) {
26
+ const groupBy = query.SELECT.groupBy;
27
+ const columns = query.SELECT.columns || [];
28
+
29
+ // Extract key columns from the SELECT
30
+ const keyColumns = new Set<string>();
31
+ const nonKeyColumns = new Set<string>();
32
+
33
+ for (const col of columns) {
34
+ if (col.ref) {
35
+ const columnName = Array.isArray(col.ref) ? col.ref.join('.') : col.ref;
36
+ const columnAlias = col.as || columnName;
37
+
38
+ if (col.key === true) {
39
+ keyColumns.add(columnName);
40
+ } else if (col.key !== true && !isAggregateFunction(col)) {
41
+ nonKeyColumns.add(columnName);
42
+ }
43
+ }
44
+ }
45
+
46
+ // Extract GROUP BY references
47
+ const groupByRefs = new Set<string>();
48
+ for (const groupByItem of groupBy) {
49
+ if (groupByItem.ref) {
50
+ const refName = Array.isArray(groupByItem.ref) ? groupByItem.ref.join('.') : groupByItem.ref;
51
+ groupByRefs.add(refName);
52
+ }
53
+ }
54
+
55
+ // Check 1: All keys must be in GROUP BY
56
+ for (const keyCol of keyColumns) {
57
+ if (!groupByRefs.has(keyCol)) {
58
+ messages.push({
59
+ ruleId: 'group-by-matches-keys',
60
+ severity: 'error',
61
+ message: `Key column '${keyCol}' in entity '${entityName}' must be present in GROUP BY clause`,
62
+ line: 1,
63
+ column: 1,
64
+ fixable: false
65
+ });
66
+ }
67
+ }
68
+
69
+ // Check 2: GROUP BY should only contain key columns (no non-key columns)
70
+ for (const groupByRef of groupByRefs) {
71
+ if (!keyColumns.has(groupByRef) && nonKeyColumns.has(groupByRef)) {
72
+ messages.push({
73
+ ruleId: 'group-by-matches-keys',
74
+ severity: 'error',
75
+ message: `Non-key column '${groupByRef}' in entity '${entityName}' should not be in GROUP BY clause. Only key columns are allowed.`,
76
+ line: 1,
77
+ column: 1,
78
+ fixable: false
79
+ });
80
+ }
81
+ }
82
+
83
+ // Check 3: GROUP BY should not have columns that aren't selected as keys
84
+ for (const groupByRef of groupByRefs) {
85
+ if (!keyColumns.has(groupByRef) && !nonKeyColumns.has(groupByRef)) {
86
+ // This might be OK if it's just not selected, but warn anyway
87
+ messages.push({
88
+ ruleId: 'group-by-matches-keys',
89
+ severity: 'warning',
90
+ message: `Column '${groupByRef}' in GROUP BY of entity '${entityName}' is not defined as a key in the SELECT`,
91
+ line: 1,
92
+ column: 1,
93
+ fixable: false
94
+ });
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ return messages;
102
+ }
103
+ };
104
+
105
+ /**
106
+ * Helper to check if a column uses an aggregate function
107
+ */
108
+ function isAggregateFunction(col: any): boolean {
109
+ if (col.func) {
110
+ const funcName = col.func.toLowerCase();
111
+ return ['min', 'max', 'sum', 'avg', 'count'].includes(funcName);
112
+ }
113
+ return false;
114
+ }
@@ -0,0 +1,25 @@
1
+ import { Rule } from '../types/cds';
2
+ import * as cdsRules from './cds-rules';
3
+
4
+ /**
5
+ * Get default set of enabled rules
6
+ */
7
+ export function getDefaultRules(): Rule[] {
8
+ return [
9
+ cdsRules.groupByMatchesKeys
10
+ ];
11
+ }
12
+
13
+ /**
14
+ * Get all available rules
15
+ */
16
+ export function getAllRules(): Rule[] {
17
+ return getDefaultRules();
18
+ }
19
+
20
+ /**
21
+ * Get a rule by name
22
+ */
23
+ export function getRule(name: string): Rule | undefined {
24
+ return getAllRules().find(rule => rule.name === name);
25
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Type definitions for @sap/cds CSN structures
3
+ */
4
+
5
+ export interface CSN {
6
+ definitions?: Record<string, Definition>;
7
+ namespace?: string;
8
+ requires?: Record<string, any>;
9
+ extensions?: any[];
10
+ $version?: string;
11
+ }
12
+
13
+ export interface Definition {
14
+ kind?: 'entity' | 'type' | 'service' | 'context' | 'aspect' | 'annotation' | 'action' | 'function' | 'event';
15
+ elements?: Record<string, Element>;
16
+ includes?: string[];
17
+ actions?: Record<string, Action>;
18
+ operations?: Record<string, Operation>;
19
+ '@': Record<string, any>; // Annotations
20
+ [key: string]: any;
21
+ }
22
+
23
+ export interface Element {
24
+ type?: string;
25
+ target?: string;
26
+ key?: boolean;
27
+ notNull?: boolean;
28
+ default?: any;
29
+ virtual?: boolean;
30
+ localized?: boolean;
31
+ items?: Element;
32
+ '@': Record<string, any>;
33
+ [key: string]: any;
34
+ }
35
+
36
+ export interface Action {
37
+ kind: 'action' | 'function';
38
+ params?: Record<string, Element>;
39
+ returns?: Element;
40
+ '@': Record<string, any>;
41
+ }
42
+
43
+ export interface Operation extends Action {}
44
+
45
+ export interface LintMessage {
46
+ ruleId: string;
47
+ severity: 'error' | 'warning' | 'info';
48
+ message: string;
49
+ line: number;
50
+ column: number;
51
+ endLine?: number;
52
+ endColumn?: number;
53
+ fixable?: boolean;
54
+ fix?: () => string;
55
+ }
56
+
57
+ export interface LintResult {
58
+ filePath: string;
59
+ messages: LintMessage[];
60
+ errorCount: number;
61
+ warningCount: number;
62
+ fixableErrorCount: number;
63
+ fixableWarningCount: number;
64
+ }
65
+
66
+ export interface LintResults {
67
+ files: LintResult[];
68
+ errorCount: number;
69
+ warningCount: number;
70
+ fixableErrorCount: number;
71
+ fixableWarningCount: number;
72
+ }
73
+
74
+ export interface Rule {
75
+ name: string;
76
+ enabled: boolean;
77
+ severity: 'error' | 'warning' | 'info';
78
+ check: (csn: CSN, content: string, filePath: string) => Promise<LintMessage[]>;
79
+ }
@@ -0,0 +1,91 @@
1
+ import chalk from 'chalk';
2
+ import { LintResults, LintResult, LintMessage } from '../types/cds';
3
+
4
+ /**
5
+ * Reporter for displaying lint results
6
+ */
7
+ export default class Reporter {
8
+ constructor(private format: string = 'stylish') {}
9
+
10
+ /**
11
+ * Report lint results
12
+ */
13
+ report(results: LintResults): void {
14
+ if (this.format === 'json') {
15
+ this.reportJson(results);
16
+ } else {
17
+ this.reportStylish(results);
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Stylish format (default, colorful)
23
+ */
24
+ private reportStylish(results: LintResults): void {
25
+ if (results.files.length === 0) {
26
+ console.log(chalk.green('✓ No files to lint'));
27
+ return;
28
+ }
29
+
30
+ let hasProblems = false;
31
+
32
+ for (const file of results.files) {
33
+ if (file.messages.length > 0) {
34
+ hasProblems = true;
35
+ console.log('\n' + chalk.underline(file.filePath));
36
+
37
+ for (const message of file.messages) {
38
+ const severityColor = message.severity === 'error' ? chalk.red : chalk.yellow;
39
+ const severityIcon = message.severity === 'error' ? '✖' : '⚠';
40
+
41
+ console.log(
42
+ ` ${chalk.dim(`${message.line}:${message.column}`)} ` +
43
+ `${severityColor(severityIcon)} ${message.message} ` +
44
+ `${chalk.dim(message.ruleId)}`
45
+ );
46
+ }
47
+ }
48
+ }
49
+
50
+ if (hasProblems) {
51
+ console.log('\n' + this.getSummary(results));
52
+ } else {
53
+ console.log(chalk.green('\n✓ No problems found'));
54
+ }
55
+ }
56
+
57
+ /**
58
+ * JSON format
59
+ */
60
+ private reportJson(results: LintResults): void {
61
+ console.log(JSON.stringify(results, null, 2));
62
+ }
63
+
64
+ /**
65
+ * Get summary text
66
+ */
67
+ private getSummary(results: LintResults): string {
68
+ const parts: string[] = [];
69
+
70
+ if (results.errorCount > 0) {
71
+ parts.push(
72
+ chalk.red.bold(`✖ ${results.errorCount} ${this.pluralize('error', results.errorCount)}`)
73
+ );
74
+ }
75
+
76
+ if (results.warningCount > 0) {
77
+ parts.push(
78
+ chalk.yellow.bold(`⚠ ${results.warningCount} ${this.pluralize('warning', results.warningCount)}`)
79
+ );
80
+ }
81
+
82
+ return parts.join(' ');
83
+ }
84
+
85
+ /**
86
+ * Pluralize a word
87
+ */
88
+ private pluralize(word: string, count: number): string {
89
+ return count === 1 ? word : word + 's';
90
+ }
91
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "commonjs",
5
+ "lib": ["ES2020"],
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true,
16
+ "moduleResolution": "node",
17
+ "types": ["node"],
18
+ "allowSyntheticDefaultImports": true
19
+ },
20
+ "include": ["src/**/*"],
21
+ "exclude": ["node_modules", "dist", "**/*.test.ts", "vitest.config.ts"]
22
+ }
@@ -0,0 +1,19 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['src/**/*.{test,spec}.ts'],
8
+ coverage: {
9
+ provider: 'v8',
10
+ reporter: ['text', 'json', 'html'],
11
+ exclude: [
12
+ 'node_modules/',
13
+ 'src/**/*.d.ts',
14
+ 'src/**/__tests__/**',
15
+ 'dist/'
16
+ ]
17
+ }
18
+ }
19
+ });