diff --git a/.cursor/rules/classify-rule-id.mdc b/.cursor/rules/classify-rule-id.mdc new file mode 100644 index 0000000..7584923 --- /dev/null +++ b/.cursor/rules/classify-rule-id.mdc @@ -0,0 +1,102 @@ +--- +description: Conventions for ClassifyRuleIdRule definitions and ruleId constant naming +globs: src/**/*.classify*.ts +alwaysApply: false +--- + +# ClassifyRuleIdRule Authoring Conventions + +## Purpose + +`ClassifyRuleIdRule` attaches a stable string identifier (`classifyRuleId`) to each diff so a `diffClassifier` can reclassify it via a simple table lookup, without re-implementing the original condition logic. + +## Type Forms + +`ClassifyRuleIdRule` is one of: + +- **Single element** – same ruleId for all actions: + ```ts + export const requiredItemClassifyRuleIdRule: ClassifyRuleIdRule = + ({ after }) => (requiredItemHasPropertyDefault(after) + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.REQUIRED_ITEM_AFTER_PROPERTY_HAS_DEFAULT + : JSON_SCHEMA_CLASSIFY_RULE_IDS.REQUIRED_ITEM_AFTER_PROPERTY_HAS_NO_DEFAULT) + ``` + +- **3-tuple `[add, remove, replace]`** – when add/remove/replace have different conditions or inspect different sides: + ```ts + export const enumItemClassifyRuleIdRule: ClassifyRuleIdRule = [ + ({ before }) => (isNotEmptyArray(before.parent) + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.ENUM_ITEM_BEFORE_ENUM_NON_EMPTY + : JSON_SCHEMA_CLASSIFY_RULE_IDS.ENUM_ITEM_BEFORE_ENUM_EMPTY), + ({ after }) => (isNotEmptyArray(after.parent) + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.ENUM_ITEM_AFTER_ENUM_NON_EMPTY + : JSON_SCHEMA_CLASSIFY_RULE_IDS.ENUM_ITEM_AFTER_ENUM_EMPTY), + JSON_SCHEMA_CLASSIFY_RULE_IDS.ENUM_ITEM, // replace: static fallback + ] + ``` + +## Side Selection in Resolvers + +| Tuple position | Inspect side | Rationale | +|---|---|---| +| add (index 0) | `after` | Added state is in `after` | +| remove (index 1) | `before` | Removed state is in `before` | +| replace (index 2) | `after` (or both) | New state or comparison between sides | +| array-item add | `before.parent` | State of the sibling array before adding | +| array-item remove | `after.parent` | State of the sibling array after removing | + +## Placement & Structure + +- Place `ClassifyRuleIdRule` **directly before** its corresponding `ClassifyRule`. +- Extract shared boolean helpers between the two (e.g., `propertyHasDefault`, `propertyIsRequired`). +- Place helpers immediately before the `ClassifyRuleIdRule`. +- Both live in the same `*.classify.ts` file; constants go in the matching `*.classify.ruleIds.ts`. + +## Do NOT Encode Action in the ruleId String + +`diff.action` is already available on the diff object. Encode the **condition**, not the action. + +```ts +// ❌ encodes action +REQUIRED_ITEM_ADD_NO_DEFAULT: 'json-schema.required.item.add-no-default' + +// ✅ describes condition from relevant side +REQUIRED_ITEM_AFTER_PROPERTY_HAS_NO_DEFAULT: 'json-schema.required.item.after-property-has-no-default' +``` + +Exception: static constants for structurally unique cases are fine (`TYPE_ADD`, `TYPE_REMOVE`). + +## Naming Conventions + +**OpenAPI / REST** (`REST_CLASSIFY_RULE_IDS` in `openapi3.classify.ruleIds.ts`): +``` +rest.{object}.{property}[.item].{details} +``` +Example: `rest.operation.security.item.after-security-requirements-are-subset-of-before` + +**JSON Schema** (`JSON_SCHEMA_CLASSIFY_RULE_IDS` in `jsonSchema.classify.ruleIds.ts`): +``` +json-schema.{keyword}[.{sub-property}][.item].{details} +``` +Example: `json-schema.required.item.after-property-has-no-default` + +`details` rules: +- Use `before-{condition}` when describing the before-state (remove context). +- Use `after-{condition}` when describing the after-state (add/replace context). +- Use a plain condition phrase when action-independent (e.g., `incompatible`, `subset-of-before`). +- Keep it in kebab-case and human-readable. + +## JSDoc for Each Constant + +Every constant must have a JSDoc comment stating: +1. What structural condition it represents. +2. The default classification behaviour. + +```ts +/** + * JSON Schema `required` array item was added and the corresponding property + * in the after object has no `default` value. + * By default, adding it to `required` is breaking. + */ +REQUIRED_ITEM_AFTER_PROPERTY_HAS_NO_DEFAULT: 'json-schema.required.item.after-property-has-no-default', +``` diff --git a/jest.config.ts b/jest.config.ts index b5b412b..6ab7570 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -3,6 +3,7 @@ module.exports = { testTimeout: 100000, transform: { '^.+\\.tsx?$': 'ts-jest', + '^.+\\.ya?ml$': '/test/transforms/yaml-transform.cjs', }, transformIgnorePatterns: [ '/node_modules/', diff --git a/src/api.ts b/src/api.ts index 0b3944e..6739d71 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,4 +1,4 @@ -import { COMPARE_MODE_DEFAULT, COMPARE_SCOPE_ROOT, CompareEngine, CompareOptions, CompareResult } from './types' +import { COMPARE_MODE_DEFAULT, COMPARE_SCOPE_ROOT, CompareEngine, CompareOptions, CompareResult, Diff, DiffClassifier, DiffType } from './types' import { compareJsonSchema } from './jsonSchema' import { compareGraphApi } from './graphapi' import { compareAsyncApi } from './asyncapi' @@ -17,6 +17,10 @@ import { OpenApiSpecVersion, } from '@netcracker/qubership-apihub-api-unifier' import { DEFAULT_NORMALIZED_RESULT, DEFAULT_OPTION_DEFAULTS_META_KEY, DEFAULT_OPTION_ORIGINS_META_KEY, DIFF_META_KEY } from './core' +import { matchingDiffClassifier, MatchingRule } from './core' +import openapi3Rules from './openapi/openapi3.classify.rules.yaml' +import jsonSchemaRules from './jsonSchema/jsonSchema.classify.rules.yaml' +import generalRules from './core/general.classify.rules.yaml' function isOpenApiSpecVersion(specType: SpecType): specType is OpenApiSpecVersion { return specType === SPEC_TYPE_OPEN_API_30 || specType === SPEC_TYPE_OPEN_API_31 @@ -54,6 +58,17 @@ export const COMPARE_ENGINES_MAP: Record = { [SPEC_TYPE_GRAPH_API]: compareGraphApi, } +function buildOobClassifier(specType: SpecType): DiffClassifier | undefined { + if (isOpenApiSpecVersion(specType)) { + return matchingDiffClassifier([ + ...(jsonSchemaRules as MatchingRule[]), + ...(openapi3Rules as MatchingRule[]), + ...(generalRules as MatchingRule[]), + ]) + } + return undefined +} + // Wrapper function. Use it! export function apiDiff(before: unknown, after: unknown, options: CompareOptions = {}): CompareResult { const beforeSpec = resolveSpec(before) @@ -61,8 +76,9 @@ export function apiDiff(before: unknown, after: unknown, options: CompareOptions if (!areSpecTypesCompatible(beforeSpec.type, afterSpec.type)) { throw new Error(`Specification cannot be different. Got ${beforeSpec.type} and ${afterSpec.type}`) } - const engine = COMPARE_ENGINES_MAP[selectEngineSpecType(beforeSpec.type, afterSpec.type)] - return engine(before, after, { + const engineSpecType = selectEngineSpecType(beforeSpec.type, afterSpec.type) + const engine = COMPARE_ENGINES_MAP[engineSpecType] + const result = engine(before, after, { mode: COMPARE_MODE_DEFAULT, normalizedResult: DEFAULT_NORMALIZED_RESULT, metaKey: DIFF_META_KEY, @@ -75,4 +91,35 @@ export function apiDiff(before: unknown, after: unknown, options: CompareOptions createdMergedJso: new Set(), ...options, }) + + const oobClassifier = buildOobClassifier(engineSpecType) + + if (oobClassifier || options.diffClassifier) { + for (const diff of result.diffs) { + const preType = diff.type + + if (oobClassifier) { + const oobResult = oobClassifier(diff) + if (oobResult?.type !== undefined) { + //TODO this a a part of validation harness for transition period + // from classify rules to separate diff identification and classification + if (oobResult.type !== preType) { + throw new Error( + `OOB classifier type mismatch for classifyRuleId '${diff.classifyRuleId}': engine='${preType}', oob='${oobResult.type}'`, + ) + } + diff.type = oobResult.type + } + } + + if (options.diffClassifier) { + const userResult = options.diffClassifier(diff) + if (userResult?.type !== undefined) { + diff.type = userResult.type + } + } + } + } + + return result } diff --git a/src/core/diff.ts b/src/core/diff.ts index 1611f80..e2cbc28 100644 --- a/src/core/diff.ts +++ b/src/core/diff.ts @@ -22,12 +22,16 @@ export const NEVER_KEY = Symbol('never-key') export const createDiff = (diff: Omit, ctx: CompareContext): D => { const classifierRule = ctx.rules?.$ ?? {}//todo. rules should be evaluated, like in json-crawl - const mutableDiffCopy = { ...diff, type: unclassified } as D + const index = diff.action === DiffAction.rename ? 2 : [DiffAction.add, DiffAction.remove, DiffAction.replace].indexOf(diff.action) + + const classifyRuleIdDef = ctx.rules?.classifyRuleId + const classifyRuleIdElement = Array.isArray(classifyRuleIdDef) ? classifyRuleIdDef[index] : classifyRuleIdDef + const classifyRuleId = isFunc(classifyRuleIdElement) ? classifyRuleIdElement(ctx) : classifyRuleIdElement + const mutableDiffCopy = { ...diff, type: unclassified, classifyRuleId, effectiveBwcScope: ctx.apiCompatibilityScope } as D if (classifierRule) { const classifier = Array.isArray(classifierRule) ? classifierRule : allUnclassified - const index = diff.action === DiffAction.rename ? 2 : [DiffAction.add, DiffAction.remove, DiffAction.replace].indexOf(diff.action) const changeType = classifier[index] try { diff --git a/src/core/general.classify.rules.yaml b/src/core/general.classify.rules.yaml new file mode 100644 index 0000000..1511583 --- /dev/null +++ b/src/core/general.classify.rules.yaml @@ -0,0 +1,20 @@ +# General matching rules that apply across all classifyRuleIds. +# +# Rules use classifyRuleId: '*' (WILDCARD_CLASSIFY_RULE_ID) to match any ruleId. +# Wildcard rules are collected into a separate list inside matchingDiffClassifier +# and applied after all specific (per-ruleId) rules have been processed. +# +# The `type` filter in a wildcard rule is checked against the accumulated result +# type from preceding specific rules, not the engine's final diff type. This +# allows the wildcard to react to the type established by specific rules. +# +# This rule replicates the engine's reclassifyBreakingToRisky logic: +# any diff whose path is marked as NOT_BACKWARD_COMPATIBLE and whose +# accumulated classification is 'breaking' is promoted to 'risky'. + +- match: + classifyRuleId: "*" + effectiveBwcScope: NOT_BACKWARD_COMPATIBLE + type: breaking + set: + type: risky diff --git a/src/core/index.ts b/src/core/index.ts index 83da564..18c468d 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -5,3 +5,4 @@ export * from './mapping' export * from './diff' export * from './rules' export * from './description' +export * from './matchingDiffClassifier' diff --git a/src/core/matchingDiffClassifier.ts b/src/core/matchingDiffClassifier.ts new file mode 100644 index 0000000..ebfa73b --- /dev/null +++ b/src/core/matchingDiffClassifier.ts @@ -0,0 +1,113 @@ +import { ActionType, ApiCompatibilityKind, CompareScope, Diff, DiffClassifier, DiffClassifierResult, DiffType } from '../types' + +/** Use this as `classifyRuleId` in a matching rule to match any ruleId. */ +export const WILDCARD_CLASSIFY_RULE_ID = '*' + +export type MatchingRuleMatch = { + /** + * The classifyRuleId to match. Use WILDCARD_CLASSIFY_RULE_ID ('*') to match any + * classifyRuleId. Wildcard rules are collected into a separate list and + * applied after all specific rules have been processed. + */ + classifyRuleId: string + action?: ActionType | ActionType[] + scope?: CompareScope | CompareScope[] + /** + * Matches against the accumulated result type from rules applied so far, + * falling back to the diff's original type when no preceding rule has + * matched yet. This lets wildcard rules react to the type set by specific + * rules rather than only the engine's final diff type. + */ + type?: DiffType | DiffType[] + effectiveBwcScope?: ApiCompatibilityKind | ApiCompatibilityKind[] +} + +export type MatchingRule = { + match: MatchingRuleMatch + set: { type: DiffType } +} + +function matchesValue(value: T | undefined, filter: T | T[]): boolean { + return Array.isArray(filter) ? filter.includes(value as T) : value === filter +} + +function ruleApplies(diff: Diff, match: MatchingRuleMatch, currentType?: DiffType): boolean { + if (match.action !== undefined && !matchesValue(diff.action as ActionType, match.action)) { + return false + } + if (match.scope !== undefined && !matchesValue(diff.scope, match.scope)) { + return false + } + if (match.type !== undefined && !matchesValue(currentType ?? diff.type, match.type)) { + return false + } + if ( + match.effectiveBwcScope !== undefined && + !matchesValue(diff.effectiveBwcScope, match.effectiveBwcScope as ApiCompatibilityKind | ApiCompatibilityKind[]) + ) { + return false + } + return true +} + +/** + * Builds a DiffClassifier from an ordered array of matching rules. + * + * Rules are grouped by classifyRuleId. Rules whose classifyRuleId is + * WILDCARD_CLASSIFY_RULE_ID ('*') are collected into a separate wildcard list and + * applied after all specific rules for the diff's classifyRuleId. + * + * Rules are applied in direct (array) order; the LAST matching rule wins. + * Place a more-general rule first and the more-specific (overriding) rule + * after it so the specific rule takes precedence. + * + * The `type` filter checks the accumulated result type from preceding rules, + * falling back to the diff's original type. This allows wildcard rules to + * react to the type established by specific rules. + */ +export function matchingDiffClassifier(rules: MatchingRule[]): DiffClassifier { + const ruleMap = new Map() + const wildcardRules: MatchingRule[] = [] + + for (const rule of rules) { + const id = rule.match.classifyRuleId + if (id === WILDCARD_CLASSIFY_RULE_ID) { + wildcardRules.push(rule) + } else { + let group = ruleMap.get(id) + if (!group) { + group = [] + ruleMap.set(id, group) + } + group.push(rule) + } + } + + return (diff: Diff): DiffClassifierResult | undefined => { + if (!diff.classifyRuleId) { + return undefined + } + const candidates = ruleMap.get(diff.classifyRuleId) + if (!candidates && wildcardRules.length === 0) { + return undefined + } + + let result: DiffClassifierResult | undefined = undefined + + // Specific rules applied in direct order; last match wins. + for (const candidate of candidates ?? []) { + if (ruleApplies(diff, candidate.match, result?.type)) { + result = { type: candidate.set.type } + } + } + + // Wildcard rules applied in direct order after all specific rules. + for (const candidate of wildcardRules) { + if (ruleApplies(diff, candidate.match, result?.type)) { + result = { type: candidate.set.type } + } + } + + return result + } +} diff --git a/src/index.ts b/src/index.ts index df62db9..016825e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,8 +27,16 @@ export type { DiffReplace, DiffRename, DiffMetaRecord, + DiffClassifier, + DiffClassifierResult, + ClassifyRuleIdResolver, + ClassifyRuleIdElement, + ClassifyRuleIdRule, } from './types' +export { matchingDiffClassifier, WILDCARD_CLASSIFY_RULE_ID } from './core' +export type { MatchingRule, MatchingRuleMatch } from './core' + export { isDiffAdd, isDiffRemove, @@ -42,3 +50,5 @@ export { onlyExistedArrayIndexes } from './utils' +export { REST_CLASSIFY_RULE_IDS } from './openapi' +export { JSON_SCHEMA_CLASSIFY_RULE_IDS } from './jsonSchema' diff --git a/src/jsonSchema/index.ts b/src/jsonSchema/index.ts index 9683592..61f0616 100644 --- a/src/jsonSchema/index.ts +++ b/src/jsonSchema/index.ts @@ -2,6 +2,7 @@ export * from './jsonSchema.compare' export * from './jsonSchema.resolver' export * from './jsonSchema.classify' export * from './jsonSchema.mapping' +export * from './jsonSchema.classify.ruleIds' export * from './jsonSchema.types' export * from './jsonSchema.adapter' export * from './jsonSchema.utils' diff --git a/src/jsonSchema/jsonSchema.classify.ruleIds.ts b/src/jsonSchema/jsonSchema.classify.ruleIds.ts new file mode 100644 index 0000000..b3b1f10 --- /dev/null +++ b/src/jsonSchema/jsonSchema.classify.ruleIds.ts @@ -0,0 +1,654 @@ +/** + * Stable identifiers for JSON Schema classification rules. + * + * Naming conventions: + * + * `{api_type}.{keyword}[.{property}][.{item}][].{details}]` + * - api_type: JSON Schema type identifier, e.g. `json-schema` + * - keyword: kebab-case JSON Schema keyword (e.g. `type`, `enum`) + * - property: optional property name within that keyword (e.g. `required`, `default`, `properties`) + * - .item suffix: appended when the rule applies to individual array items + * - details: kebab-case phrase capturing the condition that determines classification + */ +export const JSON_SCHEMA_CLASSIFY_RULE_IDS = { + /** + * JSON Schema `title` keyword changed. + * By default, annotation (both request and response). + */ + TITLE: 'json-schema.title', + + /** + * JSON Schema `description` keyword changed. + * By default, annotation (both request and response). + */ + DESCRIPTION: 'json-schema.description', + + /** + * JSON Schema `examples` keyword change (the /examples node itself). + * By default, annotation (both request and response). + */ + EXAMPLES: 'json-schema.examples', + /** + * JSON Schema `examples` array item change. + * By default, annotation (both request and response). + */ + EXAMPLES_ITEM: 'json-schema.examples.item', + /** + * JSON Schema `items` node change when items is an array (tuple validation) — + * each individual item sub-schema added. + * By default, breaking (request); non-breaking (response). + */ + ITEMS_ARRAY_ITEM_ADD: 'json-schema.items.array-item.add', + /** + * JSON Schema `items` array item removed. + * By default, breaking (request); non-breaking (response). + */ + ITEMS_ARRAY_ITEM_REMOVE: 'json-schema.items.array-item.remove', + /** + * JSON Schema `items` array item replaced. + * By default, breaking (request); non-breaking (response). + */ + ITEMS_ARRAY_ITEM_REPLACE: 'json-schema.items.array-item.replace', + + /** + * JSON Schema `items` node added when items is a single schema (all-items validation). + * By default, non-breaking (request); breaking (response). + */ + ITEMS_SCHEMA_ADD: 'json-schema.items.schema.add', + /** + * JSON Schema `items` schema removed. + * By default, non-breaking (request); breaking (response). + */ + ITEMS_SCHEMA_REMOVE: 'json-schema.items.schema.remove', + /** + * JSON Schema `items` schema replaced. + * By default, non-breaking (request); breaking (response). + */ + ITEMS_SCHEMA_REPLACE: 'json-schema.items.schema.replace', + + /** + * JSON Schema `additionalItems` keyword added. + * By default, non-breaking (request); breaking (response). + */ + ADDITIONAL_ITEMS_ADD: 'json-schema.additional-items.add', + /** + * JSON Schema `additionalItems` keyword removed. + * By default, breaking (request); non-breaking (response). + */ + ADDITIONAL_ITEMS_REMOVE: 'json-schema.additional-items.remove', + /** + * JSON Schema `additionalItems` keyword replaced. + * By default, unclassified (both request and response). + */ + ADDITIONAL_ITEMS_REPLACE: 'json-schema.additional-items.replace', + + /** + * JSON Schema `patternProperties` sub-schema added. + * By default, breaking (request); non-breaking (response). + */ + PATTERN_PROPERTIES_ITEM_ADD: 'json-schema.pattern-properties.item.add', + /** + * JSON Schema `patternProperties` sub-schema removed. + * By default, non-breaking (request); breaking (response). + */ + PATTERN_PROPERTIES_ITEM_REMOVE: 'json-schema.pattern-properties.item.remove', + /** + * JSON Schema `patternProperties` sub-schema replaced. + * By default, unclassified (both request and response). + */ + PATTERN_PROPERTIES_ITEM_REPLACE: 'json-schema.pattern-properties.item.replace', + + /** + * JSON Schema `propertyNames` keyword added. + * By default, breaking (request); non-breaking (response). + */ + PROPERTY_NAMES_ADD: 'json-schema.property-names.add', + /** + * JSON Schema `propertyNames` keyword removed. + * By default, non-breaking (both request and response). + */ + PROPERTY_NAMES_REMOVE: 'json-schema.property-names.remove', + /** + * JSON Schema `propertyNames` keyword replaced. + * By default, non-breaking (both request and response). + */ + PROPERTY_NAMES_REPLACE: 'json-schema.property-names.replace', + + /** + * JSON Schema `definitions` sub-schema added. + * By default, non-breaking (request); breaking (response). + */ + DEFINITIONS_ITEM_ADD: 'json-schema.definitions.item.add', + /** + * JSON Schema `definitions` sub-schema removed. + * By default, non-breaking (request); breaking (response). + */ + DEFINITIONS_ITEM_REMOVE: 'json-schema.definitions.item.remove', + /** + * JSON Schema `definitions` sub-schema replaced. + * By default, non-breaking (request); breaking (response). + */ + DEFINITIONS_ITEM_REPLACE: 'json-schema.definitions.item.replace', + + /** + * JSON Schema `$defs` sub-schema added. + * By default, non-breaking (request); breaking (response). + */ + DEFS_ITEM_ADD: 'json-schema.defs.item.add', + /** + * JSON Schema `$defs` sub-schema removed. + * By default, non-breaking (request); breaking (response). + */ + DEFS_ITEM_REMOVE: 'json-schema.defs.item.remove', + /** + * JSON Schema `$defs` sub-schema replaced. + * By default, non-breaking (request); breaking (response). + */ + DEFS_ITEM_REPLACE: 'json-schema.defs.item.replace', + + /** + * JSON Schema `oneOf` combiner item added. + * By default, non-breaking (request); breaking (response). + */ + ONE_OF_ITEM_ADD: 'json-schema.one-of.item.add', + /** + * JSON Schema `oneOf` combiner item removed. + * By default, breaking (request); non-breaking (response). + */ + ONE_OF_ITEM_REMOVE: 'json-schema.one-of.item.remove', + /** + * JSON Schema `oneOf` combiner item replaced. + * By default, breaking (request); non-breaking (response). + */ + ONE_OF_ITEM_REPLACE: 'json-schema.one-of.item.replace', + + /** + * JSON Schema `anyOf` combiner item added. + * By default, non-breaking (request); breaking (response). + */ + ANY_OF_ITEM_ADD: 'json-schema.any-of.item.add', + /** + * JSON Schema `anyOf` combiner item removed. + * By default, breaking (request); non-breaking (response). + */ + ANY_OF_ITEM_REMOVE: 'json-schema.any-of.item.remove', + /** + * JSON Schema `anyOf` combiner item replaced. + * By default, breaking (request); non-breaking (response). + */ + ANY_OF_ITEM_REPLACE: 'json-schema.any-of.item.replace', + + /** + * JSON Schema `allOf` combiner item added. + * By default, breaking (request); non-breaking (response). + */ + ALL_OF_ITEM_ADD: 'json-schema.all-of.item.add', + /** + * JSON Schema `allOf` combiner item removed. + * By default, breaking (request); non-breaking (response). + */ + ALL_OF_ITEM_REMOVE: 'json-schema.all-of.item.remove', + /** + * JSON Schema `allOf` combiner item replaced. + * By default, breaking (request); non-breaking (response). + */ + ALL_OF_ITEM_REPLACE: 'json-schema.all-of.item.replace', + + /** + * JSON Schema `not` keyword added (the /not node itself). + * By default, breaking (request); non-breaking (response). + */ + NOT_ADD: 'json-schema.not.add', + /** + * JSON Schema `not` keyword removed. + * By default, breaking (request); non-breaking (response). + */ + NOT_REMOVE: 'json-schema.not.remove', + /** + * JSON Schema `not` keyword replaced. + * By default, breaking (request); non-breaking (response). + */ + NOT_REPLACE: 'json-schema.not.replace', + + /** + * JSON Schema `enum` array added (the whole enum keyword). + * By default, breaking (request); non-breaking (response). + */ + ENUM_ADD: 'json-schema.enum.add', + /** + * JSON Schema `enum` array removed. + * By default, non-breaking (request); risky (response). + */ + ENUM_REMOVE: 'json-schema.enum.remove', + /** + * JSON Schema `enum` array replaced. + * By default, breaking (request); non-breaking (response). + */ + ENUM_REPLACE: 'json-schema.enum.replace', + + /** JSON Schema `enum` array item change */ + ENUM_ITEM: 'json-schema.enum.item', + /** + * JSON Schema `enum` array item change — before enum array is non-empty. + * + * By default, adding item to non-empty `enum` is non-breaking. + */ + ENUM_ITEM_BEFORE_ENUM_NON_EMPTY: 'json-schema.enum.item.before-enum-non-empty', + /** + * JSON Schema `enum` array item change — before enum array is empty. + * By default, adding item to empty `enum` (introducing constraint) is breaking. + */ + ENUM_ITEM_BEFORE_ENUM_EMPTY: 'json-schema.enum.item.before-enum-empty', + /** + * JSON Schema `enum` array item change — after enum array is non-empty. + * By default, removing non-last item from `enum` is breaking. + */ + ENUM_ITEM_AFTER_ENUM_NON_EMPTY: 'json-schema.enum.item.after-enum-non-empty', + /** + * JSON Schema `enum` array item change — after enum array is empty. + * By default, removing last item from `enum` (removing constraint) is non-breaking. + */ + ENUM_ITEM_AFTER_ENUM_EMPTY: 'json-schema.enum.item.after-enum-empty', + /** + * JSON Schema `required` array item was added or replaced and corresponding property in + * after object has a `default` value. + * By default, adding it to `required` is non-breaking. + */ + REQUIRED_ITEM_AFTER_PROPERTY_HAS_DEFAULT: 'json-schema.required.item.after-property-has-default', + /** + * JSON Schema `required` array item was added or replaced and corresponding property in + * after object has no `default` value. + * By default, adding it to `required` is breaking. + */ + REQUIRED_ITEM_AFTER_PROPERTY_HAS_NO_DEFAULT: 'json-schema.required.item.after-property-has-no-default', + + /** + * JSON Schema `properties` item added the property is in the `required` + * array AND has no `default` value. + * By default, adding such property is breaking. + */ + PROPERTY_AFTER_REQUIRED_NO_DEFAULT: 'json-schema.property.after-required-no-default', + /** + * JSON Schema `properties` item added the property is either NOT in the + * `required` array OR it has a `default` value. + * By default, adding such property is non-breaking. + */ + PROPERTY_AFTER_OPTIONAL_OR_HAS_DEFAULT: 'json-schema.property.after-optional-or-has-default', + /** + * JSON Schema `properties` item removed — the property was required. + * By default, removing such property is breaking. + */ + PROPERTY_BEFORE_REQUIRED: 'json-schema.property.before-required', + /** + * JSON Schema `properties` item removed — the property was not required. + * By default, removing such property is non-breaking. + */ + PROPERTY_BEFORE_NOT_REQUIRED: 'json-schema.property.before-not-required', + + /** JSON Schema `properties` item replaced */ + PROPERTY: 'json-schema.property', + + /** JSON Schema `type` keyword — the type constraint is added to a previously unconstrained schema. */ + TYPE_ADD: 'json-schema.type.add', + /** JSON Schema `type` keyword — the type constraint is removed, making the schema unconstrained. */ + TYPE_REMOVE: 'json-schema.type.remove', + /** + * JSON Schema `type` keyword replace — the after type is a superset of the before type + * (e.g. `integer` → `number`). + * By default, non-breaking for contravariant (request) schemas; breaking for covariant (response) schemas. + */ + TYPE_REPLACE_AFTER_SUPERSET_BEFORE: 'json-schema.type.replace.after-is-superset-of-before', + /** + * JSON Schema `type` keyword replace — the after type is a subset of the before type + * (e.g. `number` → `integer`). + * By default, breaking for contravariant (request) schemas; non-breaking for covariant (response) schemas. + */ + TYPE_REPLACE_AFTER_SUBSET_BEFORE: 'json-schema.type.replace.after-is-subset-of-before', + /** + * JSON Schema `type` keyword replace — the before and after types are mutually incompatible + * (e.g. `string` → `integer`). + * By default, always breaking in both request and response contexts. + */ + TYPE_REPLACE_INCOMPATIBLE: 'json-schema.type.replace.incompatible', + + /** + * JSON Schema `maxLength` keyword — constraint removed or modified so that the after value + * is greater than the before value, meaning the constraint was relaxed. + * By default, non-breaking (request); breaking (response). + */ + MAX_LENGTH_CONSTRAINT_RELAXED: 'json-schema.maxLength.constraint-relaxed', + + /** + * JSON Schema `maxLength` keyword — constraint added or modified so that the after value + * is less than the before value, meaning the constraint was tightened. + * By default, breaking (request); non-breaking (response). + */ + MAX_LENGTH_CONSTRAINT_TIGHTENED: 'json-schema.maxLength.constraint-tightened', + + /** + * JSON Schema `maxItems` keyword — constraint removed or modified so that the after value + * is greater than the before value, meaning the constraint was relaxed. + * By default, non-breaking (request); breaking (response). + */ + MAX_ITEMS_CONSTRAINT_RELAXED: 'json-schema.maxItems.constraint-relaxed', + + /** + * JSON Schema `maxItems` keyword — constraint added or modified so that the after value + * is less than the before value, meaning the constraint was tightened. + * By default, breaking (request); non-breaking (response). + */ + MAX_ITEMS_CONSTRAINT_TIGHTENED: 'json-schema.maxItems.constraint-tightened', + + /** + * JSON Schema `maxProperties` keyword — constraint removed or modified so that the after value + * is greater than the before value, meaning the constraint was relaxed. + * By default, non-breaking (request); breaking (response). + */ + MAX_PROPERTIES_CONSTRAINT_RELAXED: 'json-schema.maxProperties.constraint-relaxed', + + /** + * JSON Schema `maxProperties` keyword — constraint added or modified so that the after value + * is less than the before value, meaning the constraint was tightened. + * By default, breaking (request); non-breaking (response). + */ + MAX_PROPERTIES_CONSTRAINT_TIGHTENED: 'json-schema.maxProperties.constraint-tightened', + + /** + * JSON Schema `maxProperties` keyword — constraint removed or modified so that the after value + * is greater than the before value, meaning the constraint was relaxed. + * By default, non-breaking (request); breaking (response). + */ + EXCLUSIVE_MAXIMUM_CONSTRAINT_RELAXED: 'json-schema.exclusiveMaximum.constraint-relaxed', + + /** + * JSON Schema `maxProperties` keyword — constraint added or modified so that the after value + * is less than the before value, meaning the constraint was tightened. + * By default, breaking (request); non-breaking (response). + */ + EXCLUSIVE_MAXIMUM_CONSTRAINT_TIGHTENED: 'json-schema.exclusiveMaximum.constraint-tightened', + + /** + * JSON Schema `min*` keyword (minLength, minItems, minProperties, non-draft-04 exclusiveMinimum) + * — the constraint is added to a previously unconstrained schema. + * By default, breaking (request); non-breaking (response). + */ + MIN_ADD: 'json-schema.min.add', + /** + * JSON Schema `min*` keyword — the constraint is removed from the schema. + * By default, non-breaking (request); breaking (response). + */ + MIN_REMOVE: 'json-schema.min.remove', + /** + * JSON Schema `min*` keyword replace — the after value is less than or equal to the before + * value (both numeric), meaning the constraint was relaxed or kept the same. + * By default, non-breaking (request); breaking (response). + */ + MIN_REPLACE_CONSTRAINT_RELAXED: 'json-schema.min.replace.constraint-relaxed', + /** + * JSON Schema `min*` keyword replace — the after value is greater than the before value, or + * either value is non-numeric, meaning the constraint was tightened or is indeterminate. + * By default, breaking (request); non-breaking (response). + */ + MIN_REPLACE_CONSTRAINT_TIGHTENED: 'json-schema.min.replace.constraint-tightened', + + /** + * JSON Schema `minimum` keyword added — the before schema already has a numeric + * `exclusiveMinimum` that is greater than or equal to the new `minimum` value, + * so the new `minimum` does not introduce any tighter bound. + * By default, non-breaking (request); breaking (response). + */ + MINIMUM_ADD_BEFORE_EXCLUSIVE_MIN_COVERS: 'json-schema.minimum.add.before-exclusive-min-covers', + /** + * JSON Schema `additionalProperties` — the constraint is added (any action where before + * and after are both truthy, or the property is newly added). + * By default, breaking (both request and response). + */ + ADDITIONAL_PROPERTIES_ADD: 'json-schema.additional-properties.add', + /** + * JSON Schema `additionalProperties` — the constraint is removed. + * By default, breaking (both request and response). + */ + ADDITIONAL_PROPERTIES_REMOVE: 'json-schema.additional-properties.remove', + /** + * JSON Schema `additionalProperties` replace — before-value is truthy (was restrictive) + * and after-value is truthy (still restrictive). + * By default, breaking (both request and response). + */ + ADDITIONAL_PROPERTIES_REPLACE_BEFORE_TRUTHY_AFTER_TRUTHY: 'json-schema.additional-properties.replace.before-truthy-after-truthy', + /** + * JSON Schema `additionalProperties` replace — before-value is truthy (was restrictive) + * and after-value is falsy (now permissive). + * By default, breaking (request); non-breaking (response). + */ + ADDITIONAL_PROPERTIES_REPLACE_BEFORE_TRUTHY_AFTER_FALSY: 'json-schema.additional-properties.replace.before-truthy-after-falsy', + /** + * JSON Schema `additionalProperties` replace — before-value is falsy (was permissive) + * and after-value is truthy (now restrictive). + * By default, non-breaking (request); breaking (response). + */ + ADDITIONAL_PROPERTIES_REPLACE_BEFORE_FALSY_AFTER_TRUTHY: 'json-schema.additional-properties.replace.before-falsy-after-truthy', + /** + * JSON Schema `additionalProperties` replace — before-value is falsy (was permissive) + * and after-value is falsy (still permissive). + * By default, non-breaking (both request and response). + */ + ADDITIONAL_PROPERTIES_REPLACE_BEFORE_FALSY_AFTER_FALSY: 'json-schema.additional-properties.replace.before-falsy-after-falsy', + + /** + * JSON Schema `multipleOf` keyword — the constraint is added to a previously unconstrained + * schema. + * By default, breaking (request); non-breaking (response). + */ + MULTIPLE_OF_ADD: 'json-schema.multiple-of.add', + /** + * JSON Schema `multipleOf` keyword — the constraint is removed from the schema. + * By default, non-breaking (request); breaking (response). + */ + MULTIPLE_OF_REMOVE: 'json-schema.multiple-of.remove', + /** + * JSON Schema `multipleOf` keyword replace — the old value is an exact multiple of the new + * value (both numeric), so the new divisor is strictly more permissive. + * By default, non-breaking (request); breaking (response). + */ + MULTIPLE_OF_REPLACE_NEW_IS_DIVISOR_OF_OLD: 'json-schema.multiple-of.replace.new-is-divisor-of-old', + /** + * JSON Schema `multipleOf` keyword replace — either value is non-numeric, or the old value + * is not an exact multiple of the new value. + * By default, breaking (both request and response). + */ + MULTIPLE_OF_REPLACE_NEW_IS_NOT_DIVISOR_OF_OLD: 'json-schema.multiple-of.replace.new-is-not-divisor-of-old', + + /** + * JSON Schema draft-04 boolean `exclusiveMinimum` / `exclusiveMaximum` — added with value + * `true`, activating the exclusive constraint. + * By default, breaking (request); non-breaking (response). + */ + EXCLUSIVE_ADD_AFTER_TRUE: 'json-schema.exclusive.add.after-true', + /** + * JSON Schema draft-04 boolean `exclusiveMinimum` / `exclusiveMaximum` — added with a value + * other than `true` (e.g. `false`), so no exclusive constraint is activated. + * By default, unclassified (both request and response). + */ + EXCLUSIVE_ADD_AFTER_NOT_TRUE: 'json-schema.exclusive.add.after-not-true', + /** + * JSON Schema draft-04 boolean `exclusiveMinimum` / `exclusiveMaximum` — removed when its + * before-value was `true`, deactivating the exclusive constraint. + * By default, non-breaking (request); breaking (response). + */ + EXCLUSIVE_REMOVE_BEFORE_TRUE: 'json-schema.exclusive.remove.before-true', + /** + * JSON Schema draft-04 boolean `exclusiveMinimum` / `exclusiveMaximum` — removed when its + * before-value was not `true`, so no exclusive constraint was active. + * By default, unclassified (both request and response). + */ + EXCLUSIVE_REMOVE_BEFORE_NOT_TRUE: 'json-schema.exclusive.remove.before-not-true', + /** + * JSON Schema draft-04 boolean `exclusiveMinimum` / `exclusiveMaximum` replaced — after-value + * is `true`, activating the exclusive constraint. + * By default, breaking (request); non-breaking (response). + */ + EXCLUSIVE_REPLACE_AFTER_TRUE: 'json-schema.exclusive.replace.after-true', + /** + * JSON Schema draft-04 boolean `exclusiveMinimum` / `exclusiveMaximum` replaced — after-value + * is not `true`, so the exclusive constraint is deactivated or absent. + * By default, non-breaking (request); breaking (response). + */ + EXCLUSIVE_REPLACE_AFTER_NOT_TRUE: 'json-schema.exclusive.replace.after-not-true', + + /** + * JSON Schema `minimum` keyword added — either no numeric `exclusiveMinimum` existed + * in the before schema, or its value is less than the new `minimum`, meaning the new + * `minimum` introduces a tighter lower bound. + * By default, breaking (request); non-breaking (response). + */ + MINIMUM_ADD_BEFORE_EXCLUSIVE_MIN_NOT_COVERS: 'json-schema.minimum.add.before-exclusive-min-not-covers', + /** + * JSON Schema `minimum` keyword removed — the lower-bound constraint is dropped. + * By default, non-breaking (request); breaking (response). + */ + MINIMUM_REMOVE: 'json-schema.minimum.remove', + /** + * JSON Schema `minimum` keyword replace — the after value is less than or equal to the before + * value (both numeric), meaning the lower bound was relaxed or kept the same. + * By default, non-breaking (request); breaking (response). + */ + MINIMUM_REPLACE_CONSTRAINT_RELAXED: 'json-schema.minimum.replace.constraint-relaxed', + /** + * JSON Schema `minimum` keyword replace — the after value is greater than the before value, + * or either value is non-numeric, meaning the lower bound was tightened or is indeterminate. + * By default, breaking (request); non-breaking (response). + */ + MINIMUM_REPLACE_CONSTRAINT_TIGHTENED: 'json-schema.minimum.replace.constraint-tightened', + + /** + * JSON Schema `maximum` keyword removed or modified so that the after value is greater than the + * before value, meaning the the constraint was relaxed. + * By default, non-breaking (request); breaking (response). + */ + MAXIMUM_CONSTRAINT_RELAXED: 'json-schema.maximum.constraint-relaxed', + + /** + * JSON Schema `maximum` keyword added or modified so that the after value is less than the before value, + * meaning the the constraint was tightened. + * By default, breaking (request); non-breaking (response). + */ + MAXIMUM_CONSTRAINT_TIGHTENED: 'json-schema.maximum.constraint-tightened', + + /** + * JSON Schema `pattern` validator added. + * By default, breaking (request); non-breaking (response). + */ + PATTERN_ADD: 'json-schema.pattern.add', + /** + * JSON Schema `pattern` validator removed. + * By default, non-breaking (request); breaking (response). + */ + PATTERN_REMOVE: 'json-schema.pattern.remove', + /** + * JSON Schema `pattern` validator replaced. + * By default, breaking (both request and response). + */ + PATTERN_REPLACE: 'json-schema.pattern.replace', + + /** + * JSON Schema `format` keyword added. + * By default, breaking (request); non-breaking (response). + */ + FORMAT_ADD: 'json-schema.format.add', + /** + * JSON Schema `format` keyword removed. + * By default, non-breaking (request); breaking (response). + */ + FORMAT_REMOVE: 'json-schema.format.remove', + /** + * JSON Schema `format` keyword replaced. + * By default, breaking (both request and response). + */ + FORMAT_REPLACE: 'json-schema.format.replace', + + /** + * JSON Schema `uniqueItems` changed — after-value is `true` (enabling uniqueness constraint). + * By default, breaking (request); non-breaking (response). + */ + UNIQUE_ITEMS_AFTER_TRUE: 'json-schema.unique-items.after-true', + /** + * JSON Schema `uniqueItems` changed — after-value is not `true` (constraint absent or false). + * By default, non-breaking (request); breaking (response). + */ + UNIQUE_ITEMS_AFTER_NOT_TRUE: 'json-schema.unique-items.after-not-true', + /** + * JSON Schema `uniqueItems` removed. + * By default, non-breaking (request); breaking (response). + */ + UNIQUE_ITEMS_REMOVE: 'json-schema.unique-items.remove', + + /** + * JSON Schema `readOnly` changed — after-value is `true` (marking field as read-only). + * By default, breaking (request); non-breaking (response). + */ + READ_ONLY_AFTER_TRUE: 'json-schema.read-only.after-true', + /** + * JSON Schema `readOnly` changed — after-value is not `true`. + * By default, non-breaking (both request and response). + */ + READ_ONLY_AFTER_NOT_TRUE: 'json-schema.read-only.after-not-true', + /** + * JSON Schema `readOnly` removed. + * By default, non-breaking (both request and response). + */ + READ_ONLY_REMOVE: 'json-schema.read-only.remove', + + /** + * JSON Schema `writeOnly` added. + * By default, non-breaking (both request and response). + */ + WRITE_ONLY_ADD: 'json-schema.write-only.add', + /** + * JSON Schema `writeOnly` removed. + * By default, non-breaking (both request and response). + */ + WRITE_ONLY_REMOVE: 'json-schema.write-only.remove', + /** + * JSON Schema `writeOnly` replaced. + * By default, non-breaking (both request and response). + */ + WRITE_ONLY_REPLACE: 'json-schema.write-only.replace', + + /** + * JSON Schema `deprecated` flag changed. + * By default, always deprecated (both request and response). + */ + DEPRECATED: 'json-schema.deprecated', + + /** + * JSON Schema `default` value added. + * By default, non-breaking (request); breaking (response). + */ + DEFAULT_ADD: 'json-schema.default.add', + /** + * JSON Schema `default` value removed. + * By default, breaking (request); non-breaking (response). + */ + DEFAULT_REMOVE: 'json-schema.default.remove', + /** + * JSON Schema `default` value replaced. + * By default, breaking (request); non-breaking (response). + */ + DEFAULT_REPLACE: 'json-schema.default.replace', + + /** + * JSON Schema `const` keyword added. + * By default, breaking (request); non-breaking (response). + */ + CONST_ADD: 'json-schema.const.add', + /** + * JSON Schema `const` keyword removed. + * By default, non-breaking (request); breaking (response). + */ + CONST_REMOVE: 'json-schema.const.remove', + /** + * JSON Schema `const` keyword replaced. + * By default, breaking (request); non-breaking (response). + */ + CONST_REPLACE: 'json-schema.const.replace', +} as const diff --git a/src/jsonSchema/jsonSchema.classify.rules.yaml b/src/jsonSchema/jsonSchema.classify.rules.yaml new file mode 100644 index 0000000..5d2f81f --- /dev/null +++ b/src/jsonSchema/jsonSchema.classify.rules.yaml @@ -0,0 +1,1226 @@ +# OOB matching rules for JSON Schema classify rule IDs. +# +# Several ruleIds produce a different type in response schemas (scope: response) +# because openApiSchemaRules applies reverseClassifyRuleTransformer for response +# schemas. Scope values: 'root' (raw JSON Schema), 'request', 'response'. +# +# Rule ordering convention: rules are applied in direct array order; the LAST +# matching rule wins. General catch-all rules come first; more-specific rules +# (with scope/action filters) come after and override the general rule. +# +# breaking → risky reclassification (effectiveBwcScope: NOT_BACKWARD_COMPATIBLE) +# is handled for all ruleIds by general.classify.rules.yaml via the wildcard '*'. + +# --------------------------------------------------------------------------- +# type keyword +# --------------------------------------------------------------------------- + +- match: + classifyRuleId: json-schema.type.add + set: + type: breaking + +- match: + classifyRuleId: json-schema.type.remove + set: + type: breaking + +# after-is-superset-of-before (e.g. integer → number): +# request / root → non-breaking (general, lower priority) +# response → breaking (specific, higher priority) +- match: + classifyRuleId: json-schema.type.replace.after-is-superset-of-before + set: + type: non-breaking +- match: + classifyRuleId: json-schema.type.replace.after-is-superset-of-before + scope: response + set: + type: breaking + +# after-is-subset-of-before (e.g. number → integer): +# non-response → breaking (general, lower priority) +# response → non-breaking (specific, higher priority) +- match: + classifyRuleId: json-schema.type.replace.after-is-subset-of-before + set: + type: breaking +- match: + classifyRuleId: json-schema.type.replace.after-is-subset-of-before + scope: response + set: + type: non-breaking + +- match: + classifyRuleId: json-schema.type.replace.incompatible + set: + type: breaking + +# --------------------------------------------------------------------------- +# required array items +# --------------------------------------------------------------------------- + +# after-property-has-default: +# everything else → non-breaking (general, lower priority) +# response + (remove or replace) → breaking (specific, higher priority) +- match: + classifyRuleId: json-schema.required.item.after-property-has-default + set: + type: non-breaking +- match: + classifyRuleId: json-schema.required.item.after-property-has-default + action: + - remove + - replace + scope: response + set: + type: breaking + +# after-property-has-no-default (only occurs for add and replace actions): +# general (all) → breaking (lower priority) +# add + response → non-breaking (higher priority) +- match: + classifyRuleId: json-schema.required.item.after-property-has-no-default + set: + type: breaking +- match: + classifyRuleId: json-schema.required.item.after-property-has-no-default + action: add + scope: response + set: + type: non-breaking + +# --------------------------------------------------------------------------- +# properties items +# --------------------------------------------------------------------------- + +# after-required-no-default (add): +# non-response → breaking (general, lower priority) +# response → non-breaking (specific, higher priority) +- match: + classifyRuleId: json-schema.property.after-required-no-default + set: + type: breaking +- match: + classifyRuleId: json-schema.property.after-required-no-default + scope: response + set: + type: non-breaking + +- match: + classifyRuleId: json-schema.property.after-optional-or-has-default + set: + type: non-breaking + +- match: + classifyRuleId: json-schema.property.before-required + set: + type: breaking + +# before-not-required (remove): +# non-response → breaking (general, lower priority) +# response → non-breaking (specific, higher priority) +- match: + classifyRuleId: json-schema.property.before-not-required + set: + type: breaking +- match: + classifyRuleId: json-schema.property.before-not-required + scope: response + set: + type: non-breaking + +- match: + classifyRuleId: json-schema.property + set: + type: unclassified + +# --------------------------------------------------------------------------- +# additionalProperties +# --------------------------------------------------------------------------- +# add and remove are always breaking regardless of scope (position[3]=breaking, +# position[4]=breaking in the 6-element classifier). +# replace uses 4 ruleIds encoding the (before-truthy × after-truthy) combination: +# request checks !!before.value; response checks !!after.value. + +- match: + classifyRuleId: json-schema.additional-properties.add + set: + type: breaking + +- match: + classifyRuleId: json-schema.additional-properties.remove + set: + type: breaking + +# replace before-truthy-after-truthy: breaking (both) +- match: + classifyRuleId: json-schema.additional-properties.replace.before-truthy-after-truthy + set: + type: breaking + +# replace before-truthy-after-falsy: breaking (request) / non-breaking (response) +- match: + classifyRuleId: json-schema.additional-properties.replace.before-truthy-after-falsy + set: + type: breaking +- match: + classifyRuleId: json-schema.additional-properties.replace.before-truthy-after-falsy + scope: response + set: + type: non-breaking + +# replace before-falsy-after-truthy: non-breaking (request) / breaking (response) +- match: + classifyRuleId: json-schema.additional-properties.replace.before-falsy-after-truthy + set: + type: non-breaking +- match: + classifyRuleId: json-schema.additional-properties.replace.before-falsy-after-truthy + scope: response + set: + type: breaking + +# replace before-falsy-after-falsy: non-breaking (both) +- match: + classifyRuleId: json-schema.additional-properties.replace.before-falsy-after-falsy + set: + type: non-breaking + +# --------------------------------------------------------------------------- +# multipleOf keyword +# --------------------------------------------------------------------------- +# Add and remove flip for response (reverseClassifyRuleTransformer). +# replace new-is-divisor-of-old also flips (request=non-breaking, response=breaking) +# because position[5] of the 6-element classifier is explicit `breaking`. +# replace new-is-not-divisor-of-old is breaking in both contexts (no scope override needed). + +# add: breaking (request) / non-breaking (response) +- match: + classifyRuleId: json-schema.multiple-of.add + set: + type: breaking +- match: + classifyRuleId: json-schema.multiple-of.add + scope: response + set: + type: non-breaking + +# remove: non-breaking (request) / breaking (response) +- match: + classifyRuleId: json-schema.multiple-of.remove + set: + type: non-breaking +- match: + classifyRuleId: json-schema.multiple-of.remove + scope: response + set: + type: breaking + +# replace new-is-divisor-of-old: non-breaking (request) / breaking (response) +- match: + classifyRuleId: json-schema.multiple-of.replace.new-is-divisor-of-old + set: + type: non-breaking +- match: + classifyRuleId: json-schema.multiple-of.replace.new-is-divisor-of-old + scope: response + set: + type: breaking + +# replace new-is-not-divisor-of-old: breaking (both) +- match: + classifyRuleId: json-schema.multiple-of.replace.new-is-not-divisor-of-old + set: + type: breaking + +# --------------------------------------------------------------------------- +# exclusive keyword (draft-04 boolean exclusiveMinimum / exclusiveMaximum) +# --------------------------------------------------------------------------- +# `true` activates the exclusive constraint; any other value is a no-op. +# All flipping cases reverse for response schemas (reverseClassifyRuleTransformer). +# `unclassified` is the same for request and response (not reversed). + +# add after-true: breaking (request) / non-breaking (response) +- match: + classifyRuleId: json-schema.exclusive.add.after-true + set: + type: breaking +- match: + classifyRuleId: json-schema.exclusive.add.after-true + scope: response + set: + type: non-breaking + +# add after-not-true: unclassified (both) +- match: + classifyRuleId: json-schema.exclusive.add.after-not-true + set: + type: unclassified + +# remove before-true: non-breaking (request) / breaking (response) +- match: + classifyRuleId: json-schema.exclusive.remove.before-true + set: + type: non-breaking +- match: + classifyRuleId: json-schema.exclusive.remove.before-true + scope: response + set: + type: breaking + +# remove before-not-true: unclassified (both) +- match: + classifyRuleId: json-schema.exclusive.remove.before-not-true + set: + type: unclassified + +# replace after-true: breaking (request) / non-breaking (response) +- match: + classifyRuleId: json-schema.exclusive.replace.after-true + set: + type: breaking +- match: + classifyRuleId: json-schema.exclusive.replace.after-true + scope: response + set: + type: non-breaking + +# replace after-not-true: non-breaking (request) / breaking (response) +- match: + classifyRuleId: json-schema.exclusive.replace.after-not-true + set: + type: non-breaking +- match: + classifyRuleId: json-schema.exclusive.replace.after-not-true + scope: response + set: + type: breaking + +# --------------------------------------------------------------------------- +# minimum keyword (interacts with draft-04 exclusiveMinimum in the add case) +# --------------------------------------------------------------------------- +# All actions flip for response schemas (reverseClassifyRuleTransformer). + +# add — before exclusiveMinimum already covers the new minimum +# (exclusiveMinimum exists and is >= new minimum value, so no new constraint): +# non-response → non-breaking +# response → breaking +- match: + classifyRuleId: json-schema.minimum.add.before-exclusive-min-covers + set: + type: non-breaking +- match: + classifyRuleId: json-schema.minimum.add.before-exclusive-min-covers + scope: response + set: + type: breaking + +# add — before exclusiveMinimum does not cover the new minimum +# (missing, non-numeric, or less than the new minimum — a new tighter bound): +# non-response → breaking +# response → non-breaking +- match: + classifyRuleId: json-schema.minimum.add.before-exclusive-min-not-covers + set: + type: breaking +- match: + classifyRuleId: json-schema.minimum.add.before-exclusive-min-not-covers + scope: response + set: + type: non-breaking + +# remove (drops the lower-bound constraint): +# non-response → non-breaking +# response → breaking +- match: + classifyRuleId: json-schema.minimum.remove + set: + type: non-breaking +- match: + classifyRuleId: json-schema.minimum.remove + scope: response + set: + type: breaking + +# replace constraint-relaxed (after <= before, both numeric — lower bound lowered): +# non-response → non-breaking +# response → breaking +- match: + classifyRuleId: json-schema.minimum.replace.constraint-relaxed + set: + type: non-breaking +- match: + classifyRuleId: json-schema.minimum.replace.constraint-relaxed + scope: response + set: + type: breaking + +# replace constraint-tightened (after > before, or either value non-numeric): +# non-response → breaking +# response → non-breaking +- match: + classifyRuleId: json-schema.minimum.replace.constraint-tightened + set: + type: breaking +- match: + classifyRuleId: json-schema.minimum.replace.constraint-tightened + scope: response + set: + type: non-breaking + +# --------------------------------------------------------------------------- +# maximum keyword +# --------------------------------------------------------------------------- +- match: + classifyRuleId: json-schema.maximum.constraint-relaxed + set: + type: non-breaking +- match: + classifyRuleId: json-schema.maximum.constraint-relaxed + scope: response + set: + type: breaking + +- match: + classifyRuleId: json-schema.maximum.constraint-tightened + set: + type: breaking +- match: + classifyRuleId: json-schema.maximum.constraint-tightened + scope: response + set: + type: non-breaking + +# --------------------------------------------------------------------------- +# maxLength keyword +# --------------------------------------------------------------------------- +- match: + classifyRuleId: json-schema.maxLength.constraint-relaxed + set: + type: non-breaking +- match: + classifyRuleId: json-schema.maxLength.constraint-relaxed + scope: response + set: + type: breaking + +- match: + classifyRuleId: json-schema.maxLength.constraint-tightened + set: + type: breaking +- match: + classifyRuleId: json-schema.maxLength.constraint-tightened + scope: response + set: + type: non-breaking + +# --------------------------------------------------------------------------- +# maxItems keyword +# --------------------------------------------------------------------------- +- match: + classifyRuleId: json-schema.maxItems.constraint-relaxed + set: + type: non-breaking +- match: + classifyRuleId: json-schema.maxItems.constraint-relaxed + scope: response + set: + type: breaking + +- match: + classifyRuleId: json-schema.maxItems.constraint-tightened + set: + type: breaking +- match: + classifyRuleId: json-schema.maxItems.constraint-tightened + scope: response + set: + type: non-breaking + +# --------------------------------------------------------------------------- +# maxProperties keyword +# --------------------------------------------------------------------------- +- match: + classifyRuleId: json-schema.maxProperties.constraint-relaxed + set: + type: non-breaking +- match: + classifyRuleId: json-schema.maxProperties.constraint-relaxed + scope: response + set: + type: breaking + +- match: + classifyRuleId: json-schema.maxProperties.constraint-tightened + set: + type: breaking +- match: + classifyRuleId: json-schema.maxProperties.constraint-tightened + scope: response + set: + type: non-breaking + +# --------------------------------------------------------------------------- +# exclusiveMaximum keyword (non-draft-04) +# --------------------------------------------------------------------------- +- match: + classifyRuleId: json-schema.exclusiveMaximum.constraint-relaxed + set: + type: non-breaking +- match: + classifyRuleId: json-schema.exclusiveMaximum.constraint-relaxed + scope: response + set: + type: breaking + +- match: + classifyRuleId: json-schema.exclusiveMaximum.constraint-tightened + set: + type: breaking +- match: + classifyRuleId: json-schema.exclusiveMaximum.constraint-tightened + scope: response + set: + type: non-breaking + +# --------------------------------------------------------------------------- +# min* keywords (minLength, minItems, minProperties, exclusiveMinimum non-draft-04) +# --------------------------------------------------------------------------- +# All three actions flip for response schemas (reverseClassifyRuleTransformer). + +# add (introduces a lower-bound constraint): +# non-response → breaking +# response → non-breaking +- match: + classifyRuleId: json-schema.min.add + set: + type: breaking +- match: + classifyRuleId: json-schema.min.add + scope: response + set: + type: non-breaking + +# remove (drops the lower-bound constraint): +# non-response → non-breaking +# response → breaking +- match: + classifyRuleId: json-schema.min.remove + set: + type: non-breaking +- match: + classifyRuleId: json-schema.min.remove + scope: response + set: + type: breaking + +# replace constraint-relaxed (after <= before, both numeric — lower bound lowered): +# non-response → non-breaking +# response → breaking +- match: + classifyRuleId: json-schema.min.replace.constraint-relaxed + set: + type: non-breaking +- match: + classifyRuleId: json-schema.min.replace.constraint-relaxed + scope: response + set: + type: breaking + +# replace constraint-tightened (after > before, or either value non-numeric): +# non-response → breaking +# response → non-breaking +- match: + classifyRuleId: json-schema.min.replace.constraint-tightened + set: + type: breaking +- match: + classifyRuleId: json-schema.min.replace.constraint-tightened + scope: response + set: + type: non-breaking + +# --------------------------------------------------------------------------- +# enum array items +# --------------------------------------------------------------------------- + +# replace: +# non-response → breaking (general, lower priority) +# response → non-breaking (specific, higher priority) +- match: + classifyRuleId: json-schema.enum.item + set: + type: breaking +- match: + classifyRuleId: json-schema.enum.item + scope: response + set: + type: non-breaking + +# before-enum-non-empty (add): +# non-response → non-breaking (general, lower priority) +# response → risky (specific, higher priority) +# (risky from reverseClassifier — not from reclassifyBreakingToRisky) +- match: + classifyRuleId: json-schema.enum.item.before-enum-non-empty + set: + type: non-breaking +- match: + classifyRuleId: json-schema.enum.item.before-enum-non-empty + scope: response + set: + type: risky + +# before-enum-empty (add): +# non-response → breaking (general, lower priority) +# response → non-breaking (specific, higher priority) +- match: + classifyRuleId: json-schema.enum.item.before-enum-empty + set: + type: breaking +- match: + classifyRuleId: json-schema.enum.item.before-enum-empty + scope: response + set: + type: non-breaking + +# after-enum-non-empty (remove): +# non-response → breaking (general, lower priority) +# response → non-breaking (specific, higher priority) +- match: + classifyRuleId: json-schema.enum.item.after-enum-non-empty + set: + type: breaking +- match: + classifyRuleId: json-schema.enum.item.after-enum-non-empty + scope: response + set: + type: non-breaking + +# after-enum-empty (remove): +# non-response → non-breaking (general, lower priority) +# response → risky (specific, higher priority) +# (risky from reverseClassifier — not from reclassifyBreakingToRisky) +- match: + classifyRuleId: json-schema.enum.item.after-enum-empty + set: + type: non-breaking +- match: + classifyRuleId: json-schema.enum.item.after-enum-empty + scope: response + set: + type: risky + +# ============================================================ +# title keyword (annotation — symmetric, no scope override) +# ============================================================ +- match: + classifyRuleId: json-schema.title + set: + type: annotation + +# ============================================================ +# description keyword (annotation — symmetric, no scope override) +# ============================================================ +- match: + classifyRuleId: json-schema.description + set: + type: annotation + +# ============================================================ +# examples keyword and per-item examples (annotation — symmetric, no scope override) +# ============================================================ +- match: + classifyRuleId: json-schema.examples + set: + type: annotation +- match: + classifyRuleId: json-schema.examples.item + set: + type: annotation + +# ============================================================ +# items keyword — array (tuple) variant: allBreaking +# Rule: [breaking, breaking, breaking] +# ============================================================ +- match: + classifyRuleId: json-schema.items.array-item.add + set: + type: breaking +- match: + classifyRuleId: json-schema.items.array-item.add + scope: response + set: + type: non-breaking +- match: + classifyRuleId: json-schema.items.array-item.remove + set: + type: breaking +- match: + classifyRuleId: json-schema.items.array-item.remove + scope: response + set: + type: non-breaking +- match: + classifyRuleId: json-schema.items.array-item.replace + set: + type: breaking +- match: + classifyRuleId: json-schema.items.array-item.replace + scope: response + set: + type: non-breaking + +# ============================================================ +# items keyword — schema (all-items) variant: allNonBreaking +# Rule: [nonBreaking, nonBreaking, nonBreaking] +# ============================================================ +- match: + classifyRuleId: json-schema.items.schema.add + set: + type: non-breaking +- match: + classifyRuleId: json-schema.items.schema.add + scope: response + set: + type: breaking +- match: + classifyRuleId: json-schema.items.schema.remove + set: + type: non-breaking +- match: + classifyRuleId: json-schema.items.schema.remove + scope: response + set: + type: breaking +- match: + classifyRuleId: json-schema.items.schema.replace + set: + type: non-breaking +- match: + classifyRuleId: json-schema.items.schema.replace + scope: response + set: + type: breaking + +# ============================================================ +# additionalItems keyword +# Rule: [nonBreaking, breaking, unclassified] +# ============================================================ +- match: + classifyRuleId: json-schema.additional-items.add + set: + type: non-breaking +- match: + classifyRuleId: json-schema.additional-items.add + scope: response + set: + type: breaking +- match: + classifyRuleId: json-schema.additional-items.remove + set: + type: breaking +- match: + classifyRuleId: json-schema.additional-items.remove + scope: response + set: + type: non-breaking +- match: + classifyRuleId: json-schema.additional-items.replace + set: + type: unclassified + +# ============================================================ +# patternProperties sub-schemas +# Rule: [breaking, nonBreaking, unclassified] +# ============================================================ +- match: + classifyRuleId: json-schema.pattern-properties.item.add + set: + type: breaking +- match: + classifyRuleId: json-schema.pattern-properties.item.add + scope: response + set: + type: non-breaking +- match: + classifyRuleId: json-schema.pattern-properties.item.remove + set: + type: non-breaking +- match: + classifyRuleId: json-schema.pattern-properties.item.remove + scope: response + set: + type: breaking +- match: + classifyRuleId: json-schema.pattern-properties.item.replace + set: + type: unclassified + +# ============================================================ +# propertyNames keyword +# Rule: onlyAddBreaking [breaking, nonBreaking, nonBreaking] +# ============================================================ +- match: + classifyRuleId: json-schema.property-names.add + set: + type: breaking +- match: + classifyRuleId: json-schema.property-names.add + scope: response + set: + type: non-breaking +- match: + classifyRuleId: json-schema.property-names.remove + set: + type: non-breaking +- match: + classifyRuleId: json-schema.property-names.remove + scope: response + set: + type: breaking +- match: + classifyRuleId: json-schema.property-names.replace + set: + type: non-breaking +- match: + classifyRuleId: json-schema.property-names.replace + scope: response + set: + type: breaking + +# ============================================================ +# definitions/* sub-schemas +# Rule: allNonBreaking [nonBreaking, nonBreaking, nonBreaking] +# ============================================================ +- match: + classifyRuleId: json-schema.definitions.item.add + set: + type: non-breaking +- match: + classifyRuleId: json-schema.definitions.item.add + scope: response + set: + type: breaking +- match: + classifyRuleId: json-schema.definitions.item.remove + set: + type: non-breaking +- match: + classifyRuleId: json-schema.definitions.item.remove + scope: response + set: + type: breaking +- match: + classifyRuleId: json-schema.definitions.item.replace + set: + type: non-breaking +- match: + classifyRuleId: json-schema.definitions.item.replace + scope: response + set: + type: breaking + +# ============================================================ +# $defs/* sub-schemas +# Rule: allNonBreaking [nonBreaking, nonBreaking, nonBreaking] +# ============================================================ +- match: + classifyRuleId: json-schema.defs.item.add + set: + type: non-breaking +- match: + classifyRuleId: json-schema.defs.item.add + scope: response + set: + type: breaking +- match: + classifyRuleId: json-schema.defs.item.remove + set: + type: non-breaking +- match: + classifyRuleId: json-schema.defs.item.remove + scope: response + set: + type: breaking +- match: + classifyRuleId: json-schema.defs.item.replace + set: + type: non-breaking +- match: + classifyRuleId: json-schema.defs.item.replace + scope: response + set: + type: breaking + +# ============================================================ +# enum array (the /enum keyword node itself) +# Rule: [breaking, nonBreaking, breaking, nonBreaking, risky, nonBreaking] +# Response remove is risky (non-standard: reversal of nonBreaking would be breaking) +# ============================================================ +# add: breaking (request) / non-breaking (response) +- match: + classifyRuleId: json-schema.enum.add + set: + type: breaking +- match: + classifyRuleId: json-schema.enum.add + scope: response + set: + type: non-breaking +# remove: non-breaking (request) / risky (response) +- match: + classifyRuleId: json-schema.enum.remove + set: + type: non-breaking +- match: + classifyRuleId: json-schema.enum.remove + scope: response + set: + type: risky +# replace: breaking (request) / non-breaking (response) +- match: + classifyRuleId: json-schema.enum.replace + set: + type: breaking +- match: + classifyRuleId: json-schema.enum.replace + scope: response + set: + type: non-breaking + +# ============================================================ +# oneOf combiner items +# Rule: [nonBreaking, breaking, breaking] — standard reversal +# ============================================================ +- match: + classifyRuleId: json-schema.one-of.item.add + set: + type: non-breaking +- match: + classifyRuleId: json-schema.one-of.item.add + scope: response + set: + type: breaking +- match: + classifyRuleId: json-schema.one-of.item.remove + set: + type: breaking +- match: + classifyRuleId: json-schema.one-of.item.remove + scope: response + set: + type: non-breaking +- match: + classifyRuleId: json-schema.one-of.item.replace + set: + type: breaking +- match: + classifyRuleId: json-schema.one-of.item.replace + scope: response + set: + type: non-breaking + +# ============================================================ +# anyOf combiner items +# Rule: [nonBreaking, breaking, breaking] — standard reversal +# ============================================================ +- match: + classifyRuleId: json-schema.any-of.item.add + set: + type: non-breaking +- match: + classifyRuleId: json-schema.any-of.item.add + scope: response + set: + type: breaking +- match: + classifyRuleId: json-schema.any-of.item.remove + set: + type: breaking +- match: + classifyRuleId: json-schema.any-of.item.remove + scope: response + set: + type: non-breaking +- match: + classifyRuleId: json-schema.any-of.item.replace + set: + type: breaking +- match: + classifyRuleId: json-schema.any-of.item.replace + scope: response + set: + type: non-breaking + +# ============================================================ +# allOf combiner items +# Rule: allBreaking [breaking, breaking, breaking] — standard reversal +# ============================================================ +- match: + classifyRuleId: json-schema.all-of.item.add + set: + type: breaking +- match: + classifyRuleId: json-schema.all-of.item.add + scope: response + set: + type: non-breaking +- match: + classifyRuleId: json-schema.all-of.item.remove + set: + type: breaking +- match: + classifyRuleId: json-schema.all-of.item.remove + scope: response + set: + type: non-breaking +- match: + classifyRuleId: json-schema.all-of.item.replace + set: + type: breaking +- match: + classifyRuleId: json-schema.all-of.item.replace + scope: response + set: + type: non-breaking + +# ============================================================ +# not keyword ($: node) +# Rule: allBreaking [breaking, breaking, breaking] — standard reversal +# ============================================================ +- match: + classifyRuleId: json-schema.not.add + set: + type: breaking +- match: + classifyRuleId: json-schema.not.add + scope: response + set: + type: non-breaking +- match: + classifyRuleId: json-schema.not.remove + set: + type: breaking +- match: + classifyRuleId: json-schema.not.remove + scope: response + set: + type: non-breaking +- match: + classifyRuleId: json-schema.not.replace + set: + type: breaking +- match: + classifyRuleId: json-schema.not.replace + scope: response + set: + type: non-breaking + +# ============================================================ +# pattern validator +# ============================================================ +# add: breaking (request) / non-breaking (response) +- match: + classifyRuleId: json-schema.pattern.add + set: + type: breaking +- match: + classifyRuleId: json-schema.pattern.add + scope: response + set: + type: non-breaking +# remove: non-breaking (request) / breaking (response) +- match: + classifyRuleId: json-schema.pattern.remove + set: + type: non-breaking +- match: + classifyRuleId: json-schema.pattern.remove + scope: response + set: + type: breaking +# replace: breaking (both — response does NOT flip to non-breaking here) +- match: + classifyRuleId: json-schema.pattern.replace + set: + type: breaking + +# ============================================================ +# format keyword +# ============================================================ +# add: breaking (request) / non-breaking (response) +- match: + classifyRuleId: json-schema.format.add + set: + type: breaking +- match: + classifyRuleId: json-schema.format.add + scope: response + set: + type: non-breaking +# remove: non-breaking (request) / breaking (response) +- match: + classifyRuleId: json-schema.format.remove + set: + type: non-breaking +- match: + classifyRuleId: json-schema.format.remove + scope: response + set: + type: breaking +# replace: breaking (both) +- match: + classifyRuleId: json-schema.format.replace + set: + type: breaking + +# ============================================================ +# deprecated keyword [allDeprecated] +# ============================================================ +- match: + classifyRuleId: json-schema.deprecated + set: + type: deprecated + +# ============================================================ +# default value +# Rule: [nonBreaking, breaking, breaking] — standard reversal +# ============================================================ +- match: + classifyRuleId: json-schema.default.add + set: + type: non-breaking +- match: + classifyRuleId: json-schema.default.add + scope: response + set: + type: breaking +- match: + classifyRuleId: json-schema.default.remove + set: + type: breaking +- match: + classifyRuleId: json-schema.default.remove + scope: response + set: + type: non-breaking +- match: + classifyRuleId: json-schema.default.replace + set: + type: breaking +- match: + classifyRuleId: json-schema.default.replace + scope: response + set: + type: non-breaking + +# ============================================================ +# const keyword +# Rule: [breaking, nonBreaking, breaking] — standard reversal +# ============================================================ +- match: + classifyRuleId: json-schema.const.add + set: + type: breaking +- match: + classifyRuleId: json-schema.const.add + scope: response + set: + type: non-breaking +- match: + classifyRuleId: json-schema.const.remove + set: + type: non-breaking +- match: + classifyRuleId: json-schema.const.remove + scope: response + set: + type: breaking +- match: + classifyRuleId: json-schema.const.replace + set: + type: breaking +- match: + classifyRuleId: json-schema.const.replace + scope: response + set: + type: non-breaking + +# ============================================================ +# uniqueItems validator +# ============================================================ +# after-true (add/replace where uniqueItems=true): breaking (request) / non-breaking (response) +- match: + classifyRuleId: json-schema.unique-items.after-true + set: + type: breaking +- match: + classifyRuleId: json-schema.unique-items.after-true + scope: response + set: + type: non-breaking +# after-not-true (add/replace where uniqueItems!=true): non-breaking (request) / breaking (response) +- match: + classifyRuleId: json-schema.unique-items.after-not-true + set: + type: non-breaking +- match: + classifyRuleId: json-schema.unique-items.after-not-true + scope: response + set: + type: breaking +# remove: non-breaking (request) / breaking (response) +- match: + classifyRuleId: json-schema.unique-items.remove + set: + type: non-breaking +- match: + classifyRuleId: json-schema.unique-items.remove + scope: response + set: + type: breaking + +# ============================================================ +# readOnly keyword +# Rule: [...booleanClassifier, ...allNonBreaking] +# Response slots are all non-breaking (explicit override). +# ============================================================ +# after-true (add/replace where readOnly=true): breaking (request) / non-breaking (response) +- match: + classifyRuleId: json-schema.read-only.after-true + set: + type: breaking +- match: + classifyRuleId: json-schema.read-only.after-true + scope: response + set: + type: non-breaking +# after-not-true (add/replace where readOnly!=true): non-breaking (both) +- match: + classifyRuleId: json-schema.read-only.after-not-true + set: + type: non-breaking +# remove: non-breaking (both — response slot is non-breaking, not reversed) +- match: + classifyRuleId: json-schema.read-only.remove + set: + type: non-breaking + +# ============================================================ +# writeOnly keyword +# Rule: [...allNonBreaking, ...allNonBreaking] — non-breaking everywhere +# ============================================================ +- match: + classifyRuleId: json-schema.write-only.add + set: + type: non-breaking +- match: + classifyRuleId: json-schema.write-only.remove + set: + type: non-breaking +- match: + classifyRuleId: json-schema.write-only.replace + set: + type: non-breaking diff --git a/src/jsonSchema/jsonSchema.classify.ts b/src/jsonSchema/jsonSchema.classify.ts index c759ced..d7c5812 100644 --- a/src/jsonSchema/jsonSchema.classify.ts +++ b/src/jsonSchema/jsonSchema.classify.ts @@ -18,7 +18,31 @@ import { isTypeAssignable, nonBreakingIf, } from '../utils' -import type { ClassifyRule } from '../types' +import type { ClassifyRule, ClassifyRuleIdRule, NodeContext } from '../types' +import { JSON_SCHEMA_CLASSIFY_RULE_IDS } from './jsonSchema.classify.ruleIds' + +/** + * Captures the classification case for JSON Schema `/type` changes + * + * - add / remove → just capture as type add / remove + * - replace → depends on assignability between the before and after types: + * BROADENING (after ⊇ before, e.g. integer → number) + * NARROWING (after ⊆ before, e.g. number → integer) + * INCOMPATIBLE (neither, e.g. string → integer) + */ +export const schemaTypeClassifyRuleIdRule: ClassifyRuleIdRule = [ + JSON_SCHEMA_CLASSIFY_RULE_IDS.TYPE_ADD, + JSON_SCHEMA_CLASSIFY_RULE_IDS.TYPE_REMOVE, + ({ before, after }) => { + if (isTypeAssignable(before.value, after.value, false)) { + return JSON_SCHEMA_CLASSIFY_RULE_IDS.TYPE_REPLACE_AFTER_SUPERSET_BEFORE + } + if (isTypeAssignable(before.value, after.value, true)) { + return JSON_SCHEMA_CLASSIFY_RULE_IDS.TYPE_REPLACE_AFTER_SUBSET_BEFORE + } + return JSON_SCHEMA_CLASSIFY_RULE_IDS.TYPE_REPLACE_INCOMPATIBLE + }, +] export const typeClassifier: ClassifyRule = [ breaking,//not tested @@ -29,18 +53,71 @@ export const typeClassifier: ClassifyRule = [ ({ before, after }) => nonBreakingIf(isTypeAssignable(before.value, after.value, true)), ] +export const maxLengthClassifyRuleIdRule: ClassifyRuleIdRule = [ + JSON_SCHEMA_CLASSIFY_RULE_IDS.MAX_LENGTH_CONSTRAINT_TIGHTENED, + JSON_SCHEMA_CLASSIFY_RULE_IDS.MAX_LENGTH_CONSTRAINT_RELAXED, + ({ before, after }) => (isNumber(before.value) && isNumber(after.value) && before.value < after.value + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.MAX_LENGTH_CONSTRAINT_RELAXED + : JSON_SCHEMA_CLASSIFY_RULE_IDS.MAX_LENGTH_CONSTRAINT_TIGHTENED), +] + +export const maxItemsClassifyRuleIdRule: ClassifyRuleIdRule = [ + JSON_SCHEMA_CLASSIFY_RULE_IDS.MAX_ITEMS_CONSTRAINT_TIGHTENED, + JSON_SCHEMA_CLASSIFY_RULE_IDS.MAX_ITEMS_CONSTRAINT_RELAXED, + ({ before, after }) => (isNumber(before.value) && isNumber(after.value) && before.value < after.value + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.MAX_ITEMS_CONSTRAINT_RELAXED + : JSON_SCHEMA_CLASSIFY_RULE_IDS.MAX_ITEMS_CONSTRAINT_TIGHTENED), +] + +export const maxPropertiesClassifyRuleIdRule: ClassifyRuleIdRule = [ + JSON_SCHEMA_CLASSIFY_RULE_IDS.MAX_PROPERTIES_CONSTRAINT_TIGHTENED, + JSON_SCHEMA_CLASSIFY_RULE_IDS.MAX_PROPERTIES_CONSTRAINT_RELAXED, + ({ before, after }) => (isNumber(before.value) && isNumber(after.value) && before.value < after.value + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.MAX_PROPERTIES_CONSTRAINT_RELAXED + : JSON_SCHEMA_CLASSIFY_RULE_IDS.MAX_PROPERTIES_CONSTRAINT_TIGHTENED), +] + +export const exclusiveMaximumClassifyRuleIdRule: ClassifyRuleIdRule = [ + JSON_SCHEMA_CLASSIFY_RULE_IDS.EXCLUSIVE_MAXIMUM_CONSTRAINT_TIGHTENED, + JSON_SCHEMA_CLASSIFY_RULE_IDS.EXCLUSIVE_MAXIMUM_CONSTRAINT_RELAXED, + ({ before, after }) => (isNumber(before.value) && isNumber(after.value) && before.value < after.value + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.EXCLUSIVE_MAXIMUM_CONSTRAINT_RELAXED + : JSON_SCHEMA_CLASSIFY_RULE_IDS.EXCLUSIVE_MAXIMUM_CONSTRAINT_TIGHTENED), +] + export const maxClassifier: ClassifyRule = [ breaking, nonBreaking, ({ before, after }) => breakingIf(!isNumber(before.value) || !isNumber(after.value) || before.value > after.value), ] +export const minClassifyRuleIdRule: ClassifyRuleIdRule = [ + JSON_SCHEMA_CLASSIFY_RULE_IDS.MIN_ADD, + JSON_SCHEMA_CLASSIFY_RULE_IDS.MIN_REMOVE, + ({ before, after }) => (isNumber(before.value) && isNumber(after.value) && before.value >= after.value + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.MIN_REPLACE_CONSTRAINT_RELAXED + : JSON_SCHEMA_CLASSIFY_RULE_IDS.MIN_REPLACE_CONSTRAINT_TIGHTENED), +] + export const minClassifier: ClassifyRule = [ breaking, nonBreaking, ({ before, after }) => breakingIf(!isNumber(before.value) || !isNumber(after.value) || before.value < after.value), ] +export const minimumClassifyRuleIdRule: ClassifyRuleIdRule = [ + ({ before, after }) => { + const beforeExclusiveMinimum = getKeyValue(before.parent, 'exclusiveMinimum') + return (isNumber(beforeExclusiveMinimum) && isNumber(after.value) && beforeExclusiveMinimum >= after.value) + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.MINIMUM_ADD_BEFORE_EXCLUSIVE_MIN_COVERS + : JSON_SCHEMA_CLASSIFY_RULE_IDS.MINIMUM_ADD_BEFORE_EXCLUSIVE_MIN_NOT_COVERS + }, + JSON_SCHEMA_CLASSIFY_RULE_IDS.MINIMUM_REMOVE, + ({ before, after }) => (isNumber(before.value) && isNumber(after.value) && before.value >= after.value + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.MINIMUM_REPLACE_CONSTRAINT_RELAXED + : JSON_SCHEMA_CLASSIFY_RULE_IDS.MINIMUM_REPLACE_CONSTRAINT_TIGHTENED), +] + export const minimumClassifier: ClassifyRule = [ ({ before, after }) => { const beforeExclusiveMinimum = getKeyValue(before.parent, 'exclusiveMinimum') @@ -50,6 +127,19 @@ export const minimumClassifier: ClassifyRule = [ ({ before, after }) => breakingIf(!isNumber(before.value) || !isNumber(after.value) || before.value < after.value), ] +export const maximumClassifyRuleIdRule: ClassifyRuleIdRule = [ + ({ before, after }) => { + const beforeExclusiveMaximum = getKeyValue(before.parent, 'exclusiveMaximum') + return (isNumber(beforeExclusiveMaximum) && isNumber(after.value) && beforeExclusiveMaximum <= after.value) + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.MAXIMUM_CONSTRAINT_RELAXED + : JSON_SCHEMA_CLASSIFY_RULE_IDS.MAXIMUM_CONSTRAINT_TIGHTENED + }, + JSON_SCHEMA_CLASSIFY_RULE_IDS.MAXIMUM_CONSTRAINT_RELAXED, + ({ before, after }) => (isNumber(before.value) && isNumber(after.value) && before.value < after.value + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.MAXIMUM_CONSTRAINT_RELAXED + : JSON_SCHEMA_CLASSIFY_RULE_IDS.MAXIMUM_CONSTRAINT_TIGHTENED), +] + export const maximumClassifier: ClassifyRule = [ ({ before, after }) => { const beforeExclusiveMaximum = getKeyValue(before.parent, 'exclusiveMaximum') @@ -59,12 +149,32 @@ export const maximumClassifier: ClassifyRule = [ ({ before, after }) => breakingIf(!isNumber(before.value) || !isNumber(after.value) || before.value > after.value), ] +export const exclusiveClassifyRuleIdRule: ClassifyRuleIdRule = [ + ({ after }) => (after.value === true + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.EXCLUSIVE_ADD_AFTER_TRUE + : JSON_SCHEMA_CLASSIFY_RULE_IDS.EXCLUSIVE_ADD_AFTER_NOT_TRUE), + ({ before }) => (before.value === true + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.EXCLUSIVE_REMOVE_BEFORE_TRUE + : JSON_SCHEMA_CLASSIFY_RULE_IDS.EXCLUSIVE_REMOVE_BEFORE_NOT_TRUE), + ({ after }) => (after.value === true + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.EXCLUSIVE_REPLACE_AFTER_TRUE + : JSON_SCHEMA_CLASSIFY_RULE_IDS.EXCLUSIVE_REPLACE_AFTER_NOT_TRUE), +] + export const exclusiveClassifier: ClassifyRule = [ ({ after }) => (after.value === true ? breaking : unclassified), ({ before }) => (before.value === true ? nonBreaking : unclassified), breakingIfAfterTrue, ] +export const multipleOfClassifyRuleIdRule: ClassifyRuleIdRule = [ + JSON_SCHEMA_CLASSIFY_RULE_IDS.MULTIPLE_OF_ADD, + JSON_SCHEMA_CLASSIFY_RULE_IDS.MULTIPLE_OF_REMOVE, + ({ before, after }) => (isNumber(before.value) && isNumber(after.value) && (before.value as number) % (after.value as number) === 0 + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.MULTIPLE_OF_REPLACE_NEW_IS_DIVISOR_OF_OLD + : JSON_SCHEMA_CLASSIFY_RULE_IDS.MULTIPLE_OF_REPLACE_NEW_IS_NOT_DIVISOR_OF_OLD), +] + //todo think about replace multipleOf in inverse case export const multipleOfClassifier: ClassifyRule = [ breaking, @@ -75,35 +185,66 @@ export const multipleOfClassifier: ClassifyRule = [ breaking, ] +const requiredItemHasPropertyDefault = (after: NodeContext): boolean => + !isString(after.value) || isExist(strictResolveValueFromContext(after, PARENT_JUMP, PARENT_JUMP, 'properties', after.value, 'default')) + +export const requiredItemClassifyRuleIdRule: ClassifyRuleIdRule = + ({ after }) => (requiredItemHasPropertyDefault(after) + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.REQUIRED_ITEM_AFTER_PROPERTY_HAS_DEFAULT + : JSON_SCHEMA_CLASSIFY_RULE_IDS.REQUIRED_ITEM_AFTER_PROPERTY_HAS_NO_DEFAULT) + export const requiredItemClassifyRule: ClassifyRule = [ - ({ after }) => (!isString(after.value) || isExist(strictResolveValueFromContext(after, PARENT_JUMP, PARENT_JUMP, 'properties', after.value, 'default')) ? nonBreaking : breaking), + ({ after }) => (requiredItemHasPropertyDefault(after) ? nonBreaking : breaking), nonBreaking, - ({ after }) => (!isString(after.value) || isExist(strictResolveValueFromContext(after, PARENT_JUMP, PARENT_JUMP, 'properties', after.value, 'default')) ? nonBreaking : breaking), + ({ after }) => (requiredItemHasPropertyDefault(after) ? nonBreaking : breaking), nonBreaking, breaking, breaking, ] +const propertyHasDefault = (nodeCtx: NodeContext): boolean => + isExist(getKeyValue(nodeCtx.value, 'default')) + +const propertyIsRequired = (nodeCtx: NodeContext): boolean => + !!getArrayValue(strictResolveValueFromContext(nodeCtx, PARENT_JUMP, PARENT_JUMP, 'required'))?.includes(nodeCtx.key) + +export const propertyClassifyRuleIdRule: ClassifyRuleIdRule = [ + ({ after }) => (!propertyHasDefault(after) && propertyIsRequired(after) + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.PROPERTY_AFTER_REQUIRED_NO_DEFAULT + : JSON_SCHEMA_CLASSIFY_RULE_IDS.PROPERTY_AFTER_OPTIONAL_OR_HAS_DEFAULT), + ({ before }) => (propertyIsRequired(before) + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.PROPERTY_BEFORE_REQUIRED + : JSON_SCHEMA_CLASSIFY_RULE_IDS.PROPERTY_BEFORE_NOT_REQUIRED), + JSON_SCHEMA_CLASSIFY_RULE_IDS.PROPERTY, +] + //todo add logic about compliance with additionalProperties export const propertyClassifyRule: ClassifyRule = [ - ({ after }) => ( - !isExist(getKeyValue(after.value, 'default')) && - getArrayValue((strictResolveValueFromContext(after, PARENT_JUMP, PARENT_JUMP, 'required')))?.includes(after.key) ? breaking : nonBreaking - ), + ({ after }) => (!propertyHasDefault(after) && propertyIsRequired(after) ? breaking : nonBreaking), breaking, unclassified, nonBreaking, - ({ before }) => (getArrayValue(strictResolveValueFromContext(before, PARENT_JUMP, PARENT_JUMP, 'required'))?.includes(before.key) ? breaking : nonBreaking), + ({ before }) => (propertyIsRequired(before) ? breaking : nonBreaking), unclassified, ] +export const enumItemClassifyRuleIdRule: ClassifyRuleIdRule = [ + ({ before }) => (isNotEmptyArray(before.parent) + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.ENUM_ITEM_BEFORE_ENUM_NON_EMPTY + : JSON_SCHEMA_CLASSIFY_RULE_IDS.ENUM_ITEM_BEFORE_ENUM_EMPTY), + ({ after }) => (isNotEmptyArray(after.parent) + ? JSON_SCHEMA_CLASSIFY_RULE_IDS.ENUM_ITEM_AFTER_ENUM_NON_EMPTY + : JSON_SCHEMA_CLASSIFY_RULE_IDS.ENUM_ITEM_AFTER_ENUM_EMPTY), + JSON_SCHEMA_CLASSIFY_RULE_IDS.ENUM_ITEM, +] + export const enumClassifyRule: ClassifyRule = [ ({ before }) => (isNotEmptyArray(before.parent) ? nonBreaking : breaking), ({ after }) => (isNotEmptyArray(after.parent) ? breaking : nonBreaking), breaking, ({ before }) => (isNotEmptyArray(before.parent) ? risky : nonBreaking), - ({ after }) => (isNotEmptyArray(after.parent) ? nonBreaking: risky ), - nonBreaking + ({ after }) => (isNotEmptyArray(after.parent) ? nonBreaking : risky), + nonBreaking, ] export const nonInvertible = (rule: ClassifyRule): ClassifyRule => { diff --git a/src/jsonSchema/jsonSchema.rules.ts b/src/jsonSchema/jsonSchema.rules.ts index 9232ff9..58573cf 100644 --- a/src/jsonSchema/jsonSchema.rules.ts +++ b/src/jsonSchema/jsonSchema.rules.ts @@ -24,23 +24,37 @@ import { } from '../core' import { enumClassifyRule, + enumItemClassifyRuleIdRule, exclusiveClassifier, + exclusiveClassifyRuleIdRule, + exclusiveMaximumClassifyRuleIdRule, maxClassifier, + maxLengthClassifyRuleIdRule, + maxItemsClassifyRuleIdRule, + maxPropertiesClassifyRuleIdRule, maximumClassifier, + maximumClassifyRuleIdRule, minClassifier, + minClassifyRuleIdRule, minimumClassifier, + minimumClassifyRuleIdRule, multipleOfClassifier, + multipleOfClassifyRuleIdRule, propertyClassifyRule, + propertyClassifyRuleIdRule, requiredItemClassifyRule, + requiredItemClassifyRuleIdRule, + schemaTypeClassifyRuleIdRule, typeClassifier, } from './jsonSchema.classify' import { jsonSchemaAdapter } from './jsonSchema.adapter' import { jsonSchemaMappingResolver } from './jsonSchema.mapping' import { combinersCompareResolver } from './jsonSchema.resolver' -import { ClassifyRule, CompareRules, DescriptionTemplates } from '../types' +import { ClassifyRule, ClassifyRuleIdRule, CompareRules, DescriptionTemplates } from '../types' import { JsonSchemaRulesOptions, NativeAnySchemaFactory } from './jsonSchema.types' import { normalize, SPEC_TYPE_JSON_SCHEMA_04 } from '@netcracker/qubership-apihub-api-unifier' import { isBoolean, isNumber, isString } from '../utils' +import { JSON_SCHEMA_CLASSIFY_RULE_IDS } from './jsonSchema.classify.ruleIds' const simpleRule = (classify: ClassifyRule, descriptionTemplate: DescriptionTemplates) => ({ $: classify, @@ -52,10 +66,12 @@ const arrayItemsRules = (value: unknown, rules: CompareRules): CompareRules => { '/*': { ...rules, $: allBreaking, + classifyRuleId: [JSON_SCHEMA_CLASSIFY_RULE_IDS.ITEMS_ARRAY_ITEM_ADD, JSON_SCHEMA_CLASSIFY_RULE_IDS.ITEMS_ARRAY_ITEM_REMOVE, JSON_SCHEMA_CLASSIFY_RULE_IDS.ITEMS_ARRAY_ITEM_REPLACE], }, } : { ...rules, $: allNonBreaking, + classifyRuleId: [JSON_SCHEMA_CLASSIFY_RULE_IDS.ITEMS_SCHEMA_ADD, JSON_SCHEMA_CLASSIFY_RULE_IDS.ITEMS_SCHEMA_REMOVE, JSON_SCHEMA_CLASSIFY_RULE_IDS.ITEMS_SCHEMA_REPLACE], } } @@ -80,32 +96,52 @@ export const jsonSchemaRules = ({ ], mapping: jsonSchemaMappingResolver, // todo: add descriptionParamCalculator only for jsonScheme - '/title': simpleRule(allAnnotation, resolveSchemaDescriptionTemplates('title')), - '/description': simpleRule(allAnnotation, resolveSchemaDescriptionTemplates('description')), - '/type': simpleRule(typeClassifier, resolveSchemaDescriptionTemplates('type')), + '/title': { ...simpleRule(allAnnotation, resolveSchemaDescriptionTemplates('title')), classifyRuleId: JSON_SCHEMA_CLASSIFY_RULE_IDS.TITLE }, + '/description': { ...simpleRule(allAnnotation, resolveSchemaDescriptionTemplates('description')), classifyRuleId: JSON_SCHEMA_CLASSIFY_RULE_IDS.DESCRIPTION }, + '/type': { + ...simpleRule(typeClassifier, resolveSchemaDescriptionTemplates('type')), + classifyRuleId: schemaTypeClassifyRuleIdRule, + }, - '/multipleOf': simpleRule(multipleOfClassifier, resolveSchemaDescriptionTemplates('multipleOf validator')), - '/maximum': simpleRule(maximumClassifier, resolveSchemaDescriptionTemplates('maximum validator')), - '/minimum': simpleRule(minimumClassifier, resolveSchemaDescriptionTemplates('minimum validator')), + '/multipleOf': { ...simpleRule(multipleOfClassifier, resolveSchemaDescriptionTemplates('multipleOf validator')), classifyRuleId: multipleOfClassifyRuleIdRule }, + '/maximum': { ...simpleRule(maximumClassifier, resolveSchemaDescriptionTemplates('maximum validator')), classifyRuleId: maximumClassifyRuleIdRule }, + '/minimum': { ...simpleRule(minimumClassifier, resolveSchemaDescriptionTemplates('minimum validator')), classifyRuleId: minimumClassifyRuleIdRule }, ...version === SPEC_TYPE_JSON_SCHEMA_04 ? { - '/exclusiveMaximum': simpleRule(exclusiveClassifier, resolveSchemaDescriptionTemplates('exclusiveMaximum validator')), - '/exclusiveMinimum': simpleRule(exclusiveClassifier, resolveSchemaDescriptionTemplates('exclusiveMinimum validator')), + '/exclusiveMaximum': { ...simpleRule(exclusiveClassifier, resolveSchemaDescriptionTemplates('exclusiveMaximum validator')), classifyRuleId: exclusiveClassifyRuleIdRule }, + '/exclusiveMinimum': { ...simpleRule(exclusiveClassifier, resolveSchemaDescriptionTemplates('exclusiveMinimum validator')), classifyRuleId: exclusiveClassifyRuleIdRule }, } : { - '/exclusiveMaximum': simpleRule(maxClassifier, resolveSchemaDescriptionTemplates('exclusiveMaximum validator')), - '/exclusiveMinimum': simpleRule(minClassifier, resolveSchemaDescriptionTemplates('exclusiveMinimum validator')), - }, - '/maxLength': simpleRule(maxClassifier, resolveSchemaDescriptionTemplates('maxLength validator')), - '/minLength': simpleRule(minClassifier, resolveSchemaDescriptionTemplates('minLength validator')), - '/pattern': simpleRule([breaking, nonBreaking, breaking, nonBreaking, breaking, breaking], resolveSchemaDescriptionTemplates('pattern validator')), - '/maxItems': simpleRule(maxClassifier, resolveSchemaDescriptionTemplates('maxItems validator')), - '/minItems': simpleRule(minClassifier, resolveSchemaDescriptionTemplates('minItems validator')), - '/uniqueItems': simpleRule(booleanClassifier, resolveSchemaDescriptionTemplates('uniqueItems validator')), - '/maxProperties': simpleRule(maxClassifier, resolveSchemaDescriptionTemplates('maxProperties validator')), - '/minProperties': simpleRule(minClassifier, resolveSchemaDescriptionTemplates('minProperties validator')), + '/exclusiveMaximum': { ...simpleRule(maxClassifier, resolveSchemaDescriptionTemplates('exclusiveMaximum validator')), classifyRuleId: exclusiveMaximumClassifyRuleIdRule }, + '/exclusiveMinimum': { ...simpleRule(minClassifier, resolveSchemaDescriptionTemplates('exclusiveMinimum validator')), classifyRuleId: minClassifyRuleIdRule }, + }, + '/maxLength': { ...simpleRule(maxClassifier, resolveSchemaDescriptionTemplates('maxLength validator')), classifyRuleId: maxLengthClassifyRuleIdRule }, + '/minLength': { ...simpleRule(minClassifier, resolveSchemaDescriptionTemplates('minLength validator')), classifyRuleId: minClassifyRuleIdRule }, + '/pattern': { ...simpleRule([breaking, nonBreaking, breaking, nonBreaking, breaking, breaking], resolveSchemaDescriptionTemplates('pattern validator')), classifyRuleId: [JSON_SCHEMA_CLASSIFY_RULE_IDS.PATTERN_ADD, JSON_SCHEMA_CLASSIFY_RULE_IDS.PATTERN_REMOVE, JSON_SCHEMA_CLASSIFY_RULE_IDS.PATTERN_REPLACE] }, + '/maxItems': { ...simpleRule(maxClassifier, resolveSchemaDescriptionTemplates('maxItems validator')), classifyRuleId: maxItemsClassifyRuleIdRule }, + '/minItems': { ...simpleRule(minClassifier, resolveSchemaDescriptionTemplates('minItems validator')), classifyRuleId: minClassifyRuleIdRule }, + '/uniqueItems': { + ...simpleRule(booleanClassifier, resolveSchemaDescriptionTemplates('uniqueItems validator')), + classifyRuleId: [ + ({ after }) => after.value === true ? JSON_SCHEMA_CLASSIFY_RULE_IDS.UNIQUE_ITEMS_AFTER_TRUE : JSON_SCHEMA_CLASSIFY_RULE_IDS.UNIQUE_ITEMS_AFTER_NOT_TRUE, + JSON_SCHEMA_CLASSIFY_RULE_IDS.UNIQUE_ITEMS_REMOVE, + ({ after }) => after.value === true ? JSON_SCHEMA_CLASSIFY_RULE_IDS.UNIQUE_ITEMS_AFTER_TRUE : JSON_SCHEMA_CLASSIFY_RULE_IDS.UNIQUE_ITEMS_AFTER_NOT_TRUE, + ], + }, + '/maxProperties': { ...simpleRule(maxClassifier, resolveSchemaDescriptionTemplates('maxProperties validator')), classifyRuleId: maxPropertiesClassifyRuleIdRule }, + '/minProperties': { ...simpleRule(minClassifier, resolveSchemaDescriptionTemplates('minProperties validator')), classifyRuleId: minClassifyRuleIdRule }, - '/readOnly': simpleRule([...booleanClassifier, ...allNonBreaking] as ClassifyRule, resolveSchemaDescriptionTemplates('readOnly status')), - '/writeOnly': simpleRule([...allNonBreaking, ...allNonBreaking] as ClassifyRule, resolveSchemaDescriptionTemplates('writeOnly status')), - '/deprecated': simpleRule(allDeprecated, resolveSchemaDescriptionTemplates('deprecated status')), + '/readOnly': { + ...simpleRule([...booleanClassifier, ...allNonBreaking] as ClassifyRule, resolveSchemaDescriptionTemplates('readOnly status')), + classifyRuleId: [ + ({ after }) => after.value === true ? JSON_SCHEMA_CLASSIFY_RULE_IDS.READ_ONLY_AFTER_TRUE : JSON_SCHEMA_CLASSIFY_RULE_IDS.READ_ONLY_AFTER_NOT_TRUE, + JSON_SCHEMA_CLASSIFY_RULE_IDS.READ_ONLY_REMOVE, + ({ after }) => after.value === true ? JSON_SCHEMA_CLASSIFY_RULE_IDS.READ_ONLY_AFTER_TRUE : JSON_SCHEMA_CLASSIFY_RULE_IDS.READ_ONLY_AFTER_NOT_TRUE, + ], + }, + '/writeOnly': { + ...simpleRule([...allNonBreaking, ...allNonBreaking] as ClassifyRule, resolveSchemaDescriptionTemplates('writeOnly status')), + classifyRuleId: [JSON_SCHEMA_CLASSIFY_RULE_IDS.WRITE_ONLY_ADD, JSON_SCHEMA_CLASSIFY_RULE_IDS.WRITE_ONLY_REMOVE, JSON_SCHEMA_CLASSIFY_RULE_IDS.WRITE_ONLY_REPLACE], + }, + '/deprecated': { ...simpleRule(allDeprecated, resolveSchemaDescriptionTemplates('deprecated status')), classifyRuleId: JSON_SCHEMA_CLASSIFY_RULE_IDS.DEPRECATED }, '/required': { mapping: deepEqualsUniqueItemsArrayMappingResolver, '/*': ({ key, value }) => { @@ -114,16 +150,18 @@ export const jsonSchemaRules = ({ } return ({ ...simpleRule(requiredItemClassifyRule, resolveSchemaDescriptionTemplates(`required status for property '${value}'`)), + classifyRuleId: requiredItemClassifyRuleIdRule, ignoreKeyDifference: true, }) }, }, - '/format': simpleRule([breaking, nonBreaking, breaking, nonBreaking, breaking, breaking], resolveSchemaDescriptionTemplates('format')), - '/default': simpleRule([nonBreaking, breaking, breaking], resolveSchemaDescriptionTemplates('default value')), + '/format': { ...simpleRule([breaking, nonBreaking, breaking, nonBreaking, breaking, breaking], resolveSchemaDescriptionTemplates('format')), classifyRuleId: [JSON_SCHEMA_CLASSIFY_RULE_IDS.FORMAT_ADD, JSON_SCHEMA_CLASSIFY_RULE_IDS.FORMAT_REMOVE, JSON_SCHEMA_CLASSIFY_RULE_IDS.FORMAT_REPLACE] }, + '/default': { ...simpleRule([nonBreaking, breaking, breaking], resolveSchemaDescriptionTemplates('default value')), classifyRuleId: [JSON_SCHEMA_CLASSIFY_RULE_IDS.DEFAULT_ADD, JSON_SCHEMA_CLASSIFY_RULE_IDS.DEFAULT_REMOVE, JSON_SCHEMA_CLASSIFY_RULE_IDS.DEFAULT_REPLACE] }, '/enum': { $: [breaking, nonBreaking, breaking, nonBreaking, risky, nonBreaking], + classifyRuleId: [JSON_SCHEMA_CLASSIFY_RULE_IDS.ENUM_ADD, JSON_SCHEMA_CLASSIFY_RULE_IDS.ENUM_REMOVE, JSON_SCHEMA_CLASSIFY_RULE_IDS.ENUM_REPLACE], mapping: deepEqualsUniqueItemsArrayMappingResolver, '/*': ({ key, value }) => { if (!isNumber(key)) { @@ -131,6 +169,7 @@ export const jsonSchemaRules = ({ } return ({ $: enumClassifyRule, + classifyRuleId: enumItemClassifyRuleIdRule, description: diffDescription(resolveSchemaDescriptionTemplates(isString(value) || isBoolean(value) || isNumber(value) ? `possible value '${value.toString()}'` : 'some possible value')), ignoreKeyDifference: true, }) @@ -146,6 +185,7 @@ export const jsonSchemaRules = ({ return ({ ...rules, $: [nonBreaking, breaking, breaking], + classifyRuleId: [JSON_SCHEMA_CLASSIFY_RULE_IDS.ONE_OF_ITEM_ADD, JSON_SCHEMA_CLASSIFY_RULE_IDS.ONE_OF_ITEM_REMOVE, JSON_SCHEMA_CLASSIFY_RULE_IDS.ONE_OF_ITEM_REPLACE], description: diffDescription(resolveSchemaDescriptionTemplates(`oneOf[${key.toString()}]`)), }) }, @@ -159,6 +199,7 @@ export const jsonSchemaRules = ({ return ({ ...rules, $: [nonBreaking, breaking, breaking], + classifyRuleId: [JSON_SCHEMA_CLASSIFY_RULE_IDS.ANY_OF_ITEM_ADD, JSON_SCHEMA_CLASSIFY_RULE_IDS.ANY_OF_ITEM_REMOVE, JSON_SCHEMA_CLASSIFY_RULE_IDS.ANY_OF_ITEM_REPLACE], description: diffDescription(resolveSchemaDescriptionTemplates(`anyOf[${key.toString()}]`)), }) }, @@ -169,19 +210,22 @@ export const jsonSchemaRules = ({ '/*': () => ({ ...rules, $: allBreaking, + classifyRuleId: [JSON_SCHEMA_CLASSIFY_RULE_IDS.ALL_OF_ITEM_ADD, JSON_SCHEMA_CLASSIFY_RULE_IDS.ALL_OF_ITEM_REMOVE, JSON_SCHEMA_CLASSIFY_RULE_IDS.ALL_OF_ITEM_REPLACE], }), }, - '/const': simpleRule([breaking, nonBreaking, breaking], resolveSchemaDescriptionTemplates('const')), + '/const': { ...simpleRule([breaking, nonBreaking, breaking], resolveSchemaDescriptionTemplates('const')), classifyRuleId: [JSON_SCHEMA_CLASSIFY_RULE_IDS.CONST_ADD, JSON_SCHEMA_CLASSIFY_RULE_IDS.CONST_REMOVE, JSON_SCHEMA_CLASSIFY_RULE_IDS.CONST_REPLACE] }, '/not': () => ({ // TODO check ...transformCompareRules(rules, reverseClassifyRuleTransformer), $: allBreaking, + classifyRuleId: [JSON_SCHEMA_CLASSIFY_RULE_IDS.NOT_ADD, JSON_SCHEMA_CLASSIFY_RULE_IDS.NOT_REMOVE, JSON_SCHEMA_CLASSIFY_RULE_IDS.NOT_REPLACE], }), '/items': ({ value }) => arrayItemsRules(value, rules), '/additionalItems': () => ({ ...rules, $: [nonBreaking, breaking, unclassified], + classifyRuleId: [JSON_SCHEMA_CLASSIFY_RULE_IDS.ADDITIONAL_ITEMS_ADD, JSON_SCHEMA_CLASSIFY_RULE_IDS.ADDITIONAL_ITEMS_REMOVE, JSON_SCHEMA_CLASSIFY_RULE_IDS.ADDITIONAL_ITEMS_REPLACE], }), '/properties': { '/*': ({ key }) => { @@ -191,6 +235,7 @@ export const jsonSchemaRules = ({ return ({ ...rules, $: propertyClassifyRule, + classifyRuleId: propertyClassifyRuleIdRule, description: diffDescription(resolveSchemaDescriptionTemplates(`property '${key.toString()}'`)), }) }, @@ -198,32 +243,41 @@ export const jsonSchemaRules = ({ '/additionalProperties': () => ({ ...rules, $: additionalPropertiesClassifier, + classifyRuleId: additionalPropertiesClassifyRuleIdRule, }), '/patternProperties': { '/*': () => ({ ...rules, $: [breaking, nonBreaking, unclassified], + classifyRuleId: [JSON_SCHEMA_CLASSIFY_RULE_IDS.PATTERN_PROPERTIES_ITEM_ADD, JSON_SCHEMA_CLASSIFY_RULE_IDS.PATTERN_PROPERTIES_ITEM_REMOVE, JSON_SCHEMA_CLASSIFY_RULE_IDS.PATTERN_PROPERTIES_ITEM_REPLACE], }), }, - '/propertyNames': () => ({ ...rules, $: onlyAddBreaking }), + '/propertyNames': () => ({ + ...rules, + $: onlyAddBreaking, + classifyRuleId: [JSON_SCHEMA_CLASSIFY_RULE_IDS.PROPERTY_NAMES_ADD, JSON_SCHEMA_CLASSIFY_RULE_IDS.PROPERTY_NAMES_REMOVE, JSON_SCHEMA_CLASSIFY_RULE_IDS.PROPERTY_NAMES_REPLACE], + }), // TODO "/dependencies": {}, '/definitions': { '/*': () => ({ ...rules, $: allNonBreaking, + classifyRuleId: [JSON_SCHEMA_CLASSIFY_RULE_IDS.DEFINITIONS_ITEM_ADD, JSON_SCHEMA_CLASSIFY_RULE_IDS.DEFINITIONS_ITEM_REMOVE, JSON_SCHEMA_CLASSIFY_RULE_IDS.DEFINITIONS_ITEM_REPLACE], }), }, '/$defs': { '/*': () => ({ ...rules, $: allNonBreaking, + classifyRuleId: [JSON_SCHEMA_CLASSIFY_RULE_IDS.DEFS_ITEM_ADD, JSON_SCHEMA_CLASSIFY_RULE_IDS.DEFS_ITEM_REMOVE, JSON_SCHEMA_CLASSIFY_RULE_IDS.DEFS_ITEM_REPLACE], }), }, //TODO NOT BY SPECIFICATION. ONLY IN 06 VERSION. NC SPECIFIC EXCLUSION '/examples': { $: allAnnotation, - '/*': { $: allAnnotation }, + classifyRuleId: JSON_SCHEMA_CLASSIFY_RULE_IDS.EXAMPLES, + '/*': { $: allAnnotation, classifyRuleId: JSON_SCHEMA_CLASSIFY_RULE_IDS.EXAMPLES_ITEM }, }, // unknown tags @@ -235,6 +289,19 @@ export const jsonSchemaRules = ({ return rules } +const additionalPropertiesClassifyRuleIdRule: ClassifyRuleIdRule = [ + JSON_SCHEMA_CLASSIFY_RULE_IDS.ADDITIONAL_PROPERTIES_ADD, + JSON_SCHEMA_CLASSIFY_RULE_IDS.ADDITIONAL_PROPERTIES_REMOVE, + ({ before, after }) => { + const beforeTruthy = !!before.value + const afterTruthy = !!after.value + if (beforeTruthy && afterTruthy) { return JSON_SCHEMA_CLASSIFY_RULE_IDS.ADDITIONAL_PROPERTIES_REPLACE_BEFORE_TRUTHY_AFTER_TRUTHY } + if (beforeTruthy) { return JSON_SCHEMA_CLASSIFY_RULE_IDS.ADDITIONAL_PROPERTIES_REPLACE_BEFORE_TRUTHY_AFTER_FALSY } + if (afterTruthy) { return JSON_SCHEMA_CLASSIFY_RULE_IDS.ADDITIONAL_PROPERTIES_REPLACE_BEFORE_FALSY_AFTER_TRUTHY } + return JSON_SCHEMA_CLASSIFY_RULE_IDS.ADDITIONAL_PROPERTIES_REPLACE_BEFORE_FALSY_AFTER_FALSY + }, +] + const additionalPropertiesClassifier: ClassifyRule = [ breaking, breaking, diff --git a/src/openapi/index.ts b/src/openapi/index.ts index 9120ba7..4cae6cc 100644 --- a/src/openapi/index.ts +++ b/src/openapi/index.ts @@ -1,6 +1,7 @@ export * from './openapi3.classify' export * from './openapi3.compare' export * from './openapi3.mapping' +export * from './openapi3.classify.ruleIds' export * from './openapi3.rules' export * from './openapi3.utils' export * from './openapi3.types' diff --git a/src/openapi/openapi3.classify.ruleIds.ts b/src/openapi/openapi3.classify.ruleIds.ts new file mode 100644 index 0000000..5839f17 --- /dev/null +++ b/src/openapi/openapi3.classify.ruleIds.ts @@ -0,0 +1,590 @@ +/** + * Stable identifiers for OpenAPI 3.x classification rules. + * + * Naming conventions: + * + * `{api_type}.{object}.{property}[.item].{details}` + * - api_type: REST API type identifier, e.g. `rest` + * - object: kebab-case OAS object name (e.g. `operation`, `parameter`) + * - property: property name within that object (e.g. `security`, `required`) + * - .item suffix: appended when the rule applies to individual array items + * - details: kebab-case phrase capturing the condition that determines classification + */ +export const REST_CLASSIFY_RULE_IDS = { + /** + * OAS "Operation Object" > `security` array — the resulting after-state security + * requirements are empty or a subset of the before-state (global or operation-level). + * This case is non-breaking: consumers already satisfying the old requirements still can. + */ + OPERATION_SECURITY_SUBSET: 'rest.operation.security.after-security-requirements-are-subset-of-before', + /** + * OAS "Operation Object" > `security` array — the resulting after-state security + * requirements are NOT a subset of the before-state. + * This case is breaking: consumers may no longer satisfy the new requirements. + */ + OPERATION_SECURITY_NOT_SUBSET: 'rest.operation.security.after-security-requirements-are-not-a-subset-of-before', + + /** + * OAS "Operation Object" > `security` array item — the surrounding after-state + * security array (OR-list) remains compatible: either the whole OR-list still + * covers before, or the changed item itself is empty. + * This case is non-breaking. + */ + OPERATION_SECURITY_ITEM_SUBSET: 'rest.operation.security.item.after-security-requirements-are-subset-of-before', + /** + * OAS "Operation Object" > `security` array item — the surrounding after-state + * security array introduces a requirement that is NOT covered by the before-state. + * This case is breaking. + */ + OPERATION_SECURITY_ITEM_NOT_SUBSET: 'rest.operation.security.item.after-security-requirements-are-not-a-subset-of-before', + + // --------------------------------------------------------------------------- + // Parameter Object (add / remove the whole parameter) + // --------------------------------------------------------------------------- + + /** Parameter added — after-value is a standard header that is always ignored + * (Accept, Content-Type, Authorization). By default, unclassified. */ + PARAM_AFTER_IGNORED_HEADER: 'rest.parameter.after-ignored-header', + /** Parameter added — after-value is required (`required: true`) with no schema + * default. By default, breaking. */ + PARAM_AFTER_REQUIRED_NO_DEFAULT: 'rest.parameter.after-required-no-default', + /** Parameter added — after-value is optional or has a schema default. + * By default, non-breaking. */ + PARAM_AFTER_OPTIONAL_OR_HAS_DEFAULT: 'rest.parameter.after-optional-or-has-default', + /** Parameter removed — before-value was a standard ignored header. + * By default, unclassified. */ + PARAM_BEFORE_IGNORED_HEADER: 'rest.parameter.before-ignored-header', + /** Parameter removed — before-value was not an ignored header. + * By default, breaking. */ + PARAM_BEFORE_NOT_IGNORED_HEADER: 'rest.parameter.before-not-ignored-header', + /** Parameter replaced — always unclassified. */ + PARAM_REPLACE: 'rest.parameter.replace', + + // --------------------------------------------------------------------------- + // Parameter Object > explode property + // --------------------------------------------------------------------------- + + /** Parameter `explode` added — after-value is consistent with the default for the + * parameter's style (e.g. explode=true for form style). By default, annotation. */ + PARAMETER_EXPLODE_AFTER_DEFAULT_FOR_STYLE: 'rest.parameter.explode.after-default-for-style', + /** Parameter `explode` added — after-value is inconsistent with the style default. + * By default, breaking. */ + PARAMETER_EXPLODE_AFTER_NON_DEFAULT_FOR_STYLE: 'rest.parameter.explode.after-non-default-for-style', + /** Parameter `explode` removed — before-value was consistent with the style default. + * By default, annotation. */ + PARAMETER_EXPLODE_BEFORE_DEFAULT_FOR_STYLE: 'rest.parameter.explode.before-default-for-style', + /** Parameter `explode` removed — before-value was inconsistent with the style default. + * By default, breaking. */ + PARAMETER_EXPLODE_BEFORE_NON_DEFAULT_FOR_STYLE: 'rest.parameter.explode.before-non-default-for-style', + /** Parameter `explode` replaced — always breaking. */ + PARAMETER_EXPLODE_REPLACE: 'rest.parameter.explode.replace', + + // --------------------------------------------------------------------------- + // Parameter Object > allowReserved property + // --------------------------------------------------------------------------- + + /** Parameter `allowReserved` changed — parameter is in path, cookie, or header + * location where allowReserved has no effect. By default, unclassified. */ + PARAMETER_ALLOW_RESERVED_AFTER_IN_NON_QUERY: 'rest.parameter.allow-reserved.after-in-non-query', + /** Parameter `allowReserved` added or removed — parameter is in query location + * where allowReserved is applicable. add→non-breaking, remove→breaking (YAML action filter). */ + PARAMETER_ALLOW_RESERVED_AFTER_IN_QUERY: 'rest.parameter.allow-reserved.after-in-query', + /** Parameter `allowReserved` replaced to `true` in a query parameter. + * By default, non-breaking. */ + PARAMETER_ALLOW_RESERVED_REPLACE_AFTER_TRUE: 'rest.parameter.allow-reserved.replace.after-true', + /** Parameter `allowReserved` replaced to `false` (or falsy) in a query parameter. + * By default, breaking. */ + PARAMETER_ALLOW_RESERVED_REPLACE_AFTER_FALSE: 'rest.parameter.allow-reserved.replace.after-false', + + // --------------------------------------------------------------------------- + // Parameter Object > name property + // --------------------------------------------------------------------------- + + /** Parameter `name` added. By default, non-breaking. */ + PARAMETER_NAME_ADD: 'rest.parameter.name.add', + /** Parameter `name` removed. By default, breaking. */ + PARAMETER_NAME_REMOVE: 'rest.parameter.name.remove', + /** Parameter `name` replaced — parameter is a path parameter, so the rename is + * reflected in the path template. By default, annotation. */ + PARAMETER_NAME_REPLACE_BEFORE_PATH_PARAM: 'rest.parameter.name.replace.before-path-param', + /** Parameter `name` replaced — parameter is not a path parameter (query, header, + * cookie). By default, breaking. */ + PARAMETER_NAME_REPLACE_BEFORE_NON_PATH_PARAM: 'rest.parameter.name.replace.before-non-path-param', + + // --------------------------------------------------------------------------- + // Parameter Object > required property + // --------------------------------------------------------------------------- + + /** Parameter `required` added. By default, breaking. */ + PARAMETER_REQUIRED_ADD: 'rest.parameter.required.add', + /** Parameter `required` removed. By default, non-breaking. */ + PARAMETER_REQUIRED_REMOVE: 'rest.parameter.required.remove', + /** Parameter `required` replaced — the parameter schema has a `default` value, + * so the effective requirement is satisfied. By default, non-breaking. */ + PARAMETER_REQUIRED_REPLACE_HAS_SCHEMA_DEFAULT: 'rest.parameter.required.replace.after-has-schema-default', + /** Parameter `required` replaced to `true` with no schema default. + * By default, breaking. */ + PARAMETER_REQUIRED_REPLACE_AFTER_TRUE_NO_DEFAULT: 'rest.parameter.required.replace.after-true-no-schema-default', + /** Parameter `required` replaced to `false` (or removed requirement). + * By default, non-breaking. */ + PARAMETER_REQUIRED_REPLACE_AFTER_FALSE: 'rest.parameter.required.replace.after-false', + + // --------------------------------------------------------------------------- + // Parameter Object > deprecated property + // --------------------------------------------------------------------------- + /** Parameter `deprecated` flag changed. By default, always deprecated. */ + PARAMETER_DEPRECATED: 'rest.parameter.deprecated', + + /** Parameter `description` changed. By default, always annotation. */ + PARAMETER_DESCRIPTION: 'rest.parameter.description', + + /** Parameter `example` value changed. By default, always annotation. */ + PARAMETER_EXAMPLE: 'rest.parameter.example', + + /** Nested content under parameter `example`. By default, always annotation. */ + PARAMETER_EXAMPLE_ITEM: 'rest.parameter.example.item', + + // --------------------------------------------------------------------------- + // Parameter Object > allowEmptyValue property + // --------------------------------------------------------------------------- + + /** Parameter `allowEmptyValue` changed — parameter is not in query location; + * allowEmptyValue has no effect. By default, unclassified. */ + PARAMETER_ALLOW_EMPTY_VALUE_AFTER_NOT_QUERY: 'rest.parameter.allow-empty-value.after-not-query', + /** Parameter `allowEmptyValue` changed in a query parameter — after-value is `true` + * (enabling empty values, more permissive). By default, non-breaking. */ + PARAMETER_ALLOW_EMPTY_VALUE_AFTER_TRUE: 'rest.parameter.allow-empty-value.after-true', + /** Parameter `allowEmptyValue` changed in a query parameter — after-value is not + * `true` (disabling or absent). By default, breaking. */ + PARAMETER_ALLOW_EMPTY_VALUE_AFTER_NOT_TRUE: 'rest.parameter.allow-empty-value.after-not-true', + + // --------------------------------------------------------------------------- + // Parameters array (the /parameters array node itself) + // --------------------------------------------------------------------------- + + /** Parameters array added. By default, non-breaking. */ + PARAMETERS_ARRAY_ADD: 'rest.parameters-array.add', + /** Parameters array removed — every before-entry is an ignored header. + * By default, non-breaking. */ + PARAMETERS_ARRAY_REMOVE_BEFORE_ALL_IGNORED_HEADERS: 'rest.parameters-array.remove.before-all-ignored-headers', + /** Parameters array removed — at least one before-entry is not an ignored header, + * or before-value is not an array. By default, breaking. */ + PARAMETERS_ARRAY_REMOVE_BEFORE_HAS_NON_IGNORED: 'rest.parameters-array.remove.before-has-non-ignored', + /** Parameters array replaced. By default, breaking. */ + PARAMETERS_ARRAY_REPLACE: 'rest.parameters-array.replace', + + // --------------------------------------------------------------------------- + // Root-level security array + // --------------------------------------------------------------------------- + + /** Global `security` array added — after-value is an empty array (no requirements). + * By default, non-breaking. */ + GLOBAL_SECURITY_ADD_AFTER_EMPTY: 'rest.global-security.add.after-empty', + /** Global `security` array added — after-value is non-empty (introduces requirements). + * By default, breaking. */ + GLOBAL_SECURITY_ADD_AFTER_NON_EMPTY: 'rest.global-security.add.after-non-empty', + /** Global `security` array removed. By default, non-breaking. */ + GLOBAL_SECURITY_REMOVE: 'rest.global-security.remove', + /** Global `security` array replaced — after-value is a subset of (or equal to) + * before-value, or after is empty. By default, non-breaking. */ + GLOBAL_SECURITY_REPLACE_AFTER_SUBSET_OR_EMPTY: 'rest.global-security.replace.after-subset-or-empty', + /** Global `security` array replaced — after-value adds requirements not present + * in before-value. By default, breaking. */ + GLOBAL_SECURITY_REPLACE_AFTER_NOT_SUBSET: 'rest.global-security.replace.after-not-subset', + + // --------------------------------------------------------------------------- + // Root-level security array items + // --------------------------------------------------------------------------- + + /** Global `security` array item added — the before-state OR-list was non-empty + * (adding another option to an existing requirement set). By default, non-breaking. */ + GLOBAL_SECURITY_ITEM_ADD_BEFORE_NON_EMPTY: 'rest.global-security.item.before-non-empty', + /** Global `security` array item added — the before-state OR-list was empty (this + * item introduces the first security requirement). By default, breaking. */ + GLOBAL_SECURITY_ITEM_ADD_BEFORE_EMPTY: 'rest.global-security.item.before-empty', + /** Global `security` array item removed — the after-state OR-list is still non-empty + * (other options remain). By default, non-breaking. */ + GLOBAL_SECURITY_ITEM_REMOVE_AFTER_NON_EMPTY: 'rest.global-security.item.after-non-empty', + /** Global `security` array item removed — the after-state OR-list is empty (this was + * the last requirement entry). By default, breaking. */ + GLOBAL_SECURITY_ITEM_REMOVE_AFTER_EMPTY: 'rest.global-security.item.after-empty', + /** Global `security` array item replaced — after-parent covers before-parent + * requirements, or the item itself is empty. By default, non-breaking. */ + GLOBAL_SECURITY_ITEM_REPLACE_AFTER_SUBSET_OR_EMPTY: 'rest.global-security.item.replace.after-subset-or-empty', + /** Global `security` array item replaced — after-parent does not cover before-parent + * requirements. By default, breaking. */ + GLOBAL_SECURITY_ITEM_REPLACE_AFTER_NOT_SUBSET: 'rest.global-security.item.replace.after-not-subset', + + // --------------------------------------------------------------------------- + // Path item (path rename / change) + // --------------------------------------------------------------------------- + + /** Path item added. By default, non-breaking. */ + PATH_CHANGE_ADD: 'rest.path.add', + /** Path item removed. By default, breaking. */ + PATH_CHANGE_REMOVE: 'rest.path.remove', + /** Path item replaced — the effective (server-prefixed) path is unchanged, meaning + * only parameter placeholder names changed. By default, annotation. */ + PATH_CHANGE_REPLACE_SAME_EFFECTIVE_PATH: 'rest.path.replace.same-effective-path', + /** Path item replaced — the effective path differs. By default, breaking. */ + PATH_CHANGE_REPLACE_DIFFERENT_EFFECTIVE_PATH: 'rest.path.replace.different-effective-path', + /** + * The `pathChangeClassifyRuleIdRule` is attached to the `/*` wildcard inside + * `/paths`, which matches both path items AND specification extensions (x-*). + * When the key does NOT start with `/` the diff is a spec extension — its + * engine type is `unclassified` and this ruleId conveys that to the OOB + * classifier without conflicting with the path-specific ruleIds above. + */ + PATH_CHANGE_NOT_APPLICABLE: 'rest.path.not-applicable', + + // --------------------------------------------------------------------------- + // Parameter Object > in property + // --------------------------------------------------------------------------- + /** Parameter `in` property changed. add/replace=non-breaking (add), breaking (remove/replace). */ + PARAMETER_IN: 'rest.parameter.in', + + // --------------------------------------------------------------------------- + // Parameter Object > style property + // --------------------------------------------------------------------------- + /** Parameter `style` changed. By default, always breaking. */ + PARAMETER_STYLE: 'rest.parameter.style', + + // --------------------------------------------------------------------------- + // Parameter Object > schema node ($:) + // --------------------------------------------------------------------------- + /** Parameter `schema` node changed. By default, always breaking. */ + PARAMETER_SCHEMA: 'rest.parameter.schema', + + // --------------------------------------------------------------------------- + // Headers Object (container) + // --------------------------------------------------------------------------- + /** Headers object added/removed/replaced. add=non-breaking, remove/replace=breaking. */ + HEADERS: 'rest.headers', + + // --------------------------------------------------------------------------- + // Header Object (individual header) + // --------------------------------------------------------------------------- + /** Individual header added/removed/replaced. add=non-breaking, remove/replace=breaking. */ + HEADER: 'rest.header', + + /** Header `deprecated` flag changed. By default, always deprecated. */ + HEADER_DEPRECATED: 'rest.header.deprecated', + + /** Header `description` changed. By default, always annotation. */ + HEADER_DESCRIPTION: 'rest.header.description', + + /** Header `allowEmptyValue` changed. By default, always unclassified. */ + HEADER_ALLOW_EMPTY_VALUE: 'rest.header.allow-empty-value', + + /** Header `allowReserved` changed. By default, always unclassified. */ + HEADER_ALLOW_RESERVED: 'rest.header.allow-reserved', + + /** Header `example` value changed. By default, always unclassified. */ + HEADER_EXAMPLE: 'rest.header.example', + + /** Nested content under header `example`. By default, always unclassified. */ + HEADER_EXAMPLE_ITEM: 'rest.header.example.item', + + /** Header `explode` changed. By default, always unclassified. */ + HEADER_EXPLODE: 'rest.header.explode', + + /** Header `style` changed. By default, always unclassified. */ + HEADER_STYLE: 'rest.header.style', + + // --------------------------------------------------------------------------- + // Header Object > required property + // --------------------------------------------------------------------------- + /** Header `required` added. By default, breaking. */ + HEADER_REQUIRED_ADD: 'rest.header.required.add', + /** Header `required` removed. By default, non-breaking. */ + HEADER_REQUIRED_REMOVE: 'rest.header.required.remove', + /** Header `required` replaced — after-value is `true`. By default, breaking. */ + HEADER_REQUIRED_REPLACE_AFTER_TRUE: 'rest.header.required.replace.after-true', + /** Header `required` replaced — after-value is not `true`. By default, non-breaking. */ + HEADER_REQUIRED_REPLACE_AFTER_NOT_TRUE: 'rest.header.required.replace.after-not-true', + + // --------------------------------------------------------------------------- + // Header Object > schema node ($:) + // --------------------------------------------------------------------------- + /** Header `schema` node changed. By default, always breaking. */ + HEADER_SCHEMA: 'rest.header.schema', + + // --------------------------------------------------------------------------- + // Encoding Object (container and individual) + // --------------------------------------------------------------------------- + /** Encoding object added/removed/replaced. add/replace=breaking, remove=non-breaking. */ + ENCODING: 'rest.encoding', + /** Encoding `allowReserved` changed. add=non-breaking, remove/replace=breaking. */ + ENCODING_ALLOW_RESERVED: 'rest.encoding.allow-reserved', + /** Encoding `contentType` changed. add=non-breaking, remove/replace=breaking. */ + ENCODING_CONTENT_TYPE: 'rest.encoding.content-type', + /** Encoding `explode` changed. add=non-breaking, remove/replace=breaking. */ + ENCODING_EXPLODE: 'rest.encoding.explode', + /** Encoding `style` changed. add=non-breaking, remove/replace=breaking. */ + ENCODING_STYLE: 'rest.encoding.style', + + // --------------------------------------------------------------------------- + // Examples map (OpenAPI `examples` object — map node `$`) + // --------------------------------------------------------------------------- + /** OpenAPI `examples` map node. By default, always annotation. */ + EXAMPLES_MAP: 'rest.examples.map', + + /** Named Example Object in the `examples` map. By default, always annotation. */ + EXAMPLES_ITEM: 'rest.examples.item', + + /** Example Object `description`. By default, always annotation. */ + EXAMPLES_DESCRIPTION: 'rest.examples.description', + + /** Example Object `externalValue`. By default, always annotation. */ + EXAMPLES_EXTERNAL_VALUE: 'rest.examples.external-value', + + /** Example Object `summary`. By default, always annotation. */ + EXAMPLES_SUMMARY: 'rest.examples.summary', + + /** Example Object `value`. By default, always annotation. */ + EXAMPLES_VALUE: 'rest.examples.value', + + /** Deep content under Example Object `value` (`/**`). By default, always annotation. */ + EXAMPLES_VALUE_ITEM: 'rest.examples.value.item', + + /** Deep paths under `examples` map (`/**` on map rules). By default, always annotation. */ + EXAMPLES_DEEP: 'rest.examples.deep', + + // --------------------------------------------------------------------------- + // External Documentation Object + // --------------------------------------------------------------------------- + /** External Documentation object root (`$`). By default, always annotation. */ + EXTERNAL_DOCS: 'rest.external-docs', + + /** External Documentation object field (`url`, `description`, etc.). By default, always annotation. */ + EXTERNAL_DOCS_FIELD: 'rest.external-docs.field', + + // --------------------------------------------------------------------------- + // Content Object (media-type map) + // --------------------------------------------------------------------------- + /** Content object added/removed/replaced. add=non-breaking, remove/replace=breaking. */ + CONTENT: 'rest.content', + + // --------------------------------------------------------------------------- + // Media Type Object (individual entry in content map) + // --------------------------------------------------------------------------- + /** Media type entry added/removed/replaced. add/replace=non-breaking, remove=breaking. */ + MEDIA_TYPE: 'rest.media-type', + + // --------------------------------------------------------------------------- + // Media Type Object > schema node ($:) + // --------------------------------------------------------------------------- + /** Media type `schema` node changed. By default, always breaking. */ + MEDIA_TYPE_SCHEMA: 'rest.media-type.schema', + + /** Media type `example` value changed. By default, always annotation. */ + MEDIA_TYPE_EXAMPLE: 'rest.media-type.example', + + /** Nested content under media type `example`. By default, always annotation. */ + MEDIA_TYPE_EXAMPLE_ITEM: 'rest.media-type.example.item', + + // --------------------------------------------------------------------------- + // Request Body Object + // --------------------------------------------------------------------------- + /** Request body added/removed/replaced. add=non-breaking, remove/replace=breaking. */ + REQUEST_BODY: 'rest.request-body', + + /** Request body `description` changed. By default, always annotation. */ + REQUEST_BODY_DESCRIPTION: 'rest.request-body.description', + + // --------------------------------------------------------------------------- + // Request Body Object > required property + // --------------------------------------------------------------------------- + /** Request body `required` added. By default, breaking. */ + REQUEST_BODY_REQUIRED_ADD: 'rest.request-body.required.add', + /** Request body `required` removed. By default, non-breaking. */ + REQUEST_BODY_REQUIRED_REMOVE: 'rest.request-body.required.remove', + /** Request body `required` replaced — after-value is `true`. By default, breaking. */ + REQUEST_BODY_REQUIRED_REPLACE_AFTER_TRUE: 'rest.request-body.required.replace.after-true', + /** Request body `required` replaced — after-value is not `true`. By default, non-breaking. */ + REQUEST_BODY_REQUIRED_REPLACE_AFTER_NOT_TRUE: 'rest.request-body.required.replace.after-not-true', + + // --------------------------------------------------------------------------- + // Response Object (individual response entry) + // --------------------------------------------------------------------------- + /** Response added. By default, non-breaking. */ + RESPONSE_ADD: 'rest.response.add', + /** Response removed. By default, breaking. */ + RESPONSE_REMOVE: 'rest.response.remove', + /** + * Sentinel: used when responseRules's classifyRuleId is inherited by a + * non-response-code key (e.g. x-* extensions) via the `/*` wildcard merge. + * No YAML rule should map this; the OOB classifier will return undefined. + */ + RESPONSE_NOT_APPLICABLE: 'rest.response.not-applicable', + /** Response object `description` changed. By default, always annotation. */ + RESPONSE_DESCRIPTION: 'rest.response.description', + /** + * Response replaced — old and new status codes are the same (case-insensitive). + * By default, non-breaking. + */ + RESPONSE_REPLACE_SAME_CODE: 'rest.response.replace.same-code', + /** + * Response replaced — old and new status codes differ. + * By default, breaking. + */ + RESPONSE_REPLACE_DIFFERENT_CODE: 'rest.response.replace.different-code', + + // --------------------------------------------------------------------------- + // Operation Object ($: node) + // --------------------------------------------------------------------------- + /** Operation added. By default, non-breaking. */ + OPERATION_ADD: 'rest.operation.add', + /** Operation removed. By default, breaking. */ + OPERATION_REMOVE: 'rest.operation.remove', + /** Operation replaced. By default, unclassified. */ + OPERATION_REPLACE: 'rest.operation.replace', + /** Operation `deprecated` flag changed. By default, always deprecated. */ + OPERATION_DEPRECATED: 'rest.operation.deprecated', + /** + * Sentinel: used when operationRule's classifyRuleId is inherited by a + * non-operation key (e.g. servers, description, x-* extensions) via the + * `/*` wildcard merge. No YAML rule should map this; the OOB classifier + * will return undefined and the engine type is preserved as-is. + */ + OPERATION_NOT_APPLICABLE: 'rest.operation.not-applicable', + + // --------------------------------------------------------------------------- + // Operation Object > responses map ($:) + // --------------------------------------------------------------------------- + /** Responses map added/removed/replaced. add=non-breaking, remove/replace=breaking. */ + OPERATION_RESPONSES: 'rest.operation.responses', + + // --------------------------------------------------------------------------- + // Operation Object > security > item > scope group (/*/**) and individual scope (/*/*/*) + // --------------------------------------------------------------------------- + /** + * Security scope group object (e.g. `{ "bearerAuth": [...] }`) added/removed/replaced. + * By default, always breaking. + */ + OPERATION_SECURITY_SCOPE_GROUP: 'rest.operation.security.scope-group', + /** + * Individual security scope string (e.g. `"read"`) added/removed/replaced. + * add/replace=breaking, remove=non-breaking. + */ + OPERATION_SECURITY_SCOPE: 'rest.operation.security.scope', + + // --------------------------------------------------------------------------- + // OAuth Flow Object + // --------------------------------------------------------------------------- + /** OAuth flow added/removed/replaced. add/replace=breaking, remove=non-breaking. */ + OAUTH_FLOW: 'rest.oauth-flow', + /** + * Sentinel: used when oAuthFlowObjectRules's classifyRuleId is inherited by + * a non-flow key (e.g. x-* extensions) via the `/*` wildcard merge. + * No YAML rule should map this; the OOB classifier will return undefined. + */ + OAUTH_FLOW_NOT_APPLICABLE: 'rest.oauth-flow.not-applicable', + + // --------------------------------------------------------------------------- + // OAuth Flows Object + // --------------------------------------------------------------------------- + /** OAuth flows object added/removed/replaced. add/replace=breaking, remove=non-breaking. */ + OAUTH_FLOWS: 'rest.oauth-flows', + + // --------------------------------------------------------------------------- + // Path Item Object > parameters array ($:) + // --------------------------------------------------------------------------- + /** Path-item-level parameters array changed. add=non-breaking, remove/replace=breaking. */ + PATH_ITEM_PARAMETERS: 'rest.path-item.parameters', + + /** Path Item `description` changed. By default, always annotation. */ + PATH_ITEM_DESCRIPTION: 'rest.path-item.description', + + /** Path Item `summary` changed. By default, always annotation. */ + PATH_ITEM_SUMMARY: 'rest.path-item.summary', + + // --------------------------------------------------------------------------- + // Components Object ($:) + // --------------------------------------------------------------------------- + /** Components object added/removed/replaced. By default, always non-breaking. */ + COMPONENTS: 'rest.components', + + // --------------------------------------------------------------------------- + // Components sub-collections (parameters, requestBodies, responses, schemas, pathItems) + // --------------------------------------------------------------------------- + /** Components/parameters map changed. add=non-breaking, remove/replace=breaking. */ + COMPONENTS_PARAMETERS: 'rest.components.parameters', + /** Components/requestBodies map changed. add=non-breaking, remove/replace=breaking. */ + COMPONENTS_REQUEST_BODIES: 'rest.components.request-bodies', + /** Components/responses map changed. add=non-breaking, remove/replace=breaking. */ + COMPONENTS_RESPONSES: 'rest.components.responses', + /** Components/schemas map changed. add=non-breaking, remove/replace=breaking. */ + COMPONENTS_SCHEMAS: 'rest.components.schemas', + /** Components/pathItems map changed. add=non-breaking, remove/replace=breaking. */ + COMPONENTS_PATH_ITEMS: 'rest.components.path-items', + + // --------------------------------------------------------------------------- + // Components > Security Schemes map and individual scheme + // --------------------------------------------------------------------------- + /** Components/securitySchemes map changed. add/replace=breaking, remove=non-breaking. */ + COMPONENTS_SECURITY_SCHEMES: 'rest.components.security-schemes', + /** Individual security scheme added/removed/replaced. add/replace=breaking, remove=non-breaking. */ + SECURITY_SCHEME: 'rest.security-scheme', + /** + * Security scheme sub-property (in, name, scheme, type) changed. + * add/replace=breaking, remove=non-breaking. + */ + SECURITY_SCHEME_PROPERTY: 'rest.security-scheme.property', + + // --------------------------------------------------------------------------- + // /paths object ($:) — the paths map itself + // --------------------------------------------------------------------------- + /** Paths map node added/removed/replaced. By default, unclassified. */ + PATHS: 'rest.paths', + + // --------------------------------------------------------------------------- + // OpenAPI document root (`/openapi`, `/info` metadata, `/**` under `info`) + // --------------------------------------------------------------------------- + /** Document-level annotation-only metadata. By default, always annotation. */ + DOCUMENT_ANNOTATION: 'rest.document.annotation', + + // --------------------------------------------------------------------------- + // Operation Object > `tags` array (string list on the operation) + // --------------------------------------------------------------------------- + /** Operation `tags` array node (`$`). By default, always annotation. */ + OPERATION_TAGS: 'rest.operation.tags', + /** Single tag string entry in the operation `tags` array (`/*`). By default, always annotation. */ + OPERATION_TAGS_ITEM: 'rest.operation.tags.item', + + // --------------------------------------------------------------------------- + // Servers array (`servers` keyword) + // --------------------------------------------------------------------------- + /** Servers list node (`$`). By default, always annotation. */ + SERVERS: 'rest.servers', + + // --------------------------------------------------------------------------- + // Security Scheme Object — annotation-only properties + // --------------------------------------------------------------------------- + /** Security scheme `bearerFormat`. By default, always annotation. */ + SECURITY_SCHEME_BEARER_FORMAT: 'rest.security-scheme.bearer-format', + /** Security scheme `description`. By default, always annotation. */ + SECURITY_SCHEME_DESCRIPTION: 'rest.security-scheme.description', + /** Security scheme `openIdConnectUrl`. By default, always annotation. */ + SECURITY_SCHEME_OPEN_ID_CONNECT_URL: 'rest.security-scheme.open-id-connect-url', + + // --------------------------------------------------------------------------- + // Components > individual schema definition (`$` on each entry in `/schemas`) + // --------------------------------------------------------------------------- + /** Named schema under `components.schemas` (wrapper `$` before JSON Schema rules). By default, unclassified. */ + COMPONENTS_SCHEMA_DEFINITION: 'rest.components.schema-definition', + + // --------------------------------------------------------------------------- + // Tag Object (root `/tags` array items) + // --------------------------------------------------------------------------- + /** Tag object root (`$`). By default, always annotation. */ + TAG_OBJECT: 'rest.tag', + /** Tag object field via wildcard (`/*`). By default, always annotation. */ + TAG_OBJECT_FIELD: 'rest.tag.field', + + // --------------------------------------------------------------------------- + // /tags array ($:) + // --------------------------------------------------------------------------- + /** Tags array added/removed/replaced. By default, annotation. */ + TAGS: 'rest.tags', +} as const diff --git a/src/openapi/openapi3.classify.rules.yaml b/src/openapi/openapi3.classify.rules.yaml new file mode 100644 index 0000000..ca71fff --- /dev/null +++ b/src/openapi/openapi3.classify.rules.yaml @@ -0,0 +1,963 @@ +# OOB matching rules for REST (OpenAPI 3.x) classify rule IDs. +# +# The classifyRuleId already encodes the subset/not-subset condition, so a +# single rule per ruleId is sufficient — no action or scope disambiguation. +# +# Rule ordering convention: rules are applied in direct array order; the LAST +# matching rule wins. Place more-specific rules after the general catch-all. +# +# breaking → risky reclassification (effectiveBwcScope: NOT_BACKWARD_COMPATIBLE) +# is handled for all ruleIds by general.classify.rules.yaml via the wildcard '*'. + +- match: + classifyRuleId: rest.operation.security.after-security-requirements-are-subset-of-before + set: + type: non-breaking + +- match: + classifyRuleId: rest.operation.security.after-security-requirements-are-not-a-subset-of-before + set: + type: breaking + +- match: + classifyRuleId: rest.operation.security.item.after-security-requirements-are-subset-of-before + set: + type: non-breaking + +- match: + classifyRuleId: rest.operation.security.item.after-security-requirements-are-not-a-subset-of-before + set: + type: breaking + +# --------------------------------------------------------------------------- +# Parameter Object (add / remove the whole parameter) +# --------------------------------------------------------------------------- +# Parameters are always in request context; no scope overrides needed. + +- match: + classifyRuleId: rest.parameter.deprecated + set: + type: deprecated + +- match: + classifyRuleId: rest.parameter.description + set: + type: annotation + +- match: + classifyRuleId: rest.parameter.example + set: + type: annotation + +- match: + classifyRuleId: rest.parameter.example.item + set: + type: annotation + +- match: + classifyRuleId: rest.parameter.after-ignored-header + set: + type: unclassified + +- match: + classifyRuleId: rest.parameter.after-required-no-default + set: + type: breaking + +- match: + classifyRuleId: rest.parameter.after-optional-or-has-default + set: + type: non-breaking + +- match: + classifyRuleId: rest.parameter.before-ignored-header + set: + type: unclassified + +- match: + classifyRuleId: rest.parameter.before-not-ignored-header + set: + type: breaking + +- match: + classifyRuleId: rest.parameter.replace + set: + type: unclassified + +# --------------------------------------------------------------------------- +# Parameter Object > explode property +# --------------------------------------------------------------------------- +# annotation when the explode value matches the style default; breaking otherwise. +# Replace is always breaking. No scope overrides (request context only). + +- match: + classifyRuleId: rest.parameter.explode.after-default-for-style + set: + type: annotation + +- match: + classifyRuleId: rest.parameter.explode.after-non-default-for-style + set: + type: breaking + +- match: + classifyRuleId: rest.parameter.explode.before-default-for-style + set: + type: annotation + +- match: + classifyRuleId: rest.parameter.explode.before-non-default-for-style + set: + type: breaking + +- match: + classifyRuleId: rest.parameter.explode.replace + set: + type: breaking + +# --------------------------------------------------------------------------- +# Parameter Object > allowReserved property +# --------------------------------------------------------------------------- +# allowReserved only applies to query parameters; path/cookie/header → unclassified. +# For add and remove the same ruleId is used; the YAML action filter distinguishes +# the outcome (add→non-breaking, remove→breaking). + +- match: + classifyRuleId: rest.parameter.allow-reserved.after-in-non-query + set: + type: unclassified + +- match: + classifyRuleId: rest.parameter.allow-reserved.after-in-query + action: add + set: + type: non-breaking + +- match: + classifyRuleId: rest.parameter.allow-reserved.after-in-query + action: remove + set: + type: breaking + +- match: + classifyRuleId: rest.parameter.allow-reserved.replace.after-true + set: + type: non-breaking + +- match: + classifyRuleId: rest.parameter.allow-reserved.replace.after-false + set: + type: breaking + +# --------------------------------------------------------------------------- +# Parameter Object > name property +# --------------------------------------------------------------------------- + +- match: + classifyRuleId: rest.parameter.name.add + set: + type: non-breaking + +- match: + classifyRuleId: rest.parameter.name.remove + set: + type: breaking + +- match: + classifyRuleId: rest.parameter.name.replace.before-path-param + set: + type: annotation + +- match: + classifyRuleId: rest.parameter.name.replace.before-non-path-param + set: + type: breaking + +# --------------------------------------------------------------------------- +# Parameter Object > required property +# --------------------------------------------------------------------------- + +- match: + classifyRuleId: rest.parameter.required.add + set: + type: breaking + +- match: + classifyRuleId: rest.parameter.required.remove + set: + type: non-breaking + +- match: + classifyRuleId: rest.parameter.required.replace.after-has-schema-default + set: + type: non-breaking + +- match: + classifyRuleId: rest.parameter.required.replace.after-true-no-schema-default + set: + type: breaking + +- match: + classifyRuleId: rest.parameter.required.replace.after-false + set: + type: non-breaking + +# --------------------------------------------------------------------------- +# Parameter Object > allowEmptyValue property +# --------------------------------------------------------------------------- +# Only applies to query parameters. Uses reverseClassifyRule(booleanClassifier): +# booleanClassifier = [breakingIfAfterTrue, nonBreaking, breakingIfAfterTrue] +# After reversal: add/replace → nonBreaking when after=true, breaking when after≠true; +# remove → always breaking (reversed nonBreaking). +# Non-query params always yield unclassified. + +- match: + classifyRuleId: rest.parameter.allow-empty-value.after-not-query + set: + type: unclassified + +- match: + classifyRuleId: rest.parameter.allow-empty-value.after-true + set: + type: non-breaking + +- match: + classifyRuleId: rest.parameter.allow-empty-value.after-not-true + set: + type: breaking + +# --------------------------------------------------------------------------- +# Parameters array node (/parameters) +# --------------------------------------------------------------------------- +# Covers the parameters array itself, not its items. +# Remove is non-breaking only when all entries were ignored header params. + +- match: + classifyRuleId: rest.parameters-array.add + set: + type: non-breaking + +- match: + classifyRuleId: rest.parameters-array.remove.before-all-ignored-headers + set: + type: non-breaking + +- match: + classifyRuleId: rest.parameters-array.remove.before-has-non-ignored + set: + type: breaking + +- match: + classifyRuleId: rest.parameters-array.replace + set: + type: breaking + +# --------------------------------------------------------------------------- +# Root-level security array +# --------------------------------------------------------------------------- + +- match: + classifyRuleId: rest.global-security.add.after-empty + set: + type: non-breaking + +- match: + classifyRuleId: rest.global-security.add.after-non-empty + set: + type: breaking + +- match: + classifyRuleId: rest.global-security.remove + set: + type: non-breaking + +- match: + classifyRuleId: rest.global-security.replace.after-subset-or-empty + set: + type: non-breaking + +- match: + classifyRuleId: rest.global-security.replace.after-not-subset + set: + type: breaking + +# --------------------------------------------------------------------------- +# Root-level security array items +# --------------------------------------------------------------------------- +# Add: breaking when the before-parent OR-list was empty (first requirement introduced). +# Remove: breaking when the after-parent OR-list is empty (last requirement removed). + +- match: + classifyRuleId: rest.global-security.item.before-non-empty + set: + type: non-breaking + +- match: + classifyRuleId: rest.global-security.item.before-empty + set: + type: breaking + +- match: + classifyRuleId: rest.global-security.item.after-non-empty + set: + type: non-breaking + +- match: + classifyRuleId: rest.global-security.item.after-empty + set: + type: breaking + +- match: + classifyRuleId: rest.global-security.item.replace.after-subset-or-empty + set: + type: non-breaking + +- match: + classifyRuleId: rest.global-security.item.replace.after-not-subset + set: + type: breaking + +# --------------------------------------------------------------------------- +# Path item (path rename / change) +# --------------------------------------------------------------------------- +# Replace yields annotation when the unified (server-prefixed) path is unchanged +# — i.e. only path parameter placeholder names changed, not the effective path. + +- match: + classifyRuleId: rest.path.add + set: + type: non-breaking + +- match: + classifyRuleId: rest.path.remove + set: + type: breaking + +- match: + classifyRuleId: rest.path.replace.same-effective-path + set: + type: annotation + +- match: + classifyRuleId: rest.path.replace.different-effective-path + set: + type: breaking + +# Spec extensions (x-*) inside /paths share the /* wildcard with path items and +# therefore inherit the pathChangeClassifyRuleIdRule. Their engine type is +# unclassified; this ruleId conveys that to the OOB classifier. +- match: + classifyRuleId: rest.path.not-applicable + set: + type: unclassified +# ============================================================ +# Parameter Object > in property [nonBreaking, breaking, breaking] +# ============================================================ +- match: + classifyRuleId: rest.parameter.in + set: + type: breaking +- match: + classifyRuleId: rest.parameter.in + action: add + set: + type: non-breaking +# ============================================================ +# Parameter Object > style property [allBreaking] +# ============================================================ +- match: + classifyRuleId: rest.parameter.style + set: + type: breaking +# ============================================================ +# Parameter Object > schema node [allBreaking] +# ============================================================ +- match: + classifyRuleId: rest.parameter.schema + set: + type: breaking +# ============================================================ +# Headers Object [nonBreaking, breaking, breaking] +# ============================================================ +- match: + classifyRuleId: rest.headers + set: + type: breaking +- match: + classifyRuleId: rest.headers + action: add + set: + type: non-breaking +# ============================================================ +# Header Object (individual) [nonBreaking, breaking, breaking] +# ============================================================ +- match: + classifyRuleId: rest.header + set: + type: breaking +- match: + classifyRuleId: rest.header + action: add + set: + type: non-breaking +# ============================================================ +# Header Object > deprecated property [allDeprecated] +# ============================================================ +- match: + classifyRuleId: rest.header.deprecated + set: + type: deprecated + +- match: + classifyRuleId: rest.header.description + set: + type: annotation + +- match: + classifyRuleId: rest.header.allow-empty-value + set: + type: unclassified + +- match: + classifyRuleId: rest.header.allow-reserved + set: + type: unclassified + +- match: + classifyRuleId: rest.header.example + set: + type: unclassified + +- match: + classifyRuleId: rest.header.example.item + set: + type: unclassified + +- match: + classifyRuleId: rest.header.explode + set: + type: unclassified + +- match: + classifyRuleId: rest.header.style + set: + type: unclassified + +# ============================================================ +# Header Object > required property [breaking, nonBreaking, breakingIfAfterTrue] +# ============================================================ +- match: + classifyRuleId: rest.header.required.add + set: + type: breaking +- match: + classifyRuleId: rest.header.required.remove + set: + type: non-breaking +- match: + classifyRuleId: rest.header.required.replace.after-true + set: + type: breaking +- match: + classifyRuleId: rest.header.required.replace.after-not-true + set: + type: non-breaking +# ============================================================ +# Header Object > schema node [allBreaking] +# ============================================================ +- match: + classifyRuleId: rest.header.schema + set: + type: breaking +# ============================================================ +# Encoding Object [breaking, nonBreaking, breaking] +# ============================================================ +- match: + classifyRuleId: rest.encoding + set: + type: breaking +- match: + classifyRuleId: rest.encoding + action: remove + set: + type: non-breaking +# ============================================================ +# Encoding sub-properties [nonBreaking, breaking, breaking] +# ============================================================ +- match: + classifyRuleId: rest.encoding.allow-reserved + set: + type: breaking +- match: + classifyRuleId: rest.encoding.allow-reserved + action: add + set: + type: non-breaking + +- match: + classifyRuleId: rest.encoding.content-type + set: + type: breaking +- match: + classifyRuleId: rest.encoding.content-type + action: add + set: + type: non-breaking + +- match: + classifyRuleId: rest.encoding.explode + set: + type: breaking +- match: + classifyRuleId: rest.encoding.explode + action: add + set: + type: non-breaking + +- match: + classifyRuleId: rest.encoding.style + set: + type: breaking +- match: + classifyRuleId: rest.encoding.style + action: add + set: + type: non-breaking +# ============================================================ +# Examples map (`examples` object root `$`) [allAnnotation] +# ============================================================ +- match: + classifyRuleId: rest.examples.map + set: + type: annotation + +- match: + classifyRuleId: rest.examples.item + set: + type: annotation + +- match: + classifyRuleId: rest.examples.description + set: + type: annotation + +- match: + classifyRuleId: rest.examples.external-value + set: + type: annotation + +- match: + classifyRuleId: rest.examples.summary + set: + type: annotation + +- match: + classifyRuleId: rest.examples.value + set: + type: annotation + +- match: + classifyRuleId: rest.examples.value.item + set: + type: annotation + +- match: + classifyRuleId: rest.examples.deep + set: + type: annotation + +# ============================================================ +# External Documentation Object [allAnnotation] +# ============================================================ +- match: + classifyRuleId: rest.external-docs + set: + type: annotation + +- match: + classifyRuleId: rest.external-docs.field + set: + type: annotation + +# ============================================================ +# Content Object [nonBreaking, breaking, breaking] +# ============================================================ +- match: + classifyRuleId: rest.content + set: + type: breaking +- match: + classifyRuleId: rest.content + action: add + set: + type: non-breaking +# ============================================================ +# Media Type Object [nonBreaking, breaking, nonBreaking] +# ============================================================ +- match: + classifyRuleId: rest.media-type + set: + type: non-breaking +- match: + classifyRuleId: rest.media-type + action: remove + set: + type: breaking +# ============================================================ +# Media Type Object > schema node [allBreaking] +# ============================================================ +- match: + classifyRuleId: rest.media-type.schema + set: + type: breaking + +- match: + classifyRuleId: rest.media-type.example + set: + type: annotation + +- match: + classifyRuleId: rest.media-type.example.item + set: + type: annotation + +# ============================================================ +# Request Body Object [nonBreaking, breaking, breaking] +# ============================================================ +- match: + classifyRuleId: rest.request-body + set: + type: breaking +- match: + classifyRuleId: rest.request-body + action: add + set: + type: non-breaking + +- match: + classifyRuleId: rest.request-body.description + set: + type: annotation + +# ============================================================ +# Request Body Object > required property [breaking, nonBreaking, breakingIfAfterTrue] +# ============================================================ +- match: + classifyRuleId: rest.request-body.required.add + set: + type: breaking +- match: + classifyRuleId: rest.request-body.required.remove + set: + type: non-breaking +- match: + classifyRuleId: rest.request-body.required.replace.after-true + set: + type: breaking +- match: + classifyRuleId: rest.request-body.required.replace.after-not-true + set: + type: non-breaking +# ============================================================ +# Response Object [nonBreaking, breaking, nonBreakingIf(same-code)] +# ============================================================ +- match: + classifyRuleId: rest.response.add + set: + type: non-breaking +- match: + classifyRuleId: rest.response.remove + set: + type: breaking +- match: + classifyRuleId: rest.response.replace.same-code + set: + type: non-breaking +- match: + classifyRuleId: rest.response.replace.different-code + set: + type: breaking + +- match: + classifyRuleId: rest.response.description + set: + type: annotation + +# ============================================================ +# Operation Object [nonBreaking, breaking, unclassified] +# ============================================================ +- match: + classifyRuleId: rest.operation.add + set: + type: non-breaking +- match: + classifyRuleId: rest.operation.remove + set: + type: breaking +- match: + classifyRuleId: rest.operation.replace + set: + type: unclassified + +- match: + classifyRuleId: rest.operation.deprecated + set: + type: deprecated + +# ============================================================ +# Operation Object > responses map [nonBreaking, breaking, breaking] +# ============================================================ +- match: + classifyRuleId: rest.operation.responses + set: + type: breaking +- match: + classifyRuleId: rest.operation.responses + action: add + set: + type: non-breaking +# ============================================================ +# Operation Object > security scope group [allBreaking] +# ============================================================ +- match: + classifyRuleId: rest.operation.security.scope-group + set: + type: breaking +# ============================================================ +# Operation Object > security scope string [breaking, nonBreaking, breaking] +# ============================================================ +- match: + classifyRuleId: rest.operation.security.scope + set: + type: breaking +- match: + classifyRuleId: rest.operation.security.scope + action: remove + set: + type: non-breaking +# ============================================================ +# OAuth Flow Object [breaking, nonBreaking, breaking] +# ============================================================ +- match: + classifyRuleId: rest.oauth-flow + set: + type: breaking +- match: + classifyRuleId: rest.oauth-flow + action: remove + set: + type: non-breaking +# ============================================================ +# OAuth Flows Object [breaking, nonBreaking, breaking] +# ============================================================ +- match: + classifyRuleId: rest.oauth-flows + set: + type: breaking +- match: + classifyRuleId: rest.oauth-flows + action: remove + set: + type: non-breaking +# ============================================================ +# Path Item Object > description / summary [allAnnotation] +# ============================================================ +- match: + classifyRuleId: rest.path-item.description + set: + type: annotation + +- match: + classifyRuleId: rest.path-item.summary + set: + type: annotation + +# ============================================================ +# Path Item Object > parameters array [nonBreaking, breaking, breaking] +# ============================================================ +- match: + classifyRuleId: rest.path-item.parameters + set: + type: breaking +- match: + classifyRuleId: rest.path-item.parameters + action: add + set: + type: non-breaking +# ============================================================ +# Components Object [allNonBreaking] +# ============================================================ +- match: + classifyRuleId: rest.components + set: + type: non-breaking +# ============================================================ +# Components sub-collections [nonBreaking, breaking, breaking] +# ============================================================ +- match: + classifyRuleId: rest.components.parameters + set: + type: breaking +- match: + classifyRuleId: rest.components.parameters + action: add + set: + type: non-breaking + +- match: + classifyRuleId: rest.components.request-bodies + set: + type: breaking +- match: + classifyRuleId: rest.components.request-bodies + action: add + set: + type: non-breaking + +- match: + classifyRuleId: rest.components.responses + set: + type: breaking +- match: + classifyRuleId: rest.components.responses + action: add + set: + type: non-breaking + +- match: + classifyRuleId: rest.components.schemas + set: + type: breaking +- match: + classifyRuleId: rest.components.schemas + action: add + set: + type: non-breaking + +- match: + classifyRuleId: rest.components.path-items + set: + type: breaking +- match: + classifyRuleId: rest.components.path-items + action: add + set: + type: non-breaking +# ============================================================ +# Components > Security Schemes map [breaking, nonBreaking, breaking] +# ============================================================ +- match: + classifyRuleId: rest.components.security-schemes + set: + type: breaking +- match: + classifyRuleId: rest.components.security-schemes + action: remove + set: + type: non-breaking +# ============================================================ +# Security Scheme Object [breaking, nonBreaking, breaking] +# ============================================================ +- match: + classifyRuleId: rest.security-scheme + set: + type: breaking +- match: + classifyRuleId: rest.security-scheme + action: remove + set: + type: non-breaking +# ============================================================ +# Security Scheme sub-properties (in, name, scheme, type) [breaking, nonBreaking, breaking] +# ============================================================ +- match: + classifyRuleId: rest.security-scheme.property + set: + type: breaking +- match: + classifyRuleId: rest.security-scheme.property + action: remove + set: + type: non-breaking +# ============================================================ +# /paths object [allUnclassified] +# ============================================================ +- match: + classifyRuleId: rest.paths + set: + type: unclassified +# ============================================================ +# /tags array [allAnnotation] +# ============================================================ +- match: + classifyRuleId: rest.tags + set: + type: annotation + +# ============================================================ +# Document metadata (`/openapi`, `/info`, `/**` under `info`) [allAnnotation] +# ============================================================ +- match: + classifyRuleId: rest.document.annotation + set: + type: annotation + +# ============================================================ +# Operation > tags array [allAnnotation] +# ============================================================ +- match: + classifyRuleId: rest.operation.tags + set: + type: annotation + +- match: + classifyRuleId: rest.operation.tags.item + set: + type: annotation + +# ============================================================ +# Servers [allAnnotation] +# ============================================================ +- match: + classifyRuleId: rest.servers + set: + type: annotation + +# ============================================================ +# Security Scheme — annotation-only fields +# ============================================================ +- match: + classifyRuleId: rest.security-scheme.bearer-format + set: + type: annotation + +- match: + classifyRuleId: rest.security-scheme.description + set: + type: annotation + +- match: + classifyRuleId: rest.security-scheme.open-id-connect-url + set: + type: annotation + +# ============================================================ +# Components > named schema definition wrapper [allUnclassified] +# ============================================================ +- match: + classifyRuleId: rest.components.schema-definition + set: + type: unclassified + +# ============================================================ +# Tag Object (items in `/tags` array) [allAnnotation] +# ============================================================ +- match: + classifyRuleId: rest.tag + set: + type: annotation + +- match: + classifyRuleId: rest.tag.field + set: + type: annotation diff --git a/src/openapi/openapi3.classify.ts b/src/openapi/openapi3.classify.ts index ac12a6e..29529f5 100644 --- a/src/openapi/openapi3.classify.ts +++ b/src/openapi/openapi3.classify.ts @@ -12,10 +12,26 @@ import { } from '../core' import { getKeyValue, isExist, isNotEmptyArray } from '../utils' import { emptySecurity, includeSecurity } from './openapi3.utils' -import type { ClassifyRule, CompareContext } from '../types' +import type { ClassifyRule, CompareContext, ClassifyRuleIdRule } from '../types' import { DiffType } from '../types' import { createPathUnifier } from './openapi3.mapping' import { OpenAPIV3 } from 'openapi-types' +import { REST_CLASSIFY_RULE_IDS } from './openapi3.classify.ruleIds' + +export const paramClassifyRuleIdRule: ClassifyRuleIdRule = [ + ({ after }) => { + if (isIgnoredHeaderParam(after.value)) { + return REST_CLASSIFY_RULE_IDS.PARAM_AFTER_IGNORED_HEADER + } + return getKeyValue(after.value, 'required') && !isExist(getKeyValue(after.value, 'schema', 'default')) + ? REST_CLASSIFY_RULE_IDS.PARAM_AFTER_REQUIRED_NO_DEFAULT + : REST_CLASSIFY_RULE_IDS.PARAM_AFTER_OPTIONAL_OR_HAS_DEFAULT + }, + ({ before }) => (isIgnoredHeaderParam(before.value) + ? REST_CLASSIFY_RULE_IDS.PARAM_BEFORE_IGNORED_HEADER + : REST_CLASSIFY_RULE_IDS.PARAM_BEFORE_NOT_IGNORED_HEADER), + REST_CLASSIFY_RULE_IDS.PARAM_REPLACE, +] export const paramClassifyRule: ClassifyRule = [ ({ after }) => { @@ -37,6 +53,17 @@ const isIgnoredHeaderParam = (param: any): boolean => { return param.in === 'header' && NON_BREAKING_HEADERS.includes(param.name) } +export const apihubParametersRemovalClassifyRuleIdRule: ClassifyRuleIdRule = [ + REST_CLASSIFY_RULE_IDS.PARAMETERS_ARRAY_ADD, + ({ before }) => { + const value = before.value + return Array.isArray(value) && (value as unknown[]).every(isIgnoredHeaderParam) + ? REST_CLASSIFY_RULE_IDS.PARAMETERS_ARRAY_REMOVE_BEFORE_ALL_IGNORED_HEADERS + : REST_CLASSIFY_RULE_IDS.PARAMETERS_ARRAY_REMOVE_BEFORE_HAS_NON_IGNORED + }, + REST_CLASSIFY_RULE_IDS.PARAMETERS_ARRAY_REPLACE, +] + export const apihubParametersRemovalClassifyRule = (ctx: CompareContext): DiffType => { const { before: { value } } = ctx if (!Array.isArray(value)) { @@ -48,12 +75,47 @@ export const apihubParametersRemovalClassifyRule = (ctx: CompareContext): DiffTy : breaking } +const isExplodeDefaultForStyle = (value: unknown, parent: unknown): boolean => + (!!value && getKeyValue(parent, 'style') === 'form') || + (!value && getKeyValue(parent, 'style') !== 'form') + +export const parameterExplodeClassifyRuleIdRule: ClassifyRuleIdRule = [ + ({ after }) => (isExplodeDefaultForStyle(after.value, after.parent) + ? REST_CLASSIFY_RULE_IDS.PARAMETER_EXPLODE_AFTER_DEFAULT_FOR_STYLE + : REST_CLASSIFY_RULE_IDS.PARAMETER_EXPLODE_AFTER_NON_DEFAULT_FOR_STYLE), + ({ before }) => (isExplodeDefaultForStyle(before.value, before.parent) + ? REST_CLASSIFY_RULE_IDS.PARAMETER_EXPLODE_BEFORE_DEFAULT_FOR_STYLE + : REST_CLASSIFY_RULE_IDS.PARAMETER_EXPLODE_BEFORE_NON_DEFAULT_FOR_STYLE), + REST_CLASSIFY_RULE_IDS.PARAMETER_EXPLODE_REPLACE, +] + export const parameterExplodeClassifyRule: ClassifyRule = [ ({ after }) => ((after.value && getKeyValue(after.parent, 'style') === 'form') || (!after.value && getKeyValue(after.parent, 'style') !== 'form') ? annotation : breaking), ({ before }) => ((before.value && getKeyValue(before.parent, 'style') === 'form') || (!before.value && getKeyValue(before.parent, 'style') !== 'form') ? annotation : breaking), breaking, ] +const isAllowReservedNonApplicable = (after: { parent: unknown }): boolean => + ['path', 'cookie', 'header'].includes(getKeyValue(after.parent, 'in') as string) + +export const parameterAllowReservedClassifyRuleIdRule: ClassifyRuleIdRule = [ + ({ after }) => (isAllowReservedNonApplicable(after) + ? REST_CLASSIFY_RULE_IDS.PARAMETER_ALLOW_RESERVED_AFTER_IN_NON_QUERY + : REST_CLASSIFY_RULE_IDS.PARAMETER_ALLOW_RESERVED_AFTER_IN_QUERY), + // The classifier uses `after` for the remove case (parameter still present; `in` is on after.parent) + ({ after }) => (isAllowReservedNonApplicable(after) + ? REST_CLASSIFY_RULE_IDS.PARAMETER_ALLOW_RESERVED_AFTER_IN_NON_QUERY + : REST_CLASSIFY_RULE_IDS.PARAMETER_ALLOW_RESERVED_AFTER_IN_QUERY), + ({ after }) => { + if (isAllowReservedNonApplicable(after)) { + return REST_CLASSIFY_RULE_IDS.PARAMETER_ALLOW_RESERVED_AFTER_IN_NON_QUERY + } + return after.value + ? REST_CLASSIFY_RULE_IDS.PARAMETER_ALLOW_RESERVED_REPLACE_AFTER_TRUE + : REST_CLASSIFY_RULE_IDS.PARAMETER_ALLOW_RESERVED_REPLACE_AFTER_FALSE + }, +] + export const parameterAllowReservedClassifyRule: ClassifyRule = [ ({ after }) => (['path', 'cookie', 'header'].includes(getKeyValue(after.parent, 'in') as string) ? unclassified : nonBreaking), ({ after }) => (['path', 'cookie', 'header'].includes(getKeyValue(after.parent, 'in') as string) ? unclassified : breaking), @@ -65,18 +127,49 @@ export const parameterAllowReservedClassifyRule: ClassifyRule = [ }, ] +export const parameterNameClassifyRuleIdRule: ClassifyRuleIdRule = [ + REST_CLASSIFY_RULE_IDS.PARAMETER_NAME_ADD, + REST_CLASSIFY_RULE_IDS.PARAMETER_NAME_REMOVE, + ({ before }) => (getKeyValue(before.parent, 'in') === 'path' + ? REST_CLASSIFY_RULE_IDS.PARAMETER_NAME_REPLACE_BEFORE_PATH_PARAM + : REST_CLASSIFY_RULE_IDS.PARAMETER_NAME_REPLACE_BEFORE_NON_PATH_PARAM), +] + export const parameterNameClassifyRule: ClassifyRule = [ nonBreaking, breaking, ({ before }) => (getKeyValue(before.parent, 'in') === 'path' ? annotation : breaking), ] +export const parameterRequiredClassifyRuleIdRule: ClassifyRuleIdRule = [ + REST_CLASSIFY_RULE_IDS.PARAMETER_REQUIRED_ADD, + REST_CLASSIFY_RULE_IDS.PARAMETER_REQUIRED_REMOVE, + ({ after }) => { + if (getKeyValue(after.parent, 'schema', 'default')) { + return REST_CLASSIFY_RULE_IDS.PARAMETER_REQUIRED_REPLACE_HAS_SCHEMA_DEFAULT + } + return after.value + ? REST_CLASSIFY_RULE_IDS.PARAMETER_REQUIRED_REPLACE_AFTER_TRUE_NO_DEFAULT + : REST_CLASSIFY_RULE_IDS.PARAMETER_REQUIRED_REPLACE_AFTER_FALSE + }, +] + export const parameterRequiredClassifyRule: ClassifyRule = [ breaking, nonBreaking, (ctx) => (getKeyValue(ctx.after.parent, 'schema', 'default') ? nonBreaking : breakingIfAfterTrue(ctx)), ] +export const apihubAllowEmptyValueParameterClassifyRuleIdRule: ClassifyRuleIdRule = + ({ after }) => { + if (getKeyValue(after.parent, 'in') !== 'query') { + return REST_CLASSIFY_RULE_IDS.PARAMETER_ALLOW_EMPTY_VALUE_AFTER_NOT_QUERY + } + return after.value === true + ? REST_CLASSIFY_RULE_IDS.PARAMETER_ALLOW_EMPTY_VALUE_AFTER_TRUE + : REST_CLASSIFY_RULE_IDS.PARAMETER_ALLOW_EMPTY_VALUE_AFTER_NOT_TRUE + } + export const apihubAllowEmptyValueParameterClassifyRule: ClassifyRule = transformClassifyRule( reverseClassifyRule(booleanClassifier), (type, { after }, action) => ( @@ -99,6 +192,16 @@ export const paramSchemaTypeClassifyRule: ClassifyRule = [ }, ] +export const globalSecurityClassifyRuleIdRule: ClassifyRuleIdRule = [ + ({ after }) => (emptySecurity(after.value) + ? REST_CLASSIFY_RULE_IDS.GLOBAL_SECURITY_ADD_AFTER_EMPTY + : REST_CLASSIFY_RULE_IDS.GLOBAL_SECURITY_ADD_AFTER_NON_EMPTY), + REST_CLASSIFY_RULE_IDS.GLOBAL_SECURITY_REMOVE, + ({ after, before }) => (includeSecurity(after.value, before.value) || emptySecurity(after.value) + ? REST_CLASSIFY_RULE_IDS.GLOBAL_SECURITY_REPLACE_AFTER_SUBSET_OR_EMPTY + : REST_CLASSIFY_RULE_IDS.GLOBAL_SECURITY_REPLACE_AFTER_NOT_SUBSET), +] + export const globalSecurityClassifyRule: ClassifyRule = [ ({ after }) => (!emptySecurity(after.value) ? breaking : nonBreaking), nonBreaking, @@ -108,6 +211,18 @@ export const globalSecurityClassifyRule: ClassifyRule = [ }) => (includeSecurity(after.value, before.value) || emptySecurity(after.value) ? nonBreaking : breaking), ] +export const globalSecurityItemClassifyRuleIdRule: ClassifyRuleIdRule = [ + ({ before }) => (isNotEmptyArray(before.parent) + ? REST_CLASSIFY_RULE_IDS.GLOBAL_SECURITY_ITEM_ADD_BEFORE_NON_EMPTY + : REST_CLASSIFY_RULE_IDS.GLOBAL_SECURITY_ITEM_ADD_BEFORE_EMPTY), + ({ after }) => (isNotEmptyArray(after.parent) + ? REST_CLASSIFY_RULE_IDS.GLOBAL_SECURITY_ITEM_REMOVE_AFTER_NON_EMPTY + : REST_CLASSIFY_RULE_IDS.GLOBAL_SECURITY_ITEM_REMOVE_AFTER_EMPTY), + ({ after, before }) => (includeSecurity(after.parent, before.parent) || emptySecurity(after.value) + ? REST_CLASSIFY_RULE_IDS.GLOBAL_SECURITY_ITEM_REPLACE_AFTER_SUBSET_OR_EMPTY + : REST_CLASSIFY_RULE_IDS.GLOBAL_SECURITY_ITEM_REPLACE_AFTER_NOT_SUBSET), +] + export const globalSecurityItemClassifyRule: ClassifyRule = [ ({ before }) => (isNotEmptyArray(before.parent) ? nonBreaking : breaking), ({ after }) => (isNotEmptyArray(after.parent) ? nonBreaking : breaking), @@ -117,6 +232,18 @@ export const globalSecurityItemClassifyRule: ClassifyRule = [ }) => (includeSecurity(after.parent, before.parent) || emptySecurity(after.value) ? nonBreaking : breaking), ] +export const operationSecurityClassifyRuleIdRule: ClassifyRuleIdRule = [ + ({ before, after }) => (emptySecurity(after.value) || includeSecurity(after.value, getKeyValue(before.root, 'security')) + ? REST_CLASSIFY_RULE_IDS.OPERATION_SECURITY_SUBSET + : REST_CLASSIFY_RULE_IDS.OPERATION_SECURITY_NOT_SUBSET), + ({ before, after }) => (includeSecurity(getKeyValue(after.root, 'security'), before.value) + ? REST_CLASSIFY_RULE_IDS.OPERATION_SECURITY_SUBSET + : REST_CLASSIFY_RULE_IDS.OPERATION_SECURITY_NOT_SUBSET), + ({ before, after }) => (includeSecurity(after.value, before.value) || emptySecurity(after.value) + ? REST_CLASSIFY_RULE_IDS.OPERATION_SECURITY_SUBSET + : REST_CLASSIFY_RULE_IDS.OPERATION_SECURITY_NOT_SUBSET), +] + export const operationSecurityClassifyRule: ClassifyRule = [ ({ before, @@ -129,6 +256,18 @@ export const operationSecurityClassifyRule: ClassifyRule = [ }) => (includeSecurity(after.value, before.value) || emptySecurity(after.value) ? nonBreaking : breaking), ] +export const operationSecurityItemClassifyRuleIdRule: ClassifyRuleIdRule = [ + ({ before }) => (isNotEmptyArray(before.parent) + ? REST_CLASSIFY_RULE_IDS.OPERATION_SECURITY_ITEM_SUBSET + : REST_CLASSIFY_RULE_IDS.OPERATION_SECURITY_ITEM_NOT_SUBSET), + ({ after }) => (!isNotEmptyArray(after.parent) + ? REST_CLASSIFY_RULE_IDS.OPERATION_SECURITY_ITEM_SUBSET + : REST_CLASSIFY_RULE_IDS.OPERATION_SECURITY_ITEM_NOT_SUBSET), + ({ before, after }) => (includeSecurity(after.parent, before.parent) || emptySecurity(after.value) + ? REST_CLASSIFY_RULE_IDS.OPERATION_SECURITY_ITEM_SUBSET + : REST_CLASSIFY_RULE_IDS.OPERATION_SECURITY_ITEM_NOT_SUBSET), +] + export const operationSecurityItemClassifyRule: ClassifyRule = [ ({ before }) => (isNotEmptyArray(before.parent) ? nonBreaking : breaking), ({ after }) => (isNotEmptyArray(after.parent) ? breaking : nonBreaking), @@ -138,6 +277,43 @@ export const operationSecurityItemClassifyRule: ClassifyRule = [ }) => (includeSecurity(after.parent, before.parent) || emptySecurity(after.value) ? nonBreaking : breaking), ] +const computeUnifiedPaths = ({ before, after, parentContext }: { + before: { key: unknown; value: unknown } + after: { key: unknown; value: unknown } + parentContext?: { before: { root: unknown }; after: { root: unknown } } +}) => { + const beforePath = before.key as string + const afterPath = after.key as string + const beforeRootServers = (parentContext?.before.root as OpenAPIV3.Document)?.servers + const beforePathItemServers = (before.value as OpenAPIV3.PathItemObject)?.servers + const afterRootServers = (parentContext?.after.root as OpenAPIV3.Document)?.servers + const afterPathItemServers = (after.value as OpenAPIV3.PathItemObject)?.servers + return { + before: createPathUnifier(beforeRootServers)(beforePath, beforePathItemServers), + after: createPathUnifier(afterRootServers)(afterPath, afterPathItemServers), + } +} + +export const pathChangeClassifyRuleIdRule: ClassifyRuleIdRule = [ + // The `/*` wildcard in /paths matches both path items (key starts with `/`) + // and specification extensions (key starts with `x-`). Guard accordingly. + ({ after }) => (String(after.key).startsWith('/') + ? REST_CLASSIFY_RULE_IDS.PATH_CHANGE_ADD + : REST_CLASSIFY_RULE_IDS.PATH_CHANGE_NOT_APPLICABLE), + ({ before }) => (String(before.key).startsWith('/') + ? REST_CLASSIFY_RULE_IDS.PATH_CHANGE_REMOVE + : REST_CLASSIFY_RULE_IDS.PATH_CHANGE_NOT_APPLICABLE), + (ctx) => { + if (!String(ctx.before.key).startsWith('/')) { + return REST_CLASSIFY_RULE_IDS.PATH_CHANGE_NOT_APPLICABLE + } + const unified = computeUnifiedPaths(ctx) + return unified.before === unified.after + ? REST_CLASSIFY_RULE_IDS.PATH_CHANGE_REPLACE_SAME_EFFECTIVE_PATH + : REST_CLASSIFY_RULE_IDS.PATH_CHANGE_REPLACE_DIFFERENT_EFFECTIVE_PATH + }, +] + export const pathChangeClassifyRule: ClassifyRule = [ nonBreaking, breaking, diff --git a/src/openapi/openapi3.rules.ts b/src/openapi/openapi3.rules.ts index 04b66d8..78a01c6 100644 --- a/src/openapi/openapi3.rules.ts +++ b/src/openapi/openapi3.rules.ts @@ -40,17 +40,29 @@ import { OpenApi3RulesOptions } from './openapi3.types' import { openApiSchemaRules } from './openapi3.schema' import { apihubAllowEmptyValueParameterClassifyRule, + apihubAllowEmptyValueParameterClassifyRuleIdRule, apihubParametersRemovalClassifyRule, + apihubParametersRemovalClassifyRuleIdRule, globalSecurityClassifyRule, + globalSecurityClassifyRuleIdRule, globalSecurityItemClassifyRule, + globalSecurityItemClassifyRuleIdRule, operationSecurityClassifyRule, operationSecurityItemClassifyRule, + operationSecurityItemClassifyRuleIdRule, + operationSecurityClassifyRuleIdRule, paramClassifyRule, + paramClassifyRuleIdRule, parameterAllowReservedClassifyRule, + parameterAllowReservedClassifyRuleIdRule, parameterExplodeClassifyRule, + parameterExplodeClassifyRuleIdRule, parameterNameClassifyRule, + parameterNameClassifyRuleIdRule, parameterRequiredClassifyRule, + parameterRequiredClassifyRuleIdRule, pathChangeClassifyRule, + pathChangeClassifyRuleIdRule, } from './openapi3.classify' import { contentMediaTypeMappingResolver, @@ -72,8 +84,9 @@ import { examplesParamsCalculator } from './openapi3.description.examples' import { headerParamsCalculator } from './openapi3.description.header' import { encodingParamsCalculator } from './openapi3.description.encoding' import { openApiSpecificationExtensionRulesFunction } from './openapi3.compare.rules' +import { REST_CLASSIFY_RULE_IDS } from './openapi3.classify.ruleIds' -const documentAnnotationRule: CompareRules = { $: allAnnotation } +const documentAnnotationRule: CompareRules = { $: allAnnotation, classifyRuleId: REST_CLASSIFY_RULE_IDS.DOCUMENT_ANNOTATION } const operationAnnotationRule: CompareRules = { $: allAnnotation } @@ -95,6 +108,7 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { const serversRules: CompareRules = { $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.SERVERS, '/*': { '/variables': { '/*': { @@ -114,97 +128,119 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { const externalDocumentationRules: CompareRules = { $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.EXTERNAL_DOCS, ...openApiSpecificationExtensionRulesFunction(allAnnotation), '/*': { $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.EXTERNAL_DOCS_FIELD, }, } const examplesRules: CompareRules = { $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.EXAMPLES_MAP, '/*': { $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.EXAMPLES_ITEM, description: diffDescription(`[{{${TEMPLATE_PARAM_ACTION}}}] example '{{${GREP_TEMPLATE_PARAM_EXAMPLE_NAME}}}'`), descriptionParamCalculator: examplesParamsCalculator, '/description': { $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.EXAMPLES_DESCRIPTION, description: diffDescription(resolveExamplesDescriptionTemplates()), }, '/externalValue': { $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.EXAMPLES_EXTERNAL_VALUE, description: diffDescription(resolveExamplesDescriptionTemplates()), }, '/summary': { $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.EXAMPLES_SUMMARY, description: diffDescription(resolveExamplesDescriptionTemplates()), }, '/value': { $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.EXAMPLES_VALUE, description: diffDescription(resolveExamplesDescriptionTemplates()), '/**': { $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.EXAMPLES_VALUE_ITEM, description: diffDescription(resolveExamplesDescriptionTemplates()), } }, ...openApiSpecificationExtensionRulesFunction(allAnnotation), }, - '/**': { $: allAnnotation }, + '/**': { $: allAnnotation, classifyRuleId: REST_CLASSIFY_RULE_IDS.EXAMPLES_DEEP }, } const parametersRules: CompareRules = { '/*': { $: paramClassifyRule, + classifyRuleId: paramClassifyRuleIdRule, description: diffDescription([`[{{${TEMPLATE_PARAM_ACTION}}}] {{${TEMPLATE_PARAM_PARAMETER_LOCATION}}} parameter '{{${GREP_TEMPLATE_PARAM_PARAMETER_NAME}}}'`]), descriptionParamCalculator: parameterParamsCalculator, [IGNORE_DIFFERENCE_IN_KEYS_RULE]: true, [START_NEW_COMPARE_SCOPE_RULE]: COMPARE_SCOPE_REQUEST, '/allowEmptyValue': { $: apihubAllowEmptyValueParameterClassifyRule, + classifyRuleId: apihubAllowEmptyValueParameterClassifyRuleIdRule, description: diffDescription(resolveParameterDescriptionTemplates('allowEmptyValue status')) }, '/allowReserved': { $: parameterAllowReservedClassifyRule, + classifyRuleId: parameterAllowReservedClassifyRuleIdRule, description: diffDescription(resolveParameterDescriptionTemplates('allowReserved status')) }, '/deprecated': { $: allDeprecated, + classifyRuleId: REST_CLASSIFY_RULE_IDS.PARAMETER_DEPRECATED, description: diffDescription(resolveParameterDescriptionTemplates('deprecated status')) }, '/description': { $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.PARAMETER_DESCRIPTION, description: diffDescription(resolveParameterDescriptionTemplates('description')) }, '/example': { $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.PARAMETER_EXAMPLE, description: diffDescription(resolveParameterDescriptionTemplates('example')), '/**': { $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.PARAMETER_EXAMPLE_ITEM, description: diffDescription(resolveParameterDescriptionTemplates()) } }, '/examples': examplesRules, '/explode': { $: parameterExplodeClassifyRule, + classifyRuleId: parameterExplodeClassifyRuleIdRule, description: diffDescription(resolveParameterDescriptionTemplates('explode status')) }, '/in': { $: [nonBreaking, breaking, breaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.PARAMETER_IN, description: diffDescription(`[{{${TEMPLATE_PARAM_ACTION}}}] {{${TEMPLATE_PARAM_PARAMETER_LOCATION}}} parameter '{{${GREP_TEMPLATE_PARAM_PARAMETER_NAME}}}'`), }, '/name': { $: parameterNameClassifyRule, + classifyRuleId: parameterNameClassifyRuleIdRule, description: diffDescription(`[{{${TEMPLATE_PARAM_ACTION}}}] {{${TEMPLATE_PARAM_PARAMETER_LOCATION}}} parameter '{{${GREP_TEMPLATE_PARAM_PARAMETER_NAME}}}'`), }, '/required': { $: parameterRequiredClassifyRule, + classifyRuleId: parameterRequiredClassifyRuleIdRule, description: diffDescription(resolveParameterDescriptionTemplates('required status')) }, '/schema': () => ({ $: allBreaking, + classifyRuleId: REST_CLASSIFY_RULE_IDS.PARAMETER_SCHEMA, ...requestSchemaRules, }), '/style': { $: allBreaking, + classifyRuleId: REST_CLASSIFY_RULE_IDS.PARAMETER_STYLE, description: diffDescription(resolveParameterDescriptionTemplates('delimited style')) }, ...openApiSpecificationExtensionRulesFunction(), @@ -213,49 +249,65 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { const headersRules: CompareRules = { $: [nonBreaking, breaking, breaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.HEADERS, '/*': { $: [nonBreaking, breaking, breaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.HEADER, description: diffDescription(`[{{${TEMPLATE_PARAM_ACTION}}}] header '{{${GREP_TEMPLATE_PARAM_HEADER_NAME}}}'`), descriptionParamCalculator: headerParamsCalculator, '/allowEmptyValue': { $: allUnclassified, + classifyRuleId: REST_CLASSIFY_RULE_IDS.HEADER_ALLOW_EMPTY_VALUE, description: diffDescription(resolveHeaderDescriptionTemplates('allowEmptyValue status')), }, '/allowReserved': { $: allUnclassified, + classifyRuleId: REST_CLASSIFY_RULE_IDS.HEADER_ALLOW_RESERVED, description: diffDescription(resolveHeaderDescriptionTemplates('allowReserved status')), }, '/deprecated': { $: allDeprecated, + classifyRuleId: REST_CLASSIFY_RULE_IDS.HEADER_DEPRECATED, description: diffDescription(resolveHeaderDescriptionTemplates('deprecated status')), }, '/description': { $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.HEADER_DESCRIPTION, description: diffDescription(resolveHeaderDescriptionTemplates('description')), }, '/example': { $: allUnclassified, + classifyRuleId: REST_CLASSIFY_RULE_IDS.HEADER_EXAMPLE, description: diffDescription(resolveHeaderDescriptionTemplates('example')), '/**': { $: allUnclassified, + classifyRuleId: REST_CLASSIFY_RULE_IDS.HEADER_EXAMPLE_ITEM, description: diffDescription(resolveHeaderDescriptionTemplates()) } }, '/examples': examplesRules, '/explode': { $: allUnclassified, + classifyRuleId: REST_CLASSIFY_RULE_IDS.HEADER_EXPLODE, description: diffDescription(resolveHeaderDescriptionTemplates('explode status')), }, '/required': { $: [breaking, nonBreaking, breakingIfAfterTrue], + classifyRuleId: [ + REST_CLASSIFY_RULE_IDS.HEADER_REQUIRED_ADD, + REST_CLASSIFY_RULE_IDS.HEADER_REQUIRED_REMOVE, + ({ after }) => after.value === true ? REST_CLASSIFY_RULE_IDS.HEADER_REQUIRED_REPLACE_AFTER_TRUE : REST_CLASSIFY_RULE_IDS.HEADER_REQUIRED_REPLACE_AFTER_NOT_TRUE, + ], description: diffDescription(resolveHeaderDescriptionTemplates('required status')), }, '/schema': ({ path }) => ({ $: allBreaking, + classifyRuleId: REST_CLASSIFY_RULE_IDS.HEADER_SCHEMA, ...isResponseSchema(path) ? responseSchemaRules : requestSchemaRules, }), '/style': { $: allUnclassified, + classifyRuleId: REST_CLASSIFY_RULE_IDS.HEADER_STYLE, description: diffDescription(resolveHeaderDescriptionTemplates('delimited style')), }, ...openApiSpecificationExtensionRulesFunction(), @@ -264,24 +316,29 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { const encodingRules: CompareRules = { $: [breaking, nonBreaking, breaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.ENCODING, descriptionParamCalculator: encodingParamsCalculator, '/*': { description: diffDescription(resolveEncodingDescriptionTemplates()), '/allowReserved': { $: [nonBreaking, breaking, breaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.ENCODING_ALLOW_RESERVED, description: diffDescription(resolveEncodingDescriptionTemplates()) }, '/contentType': { $: [nonBreaking, breaking, breaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.ENCODING_CONTENT_TYPE, description: diffDescription(resolveEncodingDescriptionTemplates()) }, '/explode': { $: [nonBreaking, breaking, breaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.ENCODING_EXPLODE, description: diffDescription(resolveEncodingDescriptionTemplates()) }, '/headers': headersRules, '/style': { $: [nonBreaking, breaking, breaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.ENCODING_STYLE, description: diffDescription(resolveEncodingDescriptionTemplates()) }, ...openApiSpecificationExtensionRulesFunction(), @@ -290,9 +347,11 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { const contentRules: CompareRules = { $: [nonBreaking, breaking, breaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.CONTENT, mapping: contentMediaTypeMappingResolver, '/*': { $: [nonBreaking, breaking, nonBreaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.MEDIA_TYPE, description: diffDescription([ `[{{${TEMPLATE_PARAM_ACTION}}}] '{{${GREP_TEMPLATE_PARAM_MEDIA_TYPE}}}' media type {{${TEMPLATE_PARAM_PREPOSITION}}} {{${TEMPLATE_PARAM_SCOPE}}} '{{${GREP_TEMPLATE_PARAM_RESPONSE_NAME}}}'`, `[{{${TEMPLATE_PARAM_ACTION}}}] '{{${GREP_TEMPLATE_PARAM_MEDIA_TYPE}}}' media type {{${TEMPLATE_PARAM_PREPOSITION}}} '{{${TEMPLATE_PARAM_COMPONENT_PATH}}}'`, @@ -302,6 +361,7 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { '/encoding': encodingRules, '/example': { $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.MEDIA_TYPE_EXAMPLE, description: diffDescription([ `[{{${TEMPLATE_PARAM_ACTION}}}] {{${TEMPLATE_PARAM_PROPERTY_NAME}}} {{${TEMPLATE_PARAM_PREPOSITION}}} {{${TEMPLATE_PARAM_SCOPE}}} '{{${GREP_TEMPLATE_PARAM_RESPONSE_NAME}}}' ({{${GREP_TEMPLATE_PARAM_MEDIA_TYPE}}})`, `[{{${TEMPLATE_PARAM_ACTION}}}] {{${TEMPLATE_PARAM_PROPERTY_NAME}}} {{${TEMPLATE_PARAM_PREPOSITION}}} '{{${TEMPLATE_PARAM_COMPONENT_PATH}}}' ({{${GREP_TEMPLATE_PARAM_MEDIA_TYPE}}})`, @@ -309,6 +369,7 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { ]), '/**': { $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.MEDIA_TYPE_EXAMPLE_ITEM, description: diffDescription([ `[{{${TEMPLATE_PARAM_ACTION}}}] {{${TEMPLATE_PARAM_PROPERTY_NAME}}} {{${TEMPLATE_PARAM_PREPOSITION}}} {{${TEMPLATE_PARAM_SCOPE}}} '{{${GREP_TEMPLATE_PARAM_RESPONSE_NAME}}}' ({{${GREP_TEMPLATE_PARAM_MEDIA_TYPE}}})`, `[{{${TEMPLATE_PARAM_ACTION}}}] {{${TEMPLATE_PARAM_PROPERTY_NAME}}} {{${TEMPLATE_PARAM_PREPOSITION}}} '{{${TEMPLATE_PARAM_COMPONENT_PATH}}}' ({{${GREP_TEMPLATE_PARAM_MEDIA_TYPE}}})`, @@ -319,6 +380,7 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { '/examples': examplesRules, '/schema': ({ path }) => ({ $: allBreaking, + classifyRuleId: REST_CLASSIFY_RULE_IDS.MEDIA_TYPE_SCHEMA, ...isResponseSchema(path) ? responseSchemaRules : requestSchemaRules, }), ...openApiSpecificationExtensionRulesFunction(), @@ -327,16 +389,23 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { const requestBodiesRules: CompareRules = { $: [nonBreaking, breaking, breaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.REQUEST_BODY, description: diffDescription(`[{{${TEMPLATE_PARAM_ACTION}}}] request body`), descriptionParamCalculator: requestParamsCalculator, [START_NEW_COMPARE_SCOPE_RULE]: COMPARE_SCOPE_REQUEST, '/content': contentRules, '/description': { $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.REQUEST_BODY_DESCRIPTION, description: diffDescription(resolveRequestDescriptionTemplates('description')) }, '/required': { $: [breaking, nonBreaking, breakingIfAfterTrue], + classifyRuleId: [ + REST_CLASSIFY_RULE_IDS.REQUEST_BODY_REQUIRED_ADD, + REST_CLASSIFY_RULE_IDS.REQUEST_BODY_REQUIRED_REMOVE, + ({ after }) => after.value === true ? REST_CLASSIFY_RULE_IDS.REQUEST_BODY_REQUIRED_REPLACE_AFTER_TRUE : REST_CLASSIFY_RULE_IDS.REQUEST_BODY_REQUIRED_REPLACE_AFTER_NOT_TRUE, + ], description: diffDescription(resolveRequestDescriptionTemplates('required status')) }, ...openApiSpecificationExtensionRulesFunction(), @@ -344,11 +413,25 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { const responseRules: CompareRules = { $: [nonBreaking, breaking, (ctx) => nonBreakingIf(ctx.before.key.toString().toLocaleLowerCase() === ctx.after.key.toString().toLocaleLowerCase())], + // Guard: responseRules is the `/*` wildcard in /responses containers, so its + // classifyRuleId is merged into x-* extension diffs via getNodeRules. Return + // RESPONSE_NOT_APPLICABLE for x-* keys so OOB is a no-op for those leaked diffs. + classifyRuleId: [ + ({ after }) => String(after.key).startsWith('x-') ? REST_CLASSIFY_RULE_IDS.RESPONSE_NOT_APPLICABLE : REST_CLASSIFY_RULE_IDS.RESPONSE_ADD, + ({ before }) => String(before.key).startsWith('x-') ? REST_CLASSIFY_RULE_IDS.RESPONSE_NOT_APPLICABLE : REST_CLASSIFY_RULE_IDS.RESPONSE_REMOVE, + (ctx) => { + if (String(ctx.before.key).startsWith('x-')) { return REST_CLASSIFY_RULE_IDS.RESPONSE_NOT_APPLICABLE } + return ctx.before.key.toString().toLocaleLowerCase() === ctx.after.key.toString().toLocaleLowerCase() + ? REST_CLASSIFY_RULE_IDS.RESPONSE_REPLACE_SAME_CODE + : REST_CLASSIFY_RULE_IDS.RESPONSE_REPLACE_DIFFERENT_CODE + }, + ], description: diffDescription(`[{{${TEMPLATE_PARAM_ACTION}}}] response '{{${GREP_TEMPLATE_PARAM_RESPONSE_NAME}}}'`), descriptionParamCalculator: responseParamsCalculator, '/content': contentRules, '/description': { $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.RESPONSE_DESCRIPTION, description: diffDescription([ `[{{${TEMPLATE_PARAM_ACTION}}}] description {{${TEMPLATE_PARAM_PREPOSITION}}} '{{${TEMPLATE_PARAM_COMPONENT_PATH}}}'`, `[{{${TEMPLATE_PARAM_ACTION}}}] description {{${TEMPLATE_PARAM_PREPOSITION}}} response '{{${GREP_TEMPLATE_PARAM_RESPONSE_NAME}}}'` @@ -358,23 +441,36 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { ...openApiSpecificationExtensionRulesFunction(), } + const HTTP_METHODS = new Set(['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'trace']) + const operationRule: CompareRules = { $: [nonBreaking, breaking, unclassified], + // Guard: operationRule is the `/*` wildcard in pathItemObjectRules, so its + // classifyRuleId is merged into ALL path-item child rules (servers, description, + // etc.) via getNodeRules. Return OPERATION_NOT_APPLICABLE for non-HTTP-method + // keys so the OOB classifier is a no-op for those leaked diffs. + classifyRuleId: [ + ({ after }) => HTTP_METHODS.has(String(after.key)) ? REST_CLASSIFY_RULE_IDS.OPERATION_ADD : REST_CLASSIFY_RULE_IDS.OPERATION_NOT_APPLICABLE, + ({ before }) => HTTP_METHODS.has(String(before.key)) ? REST_CLASSIFY_RULE_IDS.OPERATION_REMOVE : REST_CLASSIFY_RULE_IDS.OPERATION_NOT_APPLICABLE, + (ctx) => HTTP_METHODS.has(String(ctx.before.key)) ? REST_CLASSIFY_RULE_IDS.OPERATION_REPLACE : REST_CLASSIFY_RULE_IDS.OPERATION_NOT_APPLICABLE, + ], '/callbacks': { '/*': { //no support? }, }, - '/deprecated': { $: allDeprecated }, + '/deprecated': { $: allDeprecated, classifyRuleId: REST_CLASSIFY_RULE_IDS.OPERATION_DEPRECATED }, '/externalDocs': externalDocumentationRules, '/parameters': { $: [nonBreaking, apihubParametersRemovalClassifyRule, breaking], + classifyRuleId: apihubParametersRemovalClassifyRuleIdRule, mapping: paramMappingResolver(2), ...parametersRules, }, '/requestBody': requestBodiesRules, '/responses': { $: [nonBreaking, breaking, breaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.OPERATION_RESPONSES, [START_NEW_COMPARE_SCOPE_RULE]: COMPARE_SCOPE_RESPONSE, mapping: apihubCaseInsensitiveKeyMappingResolver, ...openApiSpecificationExtensionRulesFunction(), @@ -382,13 +478,17 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { }, '/security': { $: operationSecurityClassifyRule, + classifyRuleId: operationSecurityClassifyRuleIdRule, '/*': { $: operationSecurityItemClassifyRule, + classifyRuleId: operationSecurityItemClassifyRuleIdRule, '/*': { $: allBreaking, + classifyRuleId: REST_CLASSIFY_RULE_IDS.OPERATION_SECURITY_SCOPE_GROUP, mapping: deepEqualsUniqueItemsArrayMappingResolver, '/*': { $: [breaking, nonBreaking, breaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.OPERATION_SECURITY_SCOPE, ignoreKeyDifference: true, }, }, @@ -396,10 +496,12 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { }, '/servers': serversRules, '/tags': { - ...operationAnnotationRule, + $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.OPERATION_TAGS, mapping: deepEqualsUniqueItemsArrayMappingResolver, '/*': { - ...operationAnnotationRule, + $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.OPERATION_TAGS_ITEM, [IGNORE_DIFFERENCE_IN_KEYS_RULE]: true, }, }, @@ -407,78 +509,101 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { '/*': operationAnnotationRule, } + const OAUTH_FLOW_TYPES = new Set(['implicit', 'password', 'clientCredentials', 'authorizationCode']) + const oAuthFlowObjectRules: CompareRules = { $: [breaking, nonBreaking, breaking], + // Guard: oAuthFlowObjectRules is the `/*` wildcard in oAuthFlowsObjectRules, so its + // classifyRuleId is merged into x-* extension diffs. Return OAUTH_FLOW_NOT_APPLICABLE + // for non-flow keys so OOB is a no-op for those leaked diffs. + classifyRuleId: [ + ({ after }) => OAUTH_FLOW_TYPES.has(String(after.key)) ? REST_CLASSIFY_RULE_IDS.OAUTH_FLOW : REST_CLASSIFY_RULE_IDS.OAUTH_FLOW_NOT_APPLICABLE, + ({ before }) => OAUTH_FLOW_TYPES.has(String(before.key)) ? REST_CLASSIFY_RULE_IDS.OAUTH_FLOW : REST_CLASSIFY_RULE_IDS.OAUTH_FLOW_NOT_APPLICABLE, + (ctx) => OAUTH_FLOW_TYPES.has(String(ctx.before.key)) ? REST_CLASSIFY_RULE_IDS.OAUTH_FLOW : REST_CLASSIFY_RULE_IDS.OAUTH_FLOW_NOT_APPLICABLE, + ], ...openApiSpecificationExtensionRulesFunction(), } const oAuthFlowsObjectRules: CompareRules = { $: [breaking, nonBreaking, breaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.OAUTH_FLOWS, ...openApiSpecificationExtensionRulesFunction(), '/*': oAuthFlowObjectRules, } const tagObjectCompareRules: CompareRules = { $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.TAG_OBJECT, '/externalDocs': externalDocumentationRules, ...openApiSpecificationExtensionRulesFunction(allAnnotation), - '/*': { $: allAnnotation }, + '/*': { $: allAnnotation, classifyRuleId: REST_CLASSIFY_RULE_IDS.TAG_OBJECT_FIELD }, } const pathItemObjectRules = (options: OpenApi3RulesOptions): CompareRules => ({ $: pathChangeClassifyRule, + classifyRuleId: pathChangeClassifyRuleIdRule, mapping: options.mode === COMPARE_MODE_OPERATION ? singleOperationPathMappingResolver : methodMappingResolver, - '/description': { $: allAnnotation }, + '/description': { $: allAnnotation, classifyRuleId: REST_CLASSIFY_RULE_IDS.PATH_ITEM_DESCRIPTION }, '/parameters': { $: [nonBreaking, breaking, breaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.PATH_ITEM_PARAMETERS, mapping: paramMappingResolver(1), ...parametersRules, }, '/servers': serversRules, - '/summary': { $: allAnnotation }, + '/summary': { $: allAnnotation, classifyRuleId: REST_CLASSIFY_RULE_IDS.PATH_ITEM_SUMMARY }, ...openApiSpecificationExtensionRulesFunction(), '/*': operationRule, }) const componentsRule: CompareRules = { $: allNonBreaking, + classifyRuleId: REST_CLASSIFY_RULE_IDS.COMPONENTS, [START_NEW_COMPARE_SCOPE_RULE]: COMPARE_SCOPE_COMPONENTS, '/examples': examplesRules, '/headers': headersRules, '/parameters': { $: [nonBreaking, breaking, breaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.COMPONENTS_PARAMETERS, ...parametersRules, }, '/requestBodies': { $: [nonBreaking, breaking, breaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.COMPONENTS_REQUEST_BODIES, '/*': requestBodiesRules, }, '/responses': { $: [nonBreaking, breaking, breaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.COMPONENTS_RESPONSES, '/*': responseRules, }, '/schemas': { $: [nonBreaking, breaking, breaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.COMPONENTS_SCHEMAS, '/*': () => ({ $: allUnclassified,/*for mode One operation*/ + classifyRuleId: REST_CLASSIFY_RULE_IDS.COMPONENTS_SCHEMA_DEFINITION, ...requestSchemaRules, }), }, '/pathItems': { $: [nonBreaking, breaking, breaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.COMPONENTS_PATH_ITEMS, '/*': pathItemObjectRules(options), }, '/securitySchemes': { $: [breaking, nonBreaking, breaking], + classifyRuleId: REST_CLASSIFY_RULE_IDS.COMPONENTS_SECURITY_SCHEMES, '/*': { $: [breaking, nonBreaking, breaking], - '/bearerFormat': { $: allAnnotation }, - '/description': { $: allAnnotation }, + classifyRuleId: REST_CLASSIFY_RULE_IDS.SECURITY_SCHEME, + '/bearerFormat': { $: allAnnotation, classifyRuleId: REST_CLASSIFY_RULE_IDS.SECURITY_SCHEME_BEARER_FORMAT }, + '/description': { $: allAnnotation, classifyRuleId: REST_CLASSIFY_RULE_IDS.SECURITY_SCHEME_DESCRIPTION }, '/flows': oAuthFlowsObjectRules, - '/in': { $: [breaking, nonBreaking, breaking] }, - '/name': { $: [breaking, nonBreaking, breaking] }, - '/openIdConnectUrl': { $: allAnnotation }, - '/scheme': { $: [breaking, nonBreaking, breaking] }, - '/type': { $: [breaking, nonBreaking, breaking] }, + '/in': { $: [breaking, nonBreaking, breaking], classifyRuleId: REST_CLASSIFY_RULE_IDS.SECURITY_SCHEME_PROPERTY }, + '/name': { $: [breaking, nonBreaking, breaking], classifyRuleId: REST_CLASSIFY_RULE_IDS.SECURITY_SCHEME_PROPERTY }, + '/openIdConnectUrl': { $: allAnnotation, classifyRuleId: REST_CLASSIFY_RULE_IDS.SECURITY_SCHEME_OPEN_ID_CONNECT_URL }, + '/scheme': { $: [breaking, nonBreaking, breaking], classifyRuleId: REST_CLASSIFY_RULE_IDS.SECURITY_SCHEME_PROPERTY }, + '/type': { $: [breaking, nonBreaking, breaking], classifyRuleId: REST_CLASSIFY_RULE_IDS.SECURITY_SCHEME_PROPERTY }, ...openApiSpecificationExtensionRulesFunction(), }, }, @@ -502,6 +627,7 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { '/servers': serversRules, '/paths': { $: allUnclassified, + classifyRuleId: REST_CLASSIFY_RULE_IDS.PATHS, mapping: options.mode === COMPARE_MODE_OPERATION ? singleOperationPathMappingResolver : pathMappingResolver, syntheticDiffs: options.operationSyntheticDiffs && syntheticDiffsResolver, '/*': pathItemObjectRules(options), @@ -510,10 +636,12 @@ export const openApi3Rules = (options: OpenApi3RulesOptions): CompareRules => { '/components': componentsRule, '/security': { $: globalSecurityClassifyRule, - '/*': { $: globalSecurityItemClassifyRule }, + classifyRuleId: globalSecurityClassifyRuleIdRule, + '/*': { $: globalSecurityItemClassifyRule, classifyRuleId: globalSecurityItemClassifyRuleIdRule }, }, '/tags': { $: allAnnotation, + classifyRuleId: REST_CLASSIFY_RULE_IDS.TAGS, '/*': tagObjectCompareRules, }, '/externalDocs': externalDocumentationRules, diff --git a/src/types/compare.ts b/src/types/compare.ts index e56b5d4..1b2bc63 100644 --- a/src/types/compare.ts +++ b/src/types/compare.ts @@ -17,6 +17,15 @@ interface DiffBase { type: T scope: CompareScope description?: string + classifyRuleId?: string + /** + * The effective backward-compatibility scope at the point where the diff was + * created, as computed by `apiCompatibilityScopeFunction`. Captures the BWC + * scope so that post-processing classifiers (e.g. matching rules) can + * replicate the `reclassifyBreakingToRisky` logic without requiring access to + * the original compare context. + */ + effectiveBwcScope?: ApiCompatibilityKind } export interface DiffAdd extends DiffBase { @@ -70,6 +79,12 @@ export interface DiffRename extends DiffBase { export type Diff = DiffAdd | DiffRemove | DiffReplace | DiffRename +export type DiffClassifierResult = { + type?: DiffType +} + +export type DiffClassifier = (diff: Diff) => DiffClassifierResult | undefined + export interface CompareResult { diffs: Diff[] ownerDiffEntry: DiffEntry | undefined @@ -123,6 +138,14 @@ export interface CompareOptions extends Omit { * Set automatically by the AsyncAPI engine when `firstReferenceKeyProperty` is user-provided. */ retainFirstReferenceKeyProperty?: boolean + + /** + * Optional function to post-process diffs after classification. + * Called for each diff after all diffs are collected. + * Return a non-undefined value to override the diff's `type`. + * Return `undefined` to leave the diff unchanged. + */ + diffClassifier?: DiffClassifier } export type DiffCallback = (diff: Diff/*, ctx: CompareContext*/) => void diff --git a/src/types/rules.ts b/src/types/rules.ts index b3672c4..5a245c4 100644 --- a/src/types/rules.ts +++ b/src/types/rules.ts @@ -6,6 +6,18 @@ import { DiffAction } from '../core' import { OriginLeafs } from '@netcracker/qubership-apihub-api-unifier' export type DiffTypeClassifier = (ctx: CompareContext) => DiffType +export type ClassifyRuleIdResolver = (ctx: CompareContext) => string + +/** A single element in a RuleIdRule tuple — constant string, function resolver, or undefined (not yet assigned). */ +export type ClassifyRuleIdElement = string | ClassifyRuleIdResolver + +/** + * Specifies which ruleId to attach to a diff, mirroring the structure of ClassifyRule: + * - string | RuleIdResolver — same ruleId (or resolver) for all action types + * - [add, remove, replace] — different string/resolver per action, matching ClassifyRule tuple positions + * - undefined — ruleId not yet assigned for this rule + */ +export type ClassifyRuleIdRule = undefined | ClassifyRuleIdElement | [ClassifyRuleIdElement, ClassifyRuleIdElement, ClassifyRuleIdElement] export type ClassifyRule = [AddDiffType, RemoveDiffType, ReplaceDiffType] | @@ -77,6 +89,7 @@ export type DynamicParams = Record export const FAILED_PARAMS_CALCULATION = {} as DynamicParams export const CLASSIFIER_RULE = '$' +export const CLASSIFY_RULE_ID = 'classifyRuleId' export const COMPARE_RULE = 'compare' export const ADAPTER_RULE = 'adapter' export const MAPPING_RULE = 'mapping' @@ -89,6 +102,7 @@ export const SYNTHETIC_DIFF = 'syntheticDiffs' export type CompareRule = { [CLASSIFIER_RULE]?: ClassifyRule // classifier for current node + [CLASSIFY_RULE_ID]?: ClassifyRuleIdRule // stable identifier for the rule [COMPARE_RULE]?: CompareResolver // compare handler for current node [ADAPTER_RULE]?: AdapterResolver[] // mutations (not deep) [MAPPING_RULE]?: MappingResolver // key mapping rules diff --git a/src/types/yaml.d.ts b/src/types/yaml.d.ts new file mode 100644 index 0000000..f9cb236 --- /dev/null +++ b/src/types/yaml.d.ts @@ -0,0 +1,4 @@ +declare module '*.yaml' { + const value: unknown + export default value +} diff --git a/test/transforms/yaml-transform.cjs b/test/transforms/yaml-transform.cjs new file mode 100644 index 0000000..46891d3 --- /dev/null +++ b/test/transforms/yaml-transform.cjs @@ -0,0 +1,11 @@ +// Jest transform for *.yaml files — parses YAML content and exposes it as a +// CommonJS default export so that `import rules from '*.yaml'` works in tests. +const yaml = require('js-yaml') + +module.exports = { + process(content) { + return { + code: `module.exports = ${JSON.stringify(yaml.load(content))}`, + } + }, +} diff --git a/vite.config.ts b/vite.config.ts index 064656d..e443bb0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,23 @@ import * as path from 'path' -import { defineConfig } from 'vite' +import { defineConfig, Plugin } from 'vite' import dts from 'vite-plugin-dts' import pkg from './package.json' +import { load as loadYaml } from 'js-yaml' + +function yamlPlugin(): Plugin { + return { + name: 'yaml', + transform(code, id) { + if (!id.endsWith('.yaml') && !id.endsWith('.yml')) return null + const data = loadYaml(code) + return { code: `export default ${JSON.stringify(data)}`, map: null } + }, + } +} export default defineConfig({ plugins: [ + yamlPlugin(), dts({ insertTypesEntry: true, tsconfigPath: path.resolve(__dirname, 'tsconfig.build.json'),