diff --git a/.gitignore b/.gitignore index 7097f61c71..87dae3c541 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ *.vscode *.out +**/*ipa-collector-results-combined.log diff --git a/tools/spectral/ipa/__tests__/metrics/collector.test.js b/tools/spectral/ipa/__tests__/metrics/collector.test.js new file mode 100644 index 0000000000..c11b8c49f6 --- /dev/null +++ b/tools/spectral/ipa/__tests__/metrics/collector.test.js @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it } from '@jest/globals'; +import collector, { EntryType } from '../../metrics/collector'; +import * as fs from 'node:fs'; + +jest.mock('node:fs'); + +describe('Collector Class', () => { + const expectedOutput = { + violations: [ + { componentId: 'example.component', ruleName: 'rule-1' }, + { componentId: 'example.component', ruleName: 'rule-2' }, + ], + adoptions: [{ componentId: 'example.component', ruleName: 'rule-3' }], + exceptions: [{ componentId: 'example.component', ruleName: 'rule-4', exceptionReason: 'exception-reason' }], + }; + + beforeEach(() => { + collector.entries = { + [EntryType.VIOLATION]: [], + [EntryType.ADOPTION]: [], + [EntryType.EXCEPTION]: [], + }; + + jest.clearAllMocks(); + }); + + it('should collect violations, adoptions, and exceptions correctly', () => { + collector.add(EntryType.VIOLATION, ['example', 'component'], 'rule-1'); + collector.add(EntryType.VIOLATION, ['example', 'component'], 'rule-2'); + collector.add(EntryType.ADOPTION, ['example', 'component'], 'rule-3'); + collector.add(EntryType.EXCEPTION, ['example', 'component'], 'rule-4', 'exception-reason'); + + expect(collector.entries).toEqual(expectedOutput); + + collector.flushToFile(); + const writtenData = JSON.stringify(expectedOutput, null, 2); + expect(fs.writeFileSync).toHaveBeenCalledWith('ipa-collector-results-combined.log', writtenData); + }); + + it('should not add invalid entries', () => { + collector.add(null, 'rule-1', EntryType.VIOLATION); + collector.add(['example', 'component'], null, EntryType.ADOPTION); + collector.add(['example', 'component'], 'rule-4', null); + + expect(collector.entries).toEqual({ + violations: [], + adoptions: [], + exceptions: [], + }); + + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); +}); diff --git a/tools/spectral/ipa/metrics/collector.js b/tools/spectral/ipa/metrics/collector.js new file mode 100644 index 0000000000..15ec5131d2 --- /dev/null +++ b/tools/spectral/ipa/metrics/collector.js @@ -0,0 +1,67 @@ +import * as fs from 'node:fs'; + +export const EntryType = Object.freeze({ + EXCEPTION: 'exceptions', + VIOLATION: 'violations', + ADOPTION: 'adoptions', +}); + +class Collector { + static instance = null; + + static getInstance() { + if (!this.instance) { + this.instance = new Collector(); + } + return this.instance; + } + + constructor() { + if (Collector.instance) { + throw new Error('Use Collector.getInstance()'); + } + + this.entries = { + [EntryType.VIOLATION]: [], + [EntryType.ADOPTION]: [], + [EntryType.EXCEPTION]: [], + }; + + this.fileName = 'ipa-collector-results-combined.log'; + + process.on('exit', () => this.flushToFile()); + process.on('SIGINT', () => { + this.flushToFile(); + process.exit(); + }); + } + + add(type, componentId, ruleName, exceptionReason = null) { + if (componentId && ruleName && type) { + if (!Object.values(EntryType).includes(type)) { + throw new Error(`Invalid entry type: ${type}`); + } + + componentId = componentId.join('.'); + const entry = { componentId, ruleName }; + + if (type === EntryType.EXCEPTION && exceptionReason) { + entry.exceptionReason = exceptionReason; + } + + this.entries[type].push(entry); + } + } + + flushToFile() { + try { + const data = JSON.stringify(this.entries, null, 2); + fs.writeFileSync(this.fileName, data); + } catch (error) { + console.error('Error writing exceptions to file:', error); + } + } +} + +const collector = Collector.getInstance(); +export default collector; diff --git a/tools/spectral/ipa/rulesets/functions/eachCustomMethodMustBeGetOrPost.js b/tools/spectral/ipa/rulesets/functions/eachCustomMethodMustBeGetOrPost.js index fa14929c6f..3327c2af31 100644 --- a/tools/spectral/ipa/rulesets/functions/eachCustomMethodMustBeGetOrPost.js +++ b/tools/spectral/ipa/rulesets/functions/eachCustomMethodMustBeGetOrPost.js @@ -1,9 +1,9 @@ import { isCustomMethod } from './utils/resourceEvaluation.js'; import { hasException } from './utils/exceptions.js'; +import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js'; const RULE_NAME = 'xgen-IPA-109-custom-method-must-be-GET-or-POST'; const ERROR_MESSAGE = 'The HTTP method for custom methods must be GET or POST.'; -const ERROR_RESULT = [{ message: ERROR_MESSAGE }]; const VALID_METHODS = ['get', 'post']; const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; @@ -14,6 +14,7 @@ export default (input, opts, { path }) => { if (!isCustomMethod(pathKey)) return; if (hasException(input, RULE_NAME)) { + collectException(input, RULE_NAME, path); return; } @@ -23,13 +24,15 @@ export default (input, opts, { path }) => { // Check for invalid methods if (httpMethods.some((method) => !VALID_METHODS.includes(method))) { - return ERROR_RESULT; + return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE); } // Check for multiple valid methods const validMethodCount = httpMethods.filter((method) => VALID_METHODS.includes(method)).length; if (validMethodCount > 1) { - return ERROR_RESULT; + return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE); } + + collectAdoption(path, RULE_NAME); }; diff --git a/tools/spectral/ipa/rulesets/functions/eachCustomMethodMustUseCamelCase.js b/tools/spectral/ipa/rulesets/functions/eachCustomMethodMustUseCamelCase.js index d027d78f67..2862054155 100644 --- a/tools/spectral/ipa/rulesets/functions/eachCustomMethodMustUseCamelCase.js +++ b/tools/spectral/ipa/rulesets/functions/eachCustomMethodMustUseCamelCase.js @@ -1,6 +1,7 @@ import { getCustomMethodName, isCustomMethod } from './utils/resourceEvaluation.js'; import { hasException } from './utils/exceptions.js'; import { casing } from '@stoplight/spectral-functions'; +import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js'; const RULE_NAME = 'xgen-IPA-109-custom-method-must-use-camel-case'; @@ -11,15 +12,20 @@ export default (input, opts, { path }) => { if (!isCustomMethod(pathKey)) return; if (hasException(input, RULE_NAME)) { + collectException(input, RULE_NAME, path); return; } let methodName = getCustomMethodName(pathKey); if (methodName.length === 0 || methodName.trim().length === 0) { - return [{ message: 'Custom method name cannot be empty or blank.' }]; + const errorMessage = 'Custom method name cannot be empty or blank.'; + return collectAndReturnViolation(path, RULE_NAME, errorMessage); } if (casing(methodName, { type: 'camel', disallowDigits: true })) { - return [{ message: `${methodName} must use camelCase format.` }]; + const errorMessage = `${methodName} must use camelCase format.`; + return collectAndReturnViolation(path, RULE_NAME, errorMessage); } + + collectAdoption(path, RULE_NAME); }; diff --git a/tools/spectral/ipa/rulesets/functions/eachEnumValueMustBeUpperSnakeCase.js b/tools/spectral/ipa/rulesets/functions/eachEnumValueMustBeUpperSnakeCase.js index 39c05ae5d0..7f798e361e 100644 --- a/tools/spectral/ipa/rulesets/functions/eachEnumValueMustBeUpperSnakeCase.js +++ b/tools/spectral/ipa/rulesets/functions/eachEnumValueMustBeUpperSnakeCase.js @@ -1,6 +1,7 @@ import { hasException } from './utils/exceptions.js'; import { resolveObject } from './utils/componentUtils.js'; import { casing } from '@stoplight/spectral-functions'; +import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js'; const RULE_NAME = 'xgen-IPA-123-enum-values-must-be-upper-snake-case'; const ERROR_MESSAGE = 'enum value must be UPPER_SNAKE_CASE.'; @@ -18,6 +19,7 @@ export default (input, _, { path, documentInventory }) => { const schemaPath = getSchemaPathFromEnumPath(path); const schemaObject = resolveObject(oas, schemaPath); if (hasException(schemaObject, RULE_NAME)) { + collectException(schemaObject, RULE_NAME, schemaPath); return; } @@ -33,5 +35,9 @@ export default (input, _, { path, documentInventory }) => { } }); - return errors; + if (errors.length === 0) { + collectAdoption(schemaPath, RULE_NAME); + } else { + return collectAndReturnViolation(schemaPath, RULE_NAME, errors); + } }; diff --git a/tools/spectral/ipa/rulesets/functions/eachPathAlternatesBetweenResourceNameAndPathParam.js b/tools/spectral/ipa/rulesets/functions/eachPathAlternatesBetweenResourceNameAndPathParam.js index eb418aae61..d432f9686f 100644 --- a/tools/spectral/ipa/rulesets/functions/eachPathAlternatesBetweenResourceNameAndPathParam.js +++ b/tools/spectral/ipa/rulesets/functions/eachPathAlternatesBetweenResourceNameAndPathParam.js @@ -1,9 +1,9 @@ import { isPathParam } from './utils/componentUtils.js'; import { hasException } from './utils/exceptions.js'; +import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js'; const RULE_NAME = 'xgen-IPA-102-path-alternate-resource-name-path-param'; const ERROR_MESSAGE = 'API paths must alternate between resource name and path params.'; -const ERROR_RESULT = [{ message: ERROR_MESSAGE }]; const AUTH_PREFIX = '/api/atlas/v2'; const UNAUTH_PREFIX = '/api/atlas/v2/unauth'; @@ -24,9 +24,10 @@ const validatePathStructure = (elements) => { }); }; -export default (input, _, { documentInventory }) => { +export default (input, _, { path, documentInventory }) => { const oas = documentInventory.resolved; if (hasException(oas.paths[input], RULE_NAME)) { + collectException(oas.paths[input], RULE_NAME, path); return; } @@ -43,6 +44,8 @@ export default (input, _, { documentInventory }) => { let suffix = suffixWithLeadingSlash.slice(1); let elements = suffix.split('/'); if (!validatePathStructure(elements)) { - return ERROR_RESULT; + return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE); } + + collectAdoption(path, RULE_NAME); }; diff --git a/tools/spectral/ipa/rulesets/functions/eachResourceHasGetMethod.js b/tools/spectral/ipa/rulesets/functions/eachResourceHasGetMethod.js index 840b18320d..34388b7bf6 100644 --- a/tools/spectral/ipa/rulesets/functions/eachResourceHasGetMethod.js +++ b/tools/spectral/ipa/rulesets/functions/eachResourceHasGetMethod.js @@ -7,11 +7,12 @@ import { getResourcePaths, } from './utils/resourceEvaluation.js'; import { hasException } from './utils/exceptions.js'; +import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js'; const RULE_NAME = 'xgen-IPA-104-resource-has-GET'; const ERROR_MESSAGE = 'APIs must provide a get method for resources.'; -export default (input, _, { documentInventory }) => { +export default (input, _, { path, documentInventory }) => { if (isChild(input) || isCustomMethod(input)) { return; } @@ -19,6 +20,7 @@ export default (input, _, { documentInventory }) => { const oas = documentInventory.resolved; if (hasException(oas.paths[input], RULE_NAME)) { + collectException(oas.paths[input], RULE_NAME, path); return; } @@ -26,19 +28,13 @@ export default (input, _, { documentInventory }) => { if (isSingletonResource(resourcePaths)) { if (!hasGetMethod(oas.paths[resourcePaths[0]])) { - return [ - { - message: ERROR_MESSAGE, - }, - ]; + return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE); } } else if (isStandardResource(resourcePaths)) { if (!hasGetMethod(oas.paths[resourcePaths[1]])) { - return [ - { - message: ERROR_MESSAGE, - }, - ]; + return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE); } } + + collectAdoption(path, RULE_NAME); }; diff --git a/tools/spectral/ipa/rulesets/functions/exceptionExtensionFormat.js b/tools/spectral/ipa/rulesets/functions/exceptionExtensionFormat.js index 0cb71e7c56..21289d08c6 100644 --- a/tools/spectral/ipa/rulesets/functions/exceptionExtensionFormat.js +++ b/tools/spectral/ipa/rulesets/functions/exceptionExtensionFormat.js @@ -1,3 +1,6 @@ +import { collectAdoption, collectAndReturnViolation } from './utils/collectionUtils.js'; + +const RULE_NAME = 'xgen-IPA-005-exception-extension-format'; const ERROR_MESSAGE = 'IPA exceptions must have a valid rule name and a reason.'; const RULE_NAME_PREFIX = 'xgen-IPA-'; @@ -16,6 +19,12 @@ export default (input, _, { path }) => { } }); + if (errors.length === 0) { + collectAdoption(path, RULE_NAME); + } else { + return collectAndReturnViolation(path, RULE_NAME, errors); + } + return errors; }; diff --git a/tools/spectral/ipa/rulesets/functions/singletonHasNoId.js b/tools/spectral/ipa/rulesets/functions/singletonHasNoId.js index 038514c95a..d208774fbd 100644 --- a/tools/spectral/ipa/rulesets/functions/singletonHasNoId.js +++ b/tools/spectral/ipa/rulesets/functions/singletonHasNoId.js @@ -7,6 +7,7 @@ import { } from './utils/resourceEvaluation.js'; import { hasException } from './utils/exceptions.js'; import { getAllSuccessfulGetResponseSchemas } from './utils/methodUtils.js'; +import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js'; const RULE_NAME = 'xgen-IPA-113-singleton-must-not-have-id'; const ERROR_MESSAGE = 'Singleton resources must not have a user-provided or system-generated ID.'; @@ -19,6 +20,7 @@ export default (input, opts, { path, documentInventory }) => { } if (hasException(input, RULE_NAME)) { + collectException(input, RULE_NAME, path); return; } @@ -28,13 +30,11 @@ export default (input, opts, { path, documentInventory }) => { if (isSingletonResource(resourcePaths) && hasGetMethod(input)) { const resourceSchemas = getAllSuccessfulGetResponseSchemas(input); if (resourceSchemas.some((schema) => schemaHasIdProperty(schema))) { - return [ - { - message: ERROR_MESSAGE, - }, - ]; + return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE); } } + + collectAdoption(path, RULE_NAME); }; function schemaHasIdProperty(schema) { diff --git a/tools/spectral/ipa/rulesets/functions/utils/collectionUtils.js b/tools/spectral/ipa/rulesets/functions/utils/collectionUtils.js new file mode 100644 index 0000000000..9cfd621d16 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/utils/collectionUtils.js @@ -0,0 +1,47 @@ +import collector, { EntryType } from '../../../metrics/collector.js'; +import { EXCEPTION_EXTENSION } from './exceptions.js'; +/** + * Collects a violation entry and returns formatted error data. + * + * @param {string} path - The JSON path for the object where the rule violation occurred. + * @param {string} ruleName - The name of the rule that was violated. + * @param {string|Array} errorData - The error information. Can be either a string message or an array of error objects. + * @returns {Array} An array of error objects. Each object has a 'message' property. + * @throws {Error} Throws an error if errorData is neither a string nor an array. + * + */ +export function collectAndReturnViolation(path, ruleName, errorData) { + collector.add(EntryType.VIOLATION, path, ruleName); + + if (typeof errorData === 'string') { + return [{ message: errorData }]; + } else if (Array.isArray(errorData)) { + return errorData; + } else { + throw new Error('Invalid error data type. Expected string or array.'); + } +} + +/** + * Collects an adoption entry. + * + * @param {string} path - The JSON path for the object where the rule adoption occurred. + * @param {string} ruleName - The name of the rule that was adopted. + */ +export function collectAdoption(path, ruleName) { + collector.add(EntryType.ADOPTION, path, ruleName); +} + +/** + * Collects an exception entry. + * + * @param object the object to evaluate + * @param {string} path - The JSON path for the object where the rule exception occurred. + * @param {string} ruleName - The name of the rule that the exception is defined for. + */ +export function collectException(object, ruleName, path) { + let exceptionReason = object[EXCEPTION_EXTENSION][ruleName]; + if (exceptionReason) { + collector.add(EntryType.EXCEPTION, path, ruleName, exceptionReason); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/utils/exceptions.js b/tools/spectral/ipa/rulesets/functions/utils/exceptions.js index 91b6f84f68..256cab89dc 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/exceptions.js +++ b/tools/spectral/ipa/rulesets/functions/utils/exceptions.js @@ -1,4 +1,4 @@ -const EXCEPTION_EXTENSION = 'x-xgen-IPA-exception'; +export const EXCEPTION_EXTENSION = 'x-xgen-IPA-exception'; /** * Checks if the object has an exception extension "x-xgen-IPA-exception"