From b43e9b32bea3033e6b7e9d862690b04b34869b12 Mon Sep 17 00:00:00 2001 From: "mongodb-sage-bot[bot]" <247496174+mongodb-sage-bot[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:11:49 +0000 Subject: [PATCH 1/5] CLOUDP-373281: support consumer-provided IPA word lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Proposed changes Lets consumers of the IPA validation ruleset (e.g. MMS) supply their own additions to the shared `ignoreList` and `grammaticalWords` lists used by the casing rules `xgen-IPA-117-operation-summary-format` and `xgen-IPA-126-tag-names-should-use-title-case`, without requiring a new release of the `@mongodb-js/ipa-validation-ruleset` package. Investigation findings: - The lists were never hardcoded in the rule functions — they are already passed via Spectral `functionOptions` in `IPA-117.yaml` / `IPA-126.yaml`. Spectral's function API does support consumer-provided configuration, but overriding `functionOptions` requires a consumer to re-declare the whole rule, which is the source of the friction described in the ticket. - Recommended approach (implemented here as the PoC): a consumer points the `IPA_WORD_LISTS` environment variable at a JSON file they own (living in their repository, e.g. MMS). A new `resolveWordLists` helper merges those words, de-duplicated, with the package-provided defaults. Consumers can only add words, never remove package-provided ones. - The openapi repository's own CI does not set `IPA_WORD_LISTS`, so its validation behaviour is unchanged; there are no CI or test implications for this repo, and the lists remain owned by the package by default. Changes: - Add `rulesets/functions/utils/wordLists.js` with `loadExtraWordLists` and `resolveWordLists`, reading and caching the optional JSON file. - Wire `resolveWordLists(options)` into the IPA-117 and IPA-126 rule functions. - Document the `IPA_WORD_LISTS` option for consumers in the IPA README. _Jira ticket:_ CLOUDP-373281 ## Checklist - [ ] I have signed the [MongoDB CLA](https://www.mongodb.com/legal/contributor-agreement) - [x] I have added tests that prove my fix is effective or that my feature works ### Changes to Spectral - [x] I have read the [README](../tools/spectral/README.md) file for Spectral Updates ## Further comments Behaviour is fully backwards compatible: when `IPA_WORD_LISTS` is unset, the package-provided lists from `functionOptions` are used unchanged. Unit tests cover the loader and merge/de-duplication logic, and an end-to-end test runs both rules through Spectral to prove a consumer-supplied acronym (`ignoreList`) and grammatical word (`grammaticalWords`) change validation output only when the env var is set. --- tools/spectral/ipa/README.md | 27 +++++++ .../ipa/__tests__/ConsumerWordLists.test.js | 74 +++++++++++++++++ .../ipa/__tests__/utils/wordLists.test.js | 79 +++++++++++++++++++ .../functions/IPA117OperationSummaryFormat.js | 4 +- .../IPA126TagNamesShouldUseTitleCase.js | 4 +- .../ipa/rulesets/functions/utils/wordLists.js | 68 ++++++++++++++++ 6 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 tools/spectral/ipa/__tests__/ConsumerWordLists.test.js create mode 100644 tools/spectral/ipa/__tests__/utils/wordLists.test.js create mode 100644 tools/spectral/ipa/rulesets/functions/utils/wordLists.js diff --git a/tools/spectral/ipa/README.md b/tools/spectral/ipa/README.md index e317e5d5a9..22e6f7bfd8 100644 --- a/tools/spectral/ipa/README.md +++ b/tools/spectral/ipa/README.md @@ -76,6 +76,33 @@ overrides: x-xgen-IPA-xxx-rule: 'off' ``` +#### Consumer-Provided Word Lists + +The casing rules `xgen-IPA-117-operation-summary-format` and `xgen-IPA-126-tag-names-should-use-title-case` +use two shared lists: + +- `ignoreList`: words allowed to keep their specific casing (e.g. acronyms such as `API`, `AWS`, `DNS`) +- `grammaticalWords`: common words allowed to stay lowercase in titles (e.g. `and`, `or`, `the`) + +The ruleset ships with default lists, but consumers can supply **additional** words without editing the +ruleset or waiting for a new package release. Point the `IPA_WORD_LISTS` environment variable at a JSON +file that lives in your own repository: + +```json +{ + "ignoreList": ["MMS"], + "grammaticalWords": ["per"] +} +``` + +``` +IPA_WORD_LISTS=./ipa-word-lists.json spectral lint --ruleset= +``` + +Consumer-provided words are merged with (and de-duplicated against) the package defaults — they only ever +add words, never remove them. When `IPA_WORD_LISTS` is unset (as in this repository's own CI), the +package-provided lists are used unchanged. + ### CI/CD Integration #### GitHub Actions Example diff --git a/tools/spectral/ipa/__tests__/ConsumerWordLists.test.js b/tools/spectral/ipa/__tests__/ConsumerWordLists.test.js new file mode 100644 index 0000000000..0b84fdf67f --- /dev/null +++ b/tools/spectral/ipa/__tests__/ConsumerWordLists.test.js @@ -0,0 +1,74 @@ +import { afterAll, describe, expect, it } from '@jest/globals'; +import * as fs from 'node:fs'; +import os from 'node:os'; +import * as path from 'node:path'; +import { Spectral } from '@stoplight/spectral-core'; +import { httpAndFileResolver } from '@stoplight/spectral-ref-resolver'; +import { bundleAndLoadRuleset } from '@stoplight/spectral-ruleset-bundler/with-loader'; +import { WORD_LISTS_ENV_VAR } from '../rulesets/functions/utils/wordLists.js'; + +// End-to-end proof that consumer-provided word lists (referenced via the +// IPA_WORD_LISTS environment variable) are merged into the package-provided lists +// and change validation behaviour for both IPA-117 and IPA-126, without editing the ruleset. +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ipa-consumer-lists-')); +const listsFile = path.join(tmpDir, 'lists.json'); +fs.writeFileSync(listsFile, JSON.stringify({ ignoreList: ['MMS'], grammaticalWords: ['per'] })); + +async function runRule(ruleName, rulesetFile, document) { + const rulesetPath = path.join(__dirname, '../rulesets', rulesetFile); + const s = new Spectral({ resolver: httpAndFileResolver }); + const ruleset = Object(await bundleAndLoadRuleset(rulesetPath, { fs, fetch })).toJSON(); + const scopedRuleset = { rules: { [ruleName]: ruleset.rules[ruleName].definition } }; + if (ruleset.aliases) { + scopedRuleset.aliases = ruleset.aliases; + } + s.setRuleset(scopedRuleset); + return s.run(JSON.stringify(document)); +} + +describe('Consumer-provided word lists via IPA_WORD_LISTS', () => { + const originalEnv = process.env[WORD_LISTS_ENV_VAR]; + + afterAll(() => { + if (originalEnv === undefined) { + delete process.env[WORD_LISTS_ENV_VAR]; + } else { + process.env[WORD_LISTS_ENV_VAR] = originalEnv; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('xgen-IPA-126-tag-names-should-use-title-case (ignoreList)', () => { + const document = { tags: [{ name: 'MMS Alerts' }] }; + + it('flags a consumer acronym that is not in the package ignoreList', async () => { + delete process.env[WORD_LISTS_ENV_VAR]; + const errors = await runRule('xgen-IPA-126-tag-names-should-use-title-case', 'IPA-126.yaml', document); + expect(errors).toHaveLength(1); + expect(errors[0].code).toEqual('xgen-IPA-126-tag-names-should-use-title-case'); + }); + + it('accepts the consumer acronym once supplied via the env var', async () => { + process.env[WORD_LISTS_ENV_VAR] = listsFile; + const errors = await runRule('xgen-IPA-126-tag-names-should-use-title-case', 'IPA-126.yaml', document); + expect(errors).toHaveLength(0); + }); + }); + + describe('xgen-IPA-117-operation-summary-format (grammaticalWords)', () => { + const document = { paths: { '/resource': { get: { summary: 'Return One Resource per Project' } } } }; + + it('flags a consumer grammatical word that is not in the package list', async () => { + delete process.env[WORD_LISTS_ENV_VAR]; + const errors = await runRule('xgen-IPA-117-operation-summary-format', 'IPA-117.yaml', document); + expect(errors).toHaveLength(1); + expect(errors[0].code).toEqual('xgen-IPA-117-operation-summary-format'); + }); + + it('accepts the consumer grammatical word once supplied via the env var', async () => { + process.env[WORD_LISTS_ENV_VAR] = listsFile; + const errors = await runRule('xgen-IPA-117-operation-summary-format', 'IPA-117.yaml', document); + expect(errors).toHaveLength(0); + }); + }); +}); diff --git a/tools/spectral/ipa/__tests__/utils/wordLists.test.js b/tools/spectral/ipa/__tests__/utils/wordLists.test.js new file mode 100644 index 0000000000..d6417ab645 --- /dev/null +++ b/tools/spectral/ipa/__tests__/utils/wordLists.test.js @@ -0,0 +1,79 @@ +import { afterAll, describe, expect, it } from '@jest/globals'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { loadExtraWordLists, resolveWordLists, WORD_LISTS_ENV_VAR } from '../../rulesets/functions/utils/wordLists.js'; + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ipa-word-lists-')); +let fileCounter = 0; + +// The loader caches by file path, so use a unique file per test to keep them isolated. +function writeListsFile(contents) { + const filePath = path.join(tmpDir, `lists-${fileCounter++}.json`); + fs.writeFileSync(filePath, typeof contents === 'string' ? contents : JSON.stringify(contents)); + return filePath; +} + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('tools/spectral/ipa/rulesets/functions/utils/wordLists.js', () => { + describe('loadExtraWordLists', () => { + it('returns empty lists when the env var is unset', () => { + expect(loadExtraWordLists({})).toEqual({ ignoreList: [], grammaticalWords: [] }); + }); + + it('reads both lists from the referenced file', () => { + const filePath = writeListsFile({ ignoreList: ['MMS'], grammaticalWords: ['per'] }); + expect(loadExtraWordLists({ [WORD_LISTS_ENV_VAR]: filePath })).toEqual({ + ignoreList: ['MMS'], + grammaticalWords: ['per'], + }); + }); + + it('defaults missing lists to empty arrays', () => { + const filePath = writeListsFile({ ignoreList: ['MMS'] }); + expect(loadExtraWordLists({ [WORD_LISTS_ENV_VAR]: filePath })).toEqual({ + ignoreList: ['MMS'], + grammaticalWords: [], + }); + }); + + it('throws a descriptive error when the file cannot be parsed', () => { + const filePath = writeListsFile('{ not json'); + expect(() => loadExtraWordLists({ [WORD_LISTS_ENV_VAR]: filePath })).toThrow(WORD_LISTS_ENV_VAR); + }); + }); + + describe('resolveWordLists', () => { + const options = { ignoreList: ['API', 'AWS'], grammaticalWords: ['and', 'or'] }; + + it('returns the package-provided lists unchanged when no additions are configured', () => { + expect(resolveWordLists(options, {})).toEqual({ + ignoreList: ['API', 'AWS'], + grammaticalWords: ['and', 'or'], + }); + }); + + it('merges consumer additions with the package-provided lists', () => { + const filePath = writeListsFile({ ignoreList: ['MMS'], grammaticalWords: ['per'] }); + expect(resolveWordLists(options, { [WORD_LISTS_ENV_VAR]: filePath })).toEqual({ + ignoreList: ['API', 'AWS', 'MMS'], + grammaticalWords: ['and', 'or', 'per'], + }); + }); + + it('de-duplicates words already present in the package-provided lists', () => { + const filePath = writeListsFile({ ignoreList: ['AWS', 'MMS'], grammaticalWords: ['and'] }); + expect(resolveWordLists(options, { [WORD_LISTS_ENV_VAR]: filePath })).toEqual({ + ignoreList: ['API', 'AWS', 'MMS'], + grammaticalWords: ['and', 'or'], + }); + }); + + it('handles undefined options', () => { + expect(resolveWordLists(undefined, {})).toEqual({ ignoreList: [], grammaticalWords: [] }); + }); + }); +}); diff --git a/tools/spectral/ipa/rulesets/functions/IPA117OperationSummaryFormat.js b/tools/spectral/ipa/rulesets/functions/IPA117OperationSummaryFormat.js index 3356bd2198..f715c07ddf 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA117OperationSummaryFormat.js +++ b/tools/spectral/ipa/rulesets/functions/IPA117OperationSummaryFormat.js @@ -1,10 +1,12 @@ import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; import { isTitleCase } from './utils/casing.js'; import { resolveObject } from './utils/componentUtils.js'; +import { resolveWordLists } from './utils/wordLists.js'; -export default (input, { ignoreList, grammaticalWords }, { path, rule, documentInventory }) => { +export default (input, options, { path, rule, documentInventory }) => { const operationObjectPath = path.slice(0, -1); const operationObject = resolveObject(documentInventory.resolved, operationObjectPath); + const { ignoreList, grammaticalWords } = resolveWordLists(options); const errors = checkViolationsAndReturnErrors(input, ignoreList, grammaticalWords, operationObjectPath, rule.name); return evaluateAndCollectAdoptionStatus(errors, rule.name, operationObject, operationObjectPath); }; diff --git a/tools/spectral/ipa/rulesets/functions/IPA126TagNamesShouldUseTitleCase.js b/tools/spectral/ipa/rulesets/functions/IPA126TagNamesShouldUseTitleCase.js index d29d3de912..f41eaf4f66 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA126TagNamesShouldUseTitleCase.js +++ b/tools/spectral/ipa/rulesets/functions/IPA126TagNamesShouldUseTitleCase.js @@ -1,9 +1,11 @@ import { evaluateAndCollectAdoptionStatus } from './utils/collectionUtils.js'; import { isTitleCase } from './utils/casing.js'; +import { resolveWordLists } from './utils/wordLists.js'; -export default (input, { ignoreList, grammaticalWords }, { path, rule }) => { +export default (input, options, { path, rule }) => { const ruleName = rule.name; const tagName = input.name; + const { ignoreList, grammaticalWords } = resolveWordLists(options); // Check if the tag name uses Title Case let errors = []; diff --git a/tools/spectral/ipa/rulesets/functions/utils/wordLists.js b/tools/spectral/ipa/rulesets/functions/utils/wordLists.js new file mode 100644 index 0000000000..b1ac26b54a --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/utils/wordLists.js @@ -0,0 +1,68 @@ +import fs from 'node:fs'; + +/** + * Environment variable that consumers of the ruleset (e.g. MMS) can point at a JSON + * file to extend the shared `ignoreList` and `grammaticalWords` configuration used by + * IPA-117 and IPA-126. This lets consumers supply their own words without requiring a + * new release of the ruleset package. + * + * The referenced JSON file may define `ignoreList` and/or `grammaticalWords` arrays: + * { "ignoreList": ["MMS"], "grammaticalWords": ["per"] } + * + * The openapi repository's own CI does not set this variable, so the package-provided + * lists in the rulesets are used unchanged. + */ +export const WORD_LISTS_ENV_VAR = 'IPA_WORD_LISTS'; + +const EMPTY_LISTS = { ignoreList: [], grammaticalWords: [] }; + +// Cache parsed files by path. The env var is fixed for the lifetime of a validation +// run, so this avoids re-reading the same file for every tag or operation summary. +const cacheByPath = new Map(); + +/** + * Reads consumer-provided word list additions from the JSON file referenced by the + * IPA_WORD_LISTS environment variable. Returns empty lists when the variable is unset. + * + * @param {NodeJS.ProcessEnv} env the environment to read the variable from + * @returns {{ignoreList: Array, grammaticalWords: Array}} the additions + */ +export function loadExtraWordLists(env = process.env) { + const filePath = env[WORD_LISTS_ENV_VAR]; + if (!filePath) { + return EMPTY_LISTS; + } + if (cacheByPath.has(filePath)) { + return cacheByPath.get(filePath); + } + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (e) { + throw new Error(`Failed to load ${WORD_LISTS_ENV_VAR} from "${filePath}": ${e.message}`, { cause: e }); + } + const lists = { + ignoreList: parsed.ignoreList ?? [], + grammaticalWords: parsed.grammaticalWords ?? [], + }; + cacheByPath.set(filePath, lists); + return lists; +} + +/** + * Merges the package-provided lists (from the rule's functionOptions) with any + * consumer-provided additions, de-duplicating entries. Consumer additions only ever + * add words, they never remove package-provided ones. + * + * @param {{ignoreList?: Array, grammaticalWords?: Array}} options the rule's functionOptions + * @param {NodeJS.ProcessEnv} env the environment to read consumer additions from + * @returns {{ignoreList: Array, grammaticalWords: Array}} the resolved lists + */ +export function resolveWordLists(options = {}, env = process.env) { + const { ignoreList = [], grammaticalWords = [] } = options ?? {}; + const extra = loadExtraWordLists(env); + return { + ignoreList: [...new Set([...ignoreList, ...extra.ignoreList])], + grammaticalWords: [...new Set([...grammaticalWords, ...extra.grammaticalWords])], + }; +} From a98f8fdb8a2a629a757e54ccfbb5add5f26d2f31 Mon Sep 17 00:00:00 2001 From: "mongodb-sage-bot[bot]" <247496174+mongodb-sage-bot[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:56:24 +0000 Subject: [PATCH 2/5] Use native Spectral functionOptions for word lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the custom IPA_WORD_LISTS environment-variable file loader with Spectral's native functionOptions mechanism for the ignoreList and grammaticalWords configuration used by xgen-IPA-117-operation-summary-format and xgen-IPA-126-tag-names-should-use-title-case. Research finding (addressing @wtrocki's feedback): the rules already receive their lists through functionOptions, and consumers extending the ruleset can override them natively by redeclaring the rule with their own functionOptions in .spectral.yaml. No bespoke env-var loader is required, which avoids the definition-duplication and synchronization concern for the openapi repository's own validation — the lists in IPA-117.yaml and IPA-126.yaml stay the canonical package defaults used by this repo's CI and are unaffected by consumer overrides. Changes: - Remove rulesets/functions/utils/wordLists.js and its unit tests. - Revert the IPA-117 and IPA-126 rule functions to destructure ignoreList and grammaticalWords directly from functionOptions, matching the existing pattern used by other rule functions. - Rewrite the README "Consumer-Provided Word Lists" section to document the native functionOptions override approach and remove MMS-specific mentions. - Rework the end-to-end test to prove consumers can override the lists via functionOptions without editing the package. --- tools/spectral/ipa/README.md | 45 ++++++----- .../ipa/__tests__/ConsumerWordLists.test.js | 69 ++++++++-------- .../ipa/__tests__/utils/wordLists.test.js | 79 ------------------- .../functions/IPA117OperationSummaryFormat.js | 4 +- .../IPA126TagNamesShouldUseTitleCase.js | 4 +- .../ipa/rulesets/functions/utils/wordLists.js | 68 ---------------- 6 files changed, 63 insertions(+), 206 deletions(-) delete mode 100644 tools/spectral/ipa/__tests__/utils/wordLists.test.js delete mode 100644 tools/spectral/ipa/rulesets/functions/utils/wordLists.js diff --git a/tools/spectral/ipa/README.md b/tools/spectral/ipa/README.md index 22e6f7bfd8..55814340ed 100644 --- a/tools/spectral/ipa/README.md +++ b/tools/spectral/ipa/README.md @@ -79,29 +79,38 @@ overrides: #### Consumer-Provided Word Lists The casing rules `xgen-IPA-117-operation-summary-format` and `xgen-IPA-126-tag-names-should-use-title-case` -use two shared lists: +use two configurable lists: - `ignoreList`: words allowed to keep their specific casing (e.g. acronyms such as `API`, `AWS`, `DNS`) - `grammaticalWords`: common words allowed to stay lowercase in titles (e.g. `and`, `or`, `the`) -The ruleset ships with default lists, but consumers can supply **additional** words without editing the -ruleset or waiting for a new package release. Point the `IPA_WORD_LISTS` environment variable at a JSON -file that lives in your own repository: +Both lists are passed to the rule functions through Spectral's native `functionOptions`. Consumers who +extend the ruleset can provide their own lists by redeclaring the rule in their `.spectral.yaml`, without +editing this package or waiting for a new release: -```json -{ - "ignoreList": ["MMS"], - "grammaticalWords": ["per"] -} -``` - -``` -IPA_WORD_LISTS=./ipa-word-lists.json spectral lint --ruleset= -``` - -Consumer-provided words are merged with (and de-duplicated against) the package defaults — they only ever -add words, never remove them. When `IPA_WORD_LISTS` is unset (as in this repository's own CI), the -package-provided lists are used unchanged. +```yaml +extends: + - '@mongodb/ipa-validation-ruleset' + +rules: + xgen-IPA-126-tag-names-should-use-title-case: + given: $.tags[?(@.name && @.name.length > 0)] + then: + function: IPA126TagNamesShouldUseTitleCase + functionOptions: + ignoreList: + - API + - AWS + # ...the words this repository needs + grammaticalWords: + - and + - or +``` + +Because Spectral requires the whole rule to be redeclared when overriding `functionOptions`, consumers own +the complete lists for their repository. The lists defined in `IPA-117.yaml` and `IPA-126.yaml` remain the +canonical package defaults and are the ones used by this repository's own CI, so validation here is +unaffected by any consumer overrides. ### CI/CD Integration diff --git a/tools/spectral/ipa/__tests__/ConsumerWordLists.test.js b/tools/spectral/ipa/__tests__/ConsumerWordLists.test.js index 0b84fdf67f..ccee34ed1b 100644 --- a/tools/spectral/ipa/__tests__/ConsumerWordLists.test.js +++ b/tools/spectral/ipa/__tests__/ConsumerWordLists.test.js @@ -1,24 +1,23 @@ -import { afterAll, describe, expect, it } from '@jest/globals'; +import { describe, expect, it } from '@jest/globals'; import * as fs from 'node:fs'; -import os from 'node:os'; import * as path from 'node:path'; import { Spectral } from '@stoplight/spectral-core'; import { httpAndFileResolver } from '@stoplight/spectral-ref-resolver'; import { bundleAndLoadRuleset } from '@stoplight/spectral-ruleset-bundler/with-loader'; -import { WORD_LISTS_ENV_VAR } from '../rulesets/functions/utils/wordLists.js'; -// End-to-end proof that consumer-provided word lists (referenced via the -// IPA_WORD_LISTS environment variable) are merged into the package-provided lists -// and change validation behaviour for both IPA-117 and IPA-126, without editing the ruleset. -const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ipa-consumer-lists-')); -const listsFile = path.join(tmpDir, 'lists.json'); -fs.writeFileSync(listsFile, JSON.stringify({ ignoreList: ['MMS'], grammaticalWords: ['per'] })); - -async function runRule(ruleName, rulesetFile, document) { +// Proof that consumers extending the ruleset can supply their own `ignoreList` and +// `grammaticalWords` by redeclaring the rule with their own `functionOptions` — the +// native Spectral mechanism — without editing this package. The lists in the shipped +// rulesets remain the canonical defaults used by this repository's own CI. +async function runRuleWithOptions(ruleName, rulesetFile, functionOptions, document) { const rulesetPath = path.join(__dirname, '../rulesets', rulesetFile); const s = new Spectral({ resolver: httpAndFileResolver }); const ruleset = Object(await bundleAndLoadRuleset(rulesetPath, { fs, fetch })).toJSON(); - const scopedRuleset = { rules: { [ruleName]: ruleset.rules[ruleName].definition } }; + const definition = ruleset.rules[ruleName].definition; + if (functionOptions) { + definition.then.functionOptions = functionOptions; + } + const scopedRuleset = { rules: { [ruleName]: definition } }; if (ruleset.aliases) { scopedRuleset.aliases = ruleset.aliases; } @@ -26,31 +25,28 @@ async function runRule(ruleName, rulesetFile, document) { return s.run(JSON.stringify(document)); } -describe('Consumer-provided word lists via IPA_WORD_LISTS', () => { - const originalEnv = process.env[WORD_LISTS_ENV_VAR]; - - afterAll(() => { - if (originalEnv === undefined) { - delete process.env[WORD_LISTS_ENV_VAR]; - } else { - process.env[WORD_LISTS_ENV_VAR] = originalEnv; - } - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - +describe('Consumer-provided word lists via functionOptions', () => { describe('xgen-IPA-126-tag-names-should-use-title-case (ignoreList)', () => { - const document = { tags: [{ name: 'MMS Alerts' }] }; + const document = { tags: [{ name: 'ACME Alerts' }] }; it('flags a consumer acronym that is not in the package ignoreList', async () => { - delete process.env[WORD_LISTS_ENV_VAR]; - const errors = await runRule('xgen-IPA-126-tag-names-should-use-title-case', 'IPA-126.yaml', document); + const errors = await runRuleWithOptions( + 'xgen-IPA-126-tag-names-should-use-title-case', + 'IPA-126.yaml', + null, + document + ); expect(errors).toHaveLength(1); expect(errors[0].code).toEqual('xgen-IPA-126-tag-names-should-use-title-case'); }); - it('accepts the consumer acronym once supplied via the env var', async () => { - process.env[WORD_LISTS_ENV_VAR] = listsFile; - const errors = await runRule('xgen-IPA-126-tag-names-should-use-title-case', 'IPA-126.yaml', document); + it('accepts the consumer acronym once added to functionOptions', async () => { + const errors = await runRuleWithOptions( + 'xgen-IPA-126-tag-names-should-use-title-case', + 'IPA-126.yaml', + { ignoreList: ['ACME'], grammaticalWords: [] }, + document + ); expect(errors).toHaveLength(0); }); }); @@ -59,15 +55,18 @@ describe('Consumer-provided word lists via IPA_WORD_LISTS', () => { const document = { paths: { '/resource': { get: { summary: 'Return One Resource per Project' } } } }; it('flags a consumer grammatical word that is not in the package list', async () => { - delete process.env[WORD_LISTS_ENV_VAR]; - const errors = await runRule('xgen-IPA-117-operation-summary-format', 'IPA-117.yaml', document); + const errors = await runRuleWithOptions('xgen-IPA-117-operation-summary-format', 'IPA-117.yaml', null, document); expect(errors).toHaveLength(1); expect(errors[0].code).toEqual('xgen-IPA-117-operation-summary-format'); }); - it('accepts the consumer grammatical word once supplied via the env var', async () => { - process.env[WORD_LISTS_ENV_VAR] = listsFile; - const errors = await runRule('xgen-IPA-117-operation-summary-format', 'IPA-117.yaml', document); + it('accepts the consumer grammatical word once added to functionOptions', async () => { + const errors = await runRuleWithOptions( + 'xgen-IPA-117-operation-summary-format', + 'IPA-117.yaml', + { ignoreList: [], grammaticalWords: ['per'] }, + document + ); expect(errors).toHaveLength(0); }); }); diff --git a/tools/spectral/ipa/__tests__/utils/wordLists.test.js b/tools/spectral/ipa/__tests__/utils/wordLists.test.js deleted file mode 100644 index d6417ab645..0000000000 --- a/tools/spectral/ipa/__tests__/utils/wordLists.test.js +++ /dev/null @@ -1,79 +0,0 @@ -import { afterAll, describe, expect, it } from '@jest/globals'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { loadExtraWordLists, resolveWordLists, WORD_LISTS_ENV_VAR } from '../../rulesets/functions/utils/wordLists.js'; - -const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ipa-word-lists-')); -let fileCounter = 0; - -// The loader caches by file path, so use a unique file per test to keep them isolated. -function writeListsFile(contents) { - const filePath = path.join(tmpDir, `lists-${fileCounter++}.json`); - fs.writeFileSync(filePath, typeof contents === 'string' ? contents : JSON.stringify(contents)); - return filePath; -} - -afterAll(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); -}); - -describe('tools/spectral/ipa/rulesets/functions/utils/wordLists.js', () => { - describe('loadExtraWordLists', () => { - it('returns empty lists when the env var is unset', () => { - expect(loadExtraWordLists({})).toEqual({ ignoreList: [], grammaticalWords: [] }); - }); - - it('reads both lists from the referenced file', () => { - const filePath = writeListsFile({ ignoreList: ['MMS'], grammaticalWords: ['per'] }); - expect(loadExtraWordLists({ [WORD_LISTS_ENV_VAR]: filePath })).toEqual({ - ignoreList: ['MMS'], - grammaticalWords: ['per'], - }); - }); - - it('defaults missing lists to empty arrays', () => { - const filePath = writeListsFile({ ignoreList: ['MMS'] }); - expect(loadExtraWordLists({ [WORD_LISTS_ENV_VAR]: filePath })).toEqual({ - ignoreList: ['MMS'], - grammaticalWords: [], - }); - }); - - it('throws a descriptive error when the file cannot be parsed', () => { - const filePath = writeListsFile('{ not json'); - expect(() => loadExtraWordLists({ [WORD_LISTS_ENV_VAR]: filePath })).toThrow(WORD_LISTS_ENV_VAR); - }); - }); - - describe('resolveWordLists', () => { - const options = { ignoreList: ['API', 'AWS'], grammaticalWords: ['and', 'or'] }; - - it('returns the package-provided lists unchanged when no additions are configured', () => { - expect(resolveWordLists(options, {})).toEqual({ - ignoreList: ['API', 'AWS'], - grammaticalWords: ['and', 'or'], - }); - }); - - it('merges consumer additions with the package-provided lists', () => { - const filePath = writeListsFile({ ignoreList: ['MMS'], grammaticalWords: ['per'] }); - expect(resolveWordLists(options, { [WORD_LISTS_ENV_VAR]: filePath })).toEqual({ - ignoreList: ['API', 'AWS', 'MMS'], - grammaticalWords: ['and', 'or', 'per'], - }); - }); - - it('de-duplicates words already present in the package-provided lists', () => { - const filePath = writeListsFile({ ignoreList: ['AWS', 'MMS'], grammaticalWords: ['and'] }); - expect(resolveWordLists(options, { [WORD_LISTS_ENV_VAR]: filePath })).toEqual({ - ignoreList: ['API', 'AWS', 'MMS'], - grammaticalWords: ['and', 'or'], - }); - }); - - it('handles undefined options', () => { - expect(resolveWordLists(undefined, {})).toEqual({ ignoreList: [], grammaticalWords: [] }); - }); - }); -}); diff --git a/tools/spectral/ipa/rulesets/functions/IPA117OperationSummaryFormat.js b/tools/spectral/ipa/rulesets/functions/IPA117OperationSummaryFormat.js index f715c07ddf..3356bd2198 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA117OperationSummaryFormat.js +++ b/tools/spectral/ipa/rulesets/functions/IPA117OperationSummaryFormat.js @@ -1,12 +1,10 @@ import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; import { isTitleCase } from './utils/casing.js'; import { resolveObject } from './utils/componentUtils.js'; -import { resolveWordLists } from './utils/wordLists.js'; -export default (input, options, { path, rule, documentInventory }) => { +export default (input, { ignoreList, grammaticalWords }, { path, rule, documentInventory }) => { const operationObjectPath = path.slice(0, -1); const operationObject = resolveObject(documentInventory.resolved, operationObjectPath); - const { ignoreList, grammaticalWords } = resolveWordLists(options); const errors = checkViolationsAndReturnErrors(input, ignoreList, grammaticalWords, operationObjectPath, rule.name); return evaluateAndCollectAdoptionStatus(errors, rule.name, operationObject, operationObjectPath); }; diff --git a/tools/spectral/ipa/rulesets/functions/IPA126TagNamesShouldUseTitleCase.js b/tools/spectral/ipa/rulesets/functions/IPA126TagNamesShouldUseTitleCase.js index f41eaf4f66..d29d3de912 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA126TagNamesShouldUseTitleCase.js +++ b/tools/spectral/ipa/rulesets/functions/IPA126TagNamesShouldUseTitleCase.js @@ -1,11 +1,9 @@ import { evaluateAndCollectAdoptionStatus } from './utils/collectionUtils.js'; import { isTitleCase } from './utils/casing.js'; -import { resolveWordLists } from './utils/wordLists.js'; -export default (input, options, { path, rule }) => { +export default (input, { ignoreList, grammaticalWords }, { path, rule }) => { const ruleName = rule.name; const tagName = input.name; - const { ignoreList, grammaticalWords } = resolveWordLists(options); // Check if the tag name uses Title Case let errors = []; diff --git a/tools/spectral/ipa/rulesets/functions/utils/wordLists.js b/tools/spectral/ipa/rulesets/functions/utils/wordLists.js deleted file mode 100644 index b1ac26b54a..0000000000 --- a/tools/spectral/ipa/rulesets/functions/utils/wordLists.js +++ /dev/null @@ -1,68 +0,0 @@ -import fs from 'node:fs'; - -/** - * Environment variable that consumers of the ruleset (e.g. MMS) can point at a JSON - * file to extend the shared `ignoreList` and `grammaticalWords` configuration used by - * IPA-117 and IPA-126. This lets consumers supply their own words without requiring a - * new release of the ruleset package. - * - * The referenced JSON file may define `ignoreList` and/or `grammaticalWords` arrays: - * { "ignoreList": ["MMS"], "grammaticalWords": ["per"] } - * - * The openapi repository's own CI does not set this variable, so the package-provided - * lists in the rulesets are used unchanged. - */ -export const WORD_LISTS_ENV_VAR = 'IPA_WORD_LISTS'; - -const EMPTY_LISTS = { ignoreList: [], grammaticalWords: [] }; - -// Cache parsed files by path. The env var is fixed for the lifetime of a validation -// run, so this avoids re-reading the same file for every tag or operation summary. -const cacheByPath = new Map(); - -/** - * Reads consumer-provided word list additions from the JSON file referenced by the - * IPA_WORD_LISTS environment variable. Returns empty lists when the variable is unset. - * - * @param {NodeJS.ProcessEnv} env the environment to read the variable from - * @returns {{ignoreList: Array, grammaticalWords: Array}} the additions - */ -export function loadExtraWordLists(env = process.env) { - const filePath = env[WORD_LISTS_ENV_VAR]; - if (!filePath) { - return EMPTY_LISTS; - } - if (cacheByPath.has(filePath)) { - return cacheByPath.get(filePath); - } - let parsed; - try { - parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); - } catch (e) { - throw new Error(`Failed to load ${WORD_LISTS_ENV_VAR} from "${filePath}": ${e.message}`, { cause: e }); - } - const lists = { - ignoreList: parsed.ignoreList ?? [], - grammaticalWords: parsed.grammaticalWords ?? [], - }; - cacheByPath.set(filePath, lists); - return lists; -} - -/** - * Merges the package-provided lists (from the rule's functionOptions) with any - * consumer-provided additions, de-duplicating entries. Consumer additions only ever - * add words, they never remove package-provided ones. - * - * @param {{ignoreList?: Array, grammaticalWords?: Array}} options the rule's functionOptions - * @param {NodeJS.ProcessEnv} env the environment to read consumer additions from - * @returns {{ignoreList: Array, grammaticalWords: Array}} the resolved lists - */ -export function resolveWordLists(options = {}, env = process.env) { - const { ignoreList = [], grammaticalWords = [] } = options ?? {}; - const extra = loadExtraWordLists(env); - return { - ignoreList: [...new Set([...ignoreList, ...extra.ignoreList])], - grammaticalWords: [...new Set([...grammaticalWords, ...extra.grammaticalWords])], - }; -} From 61f1966845259e63a606c7ddf6ea1242926bb9a6 Mon Sep 17 00:00:00 2001 From: "mongodb-sage-bot[bot]" <247496174+mongodb-sage-bot[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:53:52 +0000 Subject: [PATCH 3/5] Disable consumer-owned casing rules in repo validation The vocabulary-based rules xgen-IPA-117-operation-summary-format and xgen-IPA-126-tag-names-should-use-title-case rely on ignoreList and grammaticalWords word lists that are driven by the OpenAPI specs of the downstream repositories consuming this ruleset. Keeping copies of those lists in this repository in sync just to satisfy its own second layer of validation is a maintenance burden: once a consumer updates its lists, this repository's validation would miss the new entries and fail. Address the feedback from @wtrocki and @julius-jogela by turning both rules off in ipa-spectral.yaml, so this repository no longer runs them as its second layer of validation. The rule definitions, their default word lists, and their unit tests remain in IPA-117.yaml and IPA-126.yaml for future development and for consumers who redeclare the rules with their own functionOptions. - Turn off both rules in ipa-spectral.yaml with an explanatory comment. - Update the README "Consumer-Provided Word Lists" section to document that the rules are disabled here and why. - Add a test asserting the full ipa-spectral.yaml ruleset does not run either rule. --- tools/spectral/ipa/README.md | 10 ++++--- .../ipa/__tests__/ConsumerWordLists.test.js | 27 ++++++++++++++++++- tools/spectral/ipa/ipa-spectral.yaml | 11 ++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/tools/spectral/ipa/README.md b/tools/spectral/ipa/README.md index 55814340ed..e66cc708f3 100644 --- a/tools/spectral/ipa/README.md +++ b/tools/spectral/ipa/README.md @@ -108,9 +108,13 @@ rules: ``` Because Spectral requires the whole rule to be redeclared when overriding `functionOptions`, consumers own -the complete lists for their repository. The lists defined in `IPA-117.yaml` and `IPA-126.yaml` remain the -canonical package defaults and are the ones used by this repository's own CI, so validation here is -unaffected by any consumer overrides. +the complete lists for their repository. The lists defined in `IPA-117.yaml` and `IPA-126.yaml` are the +package defaults consumers inherit when they don't override them. + +Since these word lists are driven by the OpenAPI specs of the consuming repositories, keeping copies of them +in sync just to satisfy this repository's own validation would be a maintenance burden. For that reason both +rules are turned `off` in `ipa-spectral.yaml`, so this repository's second layer of validation does not run +them. The rule definitions and their tests are kept for development and remain available to consumers. ### CI/CD Integration diff --git a/tools/spectral/ipa/__tests__/ConsumerWordLists.test.js b/tools/spectral/ipa/__tests__/ConsumerWordLists.test.js index ccee34ed1b..f02ad1c6d6 100644 --- a/tools/spectral/ipa/__tests__/ConsumerWordLists.test.js +++ b/tools/spectral/ipa/__tests__/ConsumerWordLists.test.js @@ -8,7 +8,8 @@ import { bundleAndLoadRuleset } from '@stoplight/spectral-ruleset-bundler/with-l // Proof that consumers extending the ruleset can supply their own `ignoreList` and // `grammaticalWords` by redeclaring the rule with their own `functionOptions` — the // native Spectral mechanism — without editing this package. The lists in the shipped -// rulesets remain the canonical defaults used by this repository's own CI. +// rulesets remain the defaults consumers inherit; this repository disables the two rules +// in ipa-spectral.yaml so it does not have to keep those consumer-owned lists in sync. async function runRuleWithOptions(ruleName, rulesetFile, functionOptions, document) { const rulesetPath = path.join(__dirname, '../rulesets', rulesetFile); const s = new Spectral({ resolver: httpAndFileResolver }); @@ -25,6 +26,14 @@ async function runRuleWithOptions(ruleName, rulesetFile, functionOptions, docume return s.run(JSON.stringify(document)); } +async function runFullRuleset(document) { + const rulesetPath = path.join(__dirname, '../ipa-spectral.yaml'); + const s = new Spectral({ resolver: httpAndFileResolver }); + const ruleset = Object(await bundleAndLoadRuleset(rulesetPath, { fs, fetch })); + s.setRuleset(ruleset); + return s.run(JSON.stringify(document)); +} + describe('Consumer-provided word lists via functionOptions', () => { describe('xgen-IPA-126-tag-names-should-use-title-case (ignoreList)', () => { const document = { tags: [{ name: 'ACME Alerts' }] }; @@ -70,4 +79,20 @@ describe('Consumer-provided word lists via functionOptions', () => { expect(errors).toHaveLength(0); }); }); + + // These rules are the second layer of validation this repository runs against its own + // OpenAPI files. Because their word lists are owned by the consuming repositories, they + // are turned off in ipa-spectral.yaml to avoid keeping the lists in sync here. + describe('vocabulary-based rules are disabled in ipa-spectral.yaml', () => { + it('does not run the consumer-owned casing rules', async () => { + const document = { + tags: [{ name: 'not title case' }], + paths: { '/resource': { get: { summary: 'return all resources.' } } }, + }; + const results = await runFullRuleset(document); + const codes = results.map((r) => r.code); + expect(codes).not.toContain('xgen-IPA-117-operation-summary-format'); + expect(codes).not.toContain('xgen-IPA-126-tag-names-should-use-title-case'); + }); + }); }); diff --git a/tools/spectral/ipa/ipa-spectral.yaml b/tools/spectral/ipa/ipa-spectral.yaml index 68b72b3d1c..ee4a82c0d8 100644 --- a/tools/spectral/ipa/ipa-spectral.yaml +++ b/tools/spectral/ipa/ipa-spectral.yaml @@ -20,6 +20,17 @@ extends: - ./rulesets/IPA-125.yaml - ./rulesets/IPA-126.yaml +# The vocabulary-based casing rules rely on `ignoreList`/`grammaticalWords` word lists +# that are owned by the repositories consuming this ruleset (they carry the vocabulary of +# their own OpenAPI specs). Running them as this repository's second layer of validation +# would require keeping copies of those lists in sync with the current OpenAPI files, so +# they are disabled here. The rules and their tests remain defined in IPA-117.yaml and +# IPA-126.yaml for development and for consumers who redeclare them with their own +# functionOptions. +rules: + xgen-IPA-117-operation-summary-format: 'off' + xgen-IPA-126-tag-names-should-use-title-case: 'off' + overrides: - files: - '**#/components/schemas/DataLakeDatabaseDataSourceSettings' From 35d436a40ff10d2e0ebf1da12b6ad0a908036134 Mon Sep 17 00:00:00 2001 From: Julius Jogela Date: Fri, 24 Jul 2026 15:55:03 +0100 Subject: [PATCH 4/5] Fix ConsumerWordLists full-ruleset test and clarify README Assign a document source in the full-ruleset test so Spectral does not error on ipa-spectral.yaml's path-based overrides, and add @stoplight/spectral-parsers as a direct dependency. Clarify in the README that word-list overrides live in the consuming repository, not openapi. --- package-lock.json | 1 + package.json | 1 + tools/spectral/ipa/README.md | 6 ++++-- tools/spectral/ipa/__tests__/ConsumerWordLists.test.js | 7 +++++-- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2684320958..5573282e16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "@stoplight/spectral-cli": "^6.16.1", "@stoplight/spectral-core": "^1.23.1", "@stoplight/spectral-functions": "^1.10.5", + "@stoplight/spectral-parsers": "^1.0.5", "@stoplight/spectral-ref-resolver": "^1.0.5", "@stoplight/spectral-ruleset-bundler": "^1.7.0", "apache-arrow": "^21.1.0", diff --git a/package.json b/package.json index 4a0de8c5c2..da4909ca23 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@stoplight/spectral-cli": "^6.16.1", "@stoplight/spectral-core": "^1.23.1", "@stoplight/spectral-functions": "^1.10.5", + "@stoplight/spectral-parsers": "^1.0.5", "@stoplight/spectral-ref-resolver": "^1.0.5", "@stoplight/spectral-ruleset-bundler": "^1.7.0", "apache-arrow": "^21.1.0", diff --git a/tools/spectral/ipa/README.md b/tools/spectral/ipa/README.md index e66cc708f3..058b20e7e9 100644 --- a/tools/spectral/ipa/README.md +++ b/tools/spectral/ipa/README.md @@ -85,8 +85,10 @@ use two configurable lists: - `grammaticalWords`: common words allowed to stay lowercase in titles (e.g. `and`, `or`, `the`) Both lists are passed to the rule functions through Spectral's native `functionOptions`. Consumers who -extend the ruleset can provide their own lists by redeclaring the rule in their `.spectral.yaml`, without -editing this package or waiting for a new release: +extend the ruleset can provide their own lists by redeclaring the rule **in their own repository's +`.spectral.yaml`**, without editing this package or waiting for a new release. This is done in the +repository that _consumes_ the ruleset, not in this `openapi` repository — note the `extends` on the +published package below: ```yaml extends: diff --git a/tools/spectral/ipa/__tests__/ConsumerWordLists.test.js b/tools/spectral/ipa/__tests__/ConsumerWordLists.test.js index f02ad1c6d6..24bef4e57f 100644 --- a/tools/spectral/ipa/__tests__/ConsumerWordLists.test.js +++ b/tools/spectral/ipa/__tests__/ConsumerWordLists.test.js @@ -1,7 +1,8 @@ import { describe, expect, it } from '@jest/globals'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { Spectral } from '@stoplight/spectral-core'; +import { Document, Spectral } from '@stoplight/spectral-core'; +import * as Parsers from '@stoplight/spectral-parsers'; import { httpAndFileResolver } from '@stoplight/spectral-ref-resolver'; import { bundleAndLoadRuleset } from '@stoplight/spectral-ruleset-bundler/with-loader'; @@ -31,7 +32,9 @@ async function runFullRuleset(document) { const s = new Spectral({ resolver: httpAndFileResolver }); const ruleset = Object(await bundleAndLoadRuleset(rulesetPath, { fs, fetch })); s.setRuleset(ruleset); - return s.run(JSON.stringify(document)); + // ipa-spectral.yaml declares path-based `overrides`, so Spectral requires the + // document to have a source assigned. + return s.run(new Document(JSON.stringify(document), Parsers.Json, 'openapi.json')); } describe('Consumer-provided word lists via functionOptions', () => { From 4ade00f5937fffa99cecb6152feec53c8bd4e4c6 Mon Sep 17 00:00:00 2001 From: Julius Jogela Date: Fri, 24 Jul 2026 16:25:20 +0100 Subject: [PATCH 5/5] Revert package-wide disable of IPA-117/126 casing rules Turning the two vocabulary rules off in ipa-spectral.yaml disabled them for every consumer of the published package, since ipa-spectral.yaml is the package entry point (main). Remove that block; the second-run disable will be scoped to a repo-only config instead. --- tools/spectral/ipa/ipa-spectral.yaml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tools/spectral/ipa/ipa-spectral.yaml b/tools/spectral/ipa/ipa-spectral.yaml index ee4a82c0d8..68b72b3d1c 100644 --- a/tools/spectral/ipa/ipa-spectral.yaml +++ b/tools/spectral/ipa/ipa-spectral.yaml @@ -20,17 +20,6 @@ extends: - ./rulesets/IPA-125.yaml - ./rulesets/IPA-126.yaml -# The vocabulary-based casing rules rely on `ignoreList`/`grammaticalWords` word lists -# that are owned by the repositories consuming this ruleset (they carry the vocabulary of -# their own OpenAPI specs). Running them as this repository's second layer of validation -# would require keeping copies of those lists in sync with the current OpenAPI files, so -# they are disabled here. The rules and their tests remain defined in IPA-117.yaml and -# IPA-126.yaml for development and for consumers who redeclare them with their own -# functionOptions. -rules: - xgen-IPA-117-operation-summary-format: 'off' - xgen-IPA-126-tag-names-should-use-title-case: 'off' - overrides: - files: - '**#/components/schemas/DataLakeDatabaseDataSourceSettings'