@amritk/lint 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +18 -0
  2. package/dist/core/glob.d.ts +1 -1
  3. package/dist/core/glob.js +89 -5
  4. package/dist/core/index.d.ts +1 -1
  5. package/dist/core/index.js +1 -1
  6. package/dist/core/jsonpath.d.ts +17 -1
  7. package/dist/core/jsonpath.js +218 -23
  8. package/dist/core/lint.d.ts +15 -8
  9. package/dist/core/lint.js +12 -3
  10. package/dist/core/plugin.d.ts +6 -0
  11. package/dist/core/plugin.js +6 -0
  12. package/dist/core/pointers.js +15 -15
  13. package/dist/core/ruleset.js +0 -0
  14. package/dist/core/runner.d.ts +6 -1
  15. package/dist/core/runner.js +127 -43
  16. package/dist/core/types.d.ts +17 -2
  17. package/dist/core/validate-ruleset.js +16 -0
  18. package/dist/fix/apply.d.ts +8 -2
  19. package/dist/fix/apply.js +68 -18
  20. package/dist/functions/alphabetical.js +40 -13
  21. package/dist/functions/casing.js +27 -5
  22. package/dist/functions/enumeration.d.ts +5 -3
  23. package/dist/functions/enumeration.js +18 -1
  24. package/dist/functions/index.d.ts +1 -0
  25. package/dist/functions/index.js +3 -0
  26. package/dist/functions/length.d.ts +13 -3
  27. package/dist/functions/length.js +11 -3
  28. package/dist/functions/or.d.ts +11 -0
  29. package/dist/functions/or.js +25 -0
  30. package/dist/functions/pattern.d.ts +5 -3
  31. package/dist/functions/pattern.js +42 -9
  32. package/dist/functions/schema.d.ts +13 -0
  33. package/dist/functions/schema.js +95 -2
  34. package/dist/functions/typed-enum.js +7 -1
  35. package/dist/functions/unreferenced-reusable-object.d.ts +7 -1
  36. package/dist/functions/unreferenced-reusable-object.js +18 -3
  37. package/dist/functions/xor.js +8 -1
  38. package/dist/index.js +9 -1
  39. package/dist/parsers/edit-model.d.ts +15 -0
  40. package/dist/parsers/edit-model.js +210 -41
  41. package/dist/parsers/types.d.ts +14 -2
  42. package/dist/parsers/yaml.d.ts +10 -0
  43. package/dist/parsers/yaml.js +174 -26
  44. package/dist/rules/openapi/fixers.js +63 -4
  45. package/dist/rules/openapi/formats.js +11 -4
  46. package/dist/rules/openapi/functions/example-validation.d.ts +16 -3
  47. package/dist/rules/openapi/functions/example-validation.js +102 -39
  48. package/dist/rules/openapi/functions/helpers.d.ts +1 -0
  49. package/dist/rules/openapi/functions/helpers.js +5 -0
  50. package/dist/rules/openapi/functions/index.d.ts +3 -1
  51. package/dist/rules/openapi/functions/index.js +7 -1
  52. package/dist/rules/openapi/functions/oas-additional-operations.js +5 -5
  53. package/dist/rules/openapi/functions/oas-example-external-value.d.ts +11 -0
  54. package/dist/rules/openapi/functions/oas-example-external-value.js +23 -0
  55. package/dist/rules/openapi/functions/oas-no-nullable.d.ts +13 -0
  56. package/dist/rules/openapi/functions/oas-no-nullable.js +22 -0
  57. package/dist/rules/openapi/functions/oas-op-id-unique.js +4 -2
  58. package/dist/rules/openapi/functions/oas-op-params.d.ts +7 -1
  59. package/dist/rules/openapi/functions/oas-op-params.js +35 -10
  60. package/dist/rules/openapi/functions/oas-op-security-defined.js +2 -2
  61. package/dist/rules/openapi/functions/oas-op-success-response.js +6 -1
  62. package/dist/rules/openapi/functions/oas-path-param.d.ts +10 -1
  63. package/dist/rules/openapi/functions/oas-path-param.js +87 -26
  64. package/dist/rules/openapi/functions/oas-server-variables.d.ts +6 -1
  65. package/dist/rules/openapi/functions/oas-server-variables.js +31 -2
  66. package/dist/rules/openapi/functions/oas-unused-component.js +14 -1
  67. package/dist/rules/openapi/oas.js +82 -25
  68. package/package.json +12 -4
@@ -2,11 +2,15 @@ const PATTERNS = {
2
2
  flat: '[a-z][a-z{d}]*',
3
3
  camel: '[a-z][a-z{d}]*(?:[A-Z{d}](?:[a-z{d}]+|$))*',
4
4
  pascal: '[A-Z][a-z{d}]*(?:[A-Z{d}](?:[a-z{d}]+|$))*',
5
- kebab: '[a-z][a-z{d}]*(?:-[a-z][a-z{d}]*)*',
6
- cobol: '[A-Z][A-Z{d}]*(?:-[A-Z][A-Z{d}]*)*',
7
- snake: '[a-z][a-z{d}]*(?:_[a-z][a-z{d}]*)*',
8
- macro: '[A-Z][A-Z{d}]*(?:_[A-Z][A-Z{d}]*)*',
5
+ // Segments after a separator may start with a digit, matching Spectral (so
6
+ // "foo-2fa" is valid kebab case). The sub-pattern is `[a-z{d}]+`, not the
7
+ // stricter `[a-z][a-z{d}]*` which would require a letter right after the sep.
8
+ kebab: '[a-z][a-z{d}]*(?:-[a-z{d}]+)*',
9
+ cobol: '[A-Z][A-Z{d}]*(?:-[A-Z{d}]+)*',
10
+ snake: '[a-z][a-z{d}]*(?:_[a-z{d}]+)*',
11
+ macro: '[A-Z][A-Z{d}]*(?:_[A-Z{d}]+)*',
9
12
  };
13
+ const VALID_TYPES = Object.keys(PATTERNS);
10
14
  const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
11
15
  const buildRegExp = (options) => {
12
16
  const digits = options.disallowDigits ? '' : '0-9';
@@ -19,10 +23,28 @@ const buildRegExp = (options) => {
19
23
  };
20
24
  /** Flags a string that does not match the configured casing style. */
21
25
  export const casing = (input, options) => {
26
+ if (!options?.type)
27
+ return [];
28
+ // Guard an unknown `type` before it reaches `PATTERNS[type]`, which would be
29
+ // `undefined` and crash on `.replace`. Mirror Spectral's option schema by
30
+ // naming every accepted value in a single, clear error finding.
31
+ if (!VALID_TYPES.includes(options.type)) {
32
+ return [
33
+ {
34
+ message: `"casing" function and its "type" option accept the following values: ${VALID_TYPES.join(', ')}`,
35
+ },
36
+ ];
37
+ }
22
38
  if (typeof input !== 'string' || input.length === 0)
23
39
  return [];
24
- if (!options?.type)
40
+ // Spectral special-cases a lone separator char with `allowLeading` as valid.
41
+ // This is what keeps the OpenAPI root path "/" from being flagged.
42
+ if (input.length === 1 &&
43
+ options.separator !== undefined &&
44
+ options.separator.allowLeading === true &&
45
+ input === options.separator.char) {
25
46
  return [];
47
+ }
26
48
  if (!buildRegExp(options).test(input)) {
27
49
  return [{ message: `The value must be in ${options.type} case` }];
28
50
  }
@@ -1,5 +1,7 @@
1
1
  import type { RulesetFunction } from '../core/index.js';
2
- /** Flags a value that is not one of the allowed `values`. */
3
- export declare const enumeration: RulesetFunction<unknown, {
2
+ /** Options for {@link enumeration}. */
3
+ export type IEnumerationOptions = {
4
4
  values: unknown[];
5
- }>;
5
+ };
6
+ /** Flags a value that is not one of the allowed `values`. */
7
+ export declare const enumeration: RulesetFunction<unknown, IEnumerationOptions>;
@@ -1,6 +1,23 @@
1
+ /**
2
+ * True for the primitive kinds Spectral's input schema accepts
3
+ * (`string`, `number`, `null`, `boolean`). Objects and arrays are excluded so
4
+ * they cannot slip through the reference-equality `includes` check below and be
5
+ * flagged against a list they could never be `===` to.
6
+ */
7
+ const isPrimitive = (value) => value === null || (typeof value !== 'object' && typeof value !== 'function');
1
8
  /** Flags a value that is not one of the allowed `values`. */
2
9
  export const enumeration = (input, options) => {
3
- const values = options?.values ?? [];
10
+ const values = options?.values;
11
+ // Without a valid list of allowed values there is nothing to compare against.
12
+ // Spectral requires `values` in its option schema and no-ops when it is
13
+ // missing, so skip rather than flag everything against an empty allow-list.
14
+ if (!Array.isArray(values))
15
+ return [];
16
+ // Spectral's input schema only lets primitives reach this function. `includes`
17
+ // uses reference equality, so a non-primitive would always be reported as
18
+ // "not allowed"; match Spectral by skipping objects and arrays entirely.
19
+ if (!isPrimitive(input))
20
+ return [];
4
21
  if (!values.includes(input)) {
5
22
  return [
6
23
  { message: `The value must be one of the allowed values: ${values.map((v) => JSON.stringify(v)).join(', ')}` },
@@ -5,6 +5,7 @@ export { defined } from './defined.js';
5
5
  export { enumeration } from './enumeration.js';
6
6
  export { falsy } from './falsy.js';
7
7
  export { length } from './length.js';
8
+ export { type IOrOptions, or } from './or.js';
8
9
  export { pattern } from './pattern.js';
9
10
  export { type ISchemaOptions, schema } from './schema.js';
10
11
  export { truthy } from './truthy.js';
@@ -4,6 +4,7 @@ import { defined } from './defined.js';
4
4
  import { enumeration } from './enumeration.js';
5
5
  import { falsy } from './falsy.js';
6
6
  import { length } from './length.js';
7
+ import { or } from './or.js';
7
8
  import { pattern } from './pattern.js';
8
9
  import { schema } from './schema.js';
9
10
  import { truthy } from './truthy.js';
@@ -17,6 +18,7 @@ export { defined } from './defined.js';
17
18
  export { enumeration } from './enumeration.js';
18
19
  export { falsy } from './falsy.js';
19
20
  export { length } from './length.js';
21
+ export { or } from './or.js';
20
22
  export { pattern } from './pattern.js';
21
23
  export { schema } from './schema.js';
22
24
  export { truthy } from './truthy.js';
@@ -32,6 +34,7 @@ export const builtinFunctions = {
32
34
  enumeration: enumeration,
33
35
  falsy,
34
36
  length: length,
37
+ or: or,
35
38
  pattern: pattern,
36
39
  schema: schema,
37
40
  truthy,
@@ -1,6 +1,16 @@
1
1
  import type { RulesetFunction } from '../core/index.js';
2
- /** Flags a value whose size falls outside the `min`/`max` bounds. */
3
- export declare const length: RulesetFunction<unknown, {
2
+ /** Options for {@link length}. */
3
+ export type ILengthOptions = {
4
4
  min?: number;
5
5
  max?: number;
6
- }>;
6
+ };
7
+ /**
8
+ * Flags a value whose size falls outside the `min`/`max` bounds.
9
+ *
10
+ * With neither `min` nor `max` supplied this is a no-op on every node. That
11
+ * matches Spectral, which requires at least one bound in its option schema and
12
+ * simply produces no results once the options load without them. We also ignore
13
+ * a `min`/`max` that is not a number so a stray string like "3" cannot slip into
14
+ * the `<`/`>` comparison and be coerced into a misleading result.
15
+ */
16
+ export declare const length: RulesetFunction<unknown, ILengthOptions>;
@@ -11,16 +11,24 @@ const measure = (input) => {
11
11
  return input;
12
12
  return undefined;
13
13
  };
14
- /** Flags a value whose size falls outside the `min`/`max` bounds. */
14
+ /**
15
+ * Flags a value whose size falls outside the `min`/`max` bounds.
16
+ *
17
+ * With neither `min` nor `max` supplied this is a no-op on every node. That
18
+ * matches Spectral, which requires at least one bound in its option schema and
19
+ * simply produces no results once the options load without them. We also ignore
20
+ * a `min`/`max` that is not a number so a stray string like "3" cannot slip into
21
+ * the `<`/`>` comparison and be coerced into a misleading result.
22
+ */
15
23
  export const length = (input, options) => {
16
24
  const size = measure(input);
17
25
  if (size === undefined)
18
26
  return [];
19
27
  const results = [];
20
- if (options?.min !== undefined && size < options.min) {
28
+ if (typeof options?.min === 'number' && size < options.min) {
21
29
  results.push({ message: `The value must not be shorter than ${options.min}` });
22
30
  }
23
- if (options?.max !== undefined && size > options.max) {
31
+ if (typeof options?.max === 'number' && size > options.max) {
24
32
  results.push({ message: `The value must not be longer than ${options.max}` });
25
33
  }
26
34
  return results;
@@ -0,0 +1,11 @@
1
+ import type { RulesetFunction } from '../core/index.js';
2
+ /** Options for {@link or}. */
3
+ export type IOrOptions = {
4
+ properties: string[];
5
+ };
6
+ /**
7
+ * Flags an object when none of the listed `properties` is defined on it. Where
8
+ * {@link xor} requires exactly one, `or` requires at least one — it stays quiet
9
+ * as soon as a single listed property is present.
10
+ */
11
+ export declare const or: RulesetFunction<Record<string, unknown>, IOrOptions>;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Flags an object when none of the listed `properties` is defined on it. Where
3
+ * {@link xor} requires exactly one, `or` requires at least one — it stays quiet
4
+ * as soon as a single listed property is present.
5
+ */
6
+ export const or = (input, options) => {
7
+ if (typeof input !== 'object' || input === null)
8
+ return [];
9
+ const properties = options?.properties;
10
+ // Spectral validates that at least two properties are supplied and no-ops when
11
+ // that is not the case, so we skip in silence rather than flag every node.
12
+ if (!Array.isArray(properties) || properties.length < 2)
13
+ return [];
14
+ const present = properties.filter((property) => property in input);
15
+ if (present.length > 0)
16
+ return [];
17
+ // Match Spectral's message: a long list is abbreviated to the first three
18
+ // properties plus a count of the rest so the finding stays readable.
19
+ if (properties.length > 4) {
20
+ const shortProps = properties.slice(0, 3);
21
+ const count = `${properties.length - 3} other properties must be defined`;
22
+ return [{ message: `At least one of "${shortProps.join('" or "')}" or ${count}` }];
23
+ }
24
+ return [{ message: `At least one of "${properties.join('" or "')}" must be defined` }];
25
+ };
@@ -1,6 +1,8 @@
1
1
  import type { RulesetFunction } from '../core/index.js';
2
- /** Flags a string that fails `match` or satisfies `notMatch`. */
3
- export declare const pattern: RulesetFunction<string, {
2
+ /** Options for {@link pattern}. */
3
+ export type IPatternOptions = {
4
4
  match?: string;
5
5
  notMatch?: string;
6
- }>;
6
+ };
7
+ /** Flags a string that fails `match` or satisfies `notMatch`. */
8
+ export declare const pattern: RulesetFunction<string, IPatternOptions>;
@@ -1,20 +1,53 @@
1
- /** Parses a `/pattern/flags` string (or a bare pattern) into a RegExp. */
1
+ // Compiling a RegExp is not free, and rules run this function once per matched
2
+ // node, so we memoize by the raw pattern string (which carries its own flags in
3
+ // the `/re/flags` form). A failed compile is cached as its `Error` so a bad
4
+ // pattern is reported without being re-thrown on every node. Mirrors Spectral's
5
+ // own module-level cache.
6
+ const cache = new Map();
7
+ /** Parses a `/pattern/flags` string (or a bare pattern) into a RegExp, or the compile error. */
2
8
  const toRegExp = (pattern) => {
3
- const match = /^\/(.+)\/([a-z]*)$/s.exec(pattern);
4
- if (match)
5
- return new RegExp(match[1], match[2]);
6
- return new RegExp(pattern);
9
+ const cached = cache.get(pattern);
10
+ if (cached !== undefined) {
11
+ // A cached RegExp carrying the `g`/`y` flag keeps `lastIndex` between calls,
12
+ // which would make `.test` skip characters on the next node. Reset it so a
13
+ // reused pattern behaves the same as a freshly compiled one.
14
+ if (cached instanceof RegExp)
15
+ cached.lastIndex = 0;
16
+ return cached;
17
+ }
18
+ let compiled;
19
+ try {
20
+ const match = /^\/(.+)\/([a-z]*)$/s.exec(pattern);
21
+ compiled = match ? new RegExp(match[1], match[2]) : new RegExp(pattern);
22
+ }
23
+ catch (error) {
24
+ compiled = error instanceof Error ? error : new Error(String(error));
25
+ }
26
+ cache.set(pattern, compiled);
27
+ return compiled;
7
28
  };
8
29
  /** Flags a string that fails `match` or satisfies `notMatch`. */
9
30
  export const pattern = (input, options) => {
10
31
  if (typeof input !== 'string')
11
32
  return [];
12
33
  const results = [];
13
- if (options?.match !== undefined && !toRegExp(options.match).test(input)) {
14
- results.push({ message: `The value must match the pattern "${options.match}"` });
34
+ if (options?.match !== undefined) {
35
+ const re = toRegExp(options.match);
36
+ if (re instanceof Error) {
37
+ results.push({ message: `The "match" option is not a valid regular expression: ${re.message}` });
38
+ }
39
+ else if (!re.test(input)) {
40
+ results.push({ message: `The value must match the pattern "${options.match}"` });
41
+ }
15
42
  }
16
- if (options?.notMatch !== undefined && toRegExp(options.notMatch).test(input)) {
17
- results.push({ message: `The value must not match the pattern "${options.notMatch}"` });
43
+ if (options?.notMatch !== undefined) {
44
+ const re = toRegExp(options.notMatch);
45
+ if (re instanceof Error) {
46
+ results.push({ message: `The "notMatch" option is not a valid regular expression: ${re.message}` });
47
+ }
48
+ else if (re.test(input)) {
49
+ results.push({ message: `The value must not match the pattern "${options.notMatch}"` });
50
+ }
18
51
  }
19
52
  return results;
20
53
  };
@@ -1,8 +1,21 @@
1
1
  import type { RulesetFunction } from '../core/index.js';
2
2
  /** Options for {@link schema}. */
3
3
  export type ISchemaOptions = {
4
+ /** The JSON Schema to validate the matched value against. */
4
5
  schema: object;
6
+ /**
7
+ * Report every validation error rather than stopping at the first. Defaults to
8
+ * `false`, matching Spectral (whose underlying ajv instance runs with
9
+ * `allErrors: false` unless told otherwise).
10
+ */
5
11
  allErrors?: boolean;
12
+ /**
13
+ * Accepted for Spectral compatibility but intentionally ignored. The draft is
14
+ * auto-detected by `@amritk/runtime-validators`, so both draft-4 constructs
15
+ * (boolean `exclusiveMinimum`) and draft-7 ones (tuple `items`) validate
16
+ * without the caller naming a dialect.
17
+ */
18
+ dialect?: string;
6
19
  };
7
20
  /** Validates a value against a JSON Schema supplied in the rule's options. */
8
21
  export declare const schema: RulesetFunction<unknown, ISchemaOptions>;
@@ -13,6 +13,70 @@ const getValidator = (schema) => {
13
13
  }
14
14
  return validator;
15
15
  };
16
+ const KNOWN_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null', 'object', 'array']);
17
+ // Keywords whose value is itself a schema, a list of schemas, or a map of
18
+ // schemas. We recurse through them to reach every `type` a schema declares.
19
+ const SCHEMA_VALUE_KEYS = ['additionalProperties', 'not', 'if', 'then', 'else', 'propertyNames', 'contains'];
20
+ const SCHEMA_ITEMS_KEYS = ['items', 'additionalItems'];
21
+ const SCHEMA_LIST_KEYS = ['allOf', 'anyOf', 'oneOf'];
22
+ const SCHEMA_MAP_KEYS = ['properties', 'patternProperties', 'definitions', '$defs', 'dependencies'];
23
+ /**
24
+ * Walks a schema looking for a `type` keyword whose value is not a JSON Schema
25
+ * type. `@amritk/runtime-validators` treats an unknown `type` as "always
26
+ * matches" (so it never rejects data it does not model), which means a typo like
27
+ * `type: "Pascal"` would silently disable the rule. Finding it up front lets us
28
+ * report it instead. Returns the offending type, or `undefined` when the schema
29
+ * only uses known types.
30
+ */
31
+ const findInvalidType = (node) => {
32
+ if (Array.isArray(node) || typeof node !== 'object' || node === null)
33
+ return undefined;
34
+ const schema = node;
35
+ const declared = schema['type'];
36
+ if (declared !== undefined) {
37
+ const types = Array.isArray(declared) ? declared : [declared];
38
+ for (const type of types) {
39
+ if (typeof type !== 'string' || !KNOWN_TYPES.has(type)) {
40
+ return typeof type === 'string' ? type : String(type);
41
+ }
42
+ }
43
+ }
44
+ for (const key of SCHEMA_VALUE_KEYS) {
45
+ const found = findInvalidType(schema[key]);
46
+ if (found !== undefined)
47
+ return found;
48
+ }
49
+ for (const key of SCHEMA_ITEMS_KEYS) {
50
+ const value = schema[key];
51
+ const list = Array.isArray(value) ? value : [value];
52
+ for (const item of list) {
53
+ const found = findInvalidType(item);
54
+ if (found !== undefined)
55
+ return found;
56
+ }
57
+ }
58
+ for (const key of SCHEMA_LIST_KEYS) {
59
+ const value = schema[key];
60
+ if (Array.isArray(value)) {
61
+ for (const item of value) {
62
+ const found = findInvalidType(item);
63
+ if (found !== undefined)
64
+ return found;
65
+ }
66
+ }
67
+ }
68
+ for (const key of SCHEMA_MAP_KEYS) {
69
+ const value = schema[key];
70
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
71
+ for (const item of Object.values(value)) {
72
+ const found = findInvalidType(item);
73
+ if (found !== undefined)
74
+ return found;
75
+ }
76
+ }
77
+ }
78
+ return undefined;
79
+ };
16
80
  const pointerToPath = (pointer) => pointer
17
81
  .split('/')
18
82
  .slice(1)
@@ -26,10 +90,39 @@ const formatError = (error) => {
26
90
  export const schema = (input, options, context) => {
27
91
  if (!options?.schema)
28
92
  return [];
29
- const result = getValidator(options.schema)(input);
93
+ // A malformed schema would otherwise validate everything and silently disable
94
+ // the rule. Surface it as a finding, the way Spectral reports schema compile
95
+ // errors as results, so the ruleset author notices the mistake.
96
+ const invalidType = findInvalidType(options.schema);
97
+ if (invalidType !== undefined) {
98
+ return [
99
+ {
100
+ message: `Invalid schema: unknown type "${invalidType}". Valid types are: ${[...KNOWN_TYPES].join(', ')}`,
101
+ path: [...context.path],
102
+ },
103
+ ];
104
+ }
105
+ let result;
106
+ try {
107
+ result = getValidator(options.schema)(input);
108
+ }
109
+ catch (error) {
110
+ // Preparing or running the validator can throw on a schema shape we cannot
111
+ // interpret. Report it rather than letting it bubble up and crash the run.
112
+ return [
113
+ {
114
+ message: `Invalid schema: ${error instanceof Error ? error.message : String(error)}`,
115
+ path: [...context.path],
116
+ },
117
+ ];
118
+ }
30
119
  if (result === true)
31
120
  return [];
32
- return result.errors.map((error) => ({
121
+ // Spectral's ajv defaults to `allErrors: false`, reporting only the first
122
+ // failure. Honor the same default and only expand to the full list when the
123
+ // caller opts in.
124
+ const errors = options.allErrors ? result.errors : result.errors.slice(0, 1);
125
+ return errors.map((error) => ({
33
126
  message: formatError(error),
34
127
  path: [...context.path, ...pointerToPath(error.path)],
35
128
  }));
@@ -15,7 +15,13 @@ export const typedEnum = (input, _options, context) => {
15
15
  const values = input['enum'];
16
16
  if (declaredType === undefined || !Array.isArray(values))
17
17
  return [];
18
- const types = Array.isArray(declaredType) ? declaredType : [declaredType];
18
+ const types = Array.isArray(declaredType) ? [...declaredType] : [declaredType];
19
+ // A schema marked nullable (OpenAPI 3 `nullable` or the Swagger 2 `x-nullable`
20
+ // vendor extension) is allowed to hold `null` in addition to its declared
21
+ // type, so `null` must not be flagged as a type mismatch.
22
+ if ((input['nullable'] === true || input['x-nullable'] === true) && !types.includes('null')) {
23
+ types.push('null');
24
+ }
19
25
  const checkers = types
20
26
  .map((type) => JS_TYPES[String(type)])
21
27
  .filter((fn) => Boolean(fn));
@@ -4,5 +4,11 @@ export type IUnreferencedReusableObjectOptions = {
4
4
  /** JSON pointer to the map of reusable objects, e.g. "#/components/schemas". */
5
5
  reusableObjectsLocation: string;
6
6
  };
7
- /** Flags entries in a reusable-object map that nothing `$ref`s. */
7
+ /**
8
+ * Flags entries in a reusable-object map that nothing `$ref`s.
9
+ *
10
+ * This must run against the *unresolved* document: once `$ref`s are inlined by a
11
+ * resolver there are no references left to count, so every reusable object would
12
+ * look orphaned.
13
+ */
8
14
  export declare const unreferencedReusableObject: RulesetFunction<Record<string, unknown>, IUnreferencedReusableObjectOptions>;
@@ -14,7 +14,15 @@ const collectRefs = (node, into) => {
14
14
  }
15
15
  }
16
16
  };
17
- /** Flags entries in a reusable-object map that nothing `$ref`s. */
17
+ /** Escapes a key for use in a JSON pointer segment (`~` -> `~0`, `/` -> `~1`). */
18
+ const escapePointerSegment = (key) => key.replace(/~/g, '~0').replace(/\//g, '~1');
19
+ /**
20
+ * Flags entries in a reusable-object map that nothing `$ref`s.
21
+ *
22
+ * This must run against the *unresolved* document: once `$ref`s are inlined by a
23
+ * resolver there are no references left to count, so every reusable object would
24
+ * look orphaned.
25
+ */
18
26
  export const unreferencedReusableObject = (input, options, context) => {
19
27
  if (typeof input !== 'object' || input === null)
20
28
  return [];
@@ -25,8 +33,15 @@ export const unreferencedReusableObject = (input, options, context) => {
25
33
  collectRefs(context.document.data, refs);
26
34
  const results = [];
27
35
  for (const key of Object.keys(input)) {
28
- const pointer = `${location}/${key}`;
29
- if (!refs.has(pointer)) {
36
+ // A key such as "a/b" appears in a pointer as "a~1b", so escape it before
37
+ // building the expected reference. Without this a legitimately referenced
38
+ // object with a special character in its name looks unreferenced.
39
+ const base = `${location}/${escapePointerSegment(key)}`;
40
+ // A reference can point straight at the object (`base`) or deeper into it
41
+ // (e.g. `base/properties/x`); either counts as a use, so match the exact
42
+ // pointer or any pointer nested beneath it.
43
+ const referenced = refs.has(base) || [...refs].some((ref) => ref.startsWith(`${base}/`));
44
+ if (!referenced) {
30
45
  results.push({
31
46
  message: 'This reusable object is never referenced',
32
47
  path: [...context.path, key],
@@ -2,7 +2,14 @@
2
2
  export const xor = (input, options) => {
3
3
  if (typeof input !== 'object' || input === null)
4
4
  return [];
5
- const properties = options?.properties ?? [];
5
+ const properties = options?.properties;
6
+ // Spectral validates the option schema (an array of at least two strings)
7
+ // before the function runs and no-ops when it fails, so with fewer than two
8
+ // properties there is nothing meaningful to check. We deliberately skip in
9
+ // silence rather than push an error: an empty or single-element list would
10
+ // otherwise flag every node with a message that names nothing useful.
11
+ if (!Array.isArray(properties) || properties.length < 2)
12
+ return [];
6
13
  const present = properties.filter((property) => property in input);
7
14
  if (present.length !== 1) {
8
15
  return [{ message: `Exactly one of ${properties.map((p) => `"${p}"`).join(', ')} must be defined` }];
package/dist/index.js CHANGED
@@ -71,7 +71,11 @@ const loadFunctionByName = (basePath, dir, name) => {
71
71
  * the directory of the ruleset that declared it. YAML/JSON rulesets reference
72
72
  * functions by name; JS rulesets can instead pass direct references in `then`.
73
73
  */
74
- const collectCustomFunctions = (definition, basePath, into, seen) => {
74
+ const collectCustomFunctions = (definition, basePath, into,
75
+ // Keyed by (basePath, reference) for string extends and by object identity for
76
+ // inline ones. `loadRulesetFile` returns a fresh object per read, so object
77
+ // identity alone would never dedupe a file cycle — we key on the resolved edge.
78
+ seen) => {
75
79
  if (seen.has(definition))
76
80
  return;
77
81
  seen.add(definition);
@@ -80,6 +84,10 @@ const collectCustomFunctions = (definition, basePath, into, seen) => {
80
84
  for (const entry of entries) {
81
85
  const target = Array.isArray(entry) ? entry[0] : entry;
82
86
  if (typeof target === 'string') {
87
+ const key = `${basePath}\0${target}`;
88
+ if (seen.has(key))
89
+ continue;
90
+ seen.add(key);
83
91
  const resolved = resolveNamedRuleset(target, basePath);
84
92
  collectCustomFunctions(resolved.definition, resolved.basePath, into, seen);
85
93
  }
@@ -5,6 +5,21 @@ import type { JsonPath } from './types.js';
5
5
  * paths and values rather than raw character offsets. {@link applyEditOps} lowers
6
6
  * each op to a minimal text edit so that untouched parts of the source — including
7
7
  * comments, key order, and quoting — keep their original formatting.
8
+ *
9
+ * A few edits are deliberately conservative no-ops rather than risky rewrites:
10
+ *
11
+ * - Edits that traverse a YAML alias (`*ref`) or a `<<` merge key resolve to a
12
+ * node the edit model cannot address structurally, so they are dropped. This is
13
+ * safe — the source is left untouched — but it does mean a finding on data that
14
+ * only exists via an alias/merge is not auto-fixed.
15
+ * - `setValue` on an *anchored* node (`&anchor`) rewrites the anchor's own text,
16
+ * which every `*alias` to it re-reads; the change therefore propagates to every
17
+ * alias use-site. That is the correct YAML semantics, but callers should be
18
+ * aware the edit is not local to one path.
19
+ * - Multi-line *flow* collections (`[\n a,\n b\n]`) are rebuilt onto a single
20
+ * line by {@link rewriteYamlSeq} and the flow-map insert, and any comments that
21
+ * lived between their members are dropped. Block collections keep their layout
22
+ * and comments; only flow ones are collapsed.
8
23
  */
9
24
  export type EditOp =
10
25
  /** Replace the scalar value at `path` (the existing quoting style is preserved). */