History

Use jsonc-parser for local config

Rhys Sullivan committed 8 months ago

Commit 4ecb3081 parent 16327d74 files · +131 −64Browse files

Unified · Split

4 files
  1. bun.lock +5 −4
  2. packages/control-plane/package.json +1 −0
  3. packages/control-plane/src/runtime/local-config.test.ts +96 −0
  4. packages/control-plane/src/runtime/local-config.ts +29 −60
bun.lock+5−4
@@ -10,6 +10,9 @@ · 9 unmodified lines
10 "typescript": "^5.9.3",10 "typescript": "^5.9.3",
11 },11 },
12 },12 },
13 "apps/docs": {
14 "name": "@executor/docs",
15 },
13 "apps/executor": {16 "apps/executor": {
14 "name": "executor",17 "name": "executor",
15 "version": "1.1.9",18 "version": "1.1.9",
@@ -79,9 +82,6 @@ · 63 unmodified lines
79 "vite": "^7.3.1",82 "vite": "^7.3.1",
80 },83 },
81 },84 },
82 "docs": {
83 "name": "@executor/docs",
84 },
85 "packages/ai-sdk-adapter": {85 "packages/ai-sdk-adapter": {
86 "name": "@executor/ai-sdk-adapter",86 "name": "@executor/ai-sdk-adapter",
87 "dependencies": {87 "dependencies": {
@@ -171,6 +171,7 @@ · 83 unmodified lines
171 "drizzle-orm": "1.0.0-beta.15-859cf75",171 "drizzle-orm": "1.0.0-beta.15-859cf75",
172 "effect": "catalog:",172 "effect": "catalog:",
173 "graphql": "^16.12.0",173 "graphql": "^16.12.0",
174 "jsonc-parser": "3.3.1",
174 "postgres": "^3.4.7",175 "postgres": "^3.4.7",
175 "prettier": "^3.8.1",176 "prettier": "^3.8.1",
176 },177 },
@@ -496,7 +497,7 @@ · 319 unmodified lines
496 497
497 "@executor/control-plane": ["@executor/control-plane@workspace:packages/control-plane"],498 "@executor/control-plane": ["@executor/control-plane@workspace:packages/control-plane"],
498 499
499 "@executor/docs": ["@executor/docs@workspace:docs"],500 "@executor/docs": ["@executor/docs@workspace:apps/docs"],
500 501
501 "@executor/executor-mcp": ["@executor/executor-mcp@workspace:packages/executor-mcp"],502 "@executor/executor-mcp": ["@executor/executor-mcp@workspace:packages/executor-mcp"],
502 503
packages/control-plane/package.json+1−0
@@ -34,6 +34,7 @@ · 33 unmodified lines
34 "drizzle-orm": "1.0.0-beta.15-859cf75",34 "drizzle-orm": "1.0.0-beta.15-859cf75",
35 "effect": "catalog:",35 "effect": "catalog:",
36 "graphql": "^16.12.0",36 "graphql": "^16.12.0",
37 "jsonc-parser": "3.3.1",
37 "postgres": "^3.4.7",38 "postgres": "^3.4.7",
38 "prettier": "^3.8.1"39 "prettier": "^3.8.1"
39 },40 },
packages/control-plane/src/runtime/local-config.test.ts+96−0
@@ -0,0 +1,96 @@
1import { mkdtempSync } from "node:fs";
2import { mkdir, writeFile } from "node:fs/promises";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
5import { describe, expect, it } from "@effect/vitest";
6import * as Effect from "effect/Effect";
7
8import {
9 loadLocalExecutorConfig,
10 resolveLocalWorkspaceContext,
11} from "./local-config";
12
13const makeWorkspaceRoot = () =>
14 mkdtempSync(join(tmpdir(), "executor-local-config-"));
15
16describe("local-config", () => {
17 it.effect("parses jsonc project config with comments and trailing commas", () =>
18 Effect.gen(function* () {
19 const workspaceRoot = makeWorkspaceRoot();
20 const configDirectory = join(workspaceRoot, ".executor");
21 yield* Effect.promise(() => mkdir(configDirectory, { recursive: true }));
22 yield* Effect.promise(() =>
23 writeFile(
24 join(configDirectory, "executor.jsonc"),
25 `{
26 // local workspace config
27 "sources": {
28 "github": {
29 "kind": "openapi",
30 "connection": {
31 "endpoint": "https://api.github.com",
32 },
33 "binding": {
34 "specUrl": "https://example.com/openapi.json",
35 },
36 },
37 },
38}
39`,
40 "utf8",
41 ),
42 );
43
44 const context = yield* Effect.promise(() =>
45 resolveLocalWorkspaceContext({ workspaceRoot }),
46 );
47 const loaded = yield* Effect.promise(() =>
48 loadLocalExecutorConfig(context),
49 );
50
51 expect(loaded.config?.sources?.github?.kind).toBe("openapi");
52 expect(loaded.config?.sources?.github?.connection.endpoint).toBe(
53 "https://api.github.com",
54 );
55 }),
56 );
57
58 it.effect("reports jsonc syntax errors with line and column details", () =>
59 Effect.gen(function* () {
60 const workspaceRoot = makeWorkspaceRoot();
61 const configDirectory = join(workspaceRoot, ".executor");
62 yield* Effect.promise(() => mkdir(configDirectory, { recursive: true }));
63 yield* Effect.promise(() =>
64 writeFile(
65 join(configDirectory, "executor.jsonc"),
66 `{
67 "sources": {
68 "github": {
69 "kind": "openapi"
70 "connection": {
71 "endpoint": "https://api.github.com"
72 }
73 }
74 }
75}
76`,
77 "utf8",
78 ),
79 );
80
81 const context = yield* Effect.promise(() =>
82 resolveLocalWorkspaceContext({ workspaceRoot }),
83 );
84 const failure = yield* Effect.flip(
85 Effect.tryPromise({
86 try: () => loadLocalExecutorConfig(context),
87 catch: (cause) =>
88 cause instanceof Error ? cause : new Error(String(cause)),
89 }),
90 );
91
92 expect(failure.message).toContain("Invalid executor config");
93 expect(failure.message).toContain("line 5, column 7");
94 }),
95 );
96});
packages/control-plane/src/runtime/local-config.ts+29−60
@@ -2,6 +2,11 @@ import { createHash } from "node:crypto"; · 1 unmodified line
2import { homedir } from "node:os";2import { homedir } from "node:os";
3import { basename, dirname, isAbsolute, join, resolve } from "node:path";3import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4import { promises as fs } from "node:fs";4import { promises as fs } from "node:fs";
5import {
6 type ParseError as JsoncParseError,
7 parse as parseJsoncDocument,
8 printParseErrorCode,
9} from "jsonc-parser";
5 10
6import {11import {
7 LocalExecutorConfigSchema,12 LocalExecutorConfigSchema,
@@ -49,74 +54,38 @@ const trimOrUndefined = (value: string | undefined | null): string | undefined = · 41 unmodified lines
49 return trimmed && trimmed.length > 0 ? trimmed : undefined;54 return trimmed && trimmed.length > 0 ? trimmed : undefined;
50};55};
51 56
52const stripJsonComments = (input: string): string => {57const formatJsoncParseErrors = (content: string, errors: readonly JsoncParseError[]): string => {
53 let output = "";58 const lines = content.split("\n");
54 let inString = false;
55 let stringChar = '"';
56 let escapeNext = false;
57 let inLineComment = false;
58 let inBlockComment = false;
59
60 for (let index = 0; index < input.length; index += 1) {
61 const char = input[index]!;
62 const next = input[index + 1];
63
64 if (inLineComment) {
65 if (char === "\n") {
66 inLineComment = false;
67 output += char;
68 }
69 continue;
70 }
71 59
72 if (inBlockComment) {60 return errors
73 if (char === "*" && next === "/") {61 .map((error) => {
74 inBlockComment = false;62 const beforeOffset = content.slice(0, error.offset).split("\n");
75 index += 1;63 const line = beforeOffset.length;
76 }64 const column = beforeOffset[beforeOffset.length - 1]?.length ?? 0;
77 continue;65 const lineText = lines[line - 1];
78 }66 const location = `line ${line}, column ${column + 1}`;
67 const detail = printParseErrorCode(error.error);
79 68
80 if (inString) {69 if (!lineText) {
81 output += char;70 return `${detail} at ${location}`;
82 if (escapeNext) {
83 escapeNext = false;
84 } else if (char === "\\") {
85 escapeNext = true;
86 } else if (char === stringChar) {
87 inString = false;
88 }71 }
89 continue;
90 }
91
92 if ((char === '"' || char === "'") && !inString) {
93 inString = true;
94 stringChar = char;
95 output += char;
96 continue;
97 }
98 72
99 if (char === "/" && next === "/") {73 return `${detail} at ${location}\n${lineText}`;
100 inLineComment = true;74 })
101 index += 1;75 .join("\n");
102 continue;
103 }
104
105 if (char === "/" && next === "*") {
106 inBlockComment = true;
107 index += 1;
108 continue;
109 }
110
111 output += char;
112 }
113
114 return output;
115};76};
116 77
117const parseJsonc = (input: { path: string; content: string }): LocalExecutorConfig => {78const parseJsonc = (input: { path: string; content: string }): LocalExecutorConfig => {
79 const errors: JsoncParseError[] = [];
80
118 try {81 try {
119 const parsed = JSON.parse(stripJsonComments(input.content)) as unknown;82 const parsed = parseJsoncDocument(input.content, errors, {
83 allowTrailingComma: true,
84 });
85 if (errors.length > 0) {
86 throw new Error(formatJsoncParseErrors(input.content, errors));
87 }
88
120 return decodeLocalExecutorConfig(parsed);89 return decodeLocalExecutorConfig(parsed);
121 } catch (cause) {90 } catch (cause) {
122 const message = cause instanceof Error ? cause.message : String(cause);91 const message = cause instanceof Error ? cause.message : String(cause);