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/2] 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/2] 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])], - }; -}