diff --git a/.github/workflows/code-health-tools.yml b/.github/workflows/code-health-tools.yml index 764b2b3c67..0456e24cd6 100644 --- a/.github/workflows/code-health-tools.yml +++ b/.github/workflows/code-health-tools.yml @@ -87,6 +87,14 @@ jobs: - name: Run ESLint on JS files run: | npm run lint-js + - name: Check IPA docs up-to-date + run: | + npm run gen-ipa-docs + if [[ -n $(git status --porcelain) ]]; then + echo "IPA docs not up to date, please run 'npm run gen-ipa-docs' and commit the changes" + exit 1 + fi + exit 0 - name: Install Go uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 with: diff --git a/package-lock.json b/package-lock.json index 73b6838083..ded80a64f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "apache-arrow": "^19.0.0", "dotenv": "^16.4.7", "eslint-plugin-jest": "^28.10.0", + "markdown-table": "^3.0.4", "openapi-to-postmanv2": "4.25.0", "parquet-wasm": "^0.6.1" }, @@ -30,6 +31,9 @@ "globals": "^15.14.0", "jest": "^29.7.0", "prettier": "3.5.0" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@ampproject/remapping": { @@ -8876,6 +8880,15 @@ "integrity": "sha512-Trz4v0+XWlwy68LJIyw3bLbsJiC8XAbRCKF9DbEtZjyndKOGVx6n+wNB0VfoRmY2LKboQLeniap3xrb6LGSJ8A==", "license": "MIT" }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/package.json b/package.json index b045a2db2e..4c9030b62e 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "format": "npx prettier . --write", "format-check": "npx prettier . --check", "lint-js": "npx eslint **/*.js", + "gen-ipa-docs": "node tools/spectral/ipa/scripts/generateRulesetReadme.js", "ipa-validation": "spectral lint ./openapi/v2.yaml --ruleset=./tools/spectral/ipa/ipa-spectral.yaml", "test": "jest" }, @@ -28,6 +29,7 @@ "apache-arrow": "^19.0.0", "dotenv": "^16.4.7", "eslint-plugin-jest": "^28.10.0", + "markdown-table": "^3.0.4", "openapi-to-postmanv2": "4.25.0", "parquet-wasm": "^0.6.1" }, diff --git a/tools/spectral/ipa/metrics/metricCollection.js b/tools/spectral/ipa/metrics/metricCollection.js index 059e5f8740..2254c9ec6a 100644 --- a/tools/spectral/ipa/metrics/metricCollection.js +++ b/tools/spectral/ipa/metrics/metricCollection.js @@ -3,10 +3,9 @@ import { extractTeamOwnership, getSeverityPerRule, loadCollectorResults, - loadOpenAPIFile, - loadRuleset, merge, } from './utils/metricCollectionUtils.js'; +import { loadJsonFile, loadRuleset } from '../utils.js'; export async function runMetricCollectionJob( { @@ -18,7 +17,7 @@ export async function runMetricCollectionJob( ) { try { console.log(`Loading OpenAPI file: ${oasFilePath}`); - const oasContent = loadOpenAPIFile(oasFilePath); + const oasContent = loadJsonFile(oasFilePath); console.log('Extracting team ownership data...'); const ownershipData = extractTeamOwnership(oasContent); diff --git a/tools/spectral/ipa/metrics/utils/metricCollectionUtils.js b/tools/spectral/ipa/metrics/utils/metricCollectionUtils.js index 8222a40d1b..eabf88ccbf 100644 --- a/tools/spectral/ipa/metrics/utils/metricCollectionUtils.js +++ b/tools/spectral/ipa/metrics/utils/metricCollectionUtils.js @@ -1,15 +1,5 @@ -import fs from 'node:fs'; -import { bundleAndLoadRuleset } from '@stoplight/spectral-ruleset-bundler/with-loader'; import { EntryType } from '../collector.js'; - -export function loadOpenAPIFile(filePath) { - try { - const content = fs.readFileSync(filePath, 'utf8'); - return JSON.parse(content); - } catch (error) { - throw new Error(`Failed to load OpenAPI file: ${error.message}`); - } -} +import { loadJsonFile } from '../../utils.js'; export function getSeverityPerRule(ruleset) { const rules = ruleset.rules || {}; @@ -20,16 +10,6 @@ export function getSeverityPerRule(ruleset) { return map; } -export async function loadRuleset(rulesetPath, spectral) { - try { - const ruleset = await bundleAndLoadRuleset(rulesetPath, { fs, fetch }); - await spectral.setRuleset(ruleset); - return ruleset; - } catch (error) { - throw new Error(`Failed to load ruleset: ${error.message}`); - } -} - export function extractTeamOwnership(oasContent) { const ownerTeams = {}; const paths = oasContent.paths || {}; @@ -53,15 +33,14 @@ export function extractTeamOwnership(oasContent) { export function loadCollectorResults(collectorResultsFilePath) { try { - const content = fs.readFileSync(collectorResultsFilePath, 'utf8'); - const contentParsed = JSON.parse(content); + const content = loadJsonFile(collectorResultsFilePath); return { - [EntryType.VIOLATION]: contentParsed[EntryType.VIOLATION], - [EntryType.ADOPTION]: contentParsed[EntryType.ADOPTION], - [EntryType.EXCEPTION]: contentParsed[EntryType.EXCEPTION], + [EntryType.VIOLATION]: content[EntryType.VIOLATION], + [EntryType.ADOPTION]: content[EntryType.ADOPTION], + [EntryType.EXCEPTION]: content[EntryType.EXCEPTION], }; } catch (error) { - throw new Error(`Failed to load Collector Results file: ${error.message}`); + throw new Error(`Failed to parse Collector Results: ${error.message}`); } } diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md new file mode 100644 index 0000000000..1698192e74 --- /dev/null +++ b/tools/spectral/ipa/rulesets/README.md @@ -0,0 +1,60 @@ + + +# IPA Validation Rules + +All Spectral rules used in the IPA validation are defined in rulesets grouped by IPA number (`IPA-XXX.yaml`). These rulesets are imported into the main IPA ruleset [ipa-spectral.yaml](https://github.com/mongodb/openapi/blob/main/tools/spectral/ipa/ipa-spectral.yaml) which is used for running the validation. + +## Rulesets + +The tables below lists all available rules, their descriptions and severity level. + +### IPA-005 + +For rule definitions, see [IPA-005.yaml](https://github.com/mongodb/openapi/blob/main/tools/spectral/ipa/rulesets/IPA-005.yaml). + +| Rule Name | Description | Severity | +| --------------------------------------- | ------------------------------------------------------------------------ | -------- | +| xgen-IPA-005-exception-extension-format | IPA exception extensions must follow the correct format. http://go/ipa/5 | warn | + +### IPA-102 + +For rule definitions, see [IPA-102.yaml](https://github.com/mongodb/openapi/blob/main/tools/spectral/ipa/rulesets/IPA-102.yaml). + +| Rule Name | Description | Severity | +| ---------------------------------------------------- | -------------------------------------------------------------------------------- | -------- | +| xgen-IPA-102-path-alternate-resource-name-path-param | Paths should alternate between resource names and path params. http://go/ipa/102 | warn | + +### IPA-104 + +For rule definitions, see [IPA-104.yaml](https://github.com/mongodb/openapi/blob/main/tools/spectral/ipa/rulesets/IPA-104.yaml). + +| Rule Name | Description | Severity | +| ----------------------------- | --------------------------------------------------------------- | -------- | +| xgen-IPA-104-resource-has-GET | APIs must provide a get method for resources. http://go/ipa/104 | warn | + +### IPA-109 + +For rule definitions, see [IPA-109.yaml](https://github.com/mongodb/openapi/blob/main/tools/spectral/ipa/rulesets/IPA-109.yaml). + +| Rule Name | Description | Severity | +| ---------------------------------------------- | ------------------------------------------------------------------------- | -------- | +| xgen-IPA-109-custom-method-must-be-GET-or-POST | The HTTP method for custom methods must be GET or POST. http://go/ipa/109 | warn | +| xgen-IPA-109-custom-method-must-use-camel-case | The custom method must use camelCase format. http://go/ipa/109 | warn | + +### IPA-113 + +For rule definitions, see [IPA-113.yaml](https://github.com/mongodb/openapi/blob/main/tools/spectral/ipa/rulesets/IPA-113.yaml). + +| Rule Name | Description | Severity | +| --------------------------------------- | ------------------------------------------------------------------------------------------- | -------- | +| xgen-IPA-113-singleton-must-not-have-id | Singleton resources must not have a user-provided or system-generated ID. http://go/ipa/113 | warn | + +### IPA-123 + +For rule definitions, see [IPA-123.yaml](https://github.com/mongodb/openapi/blob/main/tools/spectral/ipa/rulesets/IPA-123.yaml). + +| Rule Name | Description | Severity | +| ------------------------------------------------- | ------------------------------------------------------- | -------- | +| xgen-IPA-123-enum-values-must-be-upper-snake-case | Enum values must be UPPER_SNAKE_CASE. http://go/ipa/123 | warn | + + diff --git a/tools/spectral/ipa/scripts/generateRulesetReadme.js b/tools/spectral/ipa/scripts/generateRulesetReadme.js new file mode 100644 index 0000000000..5e39fa18b6 --- /dev/null +++ b/tools/spectral/ipa/scripts/generateRulesetReadme.js @@ -0,0 +1,100 @@ +import fs from 'node:fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import spectral from '@stoplight/spectral-core'; +import { markdownTable } from 'markdown-table'; +import { loadRuleset } from '../utils.js'; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const readmeFilePath = path.join(dirname, '../rulesets', 'README.md'); + +const rulesetsSection = await getRulesetsSection(); + +const fileContent = + '\n\n' + + '# IPA Validation Rules\n\n' + + 'All Spectral rules used in the IPA validation are defined in rulesets grouped by IPA number (`IPA-XXX.yaml`). These rulesets are imported into the main IPA ruleset [ipa-spectral.yaml](https://github.com/mongodb/openapi/blob/main/tools/spectral/ipa/ipa-spectral.yaml) which is used for running the validation.\n\n' + + `${rulesetsSection}` + + '\n'; + +fs.writeFile(readmeFilePath, fileContent, (error) => { + if (error) { + console.error('Error while generating the IPA rulesets README.md:', error); + process.exit(1); + } +}); + +async function getRulesetsSection() { + let content = + '## Rulesets\n\n' + 'The tables below lists all available rules, their descriptions and severity level.\n\n'; + + const rules = await getAllRules(); + const ruleNames = Object.keys(rules); + const ipaNumbers = getIpaNumbers(ruleNames); + + ipaNumbers.forEach((ipaNumber) => { + const ipaRules = filterRulesByIpaNumber(ipaNumber, rules); + const table = generateRulesetTable(ipaRules); + content += + `### ${ipaNumber}\n\n` + `For rule definitions, see ${getIpaRulesetUrl(ipaNumber)}.\n\n` + `${table}\n\n`; + }); + + return content; +} + +function generateRulesetTable(rules) { + const table = [['Rule Name', 'Description', 'Severity']]; + const tableRows = []; + + const ruleNames = Object.keys(rules); + ruleNames.forEach((ruleName) => { + const rule = rules[ruleName]; + tableRows.push([ruleName, rule.description, rule.definition.severity]); + }); + + tableRows.sort(sortBySeverity); + tableRows.forEach((row) => table.push(row)); + return markdownTable(table); +} + +async function getAllRules() { + const rulesetFilePath = path.join(dirname, '..', 'ipa-spectral.yaml'); + const { Spectral } = spectral; + const ruleset = await loadRuleset(rulesetFilePath, new Spectral()); + return ruleset.rules; +} + +function getIpaNumbers(ruleNames) { + const ipaNumbers = []; + ruleNames.forEach((name) => { + const ipaName = name.substring(5, 12); + if (!ipaNumbers.includes(ipaName)) { + ipaNumbers.push(ipaName); + } + }); + return ipaNumbers.sort(); +} + +function getIpaRulesetUrl(ipaNumber) { + return `[${ipaNumber}.yaml](https://github.com/mongodb/openapi/blob/main/tools/spectral/ipa/rulesets/${ipaNumber}.yaml)`; +} + +function filterRulesByIpaNumber(ipaNumber, rules) { + return Object.keys(rules) + .filter((key) => key.includes(ipaNumber)) + .reduce((obj, key) => { + return { + ...obj, + [key]: rules[key], + }; + }, {}); +} + +function sortBySeverity(a, b) { + if (a[2] < b[2]) { + return -1; + } else if (a[2] > b[2]) { + return 1; + } + return 0; +} diff --git a/tools/spectral/ipa/utils.js b/tools/spectral/ipa/utils.js new file mode 100644 index 0000000000..632f25fe19 --- /dev/null +++ b/tools/spectral/ipa/utils.js @@ -0,0 +1,21 @@ +import { bundleAndLoadRuleset } from '@stoplight/spectral-ruleset-bundler/with-loader'; +import fs from 'node:fs'; + +export async function loadRuleset(rulesetPath, spectral) { + try { + const ruleset = await bundleAndLoadRuleset(rulesetPath, { fs, fetch }); + await spectral.setRuleset(ruleset); + return ruleset; + } catch (error) { + throw new Error(`Failed to load ruleset: ${error.message}`); + } +} + +export function loadJsonFile(filePath) { + try { + const content = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(content); + } catch (error) { + throw new Error(`Failed to load file: ${error.message}`); + } +}