api-spec-cli 0.2.2 → 0.2.4

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.
@@ -1,269 +1,295 @@
1
- import { readFileSync, existsSync } from "fs";
2
- import { resolve } from "path";
3
- import YAML from "yaml";
4
- import { out } from "../output.js";
5
- import { parseArgs } from "../args.js";
6
-
7
- export async function validateSpec(args) {
8
- const { positional } = parseArgs(args);
9
- const source = positional[0];
10
- if (!source) throw new Error("Usage: spec validate <file-or-url>");
11
-
12
- const isUrl = source.startsWith("http://") || source.startsWith("https://");
13
-
14
- let text;
15
- if (isUrl) {
16
- const res = await fetch(source);
17
- if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
18
- text = await res.text();
19
- } else {
20
- const abs = resolve(source);
21
- if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
22
- text = readFileSync(abs, "utf-8");
23
- }
24
-
25
- let doc;
26
- try {
27
- doc = JSON.parse(text);
28
- } catch {
29
- try {
30
- doc = YAML.parse(text);
31
- } catch (e) {
32
- out({ valid: false, errors: [{ path: "", message: `Parse error: ${e.message}` }] });
33
- return;
34
- }
35
- }
36
-
37
- const errors = [];
38
- const warnings = [];
39
-
40
- // Detect spec version
41
- const version = doc.openapi || doc.swagger;
42
- if (!version) {
43
- errors.push({ path: "", message: "Missing 'openapi' or 'swagger' version field" });
44
- out({ valid: false, errors, warnings });
45
- return;
46
- }
47
-
48
- const isV3 = version.startsWith("3");
49
-
50
- // info object
51
- validateInfo(doc, errors, warnings);
52
-
53
- // paths
54
- validatePaths(doc, errors, warnings, isV3);
55
-
56
- // components / definitions
57
- if (isV3) {
58
- validateComponentsV3(doc, errors, warnings);
59
- } else {
60
- validateDefinitionsV2(doc, errors, warnings);
61
- }
62
-
63
- // servers (v3) or host (v2)
64
- validateServers(doc, errors, warnings, isV3);
65
-
66
- // Check for broken $ref
67
- validateRefs(doc, doc, "", errors);
68
-
69
- out({
70
- valid: errors.length === 0,
71
- version,
72
- title: doc.info?.title || null,
73
- operationCount: countOperations(doc),
74
- errors,
75
- warnings,
76
- });
77
- }
78
-
79
- function validateInfo(doc, errors, warnings) {
80
- if (!doc.info) {
81
- errors.push({ path: "info", message: "Missing required 'info' object" });
82
- return;
83
- }
84
- if (!doc.info.title) {
85
- errors.push({ path: "info.title", message: "Missing required 'info.title'" });
86
- }
87
- if (!doc.info.version) {
88
- errors.push({ path: "info.version", message: "Missing required 'info.version'" });
89
- }
90
- if (!doc.info.description) {
91
- warnings.push({ path: "info.description", message: "Missing 'info.description' (recommended)" });
92
- }
93
- }
94
-
95
- function validatePaths(doc, errors, warnings, isV3) {
96
- if (!doc.paths) {
97
- errors.push({ path: "paths", message: "Missing required 'paths' object" });
98
- return;
99
- }
100
-
101
- if (Object.keys(doc.paths).length === 0) {
102
- warnings.push({ path: "paths", message: "No paths defined" });
103
- return;
104
- }
105
-
106
- const METHODS = new Set(["get", "post", "put", "patch", "delete", "options", "head", "trace"]);
107
- const operationIds = new Set();
108
-
109
- for (const [path, methods] of Object.entries(doc.paths)) {
110
- // Path must start with /
111
- if (!path.startsWith("/")) {
112
- errors.push({ path: `paths.${path}`, message: `Path must start with '/'` });
113
- }
114
-
115
- // Check for unbalanced path params
116
- const pathParams = (path.match(/\{([^}]+)\}/g) || []).map((p) => p.slice(1, -1));
117
-
118
- if (typeof methods !== "object" || methods === null) continue;
119
-
120
- for (const [method, op] of Object.entries(methods)) {
121
- if (method.startsWith("x-") || method === "parameters" || method === "$ref") continue;
122
-
123
- if (!METHODS.has(method)) {
124
- warnings.push({ path: `paths.${path}.${method}`, message: `Unknown HTTP method '${method}'` });
125
- continue;
126
- }
127
-
128
- if (typeof op !== "object" || op === null) continue;
129
-
130
- const opPath = `paths.${path}.${method.toUpperCase()}`;
131
-
132
- // operationId uniqueness
133
- if (op.operationId) {
134
- if (operationIds.has(op.operationId)) {
135
- errors.push({ path: opPath, message: `Duplicate operationId '${op.operationId}'` });
136
- }
137
- operationIds.add(op.operationId);
138
- } else {
139
- warnings.push({ path: opPath, message: "Missing operationId (recommended for agent use)" });
140
- }
141
-
142
- // Responses required
143
- if (!op.responses || Object.keys(op.responses).length === 0) {
144
- errors.push({ path: opPath, message: "Missing or empty 'responses'" });
145
- }
146
-
147
- // Check path params are declared
148
- const declaredParams = new Set(
149
- (op.parameters || [])
150
- .filter((p) => (p.in || p.$ref) && (p.in === "path" || !p.in))
151
- .map((p) => p.name)
152
- );
153
-
154
- // Also include path-level parameters
155
- const pathLevelParams = (methods.parameters || [])
156
- .filter((p) => p.in === "path")
157
- .map((p) => p.name);
158
-
159
- for (const n of pathLevelParams) declaredParams.add(n);
160
-
161
- for (const param of pathParams) {
162
- if (!declaredParams.has(param)) {
163
- // Only warn — the param might be declared via $ref
164
- warnings.push({ path: opPath, message: `Path parameter '{${param}}' may not be declared in parameters` });
165
- }
166
- }
167
-
168
- // Request body on GET/DELETE/HEAD
169
- if (isV3 && op.requestBody && ["get", "delete", "head"].includes(method)) {
170
- warnings.push({ path: opPath, message: `requestBody on ${method.toUpperCase()} is unusual` });
171
- }
172
- }
173
- }
174
- }
175
-
176
- function validateComponentsV3(doc, errors, warnings) {
177
- if (!doc.components) return;
178
-
179
- // Check schemas have valid types
180
- if (doc.components.schemas) {
181
- for (const [name, schema] of Object.entries(doc.components.schemas)) {
182
- validateSchema(schema, `components.schemas.${name}`, errors, warnings);
183
- }
184
- }
185
- }
186
-
187
- function validateDefinitionsV2(doc, errors, warnings) {
188
- if (!doc.definitions) return;
189
-
190
- for (const [name, schema] of Object.entries(doc.definitions)) {
191
- validateSchema(schema, `definitions.${name}`, errors, warnings);
192
- }
193
- }
194
-
195
- function validateSchema(schema, path, errors, warnings) {
196
- if (!schema || typeof schema !== "object") return;
197
- if (schema.$ref) return; // reference, skip
198
-
199
- const VALID_TYPES = new Set(["string", "number", "integer", "boolean", "array", "object", "null"]);
200
-
201
- if (schema.type && !VALID_TYPES.has(schema.type)) {
202
- errors.push({ path, message: `Invalid type '${schema.type}'` });
203
- }
204
-
205
- if (schema.type === "array" && !schema.items) {
206
- errors.push({ path, message: "Array type must have 'items'" });
207
- }
208
-
209
- // Recurse into properties
210
- if (schema.properties) {
211
- for (const [key, val] of Object.entries(schema.properties)) {
212
- validateSchema(val, `${path}.properties.${key}`, errors, warnings);
213
- }
214
- }
215
- if (schema.items) {
216
- validateSchema(schema.items, `${path}.items`, errors, warnings);
217
- }
218
- }
219
-
220
- function validateServers(doc, errors, warnings, isV3) {
221
- if (isV3) {
222
- if (!doc.servers || doc.servers.length === 0) {
223
- warnings.push({ path: "servers", message: "No servers defined — agent will need baseUrl configured" });
224
- }
225
- } else {
226
- if (!doc.host) {
227
- warnings.push({ path: "host", message: "No host defined — agent will need baseUrl configured" });
228
- }
229
- }
230
- }
231
-
232
- function validateRefs(doc, root, path, errors) {
233
- if (!doc || typeof doc !== "object") return;
234
-
235
- if (doc.$ref && typeof doc.$ref === "string") {
236
- if (doc.$ref.startsWith("#/")) {
237
- const parts = doc.$ref.slice(2).split("/");
238
- let target = root;
239
- for (const p of parts) {
240
- target = target?.[p];
241
- }
242
- if (target === undefined) {
243
- errors.push({ path: path || doc.$ref, message: `Broken $ref: '${doc.$ref}'` });
244
- }
245
- }
246
- return; // Don't recurse into $ref
247
- }
248
-
249
- if (Array.isArray(doc)) {
250
- for (let i = 0; i < doc.length; i++) {
251
- validateRefs(doc[i], root, `${path}[${i}]`, errors);
252
- }
253
- } else {
254
- for (const [key, val] of Object.entries(doc)) {
255
- if (key === "raw") continue; // skip stored raw data
256
- validateRefs(val, root, path ? `${path}.${key}` : key, errors);
257
- }
258
- }
259
- }
260
-
261
- function countOperations(doc) {
262
- let count = 0;
263
- for (const methods of Object.values(doc.paths || {})) {
264
- for (const method of Object.keys(methods)) {
265
- if (!method.startsWith("x-") && method !== "parameters" && method !== "$ref") count++;
266
- }
267
- }
268
- return count;
269
- }
1
+ import { readFileSync, existsSync } from "fs";
2
+ import { resolve } from "path";
3
+ import YAML from "yaml";
4
+ import { out } from "../output.js";
5
+ import { parseArgs } from "../args.js";
6
+
7
+ export async function validateSpec(args) {
8
+ const { positional } = parseArgs(args);
9
+ const source = positional[0];
10
+ if (!source) throw new Error("Usage: spec validate <file-or-url>");
11
+
12
+ const isUrl = source.startsWith("http://") || source.startsWith("https://");
13
+
14
+ let text;
15
+ if (isUrl) {
16
+ const res = await fetch(source);
17
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
18
+ text = await res.text();
19
+ } else {
20
+ const abs = resolve(source);
21
+ if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
22
+ text = readFileSync(abs, "utf-8");
23
+ }
24
+
25
+ let doc;
26
+ try {
27
+ doc = JSON.parse(text);
28
+ } catch {
29
+ try {
30
+ doc = YAML.parse(text);
31
+ } catch (e) {
32
+ out({ valid: false, errors: [{ path: "", message: `Parse error: ${e.message}` }] });
33
+ return;
34
+ }
35
+ }
36
+
37
+ const errors = [];
38
+ const warnings = [];
39
+
40
+ // Detect spec version
41
+ const version = doc.openapi || doc.swagger;
42
+ if (!version) {
43
+ errors.push({ path: "", message: "Missing 'openapi' or 'swagger' version field" });
44
+ out({ valid: false, errors, warnings });
45
+ return;
46
+ }
47
+
48
+ const isV3 = version.startsWith("3");
49
+
50
+ // info object
51
+ validateInfo(doc, errors, warnings);
52
+
53
+ // paths
54
+ validatePaths(doc, errors, warnings, isV3);
55
+
56
+ // components / definitions
57
+ if (isV3) {
58
+ validateComponentsV3(doc, errors, warnings);
59
+ } else {
60
+ validateDefinitionsV2(doc, errors, warnings);
61
+ }
62
+
63
+ // servers (v3) or host (v2)
64
+ validateServers(doc, errors, warnings, isV3);
65
+
66
+ // Check for broken $ref
67
+ validateRefs(doc, doc, "", errors);
68
+
69
+ out({
70
+ valid: errors.length === 0,
71
+ version,
72
+ title: doc.info?.title || null,
73
+ operationCount: countOperations(doc),
74
+ errors,
75
+ warnings,
76
+ });
77
+ }
78
+
79
+ function validateInfo(doc, errors, warnings) {
80
+ if (!doc.info) {
81
+ errors.push({ path: "info", message: "Missing required 'info' object" });
82
+ return;
83
+ }
84
+ if (!doc.info.title) {
85
+ errors.push({ path: "info.title", message: "Missing required 'info.title'" });
86
+ }
87
+ if (!doc.info.version) {
88
+ errors.push({ path: "info.version", message: "Missing required 'info.version'" });
89
+ }
90
+ if (!doc.info.description) {
91
+ warnings.push({
92
+ path: "info.description",
93
+ message: "Missing 'info.description' (recommended)",
94
+ });
95
+ }
96
+ }
97
+
98
+ function validatePaths(doc, errors, warnings, isV3) {
99
+ if (!doc.paths) {
100
+ errors.push({ path: "paths", message: "Missing required 'paths' object" });
101
+ return;
102
+ }
103
+
104
+ if (Object.keys(doc.paths).length === 0) {
105
+ warnings.push({ path: "paths", message: "No paths defined" });
106
+ return;
107
+ }
108
+
109
+ const METHODS = new Set(["get", "post", "put", "patch", "delete", "options", "head", "trace"]);
110
+ const operationIds = new Set();
111
+
112
+ for (const [path, methods] of Object.entries(doc.paths)) {
113
+ // Path must start with /
114
+ if (!path.startsWith("/")) {
115
+ errors.push({ path: `paths.${path}`, message: `Path must start with '/'` });
116
+ }
117
+
118
+ // Check for unbalanced path params
119
+ const pathParams = (path.match(/\{([^}]+)\}/g) || []).map((p) => p.slice(1, -1));
120
+
121
+ if (typeof methods !== "object" || methods === null) continue;
122
+
123
+ for (const [method, op] of Object.entries(methods)) {
124
+ if (method.startsWith("x-") || method === "parameters" || method === "$ref") continue;
125
+
126
+ if (!METHODS.has(method)) {
127
+ warnings.push({
128
+ path: `paths.${path}.${method}`,
129
+ message: `Unknown HTTP method '${method}'`,
130
+ });
131
+ continue;
132
+ }
133
+
134
+ if (typeof op !== "object" || op === null) continue;
135
+
136
+ const opPath = `paths.${path}.${method.toUpperCase()}`;
137
+
138
+ // operationId uniqueness
139
+ if (op.operationId) {
140
+ if (operationIds.has(op.operationId)) {
141
+ errors.push({ path: opPath, message: `Duplicate operationId '${op.operationId}'` });
142
+ }
143
+ operationIds.add(op.operationId);
144
+ } else {
145
+ warnings.push({ path: opPath, message: "Missing operationId (recommended for agent use)" });
146
+ }
147
+
148
+ // Responses required
149
+ if (!op.responses || Object.keys(op.responses).length === 0) {
150
+ errors.push({ path: opPath, message: "Missing or empty 'responses'" });
151
+ }
152
+
153
+ // Check path params are declared
154
+ const declaredParams = new Set(
155
+ (op.parameters || [])
156
+ .filter((p) => (p.in || p.$ref) && (p.in === "path" || !p.in))
157
+ .map((p) => p.name)
158
+ );
159
+
160
+ // Also include path-level parameters
161
+ const pathLevelParams = (methods.parameters || [])
162
+ .filter((p) => p.in === "path")
163
+ .map((p) => p.name);
164
+
165
+ for (const n of pathLevelParams) declaredParams.add(n);
166
+
167
+ for (const param of pathParams) {
168
+ if (!declaredParams.has(param)) {
169
+ // Only warn the param might be declared via $ref
170
+ warnings.push({
171
+ path: opPath,
172
+ message: `Path parameter '{${param}}' may not be declared in parameters`,
173
+ });
174
+ }
175
+ }
176
+
177
+ // Request body on GET/DELETE/HEAD
178
+ if (isV3 && op.requestBody && ["get", "delete", "head"].includes(method)) {
179
+ warnings.push({
180
+ path: opPath,
181
+ message: `requestBody on ${method.toUpperCase()} is unusual`,
182
+ });
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ function validateComponentsV3(doc, errors, warnings) {
189
+ if (!doc.components) return;
190
+
191
+ // Check schemas have valid types
192
+ if (doc.components.schemas) {
193
+ for (const [name, schema] of Object.entries(doc.components.schemas)) {
194
+ validateSchema(schema, `components.schemas.${name}`, errors, warnings);
195
+ }
196
+ }
197
+ }
198
+
199
+ function validateDefinitionsV2(doc, errors, warnings) {
200
+ if (!doc.definitions) return;
201
+
202
+ for (const [name, schema] of Object.entries(doc.definitions)) {
203
+ validateSchema(schema, `definitions.${name}`, errors, warnings);
204
+ }
205
+ }
206
+
207
+ function validateSchema(schema, path, errors, warnings) {
208
+ if (!schema || typeof schema !== "object") return;
209
+ if (schema.$ref) return; // reference, skip
210
+
211
+ const VALID_TYPES = new Set([
212
+ "string",
213
+ "number",
214
+ "integer",
215
+ "boolean",
216
+ "array",
217
+ "object",
218
+ "null",
219
+ ]);
220
+
221
+ if (schema.type && !VALID_TYPES.has(schema.type)) {
222
+ errors.push({ path, message: `Invalid type '${schema.type}'` });
223
+ }
224
+
225
+ if (schema.type === "array" && !schema.items) {
226
+ errors.push({ path, message: "Array type must have 'items'" });
227
+ }
228
+
229
+ // Recurse into properties
230
+ if (schema.properties) {
231
+ for (const [key, val] of Object.entries(schema.properties)) {
232
+ validateSchema(val, `${path}.properties.${key}`, errors, warnings);
233
+ }
234
+ }
235
+ if (schema.items) {
236
+ validateSchema(schema.items, `${path}.items`, errors, warnings);
237
+ }
238
+ }
239
+
240
+ function validateServers(doc, errors, warnings, isV3) {
241
+ if (isV3) {
242
+ if (!doc.servers || doc.servers.length === 0) {
243
+ warnings.push({
244
+ path: "servers",
245
+ message: "No servers defined — agent will need baseUrl configured",
246
+ });
247
+ }
248
+ } else {
249
+ if (!doc.host) {
250
+ warnings.push({
251
+ path: "host",
252
+ message: "No host defined — agent will need baseUrl configured",
253
+ });
254
+ }
255
+ }
256
+ }
257
+
258
+ function validateRefs(doc, root, path, errors) {
259
+ if (!doc || typeof doc !== "object") return;
260
+
261
+ if (doc.$ref && typeof doc.$ref === "string") {
262
+ if (doc.$ref.startsWith("#/")) {
263
+ const parts = doc.$ref.slice(2).split("/");
264
+ let target = root;
265
+ for (const p of parts) {
266
+ target = target?.[p];
267
+ }
268
+ if (target === undefined) {
269
+ errors.push({ path: path || doc.$ref, message: `Broken $ref: '${doc.$ref}'` });
270
+ }
271
+ }
272
+ return; // Don't recurse into $ref
273
+ }
274
+
275
+ if (Array.isArray(doc)) {
276
+ for (let i = 0; i < doc.length; i++) {
277
+ validateRefs(doc[i], root, `${path}[${i}]`, errors);
278
+ }
279
+ } else {
280
+ for (const [key, val] of Object.entries(doc)) {
281
+ if (key === "raw") continue; // skip stored raw data
282
+ validateRefs(val, root, path ? `${path}.${key}` : key, errors);
283
+ }
284
+ }
285
+ }
286
+
287
+ function countOperations(doc) {
288
+ let count = 0;
289
+ for (const methods of Object.values(doc.paths || {})) {
290
+ for (const method of Object.keys(methods)) {
291
+ if (!method.startsWith("x-") && method !== "parameters" && method !== "$ref") count++;
292
+ }
293
+ }
294
+ return count;
295
+ }
package/src/glob.js CHANGED
@@ -1,15 +1,34 @@
1
- /**
2
- * Match a string against a glob pattern or a plain substring.
3
- * - Glob chars (* ?) use regex matching
4
- * - Plain text uses case-insensitive substring matching
5
- */
6
- export function matchGlob(pattern, str) {
7
- if (!pattern.includes("*") && !pattern.includes("?")) {
8
- return str.toLowerCase().includes(pattern.toLowerCase());
9
- }
10
- const re = new RegExp(
11
- "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$",
12
- "i"
13
- );
14
- return re.test(str);
15
- }
1
+ function globToRegex(pattern) {
2
+ return new RegExp(
3
+ "^" +
4
+ pattern
5
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
6
+ .replace(/\*/g, ".*")
7
+ .replace(/\?/g, ".") +
8
+ "$",
9
+ "i"
10
+ );
11
+ }
12
+
13
+ /**
14
+ * Search match: plain text = substring, glob chars (* ?) = anchored glob.
15
+ * Used by grep — broad matching is desirable for search.
16
+ */
17
+ export function matchGlob(pattern, str) {
18
+ if (!pattern.includes("*") && !pattern.includes("?")) {
19
+ return str.toLowerCase().includes(pattern.toLowerCase());
20
+ }
21
+ return globToRegex(pattern).test(str);
22
+ }
23
+
24
+ /**
25
+ * Filter match: plain text = exact (case-insensitive), glob chars (* ?) = anchored glob.
26
+ * Used by --allow-tool / --disable-tool — precision is required for whitelists.
27
+ * Use *pattern* for explicit substring matching.
28
+ */
29
+ export function matchFilter(pattern, str) {
30
+ if (!pattern.includes("*") && !pattern.includes("?")) {
31
+ return str.toLowerCase() === pattern.toLowerCase();
32
+ }
33
+ return globToRegex(pattern).test(str);
34
+ }