@apidiff/core 1.0.0

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 (249) hide show
  1. package/dist/ast/hash.d.ts +2 -0
  2. package/dist/ast/hash.d.ts.map +1 -0
  3. package/dist/ast/index.d.ts +3 -0
  4. package/dist/ast/index.d.ts.map +1 -0
  5. package/dist/ast/traverse.d.ts +13 -0
  6. package/dist/ast/traverse.d.ts.map +1 -0
  7. package/dist/config/index.d.ts +9 -0
  8. package/dist/config/index.d.ts.map +1 -0
  9. package/dist/diff/auth-differ.d.ts +4 -0
  10. package/dist/diff/auth-differ.d.ts.map +1 -0
  11. package/dist/diff/endpoint-differ.d.ts +3 -0
  12. package/dist/diff/endpoint-differ.d.ts.map +1 -0
  13. package/dist/diff/index.d.ts +3 -0
  14. package/dist/diff/index.d.ts.map +1 -0
  15. package/dist/diff/schema-differ.d.ts +3 -0
  16. package/dist/diff/schema-differ.d.ts.map +1 -0
  17. package/dist/diff/server-differ.d.ts +3 -0
  18. package/dist/diff/server-differ.d.ts.map +1 -0
  19. package/dist/index.cjs +2902 -0
  20. package/dist/index.d.ts +15 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +2855 -0
  23. package/dist/loader/file-loader.d.ts +5 -0
  24. package/dist/loader/file-loader.d.ts.map +1 -0
  25. package/dist/loader/git-loader.d.ts +5 -0
  26. package/dist/loader/git-loader.d.ts.map +1 -0
  27. package/dist/loader/index.d.ts +7 -0
  28. package/dist/loader/index.d.ts.map +1 -0
  29. package/dist/loader/url-loader.d.ts +5 -0
  30. package/dist/loader/url-loader.d.ts.map +1 -0
  31. package/dist/output/html.d.ts +3 -0
  32. package/dist/output/html.d.ts.map +1 -0
  33. package/dist/output/index.d.ts +3 -0
  34. package/dist/output/index.d.ts.map +1 -0
  35. package/dist/output/json.d.ts +3 -0
  36. package/dist/output/json.d.ts.map +1 -0
  37. package/dist/output/markdown.d.ts +3 -0
  38. package/dist/output/markdown.d.ts.map +1 -0
  39. package/dist/output/terminal.d.ts +3 -0
  40. package/dist/output/terminal.d.ts.map +1 -0
  41. package/dist/parsers/base.d.ts +8 -0
  42. package/dist/parsers/base.d.ts.map +1 -0
  43. package/dist/parsers/graphql/index.d.ts +13 -0
  44. package/dist/parsers/graphql/index.d.ts.map +1 -0
  45. package/dist/parsers/index.d.ts +7 -0
  46. package/dist/parsers/index.d.ts.map +1 -0
  47. package/dist/parsers/openapi2/index.d.ts +9 -0
  48. package/dist/parsers/openapi2/index.d.ts.map +1 -0
  49. package/dist/parsers/openapi3/index.d.ts +9 -0
  50. package/dist/parsers/openapi3/index.d.ts.map +1 -0
  51. package/dist/parsers/openapi3/ref-resolver.d.ts +2 -0
  52. package/dist/parsers/openapi3/ref-resolver.d.ts.map +1 -0
  53. package/dist/parsers/openapi3/schema-normalizer.d.ts +3 -0
  54. package/dist/parsers/openapi3/schema-normalizer.d.ts.map +1 -0
  55. package/dist/parsers/openapi3/security-normalizer.d.ts +3 -0
  56. package/dist/parsers/openapi3/security-normalizer.d.ts.map +1 -0
  57. package/dist/parsers/protobuf/index.d.ts +14 -0
  58. package/dist/parsers/protobuf/index.d.ts.map +1 -0
  59. package/dist/rules/auth/oauth-scope-removed.d.ts +9 -0
  60. package/dist/rules/auth/oauth-scope-removed.d.ts.map +1 -0
  61. package/dist/rules/auth/security-added.d.ts +9 -0
  62. package/dist/rules/auth/security-added.d.ts.map +1 -0
  63. package/dist/rules/auth/security-removed.d.ts +9 -0
  64. package/dist/rules/auth/security-removed.d.ts.map +1 -0
  65. package/dist/rules/auth/security-scheme-type-changed.d.ts +9 -0
  66. package/dist/rules/auth/security-scheme-type-changed.d.ts.map +1 -0
  67. package/dist/rules/base.d.ts +19 -0
  68. package/dist/rules/base.d.ts.map +1 -0
  69. package/dist/rules/endpoint/endpoint-added.d.ts +9 -0
  70. package/dist/rules/endpoint/endpoint-added.d.ts.map +1 -0
  71. package/dist/rules/endpoint/endpoint-deprecated.d.ts +9 -0
  72. package/dist/rules/endpoint/endpoint-deprecated.d.ts.map +1 -0
  73. package/dist/rules/endpoint/endpoint-removed.d.ts +9 -0
  74. package/dist/rules/endpoint/endpoint-removed.d.ts.map +1 -0
  75. package/dist/rules/endpoint/http-method-changed.d.ts +9 -0
  76. package/dist/rules/endpoint/http-method-changed.d.ts.map +1 -0
  77. package/dist/rules/endpoint/path-changed.d.ts +9 -0
  78. package/dist/rules/endpoint/path-changed.d.ts.map +1 -0
  79. package/dist/rules/index.d.ts +4 -0
  80. package/dist/rules/index.d.ts.map +1 -0
  81. package/dist/rules/meta/server-removed.d.ts +9 -0
  82. package/dist/rules/meta/server-removed.d.ts.map +1 -0
  83. package/dist/rules/param/param-added.d.ts +9 -0
  84. package/dist/rules/param/param-added.d.ts.map +1 -0
  85. package/dist/rules/param/param-deprecated.d.ts +9 -0
  86. package/dist/rules/param/param-deprecated.d.ts.map +1 -0
  87. package/dist/rules/param/param-enum-value-added.d.ts +9 -0
  88. package/dist/rules/param/param-enum-value-added.d.ts.map +1 -0
  89. package/dist/rules/param/param-enum-value-removed.d.ts +9 -0
  90. package/dist/rules/param/param-enum-value-removed.d.ts.map +1 -0
  91. package/dist/rules/param/param-location-changed.d.ts +9 -0
  92. package/dist/rules/param/param-location-changed.d.ts.map +1 -0
  93. package/dist/rules/param/param-removed.d.ts +9 -0
  94. package/dist/rules/param/param-removed.d.ts.map +1 -0
  95. package/dist/rules/param/param-required-added.d.ts +9 -0
  96. package/dist/rules/param/param-required-added.d.ts.map +1 -0
  97. package/dist/rules/param/param-required-true-to-false.d.ts +9 -0
  98. package/dist/rules/param/param-required-true-to-false.d.ts.map +1 -0
  99. package/dist/rules/param/param-type-changed.d.ts +9 -0
  100. package/dist/rules/param/param-type-changed.d.ts.map +1 -0
  101. package/dist/rules/request/request-body-added-required.d.ts +9 -0
  102. package/dist/rules/request/request-body-added-required.d.ts.map +1 -0
  103. package/dist/rules/request/request-body-removed.d.ts +9 -0
  104. package/dist/rules/request/request-body-removed.d.ts.map +1 -0
  105. package/dist/rules/request/request-content-type-added.d.ts +9 -0
  106. package/dist/rules/request/request-content-type-added.d.ts.map +1 -0
  107. package/dist/rules/request/request-content-type-removed.d.ts +9 -0
  108. package/dist/rules/request/request-content-type-removed.d.ts.map +1 -0
  109. package/dist/rules/request/request-field-added-required.d.ts +9 -0
  110. package/dist/rules/request/request-field-added-required.d.ts.map +1 -0
  111. package/dist/rules/request/request-field-removed.d.ts +9 -0
  112. package/dist/rules/request/request-field-removed.d.ts.map +1 -0
  113. package/dist/rules/request/request-field-type-changed.d.ts +9 -0
  114. package/dist/rules/request/request-field-type-changed.d.ts.map +1 -0
  115. package/dist/rules/request/request-required-false-to-true.d.ts +9 -0
  116. package/dist/rules/request/request-required-false-to-true.d.ts.map +1 -0
  117. package/dist/rules/response/response-field-added.d.ts +9 -0
  118. package/dist/rules/response/response-field-added.d.ts.map +1 -0
  119. package/dist/rules/response/response-field-removed.d.ts +9 -0
  120. package/dist/rules/response/response-field-removed.d.ts.map +1 -0
  121. package/dist/rules/response/response-field-type-changed.d.ts +9 -0
  122. package/dist/rules/response/response-field-type-changed.d.ts.map +1 -0
  123. package/dist/rules/response/response-header-added-required.d.ts +9 -0
  124. package/dist/rules/response/response-header-added-required.d.ts.map +1 -0
  125. package/dist/rules/response/response-header-removed.d.ts +9 -0
  126. package/dist/rules/response/response-header-removed.d.ts.map +1 -0
  127. package/dist/rules/response/response-media-type-added.d.ts +9 -0
  128. package/dist/rules/response/response-media-type-added.d.ts.map +1 -0
  129. package/dist/rules/response/response-media-type-removed.d.ts +9 -0
  130. package/dist/rules/response/response-media-type-removed.d.ts.map +1 -0
  131. package/dist/rules/response/response-status-added.d.ts +9 -0
  132. package/dist/rules/response/response-status-added.d.ts.map +1 -0
  133. package/dist/rules/response/response-status-removed.d.ts +9 -0
  134. package/dist/rules/response/response-status-removed.d.ts.map +1 -0
  135. package/dist/types/ast.d.ts +140 -0
  136. package/dist/types/ast.d.ts.map +1 -0
  137. package/dist/types/config.d.ts +17 -0
  138. package/dist/types/config.d.ts.map +1 -0
  139. package/dist/types/diff.d.ts +43 -0
  140. package/dist/types/diff.d.ts.map +1 -0
  141. package/dist/types/errors.d.ts +21 -0
  142. package/dist/types/errors.d.ts.map +1 -0
  143. package/dist/types/index.d.ts +6 -0
  144. package/dist/types/index.d.ts.map +1 -0
  145. package/dist/types/semantic.d.ts +45 -0
  146. package/dist/types/semantic.d.ts.map +1 -0
  147. package/package.json +31 -0
  148. package/src/ast/hash.ts +26 -0
  149. package/src/ast/index.ts +2 -0
  150. package/src/ast/traverse.ts +59 -0
  151. package/src/config/index.ts +44 -0
  152. package/src/diff/auth-differ.ts +72 -0
  153. package/src/diff/endpoint-differ.ts +144 -0
  154. package/src/diff/index.ts +13 -0
  155. package/src/diff/schema-differ.ts +70 -0
  156. package/src/diff/server-differ.ts +22 -0
  157. package/src/index.ts +92 -0
  158. package/src/loader/file-loader.ts +19 -0
  159. package/src/loader/git-loader.ts +22 -0
  160. package/src/loader/index.ts +32 -0
  161. package/src/loader/url-loader.ts +25 -0
  162. package/src/output/html.ts +177 -0
  163. package/src/output/index.ts +16 -0
  164. package/src/output/json.ts +5 -0
  165. package/src/output/markdown.ts +29 -0
  166. package/src/output/terminal.ts +37 -0
  167. package/src/parsers/base.ts +8 -0
  168. package/src/parsers/graphql/index.ts +181 -0
  169. package/src/parsers/index.ts +61 -0
  170. package/src/parsers/openapi2/index.ts +218 -0
  171. package/src/parsers/openapi3/index.ts +223 -0
  172. package/src/parsers/openapi3/ref-resolver.ts +101 -0
  173. package/src/parsers/openapi3/schema-normalizer.ts +52 -0
  174. package/src/parsers/openapi3/security-normalizer.ts +18 -0
  175. package/src/parsers/protobuf/index.ts +208 -0
  176. package/src/rules/auth/oauth-scope-removed.ts +34 -0
  177. package/src/rules/auth/security-added.ts +32 -0
  178. package/src/rules/auth/security-removed.ts +47 -0
  179. package/src/rules/auth/security-scheme-type-changed.ts +30 -0
  180. package/src/rules/base.ts +29 -0
  181. package/src/rules/endpoint/endpoint-added.ts +26 -0
  182. package/src/rules/endpoint/endpoint-deprecated.ts +29 -0
  183. package/src/rules/endpoint/endpoint-removed.ts +26 -0
  184. package/src/rules/endpoint/http-method-changed.ts +29 -0
  185. package/src/rules/endpoint/path-changed.ts +29 -0
  186. package/src/rules/index.ts +83 -0
  187. package/src/rules/meta/server-removed.ts +26 -0
  188. package/src/rules/param/param-added.ts +52 -0
  189. package/src/rules/param/param-deprecated.ts +34 -0
  190. package/src/rules/param/param-enum-value-added.ts +32 -0
  191. package/src/rules/param/param-enum-value-removed.ts +32 -0
  192. package/src/rules/param/param-location-changed.ts +43 -0
  193. package/src/rules/param/param-removed.ts +52 -0
  194. package/src/rules/param/param-required-added.ts +34 -0
  195. package/src/rules/param/param-required-true-to-false.ts +34 -0
  196. package/src/rules/param/param-type-changed.ts +32 -0
  197. package/src/rules/request/request-body-added-required.ts +33 -0
  198. package/src/rules/request/request-body-removed.ts +30 -0
  199. package/src/rules/request/request-content-type-added.ts +31 -0
  200. package/src/rules/request/request-content-type-removed.ts +31 -0
  201. package/src/rules/request/request-field-added-required.ts +41 -0
  202. package/src/rules/request/request-field-removed.ts +35 -0
  203. package/src/rules/request/request-field-type-changed.ts +37 -0
  204. package/src/rules/request/request-required-false-to-true.ts +56 -0
  205. package/src/rules/response/response-field-added.ts +34 -0
  206. package/src/rules/response/response-field-removed.ts +34 -0
  207. package/src/rules/response/response-field-type-changed.ts +37 -0
  208. package/src/rules/response/response-header-added-required.ts +47 -0
  209. package/src/rules/response/response-header-removed.ts +32 -0
  210. package/src/rules/response/response-media-type-added.ts +32 -0
  211. package/src/rules/response/response-media-type-removed.ts +32 -0
  212. package/src/rules/response/response-status-added.ts +31 -0
  213. package/src/rules/response/response-status-removed.ts +31 -0
  214. package/src/types/ast.ts +164 -0
  215. package/src/types/config.ts +31 -0
  216. package/src/types/diff.ts +49 -0
  217. package/src/types/errors.ts +26 -0
  218. package/src/types/index.ts +5 -0
  219. package/src/types/semantic.ts +60 -0
  220. package/test/integration/stripe-v1.yaml +36 -0
  221. package/test/integration/stripe-v2.yaml +35 -0
  222. package/tests/ast.test.ts +60 -0
  223. package/tests/config.test.ts +43 -0
  224. package/tests/diff/auth-differ.test.ts +55 -0
  225. package/tests/diff/endpoint-differ.test.ts +42 -0
  226. package/tests/diff/schema-differ.test.ts +46 -0
  227. package/tests/diff/server-differ.test.ts +23 -0
  228. package/tests/diff.test.ts +116 -0
  229. package/tests/fixtures/openapi3/auth-added/v1.yaml +11 -0
  230. package/tests/fixtures/openapi3/auth-added/v2.yaml +18 -0
  231. package/tests/integration.test.ts +21 -0
  232. package/tests/loader-more.test.ts +75 -0
  233. package/tests/loader.test.ts +61 -0
  234. package/tests/output.test.ts +58 -0
  235. package/tests/parsers/openapi3-coverage.test.ts +77 -0
  236. package/tests/parsers/openapi3.test.ts +41 -0
  237. package/tests/parsers/parsers-other.test.ts +135 -0
  238. package/tests/parsers/ref-resolver.test.ts +78 -0
  239. package/tests/parsers/schema-normalizer.test.ts +30 -0
  240. package/tests/rules/auth-meta.test.ts +87 -0
  241. package/tests/rules/base.test.ts +44 -0
  242. package/tests/rules/endpoint.test.ts +122 -0
  243. package/tests/rules/param.test.ts +242 -0
  244. package/tests/rules/request.test.ts +147 -0
  245. package/tests/rules/response.test.ts +161 -0
  246. package/tests/rules/rules-coverage.test.ts +64 -0
  247. package/tests/types-errors.test.ts +22 -0
  248. package/tsconfig.json +8 -0
  249. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { formatHtml } from '../src/output/html.js';
3
+ import { formatMarkdown } from '../src/output/markdown.js';
4
+ import { formatTerminal } from '../src/output/terminal.js';
5
+ import { formatJson } from '../src/output/json.js';
6
+ import type { SemanticChange } from '../src/types/index.js';
7
+
8
+ describe('Output Formatters', () => {
9
+ const dummyChanges: SemanticChange[] = [
10
+ {
11
+ severity: 'breaking',
12
+ category: 'endpoint',
13
+ ruleId: 'ENDPOINT_REMOVED',
14
+ message: 'Endpoint removed',
15
+ location: { method: 'GET', path: '/test', paramName: 'id', field: 'data' },
16
+ consequence: 'Breaks things',
17
+ migration: 'Update URL'
18
+ },
19
+ {
20
+ severity: 'info',
21
+ category: 'meta',
22
+ ruleId: 'SERVER_ADDED',
23
+ message: 'Server added',
24
+ location: { method: 'ALL', path: 'GLOBAL' }
25
+ }
26
+ ];
27
+
28
+ it('formats HTML', () => {
29
+ const output = formatHtml(dummyChanges);
30
+ expect(output).toContain('Endpoint removed');
31
+ expect(output).toContain('Server added');
32
+ expect(output).toContain('Breaks things');
33
+ expect(output).toContain('Update URL');
34
+ expect(output).toContain('API Diff Report');
35
+ });
36
+
37
+ it('formats Markdown', () => {
38
+ const output = formatMarkdown(dummyChanges);
39
+ expect(output).toContain('Endpoint removed');
40
+ expect(output).toContain('Server added');
41
+ expect(output).toContain('Breaks things');
42
+ expect(output).toContain('Update URL');
43
+ expect(output).toContain('## API Changes');
44
+ });
45
+
46
+ it('formats Terminal', () => {
47
+ const output = formatTerminal(dummyChanges);
48
+ expect(output).toContain('Endpoint removed');
49
+ expect(output).toContain('Server added');
50
+ });
51
+
52
+ it('formats JSON', () => {
53
+ const output = formatJson(dummyChanges);
54
+ const parsed = JSON.parse(output);
55
+ expect(parsed.changes).toHaveLength(2);
56
+ expect(parsed.changes[0].ruleId).toBe('ENDPOINT_REMOVED');
57
+ });
58
+ });
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { OpenApi3Parser } from '../../src/parsers/openapi3/index.js';
3
+ import { normalizeSchema } from '../../src/parsers/openapi3/schema-normalizer.js';
4
+ import { resolveRefs } from '../../src/parsers/openapi3/ref-resolver.js';
5
+ import * as fs from 'node:fs';
6
+
7
+ vi.mock('node:fs');
8
+
9
+ describe('OpenAPI 3 Coverage', () => {
10
+ it('covers missing index.ts branches', async () => {
11
+ const parser = new OpenApi3Parser();
12
+ const ast = await parser.parse({
13
+ content: JSON.stringify({
14
+ openapi: '3.0.0',
15
+ info: { version: '1' }, // Missing title
16
+ paths: {
17
+ '/test': {
18
+ get: {
19
+ 'x-custom': 'ext',
20
+ responses: {
21
+ '200': {
22
+ description: 'ok',
23
+ content: { 'app/json': {} } // No schema, no headers
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }
29
+ }),
30
+ format: 'openapi3'
31
+ });
32
+
33
+ expect(ast.meta.title).toBe('Unknown API');
34
+ expect(ast.endpoints[0].extensions['x-custom']).toBe('ext');
35
+ expect(ast.endpoints[0].responses[0].headers).toEqual({});
36
+ });
37
+
38
+ it('covers ref-resolver external refs', () => {
39
+ // mock readFileSync
40
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ type: 'string' }));
41
+
42
+ const root = {
43
+ prop: { $ref: './external.json' }
44
+ };
45
+
46
+ expect(() => resolveRefs({ ...root }, root)).toThrow('Cannot resolve relative ref without sourcePath');
47
+
48
+ const resolved = resolveRefs(root, root, '/app/spec.json');
49
+ expect(resolved.prop.type).toBe('string');
50
+
51
+ expect(() => resolveRefs({ $ref: 'http://example.com' }, {}, '/app/spec.json')).toThrow('URL refs not implemented synchronously');
52
+ expect(() => resolveRefs({ $ref: 'invalid-format' }, {}, '/app/spec.json')).toThrow('Unsupported ref format');
53
+ });
54
+
55
+ it('covers schema-normalizer missing branches', () => {
56
+ // nullable string
57
+ const s1 = normalizeSchema({ type: 'string', nullable: true });
58
+ expect(s1.type).toEqual(['string', 'null']);
59
+
60
+ // nullable array type
61
+ const s2 = normalizeSchema({ type: ['string'], nullable: true });
62
+ expect(s2.type).toEqual(['string', 'null']);
63
+
64
+ // items
65
+ const s3 = normalizeSchema({ type: 'array', items: { type: 'string' } });
66
+ expect(s3.items.type).toBe('string');
67
+
68
+ // additionalProperties
69
+ const s4 = normalizeSchema({ type: 'object', additionalProperties: { type: 'number' } });
70
+ expect((s4.additionalProperties as any).type).toBe('number');
71
+
72
+ // oneOf / anyOf
73
+ const s5 = normalizeSchema({ oneOf: [{ type: 'string' }], anyOf: [{ type: 'number' }] });
74
+ expect(s5.oneOf[0].type).toBe('string');
75
+ expect(s5.anyOf[0].type).toBe('number');
76
+ });
77
+ });
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { OpenApi3Parser } from '../../src/parsers/openapi3/index.js';
3
+
4
+ describe('OpenAPI 3 Parser', () => {
5
+ it('parses valid OpenAPI 3 document', async () => {
6
+ const doc = {
7
+ openapi: '3.0.0',
8
+ info: { title: 'Test API', version: '1.0' },
9
+ servers: [{ url: 'http://test.com' }],
10
+ paths: {
11
+ '/test': {
12
+ get: {
13
+ operationId: 'getTest',
14
+ summary: 'Test',
15
+ parameters: [{ name: 'q', in: 'query', required: true, schema: { type: 'string' } }],
16
+ responses: {
17
+ '200': {
18
+ description: 'OK',
19
+ content: { 'application/json': { schema: { type: 'string' } } }
20
+ }
21
+ }
22
+ }
23
+ }
24
+ }
25
+ };
26
+
27
+ const parser = new OpenApi3Parser();
28
+ const ast = await parser.parse({ content: JSON.stringify(doc), format: 'openapi3' });
29
+ expect(ast.meta.title).toBe('Test API');
30
+ expect(ast.endpoints).toHaveLength(1);
31
+ expect(ast.endpoints[0].method).toBe('GET');
32
+ expect(ast.endpoints[0].parameters).toHaveLength(1);
33
+ expect(ast.endpoints[0].responses).toHaveLength(1);
34
+ expect(ast.endpoints[0].responses[0].statusCode).toBe('200');
35
+ });
36
+
37
+ it('throws on unsupported version', async () => {
38
+ const parser = new OpenApi3Parser();
39
+ await expect(parser.parse({ content: JSON.stringify({ swagger: '2.0' }), format: 'openapi3' })).rejects.toThrow();
40
+ });
41
+ });
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { GraphqlParser } from '../../src/parsers/graphql/index.js';
3
+ import { OpenApi2Parser } from '../../src/parsers/openapi2/index.js';
4
+ import { ProtobufParser } from '../../src/parsers/protobuf/index.js';
5
+
6
+ describe('Other Parsers', () => {
7
+ describe('GraphqlParser', () => {
8
+ it('can parse graphql', () => {
9
+ const parser = new GraphqlParser();
10
+ expect(parser.canParse({ content: 'type Query { hello: String! }', format: 'graphql' })).toBe(true);
11
+ });
12
+
13
+ it('parses simple schema', async () => {
14
+ const parser = new GraphqlParser();
15
+ const ast = await parser.parse({
16
+ content: `
17
+ type Query { hello(name: String!): String! }
18
+ type Mutation { create(id: ID!, tags: [String]): Boolean }
19
+ input Config { key: String! }
20
+ interface Node { id: ID! }
21
+ enum Status { ACTIVE INACTIVE }
22
+ `,
23
+ format: 'graphql'
24
+ });
25
+ expect(ast.endpoints).toHaveLength(2);
26
+ expect(ast.endpoints[0].id).toBe('POST:query.hello');
27
+ expect(ast.components.schemas['Status'].type).toBe('string');
28
+ expect(ast.components.schemas['Config'].type).toBe('object');
29
+ expect(ast.components.schemas['Node'].type).toBe('object');
30
+ });
31
+
32
+ it('throws on invalid', async () => {
33
+ const parser = new GraphqlParser();
34
+ await expect(parser.parse({ content: 'invalid' })).rejects.toThrow('Failed to parse GraphQL');
35
+ });
36
+ });
37
+
38
+ describe('OpenApi2Parser', () => {
39
+ it('can parse openapi2', () => {
40
+ const parser = new OpenApi2Parser();
41
+ expect(parser.canParse({ content: '', format: 'openapi2' })).toBe(true);
42
+ });
43
+
44
+ it('parses swagger 2.0', async () => {
45
+ const parser = new OpenApi2Parser();
46
+ const ast = await parser.parse({
47
+ content: JSON.stringify({
48
+ swagger: '2.0',
49
+ info: { title: 'Test', version: '1' },
50
+ host: 'api.example.com',
51
+ schemes: ['http', 'https'],
52
+ basePath: '/v1',
53
+ securityDefinitions: { basic: { type: 'basic' } },
54
+ security: [{ basic: [] }],
55
+ paths: {
56
+ '/test/{id}': {
57
+ parameters: [{ name: 'id', in: 'path', required: true, type: 'string' }],
58
+ get: {
59
+ operationId: 'getTest',
60
+ tags: ['test'],
61
+ parameters: [
62
+ { name: 'q', in: 'query', type: 'string' },
63
+ { name: 'body', in: 'body', schema: { type: 'object' } }
64
+ ],
65
+ responses: { '200': { description: 'ok', headers: { 'X-Test': { type: 'string' } }, schema: { type: 'string' } } },
66
+ 'x-test': true
67
+ },
68
+ post: {
69
+ consumes: ['multipart/form-data'],
70
+ parameters: [
71
+ { name: 'file', in: 'formData', type: 'file', required: true }
72
+ ],
73
+ responses: { '204': { description: 'ok' } }
74
+ }
75
+ }
76
+ },
77
+ definitions: { User: { type: 'object' } }
78
+ }),
79
+ format: 'openapi2'
80
+ });
81
+ expect(ast.endpoints).toHaveLength(2);
82
+ expect(ast.servers).toHaveLength(2);
83
+ expect(ast.components.schemas['User']).toBeDefined();
84
+ });
85
+
86
+ it('throws on invalid format', async () => {
87
+ const parser = new OpenApi2Parser();
88
+ await expect(parser.parse({ content: '{}' })).rejects.toThrow('Not a valid OpenAPI 2.x spec');
89
+ await expect(parser.parse({ content: 'invalid: { yaml' })).rejects.toThrow();
90
+ });
91
+ });
92
+
93
+ describe('ProtobufParser', () => {
94
+ it('can parse proto', () => {
95
+ const parser = new ProtobufParser();
96
+ expect(parser.canParse({ content: 'syntax = "proto3";', format: 'protobuf' })).toBe(true);
97
+ });
98
+
99
+ it('parses simple proto', async () => {
100
+ const parser = new ProtobufParser();
101
+ const ast = await parser.parse({
102
+ content: `
103
+ syntax = "proto3";
104
+ package test.pkg;
105
+ service TestService {
106
+ // A method
107
+ rpc Get(Request) returns (Response);
108
+ }
109
+ message Request {
110
+ string id = 1;
111
+ repeated string tags = 2;
112
+ Nested msg = 3;
113
+ double d = 4;
114
+ int32 i = 5;
115
+ int64 l = 6;
116
+ bool b = 7;
117
+ bytes by = 8;
118
+ }
119
+ message Response { bool ok = 1; }
120
+ enum Status { OK = 0; ERR = 1; }
121
+ message Nested { string n = 1; }
122
+ `,
123
+ format: 'protobuf'
124
+ });
125
+ expect(ast.endpoints).toHaveLength(1);
126
+ expect(ast.endpoints[0].id).toBe('RPC:test.pkg.TestService/Get');
127
+ expect(ast.components.schemas['test.pkg.Request']).toBeDefined();
128
+ });
129
+
130
+ it('throws on invalid', async () => {
131
+ const parser = new ProtobufParser();
132
+ await expect(parser.parse({ content: 'invalid' })).rejects.toThrow('Failed to parse Protobuf');
133
+ });
134
+ });
135
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { resolveRefs } from '../../src/parsers/openapi3/ref-resolver.js';
3
+
4
+ describe('Ref Resolver', () => {
5
+ it('resolves simple local refs', () => {
6
+ const doc = {
7
+ components: {
8
+ schemas: {
9
+ A: { type: 'string' }
10
+ }
11
+ },
12
+ paths: {
13
+ '/test': {
14
+ get: {
15
+ responses: {
16
+ '200': {
17
+ content: {
18
+ 'application/json': {
19
+ schema: { $ref: '#/components/schemas/A' }
20
+ }
21
+ }
22
+ }
23
+ }
24
+ }
25
+ }
26
+ }
27
+ };
28
+
29
+ const resolved = resolveRefs(doc, doc) as any;
30
+ expect(resolved.paths['/test'].get.responses['200'].content['application/json'].schema.type).toBe('string');
31
+ });
32
+
33
+ it('handles circular references safely', () => {
34
+ const doc = {
35
+ components: {
36
+ schemas: {
37
+ Node: {
38
+ type: 'object',
39
+ properties: {
40
+ child: { $ref: '#/components/schemas/Node' }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ };
46
+ const resolved = resolveRefs(doc, doc) as any;
47
+ // Circular reference should point to an empty object or just stop without throwing a stack overflow
48
+ expect(resolved.components.schemas.Node.properties.child).toBeDefined();
49
+ });
50
+
51
+ it('throws on unresolvable local ref', () => {
52
+ const doc = {
53
+ test: { $ref: '#/components/schemas/NotFound' }
54
+ };
55
+ expect(() => resolveRefs(doc, doc)).toThrow('Local ref not found');
56
+ });
57
+
58
+ it('throws on missing sourcePath for relative ref', () => {
59
+ const doc = {
60
+ test: { $ref: './external.yaml' }
61
+ };
62
+ expect(() => resolveRefs(doc, doc)).toThrow('Cannot resolve relative ref without sourcePath');
63
+ });
64
+
65
+ it('throws on unsupported ref format', () => {
66
+ const doc = {
67
+ test: { $ref: 'invalid-ref' }
68
+ };
69
+ expect(() => resolveRefs(doc, doc, '/base/path.yaml')).toThrow('Unsupported ref format');
70
+ });
71
+
72
+ it('throws on url ref', () => {
73
+ const doc = {
74
+ test: { $ref: 'https://example.com/schema.json' }
75
+ };
76
+ expect(() => resolveRefs(doc, doc)).toThrow('URL refs not implemented synchronously');
77
+ });
78
+ });
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { normalizeSchema } from '../../src/parsers/openapi3/schema-normalizer.js';
3
+
4
+ describe('Schema Normalizer', () => {
5
+ it('merges allOf arrays', () => {
6
+ const schema = {
7
+ allOf: [
8
+ { type: 'object', properties: { a: { type: 'string' } }, required: ['a'] },
9
+ { type: 'object', properties: { b: { type: 'number' } }, required: ['b'] }
10
+ ]
11
+ };
12
+
13
+ const normalized = normalizeSchema(schema);
14
+ expect(normalized.type).toBe('object');
15
+ expect(normalized.properties).toBeDefined();
16
+ expect(normalized.properties?.a.type).toBe('string');
17
+ expect(normalized.properties?.b.type).toBe('number');
18
+ expect(normalized.required).toContain('a');
19
+ expect(normalized.required).toContain('b');
20
+ });
21
+
22
+ it('normalizes oneOf/anyOf to empty object (fallback)', () => {
23
+ const schema = {
24
+ anyOf: [{ type: 'string' }, { type: 'number' }]
25
+ };
26
+ const normalized = normalizeSchema(schema);
27
+ expect(normalized.type).toBeUndefined(); // currently normalizer drops it or picks first?
28
+ // Looking at schema-normalizer, it says: "In a robust implementation, we'd handle anyOf/oneOf properly."
29
+ });
30
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { SecurityRemovedRule } from '../../src/rules/auth/security-removed.js';
3
+ import { SecuritySchemeTypeChangedRule } from '../../src/rules/auth/security-scheme-type-changed.js';
4
+ import { OauthScopeRemovedRule } from '../../src/rules/auth/oauth-scope-removed.js';
5
+ import { ServerRemovedRule } from '../../src/rules/meta/server-removed.js';
6
+ import type { DiffSet, RuleContext } from '../../src/types/index.js';
7
+
8
+ describe('Auth & Meta Rules', () => {
9
+ const context: RuleContext = {
10
+ config: { failOn: 'breaking', disabledRules: [], ignorePaths: [], output: { format: 'terminal' } },
11
+ oldSpec: { meta: { title: 'v1', version: '1.0.0', format: 'openapi3', rawVersion: '3.0.0' }, servers: [], endpoints: [], components: { schemas: {}, securitySchemes: {}, parameters: {}, responses: {}, headers: {}, requestBodies: {} }, security: [] },
12
+ newSpec: { meta: { title: 'v2', version: '2.0.0', format: 'openapi3', rawVersion: '3.0.0' }, servers: [], endpoints: [], components: { schemas: {}, securitySchemes: {}, parameters: {}, responses: {}, headers: {}, requestBodies: {} }, security: [] },
13
+ };
14
+
15
+ const dummyEndpoint = { id: 'GET:/test', path: '/test', method: 'GET' as const, summary: '', description: '', tags: [], deprecated: false, security: [], parameters: [], responses: [] };
16
+
17
+ it('SECURITY_REMOVED triggers when a global security scheme is removed', () => {
18
+ const diff: DiffSet = {
19
+ endpointDiffs: [], componentDiffs: [], serverDiffs: [],
20
+ securityDiffs: [
21
+ { schemeId: 'ApiKeyAuth', changeType: 'removed', fieldChanges: [] }
22
+ ]
23
+ };
24
+ const rule = new SecurityRemovedRule();
25
+ const changes = rule.apply(diff, context);
26
+ expect(changes).toHaveLength(1);
27
+ expect(changes[0].ruleId).toBe('SECURITY_REMOVED');
28
+ });
29
+
30
+ it('SECURITY_REMOVED triggers when an endpoint security requirement is removed', () => {
31
+ const diff: DiffSet = {
32
+ securityDiffs: [], componentDiffs: [], serverDiffs: [],
33
+ endpointDiffs: [
34
+ { type: 'changed', endpointId: 'GET:/test', path: '/test', method: 'GET', oldEndpoint: dummyEndpoint, newEndpoint: dummyEndpoint, fieldChanges: [
35
+ { fieldPath: ['security', 'ApiKeyAuth'], changeType: 'removed', oldValue: 'ApiKeyAuth' }
36
+ ]}
37
+ ]
38
+ };
39
+ const rule = new SecurityRemovedRule();
40
+ const changes = rule.apply(diff, context);
41
+ expect(changes).toHaveLength(1);
42
+ expect(changes[0].ruleId).toBe('SECURITY_REMOVED');
43
+ });
44
+
45
+ it('SECURITY_SCHEME_TYPE_CHANGED triggers when scheme type changes', () => {
46
+ const diff: DiffSet = {
47
+ endpointDiffs: [], componentDiffs: [], serverDiffs: [],
48
+ securityDiffs: [
49
+ { schemeId: 'MyAuth', changeType: 'changed', fieldChanges: [
50
+ { fieldPath: ['type'], changeType: 'changed', oldValue: 'http', newValue: 'oauth2' }
51
+ ] }
52
+ ]
53
+ };
54
+ const rule = new SecuritySchemeTypeChangedRule();
55
+ const changes = rule.apply(diff, context);
56
+ expect(changes).toHaveLength(1);
57
+ expect(changes[0].ruleId).toBe('SECURITY_SCHEME_TYPE_CHANGED');
58
+ });
59
+
60
+ it('OAUTH_SCOPE_REMOVED triggers when a scope is removed', () => {
61
+ const diff: DiffSet = {
62
+ securityDiffs: [], componentDiffs: [], serverDiffs: [],
63
+ endpointDiffs: [
64
+ { type: 'changed', endpointId: 'GET:/test', path: '/test', method: 'GET', oldEndpoint: dummyEndpoint, newEndpoint: dummyEndpoint, fieldChanges: [
65
+ { fieldPath: ['security', 'OAuth2', 'scopes', 'read:users'], changeType: 'removed', oldValue: 'read:users' }
66
+ ]}
67
+ ]
68
+ };
69
+ const rule = new OauthScopeRemovedRule();
70
+ const changes = rule.apply(diff, context);
71
+ expect(changes).toHaveLength(1);
72
+ expect(changes[0].ruleId).toBe('OAUTH_SCOPE_REMOVED');
73
+ });
74
+
75
+ it('SERVER_REMOVED triggers when a server is removed', () => {
76
+ const diff: DiffSet = {
77
+ endpointDiffs: [], componentDiffs: [], securityDiffs: [],
78
+ serverDiffs: [
79
+ { changeType: 'removed', oldServer: { url: 'https://api.example.com/v1', description: '' } }
80
+ ]
81
+ };
82
+ const rule = new ServerRemovedRule();
83
+ const changes = rule.apply(diff, context);
84
+ expect(changes).toHaveLength(1);
85
+ expect(changes[0].ruleId).toBe('SERVER_REMOVED');
86
+ });
87
+ });
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { BaseRule } from '../../src/rules/base.js';
3
+ import type { DiffSet, RuleContext, SemanticChange } from '../../src/types/index.js';
4
+
5
+ class MockRule extends BaseRule {
6
+ id = 'MOCK_RULE';
7
+ description = 'Mock';
8
+ severity = 'info' as const;
9
+
10
+ apply(diff: DiffSet, ctx: RuleContext): SemanticChange[] {
11
+ return [];
12
+ }
13
+
14
+ testIsIgnored(path: string, ctx: RuleContext) {
15
+ return this.isIgnored(path, ctx);
16
+ }
17
+
18
+ testMakeChange(ctx: RuleContext) {
19
+ return this.makeChange({
20
+ severity: 'warning',
21
+ category: 'endpoint',
22
+ message: 'test',
23
+ consequence: 'test',
24
+ migration: 'test',
25
+ location: { type: 'endpoint', method: 'GET', path: '/' }
26
+ }, ctx);
27
+ }
28
+ }
29
+
30
+ describe('BaseRule', () => {
31
+ it('makes changes', () => {
32
+ const rule = new MockRule();
33
+ const ctx: RuleContext = { changes: [], config: { failOn: 'breaking', ignorePaths: [], ruleSeverityOverrides: {}, disabledRules: [], customRules: [], output: { format: 'terminal', color: true, summary: false, quiet: false } }, source: { old: null as any, new: null as any } };
34
+ const change = rule.testMakeChange(ctx);
35
+ expect(change.ruleId).toBe('MOCK_RULE');
36
+ });
37
+
38
+ it('checks ignored paths', () => {
39
+ const rule = new MockRule();
40
+ const ctx: RuleContext = { changes: [], config: { failOn: 'breaking', ignorePaths: ['/test/*'], ruleSeverityOverrides: {}, disabledRules: [], customRules: [], output: { format: 'terminal', color: true, summary: false, quiet: false } }, source: { old: null as any, new: null as any } };
41
+ expect(rule.testIsIgnored('/test/foo', ctx)).toBe(true);
42
+ expect(rule.testIsIgnored('/foo', ctx)).toBe(false);
43
+ });
44
+ });
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { EndpointAddedRule } from '../../src/rules/endpoint/endpoint-added.js';
3
+ import { EndpointRemovedRule } from '../../src/rules/endpoint/endpoint-removed.js';
4
+ import { EndpointDeprecatedRule } from '../../src/rules/endpoint/endpoint-deprecated.js';
5
+ import { HttpMethodChangedRule } from '../../src/rules/endpoint/http-method-changed.js';
6
+ import { PathChangedRule } from '../../src/rules/endpoint/path-changed.js';
7
+ import type { DiffSet, EndpointDiff, RuleContext } from '../../src/types/index.js';
8
+
9
+ describe('Endpoint Rules', () => {
10
+ const context: RuleContext = {
11
+ config: { failOn: 'breaking', disabledRules: [], ignorePaths: [], output: { format: 'terminal' } },
12
+ oldSpec: { meta: { title: 'v1', version: '1.0.0', format: 'openapi3', rawVersion: '3.0.0' }, servers: [], endpoints: [], components: { schemas: {}, securitySchemes: {}, parameters: {}, responses: {}, headers: {}, requestBodies: {} }, security: [] },
13
+ newSpec: { meta: { title: 'v2', version: '2.0.0', format: 'openapi3', rawVersion: '3.0.0' }, servers: [], endpoints: [], components: { schemas: {}, securitySchemes: {}, parameters: {}, responses: {}, headers: {}, requestBodies: {} }, security: [] },
14
+ };
15
+
16
+ const dummyEndpoint = { id: 'GET:/test', path: '/test', method: 'GET' as const, summary: '', description: '', tags: [], deprecated: false, security: [], parameters: [], responses: [] };
17
+
18
+ it('ENDPOINT_ADDED triggers on added endpoint', () => {
19
+ const diff: DiffSet = {
20
+ endpointDiffs: [
21
+ { type: 'added', endpointId: 'GET:/test', path: '/test', method: 'GET', newEndpoint: dummyEndpoint, fieldChanges: [] }
22
+ ],
23
+ componentDiffs: []
24
+ };
25
+
26
+ const rule = new EndpointAddedRule();
27
+ const changes = rule.apply(diff, context);
28
+ expect(changes).toHaveLength(1);
29
+ expect(changes[0].ruleId).toBe('ENDPOINT_ADDED');
30
+ expect(changes[0].severity).toBe('info');
31
+ });
32
+
33
+ it('ENDPOINT_REMOVED triggers on removed endpoint', () => {
34
+ const diff: DiffSet = {
35
+ endpointDiffs: [
36
+ { type: 'removed', endpointId: 'GET:/test', path: '/test', method: 'GET', oldEndpoint: dummyEndpoint, fieldChanges: [] }
37
+ ],
38
+ componentDiffs: []
39
+ };
40
+
41
+ const rule = new EndpointRemovedRule();
42
+ const changes = rule.apply(diff, context);
43
+ expect(changes).toHaveLength(1);
44
+ expect(changes[0].ruleId).toBe('ENDPOINT_REMOVED');
45
+ expect(changes[0].severity).toBe('breaking');
46
+ });
47
+
48
+ it('ENDPOINT_DEPRECATED triggers on deprecated change', () => {
49
+ const diff: DiffSet = {
50
+ endpointDiffs: [
51
+ {
52
+ type: 'changed',
53
+ endpointId: 'GET:/test',
54
+ path: '/test',
55
+ method: 'GET',
56
+ oldEndpoint: dummyEndpoint,
57
+ newEndpoint: { ...dummyEndpoint, deprecated: true },
58
+ fieldChanges: [
59
+ { fieldPath: ['deprecated'], changeType: 'changed', oldValue: false, newValue: true }
60
+ ]
61
+ }
62
+ ],
63
+ componentDiffs: []
64
+ };
65
+
66
+ const rule = new EndpointDeprecatedRule();
67
+ const changes = rule.apply(diff, context);
68
+ expect(changes).toHaveLength(1);
69
+ expect(changes[0].ruleId).toBe('ENDPOINT_DEPRECATED');
70
+ expect(changes[0].severity).toBe('warning');
71
+ });
72
+
73
+ it('HTTP_METHOD_CHANGED triggers on method change', () => {
74
+ const diff: DiffSet = {
75
+ endpointDiffs: [
76
+ {
77
+ type: 'changed',
78
+ endpointId: 'GET:/test',
79
+ path: '/test',
80
+ method: 'POST',
81
+ oldEndpoint: dummyEndpoint,
82
+ newEndpoint: { ...dummyEndpoint, method: 'POST' },
83
+ fieldChanges: [
84
+ { fieldPath: ['method'], changeType: 'changed', oldValue: 'GET', newValue: 'POST' }
85
+ ]
86
+ }
87
+ ],
88
+ componentDiffs: []
89
+ };
90
+
91
+ const rule = new HttpMethodChangedRule();
92
+ const changes = rule.apply(diff, context);
93
+ expect(changes).toHaveLength(1);
94
+ expect(changes[0].ruleId).toBe('HTTP_METHOD_CHANGED');
95
+ expect(changes[0].severity).toBe('breaking');
96
+ });
97
+
98
+ it('PATH_CHANGED triggers on path string change', () => {
99
+ const diff: DiffSet = {
100
+ endpointDiffs: [
101
+ {
102
+ type: 'changed',
103
+ endpointId: 'GET:/test',
104
+ path: '/test-renamed',
105
+ method: 'GET',
106
+ oldEndpoint: dummyEndpoint,
107
+ newEndpoint: { ...dummyEndpoint, path: '/test-renamed' },
108
+ fieldChanges: [
109
+ { fieldPath: ['path'], changeType: 'changed', oldValue: '/test', newValue: '/test-renamed' }
110
+ ]
111
+ }
112
+ ],
113
+ componentDiffs: []
114
+ };
115
+
116
+ const rule = new PathChangedRule();
117
+ const changes = rule.apply(diff, context);
118
+ expect(changes).toHaveLength(1);
119
+ expect(changes[0].ruleId).toBe('PATH_CHANGED');
120
+ expect(changes[0].severity).toBe('info');
121
+ });
122
+ });