From dcdb9d198d9e027b89c2f048dbd2bee9eee9bd49 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Mon, 25 May 2026 18:55:15 +0530 Subject: [PATCH 1/2] feat(concerto-linter): lint reserved system concept declarations Signed-off-by: Rishabh Jain --- package-lock.json | 3 +- packages/concerto-linter/README.md | 22 ++++- .../concerto-linter/default-ruleset/README.md | 13 ++- .../default-ruleset/package.json | 3 +- ...nd-reserved-system-concept-declarations.ts | 91 +++++++++++++++++++ .../src/no-reserved-keywords.ts | 4 +- .../reserved-system-concept-declarations.ts | 30 ++++++ .../default-ruleset/src/ruleset-main.ts | 2 + ...eserved-keywords-system-concepts-valid.cto | 9 ++ ...em-concept-declarations-legacy-invalid.cto | 5 + ...system-concept-declarations-v3-invalid.cto | 11 +++ ...system-concept-declarations-v4-invalid.cto | 9 ++ ...d-system-concept-declarations-v4-valid.cto | 9 ++ .../test/rules/no-reserved-keywords.test.ts | 10 ++ ...served-system-concept-declarations.test.ts | 75 +++++++++++++++ packages/concerto-linter/src/index.ts | 81 +++++++++++++++-- .../test/unit/lintModel.test.ts | 82 +++++++++++++++++ 17 files changed, 444 insertions(+), 15 deletions(-) create mode 100644 packages/concerto-linter/default-ruleset/src/functions/find-reserved-system-concept-declarations.ts create mode 100644 packages/concerto-linter/default-ruleset/src/reserved-system-concept-declarations.ts create mode 100644 packages/concerto-linter/default-ruleset/test/fixtures/no-reserved-keywords-system-concepts-valid.cto create mode 100644 packages/concerto-linter/default-ruleset/test/fixtures/reserved-system-concept-declarations-legacy-invalid.cto create mode 100644 packages/concerto-linter/default-ruleset/test/fixtures/reserved-system-concept-declarations-v3-invalid.cto create mode 100644 packages/concerto-linter/default-ruleset/test/fixtures/reserved-system-concept-declarations-v4-invalid.cto create mode 100644 packages/concerto-linter/default-ruleset/test/fixtures/reserved-system-concept-declarations-v4-valid.cto create mode 100644 packages/concerto-linter/default-ruleset/test/rules/reserved-system-concept-declarations.test.ts 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..e052a32ff --- /dev/null +++ b/packages/concerto-linter/default-ruleset/src/functions/find-reserved-system-concept-declarations.ts @@ -0,0 +1,91 @@ +/* + * 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('@'); +} + +/** + * 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 shouldReport = isLegacyOrV3Model(model) || 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: `Declaration '${declaration.name}' collides with a reserved Concerto system concept. Rename the declaration, disable dangerouslyAllowReservedSystemTypeNamesInUserModels, or migrate safely before relying on this model.`, + }); + + 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..31363ab11 --- /dev/null +++ b/packages/concerto-linter/default-ruleset/test/rules/reserved-system-concept-declarations.test.ts @@ -0,0 +1,75 @@ +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'); + }); + }); + + 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'); + }); + + 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'); + }); + }); + + 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..793801b2b 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 {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'); + }); + }); From 6d68dfc05b4356f6bc12df6126e52369e9d8ea73 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Mon, 25 May 2026 20:25:17 +0530 Subject: [PATCH 2/2] fix(concerto-linter): address copilot review feedback Signed-off-by: Rishabh Jain --- .../find-reserved-system-concept-declarations.ts | 13 +++++++++++-- .../reserved-system-concept-declarations.test.ts | 3 +++ packages/concerto-linter/src/index.ts | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) 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 index e052a32ff..d1b32bf40 100644 --- 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 @@ -48,6 +48,14 @@ function isLegacyOrV3Model(model: Model): boolean { 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. @@ -71,7 +79,8 @@ export const findReservedSystemConceptDeclarations: IFunction = ( const functionOptions = options as ReservedSystemConceptOptions | undefined; const dangerousModeEnabled = Boolean(functionOptions?.dangerouslyAllowReservedSystemTypeNamesInUserModels); - const shouldReport = isLegacyOrV3Model(model) || dangerousModeEnabled; + const legacyOrV3Model = isLegacyOrV3Model(model); + const shouldReport = legacyOrV3Model || dangerousModeEnabled; if (!shouldReport) { return []; @@ -83,7 +92,7 @@ export const findReservedSystemConceptDeclarations: IFunction = ( } results.push({ - message: `Declaration '${declaration.name}' collides with a reserved Concerto system concept. Rename the declaration, disable dangerouslyAllowReservedSystemTypeNamesInUserModels, or migrate safely before relying on this model.`, + message: getReservedSystemConceptMessage(declaration.name, legacyOrV3Model), }); return results; 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 index 31363ab11..1b27b3a1e 100644 --- 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 @@ -30,6 +30,7 @@ describe('Reserved System Concept Declarations Rule', () => { 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'); }); }); @@ -41,6 +42,7 @@ describe('Reserved System Concept Declarations Rule', () => { 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 () => { @@ -61,6 +63,7 @@ describe('Reserved System Concept Declarations Rule', () => { expect(results).toHaveLength(2); results.forEach(result => { expect(result.code).toBe('reserved-system-concept-declarations'); + expect(result.message).toContain('disable dangerouslyAllowReservedSystemTypeNamesInUserModels'); }); }); diff --git a/packages/concerto-linter/src/index.ts b/packages/concerto-linter/src/index.ts index 793801b2b..913b7d075 100644 --- a/packages/concerto-linter/src/index.ts +++ b/packages/concerto-linter/src/index.ts @@ -212,7 +212,7 @@ function formatResults( * @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 {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.*'). + * @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.