diff --git a/package-lock.json b/package-lock.json
index 4f3077d66..0b09034a6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25129,7 +25129,8 @@
"@accordproject/concerto-core": "4.1.2",
"@stoplight/spectral-core": "1.20.0",
"@stoplight/spectral-functions": "1.10.1",
- "@stoplight/spectral-parsers": "1.0.5"
+ "@stoplight/spectral-parsers": "1.0.5",
+ "semver": "7.5.4"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.46.2",
diff --git a/packages/concerto-linter/README.md b/packages/concerto-linter/README.md
index d82669839..24a8bfd20 100644
--- a/packages/concerto-linter/README.md
+++ b/packages/concerto-linter/README.md
@@ -40,6 +40,10 @@ The linter provide the lintModel function which provides a robust and efficient
- **Custom Ruleset Flexibility**: Enables the use of a custom Spectral ruleset by allowing you to specify its file path for tailored linting rules.
+### Compatibility Context
+
+- **Compatibility-aware linting**: Supports `dangerouslyAllowReservedSystemTypeNamesInUserModels` so the linter can warn when reserved system concept names are used in risky compatibility modes.
+
### Namespace Filtering
- **Namespace Filtering**: Filters linting results by namespace, with configurable exclusion patterns. By default, excludes `'concerto.*'` and `'org.accordproject.*'` namespaces.
@@ -85,7 +89,7 @@ const ast = modelManager.getAst();
```
## Ruleset Configuration
-The concerto Linter provides flexible `ruleset` configuration options to suit your project needs.
+The concerto Linter provides flexible configuration options to suit your project needs.
### Default Ruleset
@@ -111,6 +115,14 @@ You can customize the linting rules in several ways:
const results = await lintModel(ast, {ruleset: 'default'});
```
+4. **Enable risky reserved system concept checks for v4 compatibility mode**
+ ```javascript
+ const results = await lintModel(ast, {
+ ruleset: 'default',
+ dangerouslyAllowReservedSystemTypeNamesInUserModels: true
+ });
+ ```
+
### Automatic Ruleset Discovery
When you call `lintModel` without specifying a ruleset, the linter will automatically search for ruleset files in the following order:
@@ -201,7 +213,13 @@ const results = await lintModel(ast, {
ruleset: "D:\\linter-test\\my-ruleset.yaml",
excludeNamespaces: ['org.example.*']
});
+
+// Match concerto-core dangerous compatibility mode during linting
+const riskyResults = await lintModel(ast, {
+ ruleset: 'default',
+ dangerouslyAllowReservedSystemTypeNamesInUserModels: true
+});
```
## License
-Accord Project source code files are made available under the Apache License, Version 2.0 (Apache-2.0), located in the LICENSE file. Accord Project documentation files are made available under the Creative Commons Attribution 4.0 International License (CC-BY-4.0).
\ No newline at end of file
+Accord Project source code files are made available under the Apache License, Version 2.0 (Apache-2.0), located in the LICENSE file. Accord Project documentation files are made available under the Creative Commons Attribution 4.0 International License (CC-BY-4.0).
diff --git a/packages/concerto-linter/default-ruleset/README.md b/packages/concerto-linter/default-ruleset/README.md
index eb39946e0..adee1b5ee 100644
--- a/packages/concerto-linter/default-ruleset/README.md
+++ b/packages/concerto-linter/default-ruleset/README.md
@@ -46,6 +46,10 @@ The following table provides an overview of the available linting rules in the d
Enforces that names used for declarations, properties, and decorators in concerto models do not use reserved keywords. Reserved keywords are language-specific terms that may cause conflicts or unexpected behavior if used as identifiers. |
+| reserved-system-concept-declarations |
+Flags declaration names that collide with reserved Concerto system concepts in legacy/v3 models, or in v4 when dangerous reserved system type names are explicitly enabled for compatibility. |
+
+
| pascal-case-declarations |
Ensures that declaration names (scalar, enum, concept, asset, participant, transaction, event) follow PascalCase naming convention (e.g., 'MyDeclaration'). This promotes consistency and readability across model declarations. |
@@ -101,6 +105,12 @@ To explicitly specify the default ruleset:
const results = await lintModel(modelText, { ruleset: 'default' });
```
+The default ruleset includes `reserved-system-concept-declarations`, which behaves as follows:
+
+- v3 and legacy models: reports reserved declaration names such as `Concept`, `Asset`, `Transaction`, `Participant`, and `Event`
+- default v4 runs: stays silent
+- dangerous v4 compatibility mode: reports the same names when `dangerouslyAllowReservedSystemTypeNamesInUserModels` is enabled through `lintModel`
+
## Customization
To create your own ruleset that fits your project needs, you can either extend the default ruleset, or create an entirely new ruleset from scratch.
@@ -147,6 +157,7 @@ Here are all the rule IDs that can be disabled:
|---------|-------------|
| `namespace-version` | Ensures namespaces include version numbers |
| `no-reserved-keywords` | Prevents use of reserved keywords |
+| `reserved-system-concept-declarations` | Flags reserved system concept declaration names in risky compatibility contexts |
| `pascal-case-declarations` | Enforces PascalCase for declarations |
| `camel-case-properties` | Enforces camelCase for properties |
| `upper-snake-case-enum-constants` | Enforces UPPER_SNAKE_CASE for enum constants |
@@ -297,4 +308,4 @@ For more complex rules, you can create custom JavaScript functions following [Sp
## License
-Accord Project source code files are made available under the Apache License, Version 2.0 (Apache-2.0), located in the LICENSE file. Accord Project documentation files are made available under the Creative Commons Attribution 4.0 International License (CC-BY-4.0).
\ No newline at end of file
+Accord Project source code files are made available under the Apache License, Version 2.0 (Apache-2.0), located in the LICENSE file. Accord Project documentation files are made available under the Creative Commons Attribution 4.0 International License (CC-BY-4.0).
diff --git a/packages/concerto-linter/default-ruleset/package.json b/packages/concerto-linter/default-ruleset/package.json
index 98203fe01..897c6c7b8 100644
--- a/packages/concerto-linter/default-ruleset/package.json
+++ b/packages/concerto-linter/default-ruleset/package.json
@@ -36,7 +36,8 @@
"@accordproject/concerto-core": "4.1.2",
"@stoplight/spectral-core": "1.20.0",
"@stoplight/spectral-functions": "1.10.1",
- "@stoplight/spectral-parsers": "1.0.5"
+ "@stoplight/spectral-parsers": "1.0.5",
+ "semver": "7.5.4"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.46.2",
diff --git a/packages/concerto-linter/default-ruleset/src/functions/find-reserved-system-concept-declarations.ts b/packages/concerto-linter/default-ruleset/src/functions/find-reserved-system-concept-declarations.ts
new file mode 100644
index 000000000..d1b32bf40
--- /dev/null
+++ b/packages/concerto-linter/default-ruleset/src/functions/find-reserved-system-concept-declarations.ts
@@ -0,0 +1,100 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { IFunction, IFunctionResult } from '@stoplight/spectral-core';
+import semver from 'semver';
+
+const RESERVED_SYSTEM_CONCEPT_NAMES = new Set([
+ 'Concept',
+ 'Asset',
+ 'Transaction',
+ 'Participant',
+ 'Event',
+]);
+
+interface Declaration {
+ name?: string;
+}
+
+interface Model {
+ namespace?: string;
+ declarations?: Declaration[];
+ concertoVersion?: string;
+}
+
+interface ReservedSystemConceptOptions {
+ dangerouslyAllowReservedSystemTypeNamesInUserModels?: boolean;
+}
+
+function isLegacyOrV3Model(model: Model): boolean {
+ if (typeof model.concertoVersion === 'string') {
+ const minimumVersion = semver.minVersion(model.concertoVersion);
+ if (minimumVersion) {
+ return minimumVersion.major === 3;
+ }
+ }
+
+ return typeof model.namespace === 'string' && !model.namespace.includes('@');
+}
+
+function getReservedSystemConceptMessage(declarationName: string, isLegacyModel: boolean): string {
+ if (isLegacyModel) {
+ return `Declaration '${declarationName}' collides with a reserved Concerto system concept. Rename the declaration or migrate safely before relying on this model.`;
+ }
+
+ return `Declaration '${declarationName}' collides with a reserved Concerto system concept. Rename the declaration, disable dangerouslyAllowReservedSystemTypeNamesInUserModels, or migrate safely before relying on this model.`;
+}
+
+/**
+ * Finds declaration names that collide with reserved Concerto system concepts
+ * in risky compatibility contexts.
+ *
+ * @param {unknown} targetVal The AST node to check, expected to be a Concerto model.
+ * @param {ReservedSystemConceptOptions} options Rule options controlling v4 dangerous mode behavior.
+ * @returns {IFunctionResult[]} A result for each matching declaration.
+ */
+export const findReservedSystemConceptDeclarations: IFunction = (
+ targetVal,
+ options?: unknown,
+): IFunctionResult[] => {
+ if (typeof targetVal !== 'object' || targetVal === null) {
+ throw new Error('Value must be a valid AST object for a Concerto model.');
+ }
+
+ const model = targetVal as Model;
+ if (!Array.isArray(model.declarations)) {
+ return [];
+ }
+
+ const functionOptions = options as ReservedSystemConceptOptions | undefined;
+ const dangerousModeEnabled = Boolean(functionOptions?.dangerouslyAllowReservedSystemTypeNamesInUserModels);
+ const legacyOrV3Model = isLegacyOrV3Model(model);
+ const shouldReport = legacyOrV3Model || dangerousModeEnabled;
+
+ if (!shouldReport) {
+ return [];
+ }
+
+ return model.declarations.reduce((results, declaration) => {
+ if (!declaration?.name || !RESERVED_SYSTEM_CONCEPT_NAMES.has(declaration.name)) {
+ return results;
+ }
+
+ results.push({
+ message: getReservedSystemConceptMessage(declaration.name, legacyOrV3Model),
+ });
+
+ return results;
+ }, []);
+};
diff --git a/packages/concerto-linter/default-ruleset/src/no-reserved-keywords.ts b/packages/concerto-linter/default-ruleset/src/no-reserved-keywords.ts
index ebcfd59a5..0ccf90951 100644
--- a/packages/concerto-linter/default-ruleset/src/no-reserved-keywords.ts
+++ b/packages/concerto-linter/default-ruleset/src/no-reserved-keywords.ts
@@ -37,7 +37,7 @@ export default {
then: {
function: pattern,
functionOptions: {
- notMatch: '/^(String|Double|Integer|Long|DateTime|Boolean|scalar|concept|enum|asset|participant|transaction|event|map|optional|length|regex|range|default)$/i'
+ notMatch: '/^(String|Double|Integer|Long|DateTime|Boolean|scalar|concept|enum|asset|participant|transaction|event|map|optional|length|regex|range|default)$/'
},
},
-};
\ No newline at end of file
+};
diff --git a/packages/concerto-linter/default-ruleset/src/reserved-system-concept-declarations.ts b/packages/concerto-linter/default-ruleset/src/reserved-system-concept-declarations.ts
new file mode 100644
index 000000000..f823e4381
--- /dev/null
+++ b/packages/concerto-linter/default-ruleset/src/reserved-system-concept-declarations.ts
@@ -0,0 +1,30 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { findReservedSystemConceptDeclarations } from './functions/find-reserved-system-concept-declarations';
+
+/**
+ * Rule: Reserved System Concept Declarations
+ * ------------------------------------------
+ * Flags declaration names that collide with reserved Concerto system concepts
+ * in legacy/v3 models, or when dangerous reserved system type names are
+ * explicitly allowed in v4 compatibility mode.
+ */
+export default {
+ given: '$.models[*]',
+ severity: 0, // 0 = error, 1 = warning, 2 = info, 3 = hint
+ then: {
+ function: findReservedSystemConceptDeclarations,
+ },
+};
diff --git a/packages/concerto-linter/default-ruleset/src/ruleset-main.ts b/packages/concerto-linter/default-ruleset/src/ruleset-main.ts
index 9e1e1d8b5..95a328bca 100644
--- a/packages/concerto-linter/default-ruleset/src/ruleset-main.ts
+++ b/packages/concerto-linter/default-ruleset/src/ruleset-main.ts
@@ -23,6 +23,7 @@ import stringLengthValidator from './string-length-validator';
import noReservedKeywords from './no-reserved-keywords';
import noEmptyDeclarations from './no-empty-declarations';
import abstractMustSubclassed from './abstract-must-subclassed';
+import reservedSystemConceptDeclarations from './reserved-system-concept-declarations';
const concertoRuleset: RulesetDefinition = {
rules: {
@@ -35,6 +36,7 @@ const concertoRuleset: RulesetDefinition = {
'string-length-validator': stringLengthValidator,
'no-empty-declarations': noEmptyDeclarations,
'abstract-must-subclassed': abstractMustSubclassed,
+ 'reserved-system-concept-declarations': reservedSystemConceptDeclarations,
}
};
diff --git a/packages/concerto-linter/default-ruleset/test/fixtures/no-reserved-keywords-system-concepts-valid.cto b/packages/concerto-linter/default-ruleset/test/fixtures/no-reserved-keywords-system-concepts-valid.cto
new file mode 100644
index 000000000..ac66c72f5
--- /dev/null
+++ b/packages/concerto-linter/default-ruleset/test/fixtures/no-reserved-keywords-system-concepts-valid.cto
@@ -0,0 +1,9 @@
+namespace org.example@1.0.0
+
+asset Asset identified by assetId {
+ o String assetId
+}
+
+transaction Transaction {
+ o String transactionId optional
+}
diff --git a/packages/concerto-linter/default-ruleset/test/fixtures/reserved-system-concept-declarations-legacy-invalid.cto b/packages/concerto-linter/default-ruleset/test/fixtures/reserved-system-concept-declarations-legacy-invalid.cto
new file mode 100644
index 000000000..62b3962f4
--- /dev/null
+++ b/packages/concerto-linter/default-ruleset/test/fixtures/reserved-system-concept-declarations-legacy-invalid.cto
@@ -0,0 +1,5 @@
+namespace org.example
+
+participant Participant identified by participantId {
+ o String participantId
+}
diff --git a/packages/concerto-linter/default-ruleset/test/fixtures/reserved-system-concept-declarations-v3-invalid.cto b/packages/concerto-linter/default-ruleset/test/fixtures/reserved-system-concept-declarations-v3-invalid.cto
new file mode 100644
index 000000000..e00bedb66
--- /dev/null
+++ b/packages/concerto-linter/default-ruleset/test/fixtures/reserved-system-concept-declarations-v3-invalid.cto
@@ -0,0 +1,11 @@
+concerto version "^3.0.0"
+
+namespace org.example@1.0.0
+
+asset Asset identified by assetId {
+ o String assetId
+}
+
+concept Concept {
+ o String name
+}
diff --git a/packages/concerto-linter/default-ruleset/test/fixtures/reserved-system-concept-declarations-v4-invalid.cto b/packages/concerto-linter/default-ruleset/test/fixtures/reserved-system-concept-declarations-v4-invalid.cto
new file mode 100644
index 000000000..69ce34971
--- /dev/null
+++ b/packages/concerto-linter/default-ruleset/test/fixtures/reserved-system-concept-declarations-v4-invalid.cto
@@ -0,0 +1,9 @@
+namespace org.example@1.0.0
+
+asset Asset identified by assetId {
+ o String assetId
+}
+
+event Event {
+ o String eventId optional
+}
diff --git a/packages/concerto-linter/default-ruleset/test/fixtures/reserved-system-concept-declarations-v4-valid.cto b/packages/concerto-linter/default-ruleset/test/fixtures/reserved-system-concept-declarations-v4-valid.cto
new file mode 100644
index 000000000..71fe0a149
--- /dev/null
+++ b/packages/concerto-linter/default-ruleset/test/fixtures/reserved-system-concept-declarations-v4-valid.cto
@@ -0,0 +1,9 @@
+namespace org.example@1.0.0
+
+asset Vehicle identified by vehicleId {
+ o String vehicleId
+}
+
+concept Shipment {
+ o String shipmentId
+}
diff --git a/packages/concerto-linter/default-ruleset/test/rules/no-reserved-keywords.test.ts b/packages/concerto-linter/default-ruleset/test/rules/no-reserved-keywords.test.ts
index 3ff8dc643..0e4ca7616 100644
--- a/packages/concerto-linter/default-ruleset/test/rules/no-reserved-keywords.test.ts
+++ b/packages/concerto-linter/default-ruleset/test/rules/no-reserved-keywords.test.ts
@@ -30,4 +30,14 @@ describe('No Reserved Keywords Rule', () => {
const messageText = results.map(r => r.message).join(' ');
expect(messageText).toContain('is a reserved keyword');
});
+
+ test('should not report reserved system concept declarations through the generic keyword rule', async () => {
+ const results = await testRules({
+ rules: {
+ 'no-reserved-keywords': noReservedKeywords,
+ }
+ }, 'no-reserved-keywords-system-concepts-valid.cto');
+
+ expect(results).toHaveLength(0);
+ });
});
diff --git a/packages/concerto-linter/default-ruleset/test/rules/reserved-system-concept-declarations.test.ts b/packages/concerto-linter/default-ruleset/test/rules/reserved-system-concept-declarations.test.ts
new file mode 100644
index 000000000..1b27b3a1e
--- /dev/null
+++ b/packages/concerto-linter/default-ruleset/test/rules/reserved-system-concept-declarations.test.ts
@@ -0,0 +1,78 @@
+import reservedSystemConceptDeclarations from '../../src/reserved-system-concept-declarations';
+import { testRules } from '../test-rule';
+
+function createRule(dangerouslyAllowReservedSystemTypeNamesInUserModels = false) {
+ const ruleFunction = reservedSystemConceptDeclarations.then.function;
+ return {
+ rules: {
+ 'reserved-system-concept-declarations': {
+ ...reservedSystemConceptDeclarations,
+ then: {
+ function: (targetVal: unknown, _options: unknown, context: unknown) => ruleFunction(
+ targetVal,
+ { dangerouslyAllowReservedSystemTypeNamesInUserModels },
+ context as never
+ ),
+ },
+ },
+ },
+ };
+}
+
+describe('Reserved System Concept Declarations Rule', () => {
+ test('should report violations for v3 models with reserved system concept declarations', async () => {
+ const results = await testRules(
+ createRule(),
+ 'reserved-system-concept-declarations-v3-invalid.cto'
+ );
+
+ expect(results).toHaveLength(2);
+ results.forEach(result => {
+ expect(result.code).toBe('reserved-system-concept-declarations');
+ expect(result.message).toContain('collides with a reserved Concerto system concept');
+ expect(result.message).not.toContain('disable dangerouslyAllowReservedSystemTypeNamesInUserModels');
+ });
+ });
+
+ test('should report violations for legacy models when concertoVersion metadata is absent', async () => {
+ const results = await testRules(
+ createRule(),
+ 'reserved-system-concept-declarations-legacy-invalid.cto'
+ );
+
+ expect(results).toHaveLength(1);
+ expect(results[0].code).toBe('reserved-system-concept-declarations');
+ expect(results[0].message).not.toContain('disable dangerouslyAllowReservedSystemTypeNamesInUserModels');
+ });
+
+ test('should stay silent for normal v4 models when dangerous mode is disabled', async () => {
+ const results = await testRules(
+ createRule(),
+ 'reserved-system-concept-declarations-v4-invalid.cto'
+ );
+
+ expect(results).toHaveLength(0);
+ });
+
+ test('should report one violation per reserved declaration when dangerous mode is enabled in v4', async () => {
+ const results = await testRules(
+ createRule(true),
+ 'reserved-system-concept-declarations-v4-invalid.cto'
+ );
+
+ expect(results).toHaveLength(2);
+ results.forEach(result => {
+ expect(result.code).toBe('reserved-system-concept-declarations');
+ expect(result.message).toContain('disable dangerouslyAllowReservedSystemTypeNamesInUserModels');
+ });
+ });
+
+ test('should not report non-reserved declarations', async () => {
+ const results = await testRules(
+ createRule(true),
+ 'reserved-system-concept-declarations-v4-valid.cto'
+ );
+
+ expect(results).toHaveLength(0);
+ });
+});
diff --git a/packages/concerto-linter/src/index.ts b/packages/concerto-linter/src/index.ts
index 9194f36d8..913b7d075 100644
--- a/packages/concerto-linter/src/index.ts
+++ b/packages/concerto-linter/src/index.ts
@@ -19,15 +19,32 @@ import { getRuleset } from '@stoplight/spectral-cli/dist/services/linter/utils/g
import concertoRuleset from '@accordproject/concerto-linter-default-ruleset';
import { Parser } from '@accordproject/concerto-cto';
-interface options {
+const RESERVED_SYSTEM_CONCEPT_DECLARATIONS_RULE = 'reserved-system-concept-declarations';
+type PatchableFunctionClause = {
+ function?: unknown;
+ functionOptions?: Record;
+};
+type PatchableFunction = (targetVal: unknown, functionOptions?: unknown, ...rest: unknown[]) => unknown;
+type PatchableRule = {
+ then?: PatchableFunctionClause | PatchableFunctionClause[];
+};
+type PatchableRuleset = (Ruleset | RulesetDefinition) & { rules?: Record };
+
+export interface LintOptions {
/** Path to a custom Spectral ruleset or 'default' to use the built-in ruleset */
ruleset?: string;
/** One or more namespaces to exclude from linting results */
excludeNamespaces?: string | string[];
+
+ /**
+ * Transitional compatibility escape hatch from concerto-core.
+ * When true, the reserved system concept declaration rule also reports in v4.
+ */
+ dangerouslyAllowReservedSystemTypeNamesInUserModels?: boolean;
}
-interface lintResult {
+export interface LintResult {
/** Unique rule identifier (e.g. 'no-reserved-keywords') */
code: string;
@@ -84,6 +101,50 @@ async function loadRuleset(ruleset?: string): Promise ({
+ ...clause,
+ function: typeof clause?.function === 'function'
+ ? (targetVal: unknown, functionOptions?: unknown, ...rest: unknown[]) => {
+ const originalFunction = clause.function as PatchableFunction;
+ const mergedFunctionOptions = typeof functionOptions === 'object' && functionOptions !== null
+ ? {
+ ...(functionOptions as Record),
+ dangerouslyAllowReservedSystemTypeNamesInUserModels,
+ }
+ : { dangerouslyAllowReservedSystemTypeNamesInUserModels };
+
+ return originalFunction(targetVal, mergedFunctionOptions, ...rest);
+ }
+ : clause?.function,
+ functionOptions: {
+ ...clause?.functionOptions,
+ dangerouslyAllowReservedSystemTypeNamesInUserModels,
+ },
+ }));
+
+ return ({
+ ...patchableRuleset,
+ rules: {
+ ...patchableRuleset.rules,
+ [RESERVED_SYSTEM_CONCEPT_DECLARATIONS_RULE]: Array.isArray(reservedSystemConceptRule.then)
+ ? patchedRule
+ : { ...patchedRule, then: patchedRule.then[0] },
+ },
+ } as unknown) as Ruleset | RulesetDefinition;
+}
+
/**
* Formats Spectral linting results by mapping them to a standardized lint result structure,
* extracting namespaces from the provided JSON AST, and filtering out results based on excluded namespaces.
@@ -100,7 +161,7 @@ function formatResults(
spectralResults: IRuleResult[],
jsonAST: string,
excludeNamespaces: string | string[] = ['concerto.*', 'org.accordproject.*']
-): lintResult[] {
+): LintResult[] {
try {
const ast = JSON.parse(jsonAST);
@@ -111,7 +172,7 @@ function formatResults(
3: 'hint',
};
- const results: lintResult[] = spectralResults.map(r => {
+ const results: LintResult[] = spectralResults.map(r => {
let namespace = 'unknown';
if (Array.isArray(r.path) && r.path.length >= 2 && r.path[0] === 'models') {
@@ -149,16 +210,20 @@ function formatResults(
/**
* Lints Concerto models using Spectral and Concerto rules.
* @param {string | object} model - The Concerto model to lint, either as a CTO string or a parsed AST object. Note: No external dependency resolution is performed.
- * @param {options} [config] - Configuration options for customizing the linting process.
+ * @param {LintOptions} [config] - Configuration options for customizing the linting process.
* @param {string} [config.ruleset] - Path to a custom Spectral ruleset file or 'default' to use the built-in ruleset.
- * @param {string | string[]} [config.excludeNamespaces] - One or more namespaces to exclude from linting results (defaults to 'concerto.*' and 'org.accord.*').
- * @returns {Promise} Promise resolving to an array of formatted linting results as a JSON object.
+ * @param {string | string[]} [config.excludeNamespaces] - One or more namespaces to exclude from linting results (defaults to 'concerto.*' and 'org.accordproject.*').
+ * @param {boolean} [config.dangerouslyAllowReservedSystemTypeNamesInUserModels] - When true, report reserved system concept declaration names in v4 compatibility mode.
+ * @returns {Promise} Promise resolving to an array of formatted linting results as a JSON object.
* @throws {Error} Throws an error if linting or model conversion fails.
*/
-export async function lintModel(model: string | object, config?: options): Promise {
+export async function lintModel(model: string | object, config?: LintOptions): Promise {
try {
const jsonAST = convertToJsonAST(model);
- const ruleset = await loadRuleset(config?.ruleset);
+ const ruleset = patchReservedSystemConceptRule(
+ await loadRuleset(config?.ruleset),
+ config?.dangerouslyAllowReservedSystemTypeNamesInUserModels
+ );
const spectral = new Spectral();
spectral.setRuleset(ruleset);
diff --git a/packages/concerto-linter/test/unit/lintModel.test.ts b/packages/concerto-linter/test/unit/lintModel.test.ts
index 1058ad8e6..94d7e48dd 100644
--- a/packages/concerto-linter/test/unit/lintModel.test.ts
+++ b/packages/concerto-linter/test/unit/lintModel.test.ts
@@ -2,11 +2,16 @@
import { jest } from '@jest/globals';
import { lintModel } from '../../src/index';
import * as configLoader from '../../src/config-loader';
+import { getRuleset } from '@stoplight/spectral-cli/dist/services/linter/utils/getRuleset';
// Only mock our own functions when needed
jest.mock('../../src/config-loader');
+jest.mock('@stoplight/spectral-cli/dist/services/linter/utils/getRuleset', () => ({
+ getRuleset: jest.fn(),
+}));
const mockedConfigLoader = configLoader as jest.Mocked;
+const mockedGetRuleset = getRuleset as jest.MockedFunction;
describe('lintModel', () => {
beforeEach(() => {
@@ -81,4 +86,81 @@ describe('lintModel', () => {
expect(Array.isArray(results)).toBe(true);
});
+ test('should stay silent for reserved system concept declarations in normal v4 mode', async () => {
+ const model = `
+ namespace com.example.test@1.0.0
+
+ asset Asset identified by assetId {
+ o String assetId length=[1,]
+ }
+ `;
+
+ mockedConfigLoader.resolveRulesetPath.mockResolvedValue(null);
+
+ const results = await lintModel(model, { ruleset: 'default', excludeNamespaces: [] });
+
+ expect(results).toEqual([]);
+ });
+
+ test('should report reserved system concept declarations when dangerous mode is enabled', async () => {
+ const model = `
+ namespace com.example.test@1.0.0
+
+ asset Asset identified by assetId {
+ o String assetId length=[1,]
+ }
+ `;
+
+ mockedConfigLoader.resolveRulesetPath.mockResolvedValue(null);
+
+ const results = await lintModel(model, {
+ ruleset: 'default',
+ excludeNamespaces: [],
+ dangerouslyAllowReservedSystemTypeNamesInUserModels: true,
+ });
+
+ expect(results).toHaveLength(1);
+ expect(results[0].code).toBe('reserved-system-concept-declarations');
+ });
+
+ test('should inject dangerous mode into loaded custom rulesets', async () => {
+ const model = `
+ namespace com.example.test@1.0.0
+
+ asset Asset identified by assetId {
+ o String assetId length=[1,]
+ }
+ `;
+
+ mockedConfigLoader.resolveRulesetPath.mockResolvedValue('/tmp/custom-ruleset.yaml');
+ mockedGetRuleset.mockResolvedValue({
+ rules: {
+ 'reserved-system-concept-declarations': {
+ given: '$.models[*]',
+ severity: 0,
+ then: {
+ function: (_targetVal: unknown, functionOptions?: { dangerouslyAllowReservedSystemTypeNamesInUserModels?: boolean }) => {
+ return functionOptions?.dangerouslyAllowReservedSystemTypeNamesInUserModels
+ ? [{
+ message: 'custom dangerous mode triggered',
+ path: ['declarations', 0, 'name'],
+ }]
+ : [];
+ },
+ },
+ },
+ },
+ } as never);
+
+ const results = await lintModel(model, {
+ ruleset: '/tmp/custom-ruleset.yaml',
+ excludeNamespaces: [],
+ dangerouslyAllowReservedSystemTypeNamesInUserModels: true,
+ });
+
+ expect(mockedGetRuleset).toHaveBeenCalledWith('/tmp/custom-ruleset.yaml');
+ expect(results).toHaveLength(1);
+ expect(results[0].message).toBe('custom dangerous mode triggered');
+ });
+
});