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 e317e5d5a9..058b20e7e9 100644 --- a/tools/spectral/ipa/README.md +++ b/tools/spectral/ipa/README.md @@ -76,6 +76,48 @@ 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 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`) + +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 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: + - '@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` 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 #### 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..24bef4e57f --- /dev/null +++ b/tools/spectral/ipa/__tests__/ConsumerWordLists.test.js @@ -0,0 +1,101 @@ +import { describe, expect, it } from '@jest/globals'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +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'; + +// 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 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 }); + const ruleset = Object(await bundleAndLoadRuleset(rulesetPath, { fs, fetch })).toJSON(); + const definition = ruleset.rules[ruleName].definition; + if (functionOptions) { + definition.then.functionOptions = functionOptions; + } + const scopedRuleset = { rules: { [ruleName]: definition } }; + if (ruleset.aliases) { + scopedRuleset.aliases = ruleset.aliases; + } + s.setRuleset(scopedRuleset); + 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); + // 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', () => { + describe('xgen-IPA-126-tag-names-should-use-title-case (ignoreList)', () => { + const document = { tags: [{ name: 'ACME Alerts' }] }; + + it('flags a consumer acronym that is not in the package ignoreList', async () => { + 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 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); + }); + }); + + 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 () => { + 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 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); + }); + }); + + // 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'); + }); + }); +});