json-explorer-mcp 1.0.1 → 1.0.3
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.
- package/README.md +170 -5
- package/dist/index.js +58 -7
- package/dist/tools/navigation.d.ts +23 -0
- package/dist/tools/navigation.js +106 -1
- package/dist/tools/query.d.ts +44 -3
- package/dist/tools/query.js +247 -9
- package/dist/tools/structure.d.ts +14 -1
- package/dist/tools/structure.js +263 -8
- package/dist/utils/json-parser.js +8 -1
- package/dist/utils/path-helpers.d.ts +6 -0
- package/dist/utils/path-helpers.js +88 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,8 +8,8 @@ An MCP (Model Context Protocol) server for efficiently exploring large JSON file
|
|
|
8
8
|
- **Smart truncation** - Large values are automatically summarized
|
|
9
9
|
- **Caching** - Parsed JSON is cached with file modification checks
|
|
10
10
|
- **Schema inference** - Understand structure without reading all data
|
|
11
|
-
- **Schema validation** - Validate data against JSON Schema
|
|
12
|
-
- **Aggregate statistics** - Get counts, distributions, and numeric stats
|
|
11
|
+
- **Schema validation** - Validate data against JSON Schema with `$ref` resolution
|
|
12
|
+
- **Aggregate statistics** - Get counts, distributions, and numeric stats for arrays
|
|
13
13
|
|
|
14
14
|
## Installation
|
|
15
15
|
|
|
@@ -110,6 +110,80 @@ Retrieve the value at a specific path. Large values are automatically truncated.
|
|
|
110
110
|
json_get(file: "/path/to/data.json", path: "$.users[0]")
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
+
### json_set
|
|
114
|
+
|
|
115
|
+
Set a value at a specific JSONPath with optional schema validation.
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
json_set(
|
|
119
|
+
file: "/path/to/data.json",
|
|
120
|
+
path: "$.users[0].name",
|
|
121
|
+
value: "New Name"
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
With schema validation (validates before writing):
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
json_set(
|
|
129
|
+
file: "/path/to/data.json",
|
|
130
|
+
path: "$.users[0]",
|
|
131
|
+
value: { id: 1, name: "Alice", email: "alice@example.com" },
|
|
132
|
+
schema: "/path/to/user-schema.json"
|
|
133
|
+
)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
With inferred schema (infers type from existing value):
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
json_set(
|
|
140
|
+
file: "/path/to/data.json",
|
|
141
|
+
path: "$.config.maxRetries",
|
|
142
|
+
value: 5,
|
|
143
|
+
inferSchema: true // Will reject if new value type doesn't match existing
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
With dry run (validates without writing):
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
json_set(
|
|
151
|
+
file: "/path/to/data.json",
|
|
152
|
+
path: "$.config.enabled",
|
|
153
|
+
value: true,
|
|
154
|
+
dryRun: true, // Returns result without modifying file
|
|
155
|
+
outputFile: "/path/to/output.json" // Or write to different file
|
|
156
|
+
)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
|
|
161
|
+
```json
|
|
162
|
+
{
|
|
163
|
+
"success": true,
|
|
164
|
+
"path": "$.config.enabled",
|
|
165
|
+
"previousValue": false,
|
|
166
|
+
"newValue": true,
|
|
167
|
+
"dryRun": false,
|
|
168
|
+
"outputFile": "/path/to/data.json"
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
On validation failure:
|
|
173
|
+
|
|
174
|
+
```json
|
|
175
|
+
{
|
|
176
|
+
"success": false,
|
|
177
|
+
"path": "$.config.maxRetries",
|
|
178
|
+
"previousValue": 3,
|
|
179
|
+
"newValue": "not a number",
|
|
180
|
+
"validationErrors": [
|
|
181
|
+
{ "path": "$", "message": "must be integer" }
|
|
182
|
+
],
|
|
183
|
+
"dryRun": true
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
113
187
|
### json_schema
|
|
114
188
|
|
|
115
189
|
Infer the JSON schema/structure at a path. For arrays, samples items to determine the item schema.
|
|
@@ -141,6 +215,13 @@ Returns:
|
|
|
141
215
|
|
|
142
216
|
Validate JSON data against a JSON Schema. Schema can be provided inline or as a path to a schema file.
|
|
143
217
|
|
|
218
|
+
**Features:**
|
|
219
|
+
- Automatic resolution of local file `$ref` references
|
|
220
|
+
- Optional network `$ref` resolution (disabled by default)
|
|
221
|
+
- Schema registry for pre-registering schemas by URI (map remote refs to local files)
|
|
222
|
+
- Validates that referenced files are actual JSON Schemas
|
|
223
|
+
- Error limiting (default 10) to avoid huge error lists
|
|
224
|
+
|
|
144
225
|
```typescript
|
|
145
226
|
json_validate(
|
|
146
227
|
file: "/path/to/data.json",
|
|
@@ -149,7 +230,7 @@ json_validate(
|
|
|
149
230
|
)
|
|
150
231
|
```
|
|
151
232
|
|
|
152
|
-
Or with a schema file:
|
|
233
|
+
Or with a schema file (local `$ref`s are automatically resolved):
|
|
153
234
|
|
|
154
235
|
```typescript
|
|
155
236
|
json_validate(
|
|
@@ -158,16 +239,58 @@ json_validate(
|
|
|
158
239
|
)
|
|
159
240
|
```
|
|
160
241
|
|
|
242
|
+
With options:
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
json_validate(
|
|
246
|
+
file: "/path/to/data.json",
|
|
247
|
+
schema: "/path/to/schema.json",
|
|
248
|
+
path: "$.users",
|
|
249
|
+
errorLimit: 5, // Max errors to return (default: 10)
|
|
250
|
+
resolveLocalRefs: true, // Resolve local file $refs (default: true)
|
|
251
|
+
resolveNetworkRefs: false // Resolve HTTP $refs (default: false)
|
|
252
|
+
)
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
With pre-registered schemas (for complex `$ref` scenarios):
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
json_validate(
|
|
259
|
+
file: "/path/to/data.json",
|
|
260
|
+
schema: "/path/to/schema.json",
|
|
261
|
+
schemas: {
|
|
262
|
+
// Map URIs to local files or inline schemas
|
|
263
|
+
"https://example.com/schemas/user.json": "/local/path/to/user-schema.json",
|
|
264
|
+
"https://example.com/schemas/address.json": { type: "object", properties: { ... } }
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
With schema directory (loads all schemas by their `$id`):
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
json_validate(
|
|
273
|
+
file: "/path/to/data.json",
|
|
274
|
+
schema: {}, // Not needed when using schemaDir+schemaId
|
|
275
|
+
schemaDir: "/path/to/schemas/", // Directory with .json schema files
|
|
276
|
+
schemaId: "main-schema" // $id of the entrypoint schema
|
|
277
|
+
)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
This is useful when you have a directory of interconnected schemas that reference each other by `$id`. All `.json` files in the directory are loaded and registered by their `$id` (or filename if no `$id` is present).
|
|
281
|
+
|
|
161
282
|
Returns:
|
|
162
283
|
|
|
163
284
|
```json
|
|
164
285
|
{
|
|
165
286
|
"valid": false,
|
|
166
|
-
"errorCount":
|
|
287
|
+
"errorCount": 15,
|
|
288
|
+
"truncatedErrorCount": 5,
|
|
167
289
|
"errors": [
|
|
168
290
|
{ "path": "/id", "message": "must be integer", "keyword": "type", "params": {} },
|
|
169
291
|
{ "path": "", "message": "must have required property 'name'", "keyword": "required", "params": {} }
|
|
170
|
-
]
|
|
292
|
+
],
|
|
293
|
+
"resolvedRefs": ["/path/to/definitions.json"]
|
|
171
294
|
}
|
|
172
295
|
```
|
|
173
296
|
|
|
@@ -230,6 +353,48 @@ All path parameters support JSONPath syntax:
|
|
|
230
353
|
- `$.users[*]` - All array items (in search results)
|
|
231
354
|
- `$["special-key"]` - Bracket notation for special characters
|
|
232
355
|
|
|
356
|
+
## Security
|
|
357
|
+
|
|
358
|
+
### Schema Validation
|
|
359
|
+
|
|
360
|
+
All referenced schemas (both local files and network URLs) are validated before use. The tool checks that fetched content is a valid JSON Schema object containing appropriate keywords (`type`, `properties`, `$ref`, etc.), rejecting arrays, primitives, or unrelated JSON files.
|
|
361
|
+
|
|
362
|
+
### Network Schema Resolution
|
|
363
|
+
|
|
364
|
+
By default, `json_validate` only resolves local file `$ref` references. Network (HTTP/HTTPS) resolution is disabled by default for security.
|
|
365
|
+
|
|
366
|
+
To enable network refs for a specific validation:
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
json_validate(file: "data.json", schema: "schema.json", resolveNetworkRefs: true)
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Disabling Network Resolution Completely
|
|
373
|
+
|
|
374
|
+
For security-conscious environments, you can completely disable network schema resolution using an environment variable:
|
|
375
|
+
|
|
376
|
+
```bash
|
|
377
|
+
JSON_EXPLORER_NO_NETWORK=1
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
When set to `1`, this overrides any `resolveNetworkRefs: true` option, ensuring schemas are never fetched from the network.
|
|
381
|
+
|
|
382
|
+
**Claude Desktop config with network disabled:**
|
|
383
|
+
|
|
384
|
+
```json
|
|
385
|
+
{
|
|
386
|
+
"mcpServers": {
|
|
387
|
+
"json-explorer": {
|
|
388
|
+
"command": "npx",
|
|
389
|
+
"args": ["-y", "json-explorer-mcp"],
|
|
390
|
+
"env": {
|
|
391
|
+
"JSON_EXPLORER_NO_NETWORK": "1"
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
```
|
|
397
|
+
|
|
233
398
|
## Development
|
|
234
399
|
|
|
235
400
|
```bash
|
package/dist/index.js
CHANGED
|
@@ -3,11 +3,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { jsonInspect, jsonSchema, jsonValidate } from "./tools/structure.js";
|
|
6
|
-
import { jsonKeys, jsonGet } from "./tools/navigation.js";
|
|
6
|
+
import { jsonKeys, jsonGet, jsonSet } from "./tools/navigation.js";
|
|
7
7
|
import { jsonSearch, jsonSample, jsonStats } from "./tools/query.js";
|
|
8
8
|
const server = new McpServer({
|
|
9
9
|
name: "json-explorer",
|
|
10
|
-
version: "1.0.
|
|
10
|
+
version: "1.0.3",
|
|
11
11
|
});
|
|
12
12
|
// Tool: json_inspect - Get file overview
|
|
13
13
|
server.tool("json_inspect", "Get an overview of a JSON file including size, structure type, and a depth-limited preview. Use this first to understand what you're working with.", {
|
|
@@ -94,6 +94,44 @@ server.tool("json_get", "Retrieve the value at a specific path. Large values are
|
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
96
|
});
|
|
97
|
+
// Tool: json_set - Set value at path with optional schema validation
|
|
98
|
+
server.tool("json_set", "Set a value at a specific JSONPath. Supports schema validation (inline, file, or inferred from existing data) and dry-run mode. Can write to the source file or a different output file.", {
|
|
99
|
+
file: z.string().describe("Absolute path to the JSON file to modify"),
|
|
100
|
+
path: z.string().describe("JSONPath where to set the value (e.g., '$.users[0].name', 'config.enabled')"),
|
|
101
|
+
value: z.unknown().describe("The value to set at the path"),
|
|
102
|
+
schema: z.union([z.string(), z.object({}).passthrough()]).optional().describe("JSON Schema to validate value against (inline object or path to schema file)"),
|
|
103
|
+
inferSchema: z.boolean().optional().describe("If true, infers schema from existing value at path and validates against it. Defaults to false."),
|
|
104
|
+
dryRun: z.boolean().optional().describe("If true, validates and returns result without writing to file. Defaults to false."),
|
|
105
|
+
outputFile: z.string().optional().describe("Output file path. If not provided, writes to the source file."),
|
|
106
|
+
}, { readOnlyHint: false }, async ({ file, path, value, schema, inferSchema, dryRun, outputFile }) => {
|
|
107
|
+
try {
|
|
108
|
+
const result = await jsonSet(file, path, value, {
|
|
109
|
+
schema,
|
|
110
|
+
inferSchema,
|
|
111
|
+
dryRun,
|
|
112
|
+
outputFile,
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
content: [
|
|
116
|
+
{
|
|
117
|
+
type: "text",
|
|
118
|
+
text: JSON.stringify(result, null, 2),
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
return {
|
|
125
|
+
content: [
|
|
126
|
+
{
|
|
127
|
+
type: "text",
|
|
128
|
+
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
isError: true,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
});
|
|
97
135
|
// Tool: json_schema - Infer structure
|
|
98
136
|
server.tool("json_schema", "Infer the JSON schema/structure at a path. For arrays, samples items to determine the item schema.", {
|
|
99
137
|
file: z.string().describe("Absolute path to the JSON file"),
|
|
@@ -123,13 +161,26 @@ server.tool("json_schema", "Infer the JSON schema/structure at a path. For array
|
|
|
123
161
|
}
|
|
124
162
|
});
|
|
125
163
|
// Tool: json_validate - Validate against JSON Schema
|
|
126
|
-
server.tool("json_validate", "Validate JSON data against a JSON Schema. Schema can be provided inline
|
|
164
|
+
server.tool("json_validate", "Validate JSON data against a JSON Schema. Schema can be provided inline, as a file path, or loaded from a directory by $id. Automatically resolves local file $refs. Use schemas to pre-register schemas by URI, or schemaDir+schemaId to load all schemas from a directory.", {
|
|
127
165
|
file: z.string().describe("Absolute path to the JSON file to validate"),
|
|
128
|
-
schema: z.union([z.string(), z.object({}).passthrough()]).describe("JSON Schema object or path to schema file"),
|
|
166
|
+
schema: z.union([z.string(), z.object({}).passthrough()]).optional().describe("JSON Schema object or path to schema file. Optional if using schemaDir+schemaId."),
|
|
129
167
|
path: z.string().optional().describe("JSONPath to validate. Defaults to root."),
|
|
130
|
-
|
|
168
|
+
errorLimit: z.number().optional().describe("Maximum number of errors to return. Defaults to 10."),
|
|
169
|
+
resolveLocalRefs: z.boolean().optional().describe("Resolve $ref to local files. Defaults to true."),
|
|
170
|
+
resolveNetworkRefs: z.boolean().optional().describe("Resolve $ref to HTTP URLs. Defaults to false for security."),
|
|
171
|
+
schemas: z.record(z.union([z.string(), z.object({}).passthrough()])).optional().describe("Pre-register schemas by URI. Map of URI to schema file path or inline schema object."),
|
|
172
|
+
schemaDir: z.string().optional().describe("Directory containing schema files. All .json files will be loaded and registered by their $id."),
|
|
173
|
+
schemaId: z.string().optional().describe("When using schemaDir, the $id of the schema to use as entrypoint."),
|
|
174
|
+
}, { readOnlyHint: true }, async ({ file, schema, path, errorLimit, resolveLocalRefs, resolveNetworkRefs, schemas, schemaDir, schemaId }) => {
|
|
131
175
|
try {
|
|
132
|
-
const result = await jsonValidate(file, schema, path
|
|
176
|
+
const result = await jsonValidate(file, schema ?? {}, path, {
|
|
177
|
+
errorLimit,
|
|
178
|
+
resolveLocalRefs,
|
|
179
|
+
resolveNetworkRefs,
|
|
180
|
+
schemas,
|
|
181
|
+
schemaDir,
|
|
182
|
+
schemaId,
|
|
183
|
+
});
|
|
133
184
|
return {
|
|
134
185
|
content: [
|
|
135
186
|
{
|
|
@@ -213,7 +264,7 @@ server.tool("json_sample", "Get sample items from an array. Supports first, last
|
|
|
213
264
|
}
|
|
214
265
|
});
|
|
215
266
|
// Tool: json_stats - Aggregate statistics for arrays
|
|
216
|
-
server.tool("json_stats", "Get aggregate statistics for
|
|
267
|
+
server.tool("json_stats", "Get aggregate statistics for arrays. Supports arrays of objects (field-level stats), strings (length distribution), numbers (min/max/avg/median/stdDev/percentiles), and mixed types. If no path provided, discovers and analyzes all arrays in the file.", {
|
|
217
268
|
file: z.string().describe("Absolute path to the JSON file"),
|
|
218
269
|
path: z.string().optional().describe("JSONPath to the array. If omitted, discovers and lists all arrays in the file."),
|
|
219
270
|
fields: z.array(z.string()).optional().describe("Specific fields to analyze. If not provided, analyzes all top-level fields."),
|
|
@@ -19,3 +19,26 @@ export interface GetResult {
|
|
|
19
19
|
originalSize?: number;
|
|
20
20
|
}
|
|
21
21
|
export declare function jsonGet(filePath: string, path: string, maxSize?: number): Promise<GetResult>;
|
|
22
|
+
export interface SetOptions {
|
|
23
|
+
/** JSON Schema to validate against (inline object or file path) */
|
|
24
|
+
schema?: object | string;
|
|
25
|
+
/** Infer schema from existing value at path. Defaults to false. */
|
|
26
|
+
inferSchema?: boolean;
|
|
27
|
+
/** If true, only validate without writing. Returns the modified JSON. */
|
|
28
|
+
dryRun?: boolean;
|
|
29
|
+
/** Output file path. If not provided, writes to the source file. */
|
|
30
|
+
outputFile?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface SetResult {
|
|
33
|
+
success: boolean;
|
|
34
|
+
path: string;
|
|
35
|
+
previousValue?: unknown;
|
|
36
|
+
newValue: unknown;
|
|
37
|
+
validationErrors?: Array<{
|
|
38
|
+
path: string;
|
|
39
|
+
message: string;
|
|
40
|
+
}>;
|
|
41
|
+
outputFile?: string;
|
|
42
|
+
dryRun: boolean;
|
|
43
|
+
}
|
|
44
|
+
export declare function jsonSet(filePath: string, path: string, value: unknown, options?: SetOptions): Promise<SetResult>;
|
package/dist/tools/navigation.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { loadJson, getValueType, truncateValue, getValuePreview } from "../utils/json-parser.js";
|
|
2
|
-
import { getValueAtPath, getKeysAtPath, buildPathTo } from "../utils/path-helpers.js";
|
|
2
|
+
import { getValueAtPath, getKeysAtPath, buildPathTo, setValueAtPath } from "../utils/path-helpers.js";
|
|
3
|
+
import { writeFile } from "fs/promises";
|
|
3
4
|
export async function jsonKeys(filePath, path, limit = 50) {
|
|
4
5
|
const data = await loadJson(filePath);
|
|
5
6
|
const targetPath = path || "$";
|
|
@@ -95,3 +96,107 @@ export async function jsonGet(filePath, path, maxSize = 5000) {
|
|
|
95
96
|
...(truncated && { originalSize }),
|
|
96
97
|
};
|
|
97
98
|
}
|
|
99
|
+
// Dynamic import for ajv (ESM/CJS compat)
|
|
100
|
+
async function getAjv() {
|
|
101
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
102
|
+
const ajvModule = await import("ajv");
|
|
103
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
104
|
+
const formatsModule = await import("ajv-formats");
|
|
105
|
+
const Ajv = ajvModule.default || ajvModule;
|
|
106
|
+
const addFormats = formatsModule.default || formatsModule;
|
|
107
|
+
return { Ajv, addFormats };
|
|
108
|
+
}
|
|
109
|
+
// Infer a simple JSON Schema from a value
|
|
110
|
+
function inferSimpleSchema(value) {
|
|
111
|
+
if (value === null) {
|
|
112
|
+
return { type: "null" };
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(value)) {
|
|
115
|
+
if (value.length === 0) {
|
|
116
|
+
return { type: "array" };
|
|
117
|
+
}
|
|
118
|
+
// Sample first item for items schema
|
|
119
|
+
return { type: "array", items: inferSimpleSchema(value[0]) };
|
|
120
|
+
}
|
|
121
|
+
if (typeof value === "object") {
|
|
122
|
+
const properties = {};
|
|
123
|
+
for (const [key, val] of Object.entries(value)) {
|
|
124
|
+
properties[key] = inferSimpleSchema(val);
|
|
125
|
+
}
|
|
126
|
+
return { type: "object", properties };
|
|
127
|
+
}
|
|
128
|
+
if (typeof value === "number") {
|
|
129
|
+
return Number.isInteger(value) ? { type: "integer" } : { type: "number" };
|
|
130
|
+
}
|
|
131
|
+
return { type: typeof value };
|
|
132
|
+
}
|
|
133
|
+
export async function jsonSet(filePath, path, value, options = {}) {
|
|
134
|
+
const { schema, inferSchema: shouldInferSchema = false, dryRun = false, outputFile, } = options;
|
|
135
|
+
// Load the JSON file
|
|
136
|
+
const data = await loadJson(filePath);
|
|
137
|
+
// Deep clone to avoid mutating cached data
|
|
138
|
+
const clonedData = JSON.parse(JSON.stringify(data));
|
|
139
|
+
// Get previous value at path (may be undefined if path doesn't exist yet)
|
|
140
|
+
const previousValue = getValueAtPath(clonedData, path);
|
|
141
|
+
// Determine schema for validation
|
|
142
|
+
let schemaToValidate;
|
|
143
|
+
if (schema) {
|
|
144
|
+
if (typeof schema === "string") {
|
|
145
|
+
// Load schema from file
|
|
146
|
+
schemaToValidate = (await loadJson(schema));
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
schemaToValidate = schema;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
else if (shouldInferSchema && previousValue !== undefined) {
|
|
153
|
+
// Infer schema from existing value
|
|
154
|
+
schemaToValidate = inferSimpleSchema(previousValue);
|
|
155
|
+
}
|
|
156
|
+
// Validate the new value against schema
|
|
157
|
+
if (schemaToValidate) {
|
|
158
|
+
const { Ajv, addFormats } = await getAjv();
|
|
159
|
+
const ajv = new Ajv({ allErrors: true });
|
|
160
|
+
addFormats(ajv);
|
|
161
|
+
const validate = ajv.compile(schemaToValidate);
|
|
162
|
+
const valid = validate(value);
|
|
163
|
+
if (!valid) {
|
|
164
|
+
const errors = (validate.errors || []).map((err) => ({
|
|
165
|
+
path: err.instancePath || "$",
|
|
166
|
+
message: err.message || "Unknown error",
|
|
167
|
+
}));
|
|
168
|
+
return {
|
|
169
|
+
success: false,
|
|
170
|
+
path,
|
|
171
|
+
previousValue,
|
|
172
|
+
newValue: value,
|
|
173
|
+
validationErrors: errors,
|
|
174
|
+
dryRun: true, // Force dryRun on validation failure
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Set the value at path
|
|
179
|
+
const modifiedData = setValueAtPath(clonedData, path, value);
|
|
180
|
+
// If dry run, return the modified data without writing
|
|
181
|
+
if (dryRun) {
|
|
182
|
+
return {
|
|
183
|
+
success: true,
|
|
184
|
+
path,
|
|
185
|
+
previousValue,
|
|
186
|
+
newValue: value,
|
|
187
|
+
dryRun: true,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
// Write to file
|
|
191
|
+
const targetFile = outputFile || filePath;
|
|
192
|
+
const jsonOutput = JSON.stringify(modifiedData, null, 2);
|
|
193
|
+
await writeFile(targetFile, jsonOutput, "utf-8");
|
|
194
|
+
return {
|
|
195
|
+
success: true,
|
|
196
|
+
path,
|
|
197
|
+
previousValue,
|
|
198
|
+
newValue: value,
|
|
199
|
+
outputFile: targetFile,
|
|
200
|
+
dryRun: false,
|
|
201
|
+
};
|
|
202
|
+
}
|
package/dist/tools/query.d.ts
CHANGED
|
@@ -39,16 +39,57 @@ export interface FieldStats {
|
|
|
39
39
|
export interface ArrayInfo {
|
|
40
40
|
path: string;
|
|
41
41
|
length: number;
|
|
42
|
-
itemType: string;
|
|
42
|
+
itemType: "object" | "string" | "number" | "mixed";
|
|
43
43
|
fields?: string[];
|
|
44
44
|
fieldCount?: number;
|
|
45
45
|
}
|
|
46
|
+
export interface PrimitiveArrayStats {
|
|
47
|
+
path: string;
|
|
48
|
+
arrayLength: number;
|
|
49
|
+
itemType: "string" | "number" | "mixed";
|
|
50
|
+
typeBreakdown?: Record<string, number>;
|
|
51
|
+
nullCount: number;
|
|
52
|
+
uniqueCount?: number;
|
|
53
|
+
stringStats?: {
|
|
54
|
+
count: number;
|
|
55
|
+
uniqueCount: number;
|
|
56
|
+
lengthStats: {
|
|
57
|
+
min: number;
|
|
58
|
+
max: number;
|
|
59
|
+
avg: number;
|
|
60
|
+
median: number;
|
|
61
|
+
};
|
|
62
|
+
distribution?: Record<string, number>;
|
|
63
|
+
};
|
|
64
|
+
lengthStats?: {
|
|
65
|
+
min: number;
|
|
66
|
+
max: number;
|
|
67
|
+
avg: number;
|
|
68
|
+
median: number;
|
|
69
|
+
};
|
|
70
|
+
distribution?: Record<string, number>;
|
|
71
|
+
numericStats?: {
|
|
72
|
+
count?: number;
|
|
73
|
+
min: number;
|
|
74
|
+
max: number;
|
|
75
|
+
avg: number;
|
|
76
|
+
median: number;
|
|
77
|
+
stdDev: number;
|
|
78
|
+
percentiles: {
|
|
79
|
+
p25: number;
|
|
80
|
+
p50: number;
|
|
81
|
+
p75: number;
|
|
82
|
+
p90: number;
|
|
83
|
+
p99: number;
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
}
|
|
46
87
|
export interface StatsResult {
|
|
47
88
|
path: string;
|
|
48
89
|
arrayLength: number;
|
|
49
90
|
fields: FieldStats[];
|
|
50
91
|
}
|
|
51
92
|
export interface MultiStatsResult {
|
|
52
|
-
stats: StatsResult[];
|
|
93
|
+
stats: (StatsResult | PrimitiveArrayStats)[];
|
|
53
94
|
}
|
|
54
|
-
export declare function jsonStats(filePath: string, path?: string, fields?: string[]): Promise<StatsResult | MultiStatsResult>;
|
|
95
|
+
export declare function jsonStats(filePath: string, path?: string, fields?: string[]): Promise<StatsResult | PrimitiveArrayStats | MultiStatsResult>;
|
package/dist/tools/query.js
CHANGED
|
@@ -118,13 +118,13 @@ export async function jsonSample(filePath, path, count = 5, mode = "first", rang
|
|
|
118
118
|
hasMore: arrayLength > count,
|
|
119
119
|
};
|
|
120
120
|
}
|
|
121
|
-
function
|
|
121
|
+
function findArrays(data, currentPath = "$", maxDepth = 5, depth = 0) {
|
|
122
122
|
const results = [];
|
|
123
123
|
if (depth > maxDepth)
|
|
124
124
|
return results;
|
|
125
125
|
if (Array.isArray(data) && data.length > 0) {
|
|
126
126
|
const firstItem = data[0];
|
|
127
|
-
//
|
|
127
|
+
// Arrays of objects
|
|
128
128
|
if (typeof firstItem === "object" && firstItem !== null && !Array.isArray(firstItem)) {
|
|
129
129
|
const fields = Object.keys(firstItem);
|
|
130
130
|
results.push({
|
|
@@ -152,29 +152,69 @@ function findArraysOfObjects(data, currentPath = "$", maxDepth = 5, depth = 0) {
|
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
|
+
// Arrays of primitives (strings, numbers, or mixed)
|
|
156
|
+
else if (typeof firstItem === "string" || typeof firstItem === "number") {
|
|
157
|
+
// Check if array has mixed types
|
|
158
|
+
const types = new Set(data.map((item) => typeof item).filter((t) => t === "string" || t === "number"));
|
|
159
|
+
if (types.size > 1) {
|
|
160
|
+
results.push({
|
|
161
|
+
path: currentPath,
|
|
162
|
+
length: data.length,
|
|
163
|
+
itemType: "mixed",
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
else if (typeof firstItem === "string") {
|
|
167
|
+
results.push({
|
|
168
|
+
path: currentPath,
|
|
169
|
+
length: data.length,
|
|
170
|
+
itemType: "string",
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
results.push({
|
|
175
|
+
path: currentPath,
|
|
176
|
+
length: data.length,
|
|
177
|
+
itemType: "number",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
155
181
|
}
|
|
156
182
|
else if (typeof data === "object" && data !== null) {
|
|
157
183
|
for (const key of Object.keys(data)) {
|
|
158
184
|
const childPath = /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)
|
|
159
185
|
? `${currentPath}.${key}`
|
|
160
186
|
: `${currentPath}["${key}"]`;
|
|
161
|
-
results.push(...
|
|
187
|
+
results.push(...findArrays(data[key], childPath, maxDepth, depth + 1));
|
|
162
188
|
}
|
|
163
189
|
}
|
|
164
190
|
return results;
|
|
165
191
|
}
|
|
166
192
|
export async function jsonStats(filePath, path, fields) {
|
|
167
193
|
const data = await loadJson(filePath);
|
|
168
|
-
// If no path provided, compute stats for all arrays
|
|
194
|
+
// If no path provided, compute stats for all arrays
|
|
169
195
|
if (!path) {
|
|
170
|
-
const arrays =
|
|
196
|
+
const arrays = findArrays(data);
|
|
171
197
|
const allStats = [];
|
|
172
198
|
for (const arr of arrays) {
|
|
173
199
|
try {
|
|
174
200
|
const value = getValueAtPath(data, arr.path);
|
|
175
201
|
if (Array.isArray(value) && value.length > 0) {
|
|
176
|
-
|
|
177
|
-
|
|
202
|
+
if (arr.itemType === "object") {
|
|
203
|
+
const stats = computeObjectArrayStats(value, arr.path, fields);
|
|
204
|
+
allStats.push(stats);
|
|
205
|
+
}
|
|
206
|
+
else if (arr.itemType === "string") {
|
|
207
|
+
const stats = computeStringArrayStats(value, arr.path);
|
|
208
|
+
allStats.push(stats);
|
|
209
|
+
}
|
|
210
|
+
else if (arr.itemType === "number") {
|
|
211
|
+
const stats = computeNumberArrayStats(value, arr.path);
|
|
212
|
+
allStats.push(stats);
|
|
213
|
+
}
|
|
214
|
+
else if (arr.itemType === "mixed") {
|
|
215
|
+
const stats = computeMixedArrayStats(value, arr.path);
|
|
216
|
+
allStats.push(stats);
|
|
217
|
+
}
|
|
178
218
|
}
|
|
179
219
|
}
|
|
180
220
|
catch {
|
|
@@ -187,9 +227,27 @@ export async function jsonStats(filePath, path, fields) {
|
|
|
187
227
|
if (!Array.isArray(value)) {
|
|
188
228
|
throw new Error(`Path "${path}" is not an array. Got: ${getValueType(value)}`);
|
|
189
229
|
}
|
|
190
|
-
|
|
230
|
+
// Determine array type
|
|
231
|
+
if (value.length === 0) {
|
|
232
|
+
return { path, arrayLength: 0, fields: [] };
|
|
233
|
+
}
|
|
234
|
+
const firstItem = value[0];
|
|
235
|
+
// Check for mixed primitive types
|
|
236
|
+
if (typeof firstItem === "string" || typeof firstItem === "number") {
|
|
237
|
+
const types = new Set(value.map((item) => typeof item).filter((t) => t === "string" || t === "number"));
|
|
238
|
+
if (types.size > 1) {
|
|
239
|
+
return computeMixedArrayStats(value, path);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (typeof firstItem === "string") {
|
|
243
|
+
return computeStringArrayStats(value, path);
|
|
244
|
+
}
|
|
245
|
+
else if (typeof firstItem === "number") {
|
|
246
|
+
return computeNumberArrayStats(value, path);
|
|
247
|
+
}
|
|
248
|
+
return computeObjectArrayStats(value, path, fields);
|
|
191
249
|
}
|
|
192
|
-
function
|
|
250
|
+
function computeObjectArrayStats(value, path, fields) {
|
|
193
251
|
if (value.length === 0) {
|
|
194
252
|
return { path, arrayLength: 0, fields: [] };
|
|
195
253
|
}
|
|
@@ -247,3 +305,183 @@ function computeArrayStats(value, path, fields) {
|
|
|
247
305
|
fields: fieldStats,
|
|
248
306
|
};
|
|
249
307
|
}
|
|
308
|
+
function computeStringArrayStats(value, path) {
|
|
309
|
+
const strings = value.filter((v) => typeof v === "string");
|
|
310
|
+
const nullCount = value.length - strings.length;
|
|
311
|
+
// Compute length stats
|
|
312
|
+
const lengths = strings.map((s) => s.length);
|
|
313
|
+
const sortedLengths = [...lengths].sort((a, b) => a - b);
|
|
314
|
+
const lengthStats = lengths.length > 0 ? {
|
|
315
|
+
min: Math.min(...lengths),
|
|
316
|
+
max: Math.max(...lengths),
|
|
317
|
+
avg: Math.round(lengths.reduce((a, b) => a + b, 0) / lengths.length * 100) / 100,
|
|
318
|
+
median: sortedLengths[Math.floor(sortedLengths.length / 2)],
|
|
319
|
+
} : undefined;
|
|
320
|
+
// Compute value distribution (if reasonable number of unique values)
|
|
321
|
+
const distribution = {};
|
|
322
|
+
for (const s of strings) {
|
|
323
|
+
distribution[s] = (distribution[s] || 0) + 1;
|
|
324
|
+
}
|
|
325
|
+
const uniqueCount = Object.keys(distribution).length;
|
|
326
|
+
// Only include distribution if <= 50 unique values
|
|
327
|
+
const result = {
|
|
328
|
+
path,
|
|
329
|
+
arrayLength: value.length,
|
|
330
|
+
itemType: "string",
|
|
331
|
+
nullCount,
|
|
332
|
+
uniqueCount,
|
|
333
|
+
lengthStats,
|
|
334
|
+
};
|
|
335
|
+
if (uniqueCount <= 50) {
|
|
336
|
+
// Sort by count descending
|
|
337
|
+
const sortedDistribution = {};
|
|
338
|
+
Object.entries(distribution)
|
|
339
|
+
.sort(([, a], [, b]) => b - a)
|
|
340
|
+
.slice(0, 20) // Top 20 values
|
|
341
|
+
.forEach(([k, v]) => { sortedDistribution[k] = v; });
|
|
342
|
+
result.distribution = sortedDistribution;
|
|
343
|
+
}
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
function computeNumberArrayStats(value, path) {
|
|
347
|
+
const numbers = value.filter((v) => typeof v === "number" && !isNaN(v));
|
|
348
|
+
const nullCount = value.length - numbers.length;
|
|
349
|
+
if (numbers.length === 0) {
|
|
350
|
+
return {
|
|
351
|
+
path,
|
|
352
|
+
arrayLength: value.length,
|
|
353
|
+
itemType: "number",
|
|
354
|
+
nullCount,
|
|
355
|
+
uniqueCount: 0,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
const sorted = [...numbers].sort((a, b) => a - b);
|
|
359
|
+
const sum = numbers.reduce((a, b) => a + b, 0);
|
|
360
|
+
const avg = sum / numbers.length;
|
|
361
|
+
// Standard deviation
|
|
362
|
+
const squaredDiffs = numbers.map((n) => Math.pow(n - avg, 2));
|
|
363
|
+
const avgSquaredDiff = squaredDiffs.reduce((a, b) => a + b, 0) / numbers.length;
|
|
364
|
+
const stdDev = Math.sqrt(avgSquaredDiff);
|
|
365
|
+
// Percentile helper
|
|
366
|
+
const percentile = (p) => {
|
|
367
|
+
const index = (p / 100) * (sorted.length - 1);
|
|
368
|
+
const lower = Math.floor(index);
|
|
369
|
+
const upper = Math.ceil(index);
|
|
370
|
+
if (lower === upper)
|
|
371
|
+
return sorted[lower];
|
|
372
|
+
return sorted[lower] + (index - lower) * (sorted[upper] - sorted[lower]);
|
|
373
|
+
};
|
|
374
|
+
// Unique values
|
|
375
|
+
const uniqueCount = new Set(numbers).size;
|
|
376
|
+
const result = {
|
|
377
|
+
path,
|
|
378
|
+
arrayLength: value.length,
|
|
379
|
+
itemType: "number",
|
|
380
|
+
nullCount,
|
|
381
|
+
uniqueCount,
|
|
382
|
+
numericStats: {
|
|
383
|
+
min: sorted[0],
|
|
384
|
+
max: sorted[sorted.length - 1],
|
|
385
|
+
avg: Math.round(avg * 1000) / 1000,
|
|
386
|
+
median: percentile(50),
|
|
387
|
+
stdDev: Math.round(stdDev * 1000) / 1000,
|
|
388
|
+
percentiles: {
|
|
389
|
+
p25: Math.round(percentile(25) * 1000) / 1000,
|
|
390
|
+
p50: Math.round(percentile(50) * 1000) / 1000,
|
|
391
|
+
p75: Math.round(percentile(75) * 1000) / 1000,
|
|
392
|
+
p90: Math.round(percentile(90) * 1000) / 1000,
|
|
393
|
+
p99: Math.round(percentile(99) * 1000) / 1000,
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
// If there are few unique values, include distribution
|
|
398
|
+
if (uniqueCount <= 20) {
|
|
399
|
+
const distribution = {};
|
|
400
|
+
for (const n of numbers) {
|
|
401
|
+
const key = String(n);
|
|
402
|
+
distribution[key] = (distribution[key] || 0) + 1;
|
|
403
|
+
}
|
|
404
|
+
result.distribution = distribution;
|
|
405
|
+
}
|
|
406
|
+
return result;
|
|
407
|
+
}
|
|
408
|
+
function computeMixedArrayStats(value, path) {
|
|
409
|
+
// Count types
|
|
410
|
+
const typeBreakdown = {};
|
|
411
|
+
for (const item of value) {
|
|
412
|
+
const t = item === null ? "null" : typeof item;
|
|
413
|
+
typeBreakdown[t] = (typeBreakdown[t] || 0) + 1;
|
|
414
|
+
}
|
|
415
|
+
const nullCount = typeBreakdown["null"] || 0 + (typeBreakdown["undefined"] || 0);
|
|
416
|
+
const result = {
|
|
417
|
+
path,
|
|
418
|
+
arrayLength: value.length,
|
|
419
|
+
itemType: "mixed",
|
|
420
|
+
typeBreakdown,
|
|
421
|
+
nullCount,
|
|
422
|
+
};
|
|
423
|
+
// Compute string stats if there are strings
|
|
424
|
+
const strings = value.filter((v) => typeof v === "string");
|
|
425
|
+
if (strings.length > 0) {
|
|
426
|
+
const lengths = strings.map((s) => s.length);
|
|
427
|
+
const sortedLengths = [...lengths].sort((a, b) => a - b);
|
|
428
|
+
const stringDistribution = {};
|
|
429
|
+
for (const s of strings) {
|
|
430
|
+
stringDistribution[s] = (stringDistribution[s] || 0) + 1;
|
|
431
|
+
}
|
|
432
|
+
const stringUniqueCount = Object.keys(stringDistribution).length;
|
|
433
|
+
result.stringStats = {
|
|
434
|
+
count: strings.length,
|
|
435
|
+
uniqueCount: stringUniqueCount,
|
|
436
|
+
lengthStats: {
|
|
437
|
+
min: Math.min(...lengths),
|
|
438
|
+
max: Math.max(...lengths),
|
|
439
|
+
avg: Math.round(lengths.reduce((a, b) => a + b, 0) / lengths.length * 100) / 100,
|
|
440
|
+
median: sortedLengths[Math.floor(sortedLengths.length / 2)],
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
// Include distribution if reasonable number of unique values
|
|
444
|
+
if (stringUniqueCount <= 30) {
|
|
445
|
+
const sortedDistribution = {};
|
|
446
|
+
Object.entries(stringDistribution)
|
|
447
|
+
.sort(([, a], [, b]) => b - a)
|
|
448
|
+
.slice(0, 15)
|
|
449
|
+
.forEach(([k, v]) => { sortedDistribution[k] = v; });
|
|
450
|
+
result.stringStats.distribution = sortedDistribution;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
// Compute number stats if there are numbers
|
|
454
|
+
const numbers = value.filter((v) => typeof v === "number" && !isNaN(v));
|
|
455
|
+
if (numbers.length > 0) {
|
|
456
|
+
const sorted = [...numbers].sort((a, b) => a - b);
|
|
457
|
+
const sum = numbers.reduce((a, b) => a + b, 0);
|
|
458
|
+
const avg = sum / numbers.length;
|
|
459
|
+
const squaredDiffs = numbers.map((n) => Math.pow(n - avg, 2));
|
|
460
|
+
const avgSquaredDiff = squaredDiffs.reduce((a, b) => a + b, 0) / numbers.length;
|
|
461
|
+
const stdDev = Math.sqrt(avgSquaredDiff);
|
|
462
|
+
const percentile = (p) => {
|
|
463
|
+
const index = (p / 100) * (sorted.length - 1);
|
|
464
|
+
const lower = Math.floor(index);
|
|
465
|
+
const upper = Math.ceil(index);
|
|
466
|
+
if (lower === upper)
|
|
467
|
+
return sorted[lower];
|
|
468
|
+
return sorted[lower] + (index - lower) * (sorted[upper] - sorted[lower]);
|
|
469
|
+
};
|
|
470
|
+
result.numericStats = {
|
|
471
|
+
count: numbers.length,
|
|
472
|
+
min: sorted[0],
|
|
473
|
+
max: sorted[sorted.length - 1],
|
|
474
|
+
avg: Math.round(avg * 1000) / 1000,
|
|
475
|
+
median: percentile(50),
|
|
476
|
+
stdDev: Math.round(stdDev * 1000) / 1000,
|
|
477
|
+
percentiles: {
|
|
478
|
+
p25: Math.round(percentile(25) * 1000) / 1000,
|
|
479
|
+
p50: Math.round(percentile(50) * 1000) / 1000,
|
|
480
|
+
p75: Math.round(percentile(75) * 1000) / 1000,
|
|
481
|
+
p90: Math.round(percentile(90) * 1000) / 1000,
|
|
482
|
+
p99: Math.round(percentile(99) * 1000) / 1000,
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
return result;
|
|
487
|
+
}
|
|
@@ -28,6 +28,19 @@ export interface ValidateResult {
|
|
|
28
28
|
valid: boolean;
|
|
29
29
|
errors: ValidationError[];
|
|
30
30
|
errorCount: number;
|
|
31
|
+
truncatedErrorCount?: number;
|
|
32
|
+
resolvedRefs?: string[];
|
|
31
33
|
}
|
|
32
|
-
export
|
|
34
|
+
export interface ValidateOptions {
|
|
35
|
+
errorLimit?: number;
|
|
36
|
+
resolveLocalRefs?: boolean;
|
|
37
|
+
resolveNetworkRefs?: boolean;
|
|
38
|
+
/** Pre-register schemas by URI. Value can be inline schema object or path to schema file. */
|
|
39
|
+
schemas?: Record<string, object | string>;
|
|
40
|
+
/** Directory containing schema files. All .json files will be loaded and registered by their $id. */
|
|
41
|
+
schemaDir?: string;
|
|
42
|
+
/** When using schemaDir, specify the $id of the schema to use as entrypoint (instead of schema param). */
|
|
43
|
+
schemaId?: string;
|
|
44
|
+
}
|
|
45
|
+
export declare function jsonValidate(filePath: string, schema: object | string, path?: string, options?: ValidateOptions | number): Promise<ValidateResult>;
|
|
33
46
|
export {};
|
package/dist/tools/structure.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { loadJson, getFileInfo, formatBytes, getValueType } from "../utils/json-parser.js";
|
|
2
2
|
import { getDepthPreview, getValueAtPath } from "../utils/path-helpers.js";
|
|
3
|
+
import { dirname, resolve, isAbsolute, join } from "path";
|
|
4
|
+
import { readdir } from "fs/promises";
|
|
3
5
|
// Dynamic import for ajv (ESM/CJS compat)
|
|
4
6
|
async function getAjv() {
|
|
5
7
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -115,34 +117,287 @@ export async function jsonSchema(filePath, path) {
|
|
|
115
117
|
schema: inferSchema(targetData),
|
|
116
118
|
};
|
|
117
119
|
}
|
|
118
|
-
|
|
120
|
+
// Find all $ref values in a schema object
|
|
121
|
+
function findRefs(obj, refs = new Set()) {
|
|
122
|
+
if (obj === null || typeof obj !== "object") {
|
|
123
|
+
return refs;
|
|
124
|
+
}
|
|
125
|
+
if (Array.isArray(obj)) {
|
|
126
|
+
for (const item of obj) {
|
|
127
|
+
findRefs(item, refs);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
const record = obj;
|
|
132
|
+
if (typeof record.$ref === "string") {
|
|
133
|
+
refs.add(record.$ref);
|
|
134
|
+
}
|
|
135
|
+
for (const value of Object.values(record)) {
|
|
136
|
+
findRefs(value, refs);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return refs;
|
|
140
|
+
}
|
|
141
|
+
// Check if a ref is a local file path (not a URL or JSON pointer)
|
|
142
|
+
function isLocalFileRef(ref) {
|
|
143
|
+
// Skip JSON pointers (start with #)
|
|
144
|
+
if (ref.startsWith("#"))
|
|
145
|
+
return false;
|
|
146
|
+
// Skip URLs
|
|
147
|
+
if (ref.startsWith("http://") || ref.startsWith("https://"))
|
|
148
|
+
return false;
|
|
149
|
+
// It's a local file ref
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
// Check if a ref is an HTTP URL
|
|
153
|
+
function isNetworkRef(ref) {
|
|
154
|
+
return ref.startsWith("http://") || ref.startsWith("https://");
|
|
155
|
+
}
|
|
156
|
+
// Resolve a local file ref relative to the schema's directory
|
|
157
|
+
function resolveLocalRef(ref, schemaDir) {
|
|
158
|
+
// Remove any JSON pointer fragment
|
|
159
|
+
const [filePath] = ref.split("#");
|
|
160
|
+
if (!filePath)
|
|
161
|
+
return ref;
|
|
162
|
+
if (isAbsolute(filePath)) {
|
|
163
|
+
return filePath;
|
|
164
|
+
}
|
|
165
|
+
return resolve(schemaDir, filePath);
|
|
166
|
+
}
|
|
167
|
+
// Recursively load all local schema refs
|
|
168
|
+
async function loadLocalRefs(schemaObj, schemaDir, loaded = new Map()) {
|
|
169
|
+
const refs = findRefs(schemaObj);
|
|
170
|
+
for (const ref of refs) {
|
|
171
|
+
if (!isLocalFileRef(ref))
|
|
172
|
+
continue;
|
|
173
|
+
const filePath = resolveLocalRef(ref, schemaDir);
|
|
174
|
+
if (loaded.has(filePath))
|
|
175
|
+
continue;
|
|
176
|
+
try {
|
|
177
|
+
const refSchema = await loadJson(filePath);
|
|
178
|
+
// Validate that it's actually a JSON Schema
|
|
179
|
+
if (!isValidJsonSchema(refSchema)) {
|
|
180
|
+
throw new Error(`File ${filePath} is not a valid JSON Schema`);
|
|
181
|
+
}
|
|
182
|
+
loaded.set(filePath, refSchema);
|
|
183
|
+
// Recursively load refs from this schema
|
|
184
|
+
const refDir = dirname(filePath);
|
|
185
|
+
await loadLocalRefs(refSchema, refDir, loaded);
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
// Re-throw validation errors, skip other errors (file not found, etc.)
|
|
189
|
+
if (err instanceof Error && err.message.includes("not a valid JSON Schema")) {
|
|
190
|
+
throw err;
|
|
191
|
+
}
|
|
192
|
+
// Skip refs we can't load - ajv will report the error
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return loaded;
|
|
196
|
+
}
|
|
197
|
+
// Validate that an object looks like a JSON Schema
|
|
198
|
+
function isValidJsonSchema(obj) {
|
|
199
|
+
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
const schema = obj;
|
|
203
|
+
// A valid JSON Schema should be an object with schema-like properties
|
|
204
|
+
// Check for common JSON Schema keywords
|
|
205
|
+
const schemaKeywords = [
|
|
206
|
+
"type", "properties", "items", "required", "enum", "const",
|
|
207
|
+
"allOf", "anyOf", "oneOf", "not", "$ref", "$id", "$schema",
|
|
208
|
+
"definitions", "$defs", "additionalProperties", "patternProperties",
|
|
209
|
+
"minimum", "maximum", "minLength", "maxLength", "pattern", "format"
|
|
210
|
+
];
|
|
211
|
+
// Must have at least one schema keyword, OR be a boolean schema (but we already checked it's an object)
|
|
212
|
+
// Empty objects {} are valid schemas (match anything), so we allow those too
|
|
213
|
+
const hasSchemaKeyword = schemaKeywords.some(keyword => keyword in schema);
|
|
214
|
+
const isEmpty = Object.keys(schema).length === 0;
|
|
215
|
+
return hasSchemaKeyword || isEmpty;
|
|
216
|
+
}
|
|
217
|
+
// Load all JSON schema files from a directory, returning map of $id -> schema
|
|
218
|
+
async function loadSchemasFromDir(dir) {
|
|
219
|
+
const schemas = new Map();
|
|
220
|
+
const files = await readdir(dir);
|
|
221
|
+
const jsonFiles = files.filter(f => f.endsWith(".json"));
|
|
222
|
+
for (const file of jsonFiles) {
|
|
223
|
+
const filePath = join(dir, file);
|
|
224
|
+
try {
|
|
225
|
+
const schema = await loadJson(filePath);
|
|
226
|
+
if (!isValidJsonSchema(schema)) {
|
|
227
|
+
continue; // Skip non-schema JSON files
|
|
228
|
+
}
|
|
229
|
+
const schemaRecord = schema;
|
|
230
|
+
const id = schemaRecord.$id;
|
|
231
|
+
if (id) {
|
|
232
|
+
schemas.set(id, { schema, file: filePath });
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
// Use filename as fallback id
|
|
236
|
+
schemas.set(file, { schema, file: filePath });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// Skip files that can't be loaded
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return schemas;
|
|
244
|
+
}
|
|
245
|
+
// Fetch a schema from a URL
|
|
246
|
+
async function fetchNetworkSchema(url) {
|
|
247
|
+
const response = await fetch(url);
|
|
248
|
+
if (!response.ok) {
|
|
249
|
+
throw new Error(`Failed to fetch schema from ${url}: ${response.status} ${response.statusText}`);
|
|
250
|
+
}
|
|
251
|
+
let data;
|
|
252
|
+
try {
|
|
253
|
+
data = await response.json();
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
throw new Error(`Invalid JSON received from ${url}`);
|
|
257
|
+
}
|
|
258
|
+
if (!isValidJsonSchema(data)) {
|
|
259
|
+
throw new Error(`URL ${url} did not return a valid JSON Schema`);
|
|
260
|
+
}
|
|
261
|
+
return data;
|
|
262
|
+
}
|
|
263
|
+
export async function jsonValidate(filePath, schema, path, options = {}) {
|
|
264
|
+
// Handle legacy signature where 4th param was errorLimit number
|
|
265
|
+
const opts = typeof options === "number"
|
|
266
|
+
? { errorLimit: options }
|
|
267
|
+
: options;
|
|
268
|
+
// Environment variable can completely disable network refs for security
|
|
269
|
+
const networkRefsDisabled = process.env.JSON_EXPLORER_NO_NETWORK === "1";
|
|
270
|
+
const { errorLimit = 10, resolveLocalRefs = true, resolveNetworkRefs = false, schemas = {}, schemaDir, schemaId, } = opts;
|
|
271
|
+
const effectiveResolveNetworkRefs = resolveNetworkRefs && !networkRefsDisabled;
|
|
119
272
|
const data = await loadJson(filePath);
|
|
120
273
|
const targetData = path ? getValueAtPath(data, path) : data;
|
|
121
274
|
if (targetData === undefined) {
|
|
122
275
|
throw new Error(`Path not found: ${path}`);
|
|
123
276
|
}
|
|
124
|
-
// Load schema from file
|
|
277
|
+
// Load schema - from schemaDir+schemaId, file path, or inline
|
|
125
278
|
let schemaObj;
|
|
126
|
-
|
|
279
|
+
let schemaBaseDir;
|
|
280
|
+
let dirSchemas;
|
|
281
|
+
if (schemaDir && schemaId) {
|
|
282
|
+
// Load all schemas from directory and use schemaId as entrypoint
|
|
283
|
+
dirSchemas = await loadSchemasFromDir(schemaDir);
|
|
284
|
+
const entry = dirSchemas.get(schemaId);
|
|
285
|
+
if (!entry) {
|
|
286
|
+
const availableIds = Array.from(dirSchemas.keys()).join(", ");
|
|
287
|
+
throw new Error(`Schema with $id "${schemaId}" not found in ${schemaDir}. Available: ${availableIds}`);
|
|
288
|
+
}
|
|
289
|
+
schemaObj = entry.schema;
|
|
290
|
+
schemaBaseDir = schemaDir;
|
|
291
|
+
}
|
|
292
|
+
else if (typeof schema === "string") {
|
|
127
293
|
schemaObj = (await loadJson(schema));
|
|
294
|
+
schemaBaseDir = dirname(resolve(schema));
|
|
128
295
|
}
|
|
129
296
|
else {
|
|
130
297
|
schemaObj = schema;
|
|
298
|
+
// For inline schemas, use current working directory
|
|
299
|
+
schemaBaseDir = process.cwd();
|
|
131
300
|
}
|
|
132
301
|
const { Ajv, addFormats } = await getAjv();
|
|
133
|
-
|
|
302
|
+
// Configure ajv options
|
|
303
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
304
|
+
const ajvOptions = { allErrors: true };
|
|
305
|
+
// Set up network schema loading if enabled
|
|
306
|
+
if (effectiveResolveNetworkRefs) {
|
|
307
|
+
ajvOptions.loadSchema = async (uri) => {
|
|
308
|
+
if (isNetworkRef(uri)) {
|
|
309
|
+
return fetchNetworkSchema(uri);
|
|
310
|
+
}
|
|
311
|
+
throw new Error(`Cannot load non-network ref: ${uri}`);
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
const ajv = new Ajv(ajvOptions);
|
|
134
315
|
addFormats(ajv);
|
|
135
|
-
|
|
316
|
+
// Pre-register user-provided schemas
|
|
317
|
+
const resolvedRefs = [];
|
|
318
|
+
for (const [uri, schemaValue] of Object.entries(schemas)) {
|
|
319
|
+
let schemaToAdd;
|
|
320
|
+
if (typeof schemaValue === "string") {
|
|
321
|
+
// Load from file
|
|
322
|
+
const loaded = await loadJson(schemaValue);
|
|
323
|
+
if (!isValidJsonSchema(loaded)) {
|
|
324
|
+
throw new Error(`Schema file ${schemaValue} is not a valid JSON Schema`);
|
|
325
|
+
}
|
|
326
|
+
schemaToAdd = loaded;
|
|
327
|
+
resolvedRefs.push(schemaValue);
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
// Inline schema object
|
|
331
|
+
if (!isValidJsonSchema(schemaValue)) {
|
|
332
|
+
throw new Error(`Inline schema for "${uri}" is not a valid JSON Schema`);
|
|
333
|
+
}
|
|
334
|
+
schemaToAdd = schemaValue;
|
|
335
|
+
}
|
|
336
|
+
ajv.addSchema(schemaToAdd, uri);
|
|
337
|
+
}
|
|
338
|
+
// Register all schemas from schemaDir (if provided)
|
|
339
|
+
if (dirSchemas) {
|
|
340
|
+
for (const [id, { schema: dirSchema, file }] of dirSchemas) {
|
|
341
|
+
// Don't re-register the entrypoint schema
|
|
342
|
+
if (id !== schemaId) {
|
|
343
|
+
ajv.addSchema(dirSchema, id);
|
|
344
|
+
resolvedRefs.push(file);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// Pre-load local refs if enabled (skip if using schemaDir since all schemas are already loaded)
|
|
349
|
+
if (resolveLocalRefs && !dirSchemas) {
|
|
350
|
+
const localRefs = await loadLocalRefs(schemaObj, schemaBaseDir);
|
|
351
|
+
for (const [refPath, refSchema] of localRefs) {
|
|
352
|
+
// If schema has $id, ajv will use that for resolution
|
|
353
|
+
// Otherwise we add it by its relative filename
|
|
354
|
+
const refRecord = refSchema;
|
|
355
|
+
if (refRecord.$id) {
|
|
356
|
+
// Schema has its own $id - ajv will register it by that
|
|
357
|
+
ajv.addSchema(refSchema);
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
// No $id - register by filename (last component of path)
|
|
361
|
+
const filename = refPath.split("/").pop() || refPath;
|
|
362
|
+
ajv.addSchema(refSchema, filename);
|
|
363
|
+
}
|
|
364
|
+
resolvedRefs.push(refPath);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Compile and validate
|
|
368
|
+
let validate;
|
|
369
|
+
try {
|
|
370
|
+
if (effectiveResolveNetworkRefs) {
|
|
371
|
+
// Use compileAsync for network refs
|
|
372
|
+
validate = await ajv.compileAsync(schemaObj);
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
validate = ajv.compile(schemaObj);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
throw new Error(`Schema compilation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
380
|
+
}
|
|
136
381
|
const valid = validate(targetData);
|
|
137
|
-
const
|
|
382
|
+
const allErrors = (validate.errors || []).map((err) => ({
|
|
138
383
|
path: err.instancePath || "$",
|
|
139
384
|
message: err.message || "Unknown error",
|
|
140
385
|
keyword: err.keyword,
|
|
141
386
|
params: err.params,
|
|
142
387
|
}));
|
|
143
|
-
|
|
388
|
+
const totalErrorCount = allErrors.length;
|
|
389
|
+
const truncated = totalErrorCount > errorLimit;
|
|
390
|
+
const errors = truncated ? allErrors.slice(0, errorLimit) : allErrors;
|
|
391
|
+
const result = {
|
|
144
392
|
valid: valid === true,
|
|
145
393
|
errors,
|
|
146
|
-
errorCount:
|
|
394
|
+
errorCount: totalErrorCount,
|
|
147
395
|
};
|
|
396
|
+
if (truncated) {
|
|
397
|
+
result.truncatedErrorCount = totalErrorCount - errorLimit;
|
|
398
|
+
}
|
|
399
|
+
if (resolvedRefs.length > 0) {
|
|
400
|
+
result.resolvedRefs = resolvedRefs;
|
|
401
|
+
}
|
|
402
|
+
return result;
|
|
148
403
|
}
|
|
@@ -27,7 +27,14 @@ export async function loadJson(filePath) {
|
|
|
27
27
|
}
|
|
28
28
|
// Read and parse file
|
|
29
29
|
const content = await readFile(filePath, "utf-8");
|
|
30
|
-
|
|
30
|
+
let data;
|
|
31
|
+
try {
|
|
32
|
+
data = JSON.parse(content);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
const message = err instanceof SyntaxError ? err.message : "Unknown parse error";
|
|
36
|
+
throw new Error(`Invalid JSON in ${filePath}: ${message}`);
|
|
37
|
+
}
|
|
31
38
|
// Update cache (with size management)
|
|
32
39
|
if (info.size < MAX_CACHE_SIZE / 2) {
|
|
33
40
|
// Only cache files smaller than half the max cache size
|
|
@@ -3,4 +3,10 @@ export declare function normalizeToJsonPath(path: string): string;
|
|
|
3
3
|
export declare function getKeysAtPath(data: unknown, path: string): string[];
|
|
4
4
|
export declare function buildPathTo(basePath: string, key: string): string;
|
|
5
5
|
export declare function findPaths(data: unknown, predicate: (value: unknown, path: string) => boolean, currentPath?: string, maxResults?: number): string[];
|
|
6
|
+
/**
|
|
7
|
+
* Set a value at a specific JSONPath in the data.
|
|
8
|
+
* Mutates the data in place and returns the modified data.
|
|
9
|
+
* Throws if the path doesn't exist (parent must exist).
|
|
10
|
+
*/
|
|
11
|
+
export declare function setValueAtPath(data: unknown, path: string, value: unknown): unknown;
|
|
6
12
|
export declare function getDepthPreview(data: unknown, maxDepth?: number, currentDepth?: number): unknown;
|
|
@@ -70,6 +70,94 @@ export function findPaths(data, predicate, currentPath = "$", maxResults = 100)
|
|
|
70
70
|
traverse(data, currentPath);
|
|
71
71
|
return results;
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Parse a JSONPath into segments for traversal.
|
|
75
|
+
* Returns array of keys/indices to traverse.
|
|
76
|
+
*/
|
|
77
|
+
function parsePathSegments(path) {
|
|
78
|
+
const normalized = normalizeToJsonPath(path);
|
|
79
|
+
if (normalized === "$")
|
|
80
|
+
return [];
|
|
81
|
+
const segments = [];
|
|
82
|
+
// Remove leading $
|
|
83
|
+
const pathWithoutRoot = normalized.slice(1);
|
|
84
|
+
// Match either .key, [index], or ["key"]
|
|
85
|
+
const regex = /\.([a-zA-Z_][a-zA-Z0-9_]*)|\.?(\[(\d+)\])|\.?\["([^"]+)"\]|\.?\['([^']+)'\]/g;
|
|
86
|
+
let match;
|
|
87
|
+
while ((match = regex.exec(pathWithoutRoot)) !== null) {
|
|
88
|
+
if (match[1] !== undefined) {
|
|
89
|
+
// .key format
|
|
90
|
+
segments.push(match[1]);
|
|
91
|
+
}
|
|
92
|
+
else if (match[3] !== undefined) {
|
|
93
|
+
// [index] format
|
|
94
|
+
segments.push(parseInt(match[3], 10));
|
|
95
|
+
}
|
|
96
|
+
else if (match[4] !== undefined) {
|
|
97
|
+
// ["key"] format
|
|
98
|
+
segments.push(match[4]);
|
|
99
|
+
}
|
|
100
|
+
else if (match[5] !== undefined) {
|
|
101
|
+
// ['key'] format
|
|
102
|
+
segments.push(match[5]);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return segments;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Set a value at a specific JSONPath in the data.
|
|
109
|
+
* Mutates the data in place and returns the modified data.
|
|
110
|
+
* Throws if the path doesn't exist (parent must exist).
|
|
111
|
+
*/
|
|
112
|
+
export function setValueAtPath(data, path, value) {
|
|
113
|
+
const segments = parsePathSegments(path);
|
|
114
|
+
if (segments.length === 0) {
|
|
115
|
+
// Setting root - just return the new value
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
// Traverse to parent
|
|
119
|
+
let current = data;
|
|
120
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
121
|
+
const segment = segments[i];
|
|
122
|
+
if (current === null || typeof current !== "object") {
|
|
123
|
+
throw new Error(`Cannot traverse path: parent at segment "${segment}" is not an object or array`);
|
|
124
|
+
}
|
|
125
|
+
if (Array.isArray(current)) {
|
|
126
|
+
if (typeof segment !== "number") {
|
|
127
|
+
throw new Error(`Cannot use string key "${segment}" on array`);
|
|
128
|
+
}
|
|
129
|
+
if (segment < 0 || segment >= current.length) {
|
|
130
|
+
throw new Error(`Array index ${segment} out of bounds (length: ${current.length})`);
|
|
131
|
+
}
|
|
132
|
+
current = current[segment];
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const key = String(segment);
|
|
136
|
+
if (!(key in current)) {
|
|
137
|
+
throw new Error(`Key "${key}" not found in object`);
|
|
138
|
+
}
|
|
139
|
+
current = current[key];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Set the value at the final segment
|
|
143
|
+
const finalSegment = segments[segments.length - 1];
|
|
144
|
+
if (current === null || typeof current !== "object") {
|
|
145
|
+
throw new Error(`Cannot set value: parent is not an object or array`);
|
|
146
|
+
}
|
|
147
|
+
if (Array.isArray(current)) {
|
|
148
|
+
if (typeof finalSegment !== "number") {
|
|
149
|
+
throw new Error(`Cannot use string key "${finalSegment}" on array`);
|
|
150
|
+
}
|
|
151
|
+
if (finalSegment < 0 || finalSegment >= current.length) {
|
|
152
|
+
throw new Error(`Array index ${finalSegment} out of bounds (length: ${current.length})`);
|
|
153
|
+
}
|
|
154
|
+
current[finalSegment] = value;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
current[String(finalSegment)] = value;
|
|
158
|
+
}
|
|
159
|
+
return data;
|
|
160
|
+
}
|
|
73
161
|
export function getDepthPreview(data, maxDepth = 2, currentDepth = 0) {
|
|
74
162
|
if (currentDepth >= maxDepth) {
|
|
75
163
|
if (data === null)
|