@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 +5 -0
- package/.czrc +3 -0
- package/.devcontainer/devcontainer.json +7 -0
- package/.gitattributes +3 -0
- package/.releaserc +37 -0
- package/README.md +122 -0
- package/azure-pipelines.yml +39 -0
- package/docs/CHANGELOG.md +14 -0
- package/examples/bad-example.cds +69 -0
- package/examples/good-example.cds +55 -0
- package/package.json +44 -0
- package/src/__tests__/index.test.ts +71 -0
- package/src/cds-linter.ts +110 -0
- package/src/cli.ts +59 -0
- package/src/index.ts +59 -0
- package/src/rules/cds-rules.ts +114 -0
- package/src/rules/index.ts +25 -0
- package/src/types/cds.d.ts +79 -0
- package/src/utils/reporter.ts +91 -0
- package/tsconfig.json +22 -0
- package/vitest.config.ts +19 -0
package/.commitlintrc
ADDED
package/.czrc
ADDED
package/.gitattributes
ADDED
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
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -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
|
+
});
|