Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/eslint-plugin/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env node

// eslint plugin rule utility
// ============================
// node index.js generate-menu
// generates the _menu.md file to make sure all rules are included.
// node index.js generate-js-stub <rule-name>
// generates a stub markdown file for a new JS rule with name <rule-name>.

import * as fs from 'node:fs'
import * as path from 'node:path'

const RULES_BASE_PATH = path.join('tools', 'cds-lint', 'rules');
const EXAMPLES_BASE_PATH = path.join('tools', 'cds-lint', 'examples');
const MENU_FILE_NAME = '_menu.md';

/**
* Get a list of all rule description files.
* @returns {string[]} An array of rule description file names.
*/
const getRuleDescriptionFiles = () =>
fs.readdirSync(RULES_BASE_PATH)
.filter(file => file.endsWith('.md'))
.filter(file => !['index.md', MENU_FILE_NAME].includes(file))
.sort()

/**
* Generates the menu markdown file
* by completely overriding its current contents.
* The menu contains links to all rule description files
* in alphabetical order.
*/
function generateMenuMarkdown () {
const rules = getRuleDescriptionFiles();
const menu = rules.map(rule => {
const clean = rule.replace('.md', '');
return `# [${clean}](${clean})`
}).join('\n');
const menuFilePath = path.join(RULES_BASE_PATH, '_menu.md')
fs.writeFileSync(menuFilePath, menu);
console.info(`generated menu to ${menuFilePath}`)
}

/**
* Generates a stub markdown file for a new JS rule.
* The passed ruleName will be placed in the stub template
* where $RULE_NAME is defined.
* @param {string} ruleName - The name of the rule.
*/
function generateJsRuleStub (ruleName) {
if (!ruleName) {
console.error('Please provide a rule name, e.g. "no-shared-handler-variables" as second argument');
process.exit(1);
}
const stubFilePath = path.join(RULES_BASE_PATH, ruleName + '.md');
if (fs.existsSync(stubFilePath)) {
console.error(`file ${stubFilePath} already exists, will not overwrite`);
process.exit(2);
}
const stub = fs.readFileSync(path.join(import.meta.dirname, 'js-rule-stub.md'), 'utf-8').replaceAll('$RULE_NAME', ruleName);
fs.writeFileSync(stubFilePath, stub);
console.info(`generated stub to ${stubFilePath}`);
const correctPath = path.join(EXAMPLES_BASE_PATH, ruleName, 'correct', 'srv');
fs.mkdirSync(correctPath, { recursive: true });
const incorrectPath = path.join(EXAMPLES_BASE_PATH, ruleName, 'incorrect', 'srv');
fs.mkdirSync(incorrectPath, { recursive: true });
console.info(`generated example directories in ${path.join(EXAMPLES_BASE_PATH, ruleName)}`);
fs.writeFileSync(path.join(correctPath, 'admin-service.js'), '// correct example\n');
fs.writeFileSync(path.join(incorrectPath, 'admin-service.js'), '// incorrect example\n');
}

function main (argv) {
switch (argv[0]) {
case 'generate-menu':
generateMenuMarkdown();
break;
case 'generate-js-stub':
generateJsRuleStub(argv[1]);
generateMenuMarkdown();
break;
default:
console.log(`Unknown command: ${argv[0]}. Use one of: generate-menu, generate-stub`);
}
}

main(process.argv.slice(2));
44 changes: 44 additions & 0 deletions .github/eslint-plugin/js-rule-stub.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
status: released
---

<script setup>
import PlaygroundBadge from '../components/PlaygroundBadge.vue'
</script>

# $RULE_NAME

## Rule Details

DETAILS

#### Version
This rule was introduced in `@sap/eslint-plugin-cds x.y.z`.

## Examples

### ✅ &nbsp; Correct example

DESCRIPTION OF CORRECT EXAMPLE

::: code-group
<<< ../examples/$RULE_NAME/correct/srv/admin-service.js#snippet{js:line-numbers} [srv/admin-service.js]
:::
<PlaygroundBadge
name="$RULE_NAME"
kind="correct"
:files="['srv/admin-service.js']"
/>

### ❌ &nbsp; Incorrect example

DESCRIPTION OF INCORRECT EXAMPLE

::: code-group
<<< ../examples/$RULE_NAME/incorrect/srv/admin-service.js#snippet{js:line-numbers} [srv/admin-service.js]
:::
<PlaygroundBadge
name="$RULE_NAME"
kind="incorrect"
:files="['srv/admin-service.js']"
/>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const cds = require('@sap/cds')

module.exports = class AdminService extends cds.ApplicationService { async init() {
this.on('READ', 'Books', () => {}) // [!code highlight]
}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const cds = require('@sap/cds')

module.exports = class AdminService extends cds.ApplicationService { async init() {
this.on('Read', 'Books', () => {}) // [!code error]
}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const cds = require('@sap/cds')
const { Books } = require('#cds-models/sap/capire/bookshop/AdminService') // [!code highlight]

Check warning on line 2 in tools/cds-lint/examples/no-cross-service-import/correct/srv/AdminService.js

View workflow job for this annotation

GitHub Actions / build

'Books' is assigned a value but never used

module.exports = class AdminService extends cds.ApplicationService {
// …
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const cds = require('@sap/cds')
const { Books } = require('#cds-models/sap/capire/bookshop/CatalogService') // [!code error]

Check warning on line 2 in tools/cds-lint/examples/no-cross-service-import/incorrect/srv/AdminService.js

View workflow job for this annotation

GitHub Actions / build

'Books' is assigned a value but never used

module.exports = class AdminService extends cds.ApplicationService {
// …
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const cds = require('@sap/cds') // [!code highlight]

module.exports = class AdminService extends cds.ApplicationService {
// …
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const cdsService = require('@sap/cds/service') // [!code error]

module.exports = class AdminService extends cdsService.ApplicationService {
// …
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const cds = require('@sap/cds')

module.exports = class AdminService extends cds.ApplicationService { async init() {
this.after('READ', 'Books', async () => {
// local variable only, no state shared between handlers
const books = await cds.run(SELECT.from('Books')) // [!code highlight]
return books
})

this.on('CREATE', 'Books', newBookHandler)
await super.init()
}
}

/** @type {import('@sap/cds').CRUDEventHandler.On} */
async function newBookHandler (req) {
const { name } = req.data
// local variable only, no state shared between handlers
const newBook = await cds.run(INSERT.into('Books').entries({ name })) // [!code highlight]
return newBook
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const cds = require('@sap/cds')

let lastCreatedBook
let lastReadBooks

module.exports = class AdminService extends cds.ApplicationService { async init() {
this.after('READ', 'Books', async () => {
// variable from surrounding scope, state is shared between handler calls
lastReadBooks = await cds.run(SELECT.from('Books')) // [!code error]
return lastReadBooks
})

this.on('CREATE', 'Books', newBookHandler)
await super.init()
}
}

/** @type {import('@sap/cds').CRUDEventHandler.On} */
async function newBookHandler (req) {
const { name } = req.data
// variable from surrounding scope, state is shared between handler calls
lastCreatedBook = await cds.run(INSERT.into('Books').entries({ name })) // [!code error]
return lastCreatedBook
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const cds = require('@sap/cds')
module.exports = class AdminService extends cds.ApplicationService { init() {
const { Authors } = cds.entities('AdminService')

this.before (['CREATE', 'UPDATE'], Authors, async (req) => {
await SELECT`ID`.from `Authors`.where `name = ${req.data.name}` // [!code highlight]
})

return super.init()
}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const cds = require('@sap/cds')
module.exports = class AdminService extends cds.ApplicationService { init() {
const { Authors } = cds.entities('AdminService')

this.before (['CREATE', 'UPDATE'], Authors, async (req) => {
await SELECT`ID`.from `Authors`.where (`name = ${req.data.name}`) // [!code error]
})

return super.init()
}}
7 changes: 6 additions & 1 deletion tools/cds-lint/rules/_menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,19 @@
# [auth-valid-restrict-keys](auth-valid-restrict-keys)
# [auth-valid-restrict-to](auth-valid-restrict-to)
# [auth-valid-restrict-where](auth-valid-restrict-where)
# [case-sensitive-well-known-events](case-sensitive-well-known-events)
# [extension-restrictions](extension-restrictions)
# [latest-cds-version](latest-cds-version)
# [no-cross-service-import](no-cross-service-import)
# [no-db-keywords](no-db-keywords)
# [no-deep-sap-cds-import](no-deep-sap-cds-import)
# [no-dollar-prefixed-names](no-dollar-prefixed-names)
# [no-java-keywords](no-java-keywords)
# [no-join-on-draft](no-join-on-draft)
# [no-shared-handler-variable](no-shared-handler-variable)
# [sql-cast-suggestion](sql-cast-suggestion)
# [sql-null-comparison](sql-null-comparison)
# [start-elements-lowercase](start-elements-lowercase)
# [start-entities-uppercase](start-entities-uppercase)
# [valid-csv-header](valid-csv-header)
# [use-cql-select-template-strings](use-cql-select-template-strings)
# [valid-csv-header](valid-csv-header)
4 changes: 2 additions & 2 deletions tools/cds-lint/rules/assoc2many-ambiguous-key.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ status: released

## Rule Details

In general an [association/composition to/of `MANY`](../../../cds/cdl#to-many-associations) that targets an entity without `ON` condition is not allowed (as it is an `n:1` relationship). Here, one should always specify an `ON` condition following the canonical expression pattern `<assoc>.<backlink> = $self`. The backlink can be any managed to-one association on the many side pointing back to the one side.
An [association/composition to/of `MANY`](../../../cds/cdl#to-many-associations) that targets an entity without an `ON` condition is not allowed because it is an `n:1` relationship. Always specify an `ON` condition following the canonical expression pattern `<assoc>.<backlink> = $self`. The backlink can be any managed to-one association on the many side pointing back to the one side.

## Examples

Expand All @@ -34,7 +34,7 @@ In the following example, we define a unique association from `Authors` to `Book

#### ❌ &nbsp; Incorrect example

If we extend this example by creating a view `AuthorView` with a key `ID` and the element `bookIDs` without an `ON` condition, the rule is triggered since the key is no longer unique and `bookIDs` leads to multiple entries:
If you extend this example by creating a view `AuthorView` with a key `ID` and the element `bookIDs` without an `ON` condition, the rule is triggered because the key is no longer unique and `bookIDs` leads to multiple entries:

::: code-group
<<< ../examples/assoc2many-ambiguous-key/incorrect/db/schema.cds#snippet{cds:line-numbers} [db/schema.cds]
Expand Down
5 changes: 2 additions & 3 deletions tools/cds-lint/rules/auth-no-empty-restrictions.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ status: released

## Rule Details

The [`@requires` annotation](../../../guides/security/authorization#requires) is a convenience shortcut for `@restrict`. You can use it to control which rule a user needs to have in order to access a given resource. Leaving this field empty is dangerous as it leads to unrestricted access to that service which is a security risk.
The [`@requires` annotation](../../../guides/security/authorization#requires) is a convenience shortcut for `@restrict`. You can use it to control which rule a user needs to access a given resource. Leaving this field empty is dangerous because it leads to unrestricted access to that service, which is a security risk.

## Examples

Expand All @@ -34,8 +34,7 @@ In the following example, the `AdminService` is correctly setup with `@requires`

#### ❌ &nbsp; Incorrect example

If we were to replace the `admin` role by an empty string or provide an empty role array as shown in the next example,
we now have unrestricted access to that service, which the rule makes us aware of:
If you replace the `admin` role with an empty string or provide an empty role array as shown in the next example, you now have unrestricted access to that service, which the rule makes you aware of:

::: code-group
<<< ../examples/auth-no-empty-restrictions/incorrect/srv/admin-service.cds#snippet{cds:line-numbers} [srv/admin-service.cds]
Expand Down
7 changes: 3 additions & 4 deletions tools/cds-lint/rules/auth-restrict-grant-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ status: released

## Rule Details

Restrictions can be defined on different types of CDS resources, but there are some limitations with regard to supported privileges (see [limitations](../../../guides/security/authorization#supported-combinations-with-cds-resources)).
Restrictions can be defined on different types of CDS resources, but there are some limitations regarding supported privileges (see [limitations](../../../guides/security/authorization#supported-combinations-with-cds-resources)).

Unsupported privilege properties are ignored by the runtime. Especially, for bound or unbound actions, the `grant` property is implicitly removed (assuming `grant: '*'` instead). The same is true for functions. This rule ensures that `@restrict.grant` on service level and for bound/unbound actions and functions is limited to `grant: '*'`.
Unsupported privilege properties are ignored by the runtime. For bound or unbound actions, the `grant` property is implicitly removed (assuming `grant: '*'` instead). The same is true for functions. This rule ensures that `@restrict.grant` on service level and for bound/unbound actions and functions is limited to `grant: '*'`.

## Examples

Expand All @@ -36,8 +36,7 @@ Let's consider the following example with the `CatalogService` where the functio

#### ❌ &nbsp; Incorrect example

If we were to slightly modify the above example and use `grant: ['WRITE']` in the privilege of the function, the rule would be
triggered to inform us that the value of `grant` is limited to `'*'`:
If you modify the above example and use `grant: ['WRITE']` in the privilege of the function, the rule would be triggered to inform you that the value of `grant` is limited to `'*'`:

::: code-group
<<< ../examples/auth-restrict-grant-service/incorrect/srv/cat-service.cds#snippet{cds:line-numbers} [srv/cat-service.cds]
Expand Down
4 changes: 2 additions & 2 deletions tools/cds-lint/rules/auth-use-requires.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ status: released

## Rule Details

Some annotations such as `@requires` or `@readonly` are just convenience shortcuts for `@restrict`. In actions and services with unrestricted events, it is recommended to use `@requires` instead of `@restrict.to`, which this rule enforces.
Some annotations such as `@requires` or `@readonly` are convenience shortcuts for `@restrict`. In actions and services with unrestricted events, it is recommended to use `@requires` instead of `@restrict.to`, which this rule enforces.

## Examples

Expand All @@ -34,7 +34,7 @@ In the following example, the `CatalogService` action `addRating` correctly uses

#### ❌ &nbsp; Incorrect example

In the following example, the `CatalogService` uses `@restrict` to assign unrestricted events (`grant: *`) to the `Admin` role (`to: Admin`). This which could be written more clearly using `@requires` and so the rule reports a warning:
In the following example, the `CatalogService` uses `@restrict` to assign unrestricted events (`grant: *`) to the `Admin` role (`to: Admin`). This could be written more clearly using `@requires` and so the rule reports a warning:

::: code-group
<<< ../examples/auth-use-requires/incorrect/srv/cat-service.cds#snippet{cds:line-numbers} [srv/cat-service.cds]
Expand Down
4 changes: 2 additions & 2 deletions tools/cds-lint/rules/auth-valid-restrict-grant.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ status: released

## Rule Details

The `grant` property of a `@restrict` privilege defines one or more events that the privilege applies. This rule checks for valid values of `@restrict.grant`, that is, all standard CDS events (such as `READ`, `CREATE`, `UPDATE`, and `DELETE`) on entities. It also suggests to use `*` only when listing events including `*` and to use `WRITE` only when using solely standard CDS events with write semantics (`CREATE`, `DELETE`, `UPDATE`, `UPSERT`).
The `grant` property of a `@restrict` privilege defines one or more events that the privilege applies to. This rule checks for valid values of `@restrict.grant`, that is, all standard CDS events (such as `READ`, `CREATE`, `UPDATE`, and `DELETE`) on entities. It also suggests using `*` only when listing events including `*` and using `WRITE` only when using solely standard CDS events with write semantics (`CREATE`, `DELETE`, `UPDATE`, `UPSERT`).

## Examples

Expand All @@ -34,7 +34,7 @@ In the following example, `CatalogService.ListOfBooks` is restricted to the `REA

#### ❌ &nbsp; Incorrect example

In the next example, the `@restrict.grant` has a typo in the event (that is, `REAAD` instead of `READ`) for the `Viewer` role, which is not a valid value for `@restrict.grant` so the rule will report a warning:
This example shows the `@restrict.grant` with a typo in the event (that is, `REAAD` instead of `READ`) for the `Viewer` role, which is not a valid value for `@restrict.grant` so the rule will report a warning:

::: code-group
<<< ../examples/auth-valid-restrict-grant/incorrect/srv/cat-service.cds#snippet{cds:line-numbers} [srv/cat-service.cds]
Expand Down
4 changes: 2 additions & 2 deletions tools/cds-lint/rules/auth-valid-restrict-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ status: released

## Rule Details

To define authorizations on a fine-grained level, the `@restrict` annotation allows you to add all kinds of restrictions that are based on static user roles, the request operation, and instance filters. The building block of such a restriction is a single privilege. This rule checks that the privileges defined in `@restrict` have properly spelled `to`, `grant`, and `where` keys.
To define authorizations on a fine-grained level, the `@restrict` annotation allows you to add all kinds of restrictions based on static user roles, the request operation, and instance filters. The building block of such a restriction is a single privilege. This rule checks that the privileges defined in `@restrict` have properly spelled `to`, `grant`, and `where` keys.

## Examples

Expand All @@ -34,7 +34,7 @@ In the following example, the `@restrict` annotation on `CatalogService.ListOfBo

#### ❌ &nbsp; Incorrect example

In the next example, the `@restrict` annotation on `CatalogService.ListOfBooks` has typos in the `grant` key (`grants` instead of `grant`), the `to` key (`too` instead of `to`), and the `where` key (`were` instead of `where`) and the rule will report them as a warning:
This example shows the `@restrict` annotation on `CatalogService.ListOfBooks` with typos in the `grant` key (`grants` instead of `grant`), the `to` key (`too` instead of `to`), and the `where` key (`were` instead of `where`) and the rule will report them as a warning:

::: code-group
<<< ../examples/auth-valid-restrict-keys/incorrect/srv/cat-service.cds#snippet{ts:line-numbers} [srv/cat-service.cds]
Expand Down
Loading