@sdxc/spec 0.0.0-pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +924 -0
  3. package/dist/ast.d.ts +193 -0
  4. package/dist/ast.js +9 -0
  5. package/dist/builtins.d.ts +29 -0
  6. package/dist/builtins.js +66 -0
  7. package/dist/cli.d.ts +21 -0
  8. package/dist/cli.js +297 -0
  9. package/dist/diagnostics.d.ts +47 -0
  10. package/dist/diagnostics.js +8 -0
  11. package/dist/errors.d.ts +131 -0
  12. package/dist/errors.js +159 -0
  13. package/dist/executor.d.ts +66 -0
  14. package/dist/executor.js +320 -0
  15. package/dist/expectation.d.ts +61 -0
  16. package/dist/expectation.js +222 -0
  17. package/dist/index.d.ts +51 -0
  18. package/dist/index.js +36 -0
  19. package/dist/lexer.d.ts +22 -0
  20. package/dist/lexer.js +284 -0
  21. package/dist/loader.d.ts +21 -0
  22. package/dist/loader.js +81 -0
  23. package/dist/parser.d.ts +24 -0
  24. package/dist/parser.js +502 -0
  25. package/dist/permissions.d.ts +139 -0
  26. package/dist/permissions.js +325 -0
  27. package/dist/plugin.d.ts +90 -0
  28. package/dist/plugin.js +9 -0
  29. package/dist/plugins/browser.d.ts +24 -0
  30. package/dist/plugins/browser.js +896 -0
  31. package/dist/plugins/cli.d.ts +17 -0
  32. package/dist/plugins/cli.js +134 -0
  33. package/dist/plugins/db-e2e-probe.d.ts +14 -0
  34. package/dist/plugins/db-e2e-probe.js +112 -0
  35. package/dist/plugins/db.d.ts +19 -0
  36. package/dist/plugins/db.js +199 -0
  37. package/dist/plugins/demo.d.ts +17 -0
  38. package/dist/plugins/demo.js +70 -0
  39. package/dist/plugins/env.d.ts +18 -0
  40. package/dist/plugins/env.js +87 -0
  41. package/dist/plugins/fs.d.ts +16 -0
  42. package/dist/plugins/fs.js +415 -0
  43. package/dist/plugins/http.d.ts +19 -0
  44. package/dist/plugins/http.js +505 -0
  45. package/dist/plugins/jwt.d.ts +17 -0
  46. package/dist/plugins/jwt.js +342 -0
  47. package/dist/plugins/sample.d.ts +27 -0
  48. package/dist/plugins/sample.js +400 -0
  49. package/dist/plugins/url.d.ts +18 -0
  50. package/dist/plugins/url.js +126 -0
  51. package/dist/project-config.d.ts +163 -0
  52. package/dist/project-config.js +497 -0
  53. package/dist/registry.d.ts +56 -0
  54. package/dist/registry.js +110 -0
  55. package/dist/reporter.d.ts +30 -0
  56. package/dist/reporter.js +237 -0
  57. package/dist/run.d.ts +74 -0
  58. package/dist/run.js +179 -0
  59. package/dist/runner.d.ts +52 -0
  60. package/dist/runner.js +38 -0
  61. package/dist/source.d.ts +37 -0
  62. package/dist/source.js +31 -0
  63. package/dist/sources.d.ts +45 -0
  64. package/dist/sources.js +54 -0
  65. package/dist/tokens.d.ts +34 -0
  66. package/dist/tokens.js +25 -0
  67. package/dist/transport-stdio.d.ts +34 -0
  68. package/dist/transport-stdio.js +400 -0
  69. package/dist/values.d.ts +48 -0
  70. package/dist/values.js +52 -0
  71. package/dist/workers.d.ts +40 -0
  72. package/dist/workers.js +26 -0
  73. package/dist/workspace-none.d.ts +23 -0
  74. package/dist/workspace-none.js +33 -0
  75. package/dist/workspace.d.ts +47 -0
  76. package/dist/workspace.js +116 -0
  77. package/package.json +28 -0
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The suite's project configuration file, `spec/config.jsonc`: the CLI-internal
3
+ * machinery that reads it, decides which declared plugins the caller authorized
4
+ * to launch, and connects the authorized ones over the stdio transport. The
5
+ * file's `plugins` key maps a namespace to its launch command; launching one
6
+ * runs project-declared code, so it is deny-by-default — a plugin starts only
7
+ * when `--allow-plugins` grants it. `config.jsonc` is the suite's general
8
+ * configuration home; its `permissions` key is parsed here too.
9
+ *
10
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
11
+ * @copyright Sergio Xalambrí 2026
12
+ */
13
+ import type { Result } from "@sdxc/result";
14
+ import type { ConfigPermissionEntry } from "./permissions.js";
15
+ import type { Plugin } from "./plugin.js";
16
+ import type { LoadedSuite } from "./sources.js";
17
+ import { SpecError } from "./errors.js";
18
+ /**
19
+ * Whether, and how far, the caller authorized launching declared plugins.
20
+ * Absent `--allow-plugins` leaves it `denied`; a bare flag is `all`; a scoped
21
+ * `--allow-plugins=a,b` names the namespaces allowed to launch.
22
+ */
23
+ export type PluginLaunchGrant = {
24
+ mode: "denied";
25
+ } | {
26
+ mode: "all";
27
+ } | {
28
+ mode: "scoped";
29
+ namespaces: string[];
30
+ };
31
+ /** One plugin a project declares: its namespace and the command to launch it. */
32
+ export interface PluginDeclaration {
33
+ /** The namespace the plugin's tools live under, e.g. `"demo"`. */
34
+ namespace: string;
35
+ /**
36
+ * The argv that launches the plugin. Relative path arguments (those starting
37
+ * with `.`) are already resolved absolute against the config file's
38
+ * directory, so the command runs the same from any working directory.
39
+ */
40
+ command: string[];
41
+ }
42
+ /**
43
+ * A parsed `spec/config.jsonc`: the suite's project configuration. The
44
+ * `plugins` key lists the plugins a project declares, in file order; the
45
+ * `permissions` key declares the grants that stay inert until `--allow-config`.
46
+ */
47
+ export interface ProjectConfig {
48
+ /** The declared plugins; empty when no config exists or it declares none. */
49
+ plugins: PluginDeclaration[];
50
+ /** The suite's declared permission requirements; inert without `--allow-config`. */
51
+ permissions: PermissionsConfig;
52
+ }
53
+ /**
54
+ * The `permissions` key of `spec/config.jsonc`: a suite's declared grant
55
+ * requirements. Deny-by-default is preserved — these are inert until the caller
56
+ * passes `--allow-config`, so a cloned repo cannot self-grant.
57
+ */
58
+ export interface PermissionsConfig {
59
+ /** The declared grants, in file order; empty when none are declared. */
60
+ allow: ConfigPermissionEntry[];
61
+ }
62
+ /** Splitting the config's declared plugins into those to launch and those refused. */
63
+ export interface LaunchPlan {
64
+ /** Declarations the grant authorized, to be connected. */
65
+ launch: PluginDeclaration[];
66
+ /** Namespaces declared but not authorized to launch. */
67
+ deniedNamespaces: string[];
68
+ }
69
+ /**
70
+ * Extract the `--allow-plugins` grant from a raw argument list, leaving the
71
+ * remaining arguments for the permission parser to handle. Plugin launch sits
72
+ * outside the four `--allow-*` capability families, so it gets its own parse here.
73
+ *
74
+ * @param args - The raw CLI arguments after `run`.
75
+ * @returns The launch grant plus the arguments that were not `--allow-plugins`.
76
+ */
77
+ export declare function parsePluginGrant(args: string[]): Result<{
78
+ grant: PluginLaunchGrant;
79
+ remaining: string[];
80
+ }, SpecError>;
81
+ /**
82
+ * Read the `spec/config.jsonc` that governs a suite directory (JSONC, so
83
+ * comments and trailing commas are tolerated), trying `config.jsonc` before
84
+ * `config.json`. A missing file simply declares no plugins.
85
+ *
86
+ * @param suiteRoot - The suite directory `spec run` was pointed at.
87
+ * @returns The parsed config, or the failure that made it unreadable.
88
+ */
89
+ export declare function loadProjectConfig(suiteRoot: string): Promise<Result<ProjectConfig, SpecError>>;
90
+ /**
91
+ * Split the config's declarations into the ones the grant authorizes and the
92
+ * namespaces it refuses. An unimported refusal stays harmless — only
93
+ * {@link deniedReferences} turns a refusal the suite depends on into a failure.
94
+ *
95
+ * @param config - The parsed project config.
96
+ * @param grant - The caller's `--allow-plugins` grant.
97
+ * @returns The plugins to launch and the namespaces refused.
98
+ */
99
+ export declare function planPluginLaunch(config: ProjectConfig, grant: PluginLaunchGrant): LaunchPlan;
100
+ /**
101
+ * Which refused namespaces the suite actually imports with `use` — a denied
102
+ * declaration only matters when a spec depends on it. The result drives the
103
+ * deny-by-default diagnostic, naming exactly the grant a caller is missing.
104
+ *
105
+ * @param suite - The loaded suite, for its files' `use` imports.
106
+ * @param deniedNamespaces - The namespaces refused by {@link planPluginLaunch}.
107
+ * @returns The refused namespaces the suite imports, in first-seen order.
108
+ */
109
+ export declare function deniedReferences(suite: LoadedSuite, deniedNamespaces: string[]): string[];
110
+ /**
111
+ * Build the deny-by-default diagnostic for a suite that imports plugins it was
112
+ * not authorized to launch, shaped like every other refused capability: code
113
+ * `permission-denied` with a `--allow-plugins` remedy.
114
+ *
115
+ * @param namespaces - The imported-but-unauthorized namespaces.
116
+ * @returns The error the CLI reports before any test runs.
117
+ */
118
+ export declare function launchDeniedError(namespaces: string[]): SpecError;
119
+ /**
120
+ * Connect the authorized plugins over the stdio transport, in declaration
121
+ * order, each spawning its command and completing the describe handshake. A
122
+ * failure disposes every plugin already connected, so a launch never leaks a child.
123
+ *
124
+ * @param launch - The declarations {@link planPluginLaunch} authorized.
125
+ * @returns The connected plugins, ready to pass to `runSuite`, or the failure.
126
+ */
127
+ export declare function connectDeclaredPlugins(launch: PluginDeclaration[]): Promise<Result<Plugin[], SpecError>>;
128
+ /**
129
+ * Dispose a set of connected plugins, best-effort. Used to unwind a partial
130
+ * launch and by the CLI when a run never reaches the runner's own teardown;
131
+ * a throwing or absent `dispose` is ignored, exactly as the runner treats it.
132
+ *
133
+ * @param plugins - The plugins to release.
134
+ */
135
+ export declare function disposeAll(plugins: Plugin[]): Promise<void>;
136
+ /**
137
+ * The plugin launch grant a config's `permissions.allow` declares: a bare
138
+ * `"plugins"` entry authorizes every plugin, a `["plugins", ...]` tuple names
139
+ * the namespaces, applied under `--allow-config` and unioned with `--allow-plugins`.
140
+ *
141
+ * @param entries - The validated allow-list entries.
142
+ * @returns The launch grant the config declares.
143
+ */
144
+ export declare function pluginGrantFromConfig(entries: readonly ConfigPermissionEntry[]): PluginLaunchGrant;
145
+ /**
146
+ * Union two plugin launch grants, widening `base` by whatever `extra` adds —
147
+ * the launch-grant analogue of the capability-grant union, used to fold a
148
+ * config's declared `plugins` grant into the caller's `--allow-plugins` grant.
149
+ *
150
+ * @param base - The caller's `--allow-plugins` grant.
151
+ * @param extra - The config's declared plugin launch grant.
152
+ * @returns The unioned launch grant.
153
+ */
154
+ export declare function mergePluginGrants(base: PluginLaunchGrant, extra: PluginLaunchGrant): PluginLaunchGrant;
155
+ /**
156
+ * Whether a plugin launch grant authorizes launching a namespace — exposed so
157
+ * the CLI can decide the `--allow-config` hint for a plugin-launch denial.
158
+ *
159
+ * @param grant - The launch grant to test.
160
+ * @param namespace - The namespace being launched.
161
+ * @returns Whether the grant admits it.
162
+ */
163
+ export declare function pluginGrantAdmits(grant: PluginLaunchGrant, namespace: string): boolean;
@@ -0,0 +1,497 @@
1
+ /**
2
+ * The suite's project configuration file, `spec/config.jsonc`: the CLI-internal
3
+ * machinery that reads it, decides which declared plugins the caller authorized
4
+ * to launch, and connects the authorized ones over the stdio transport. The
5
+ * file's `plugins` key maps a namespace to its launch command; launching one
6
+ * runs project-declared code, so it is deny-by-default — a plugin starts only
7
+ * when `--allow-plugins` grants it. `config.jsonc` is the suite's general
8
+ * configuration home; its `permissions` key is parsed here too.
9
+ *
10
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
11
+ * @copyright Sergio Xalambrí 2026
12
+ */
13
+ import { existsSync } from "node:fs";
14
+ import { readFile } from "node:fs/promises";
15
+ import { isAbsolute, resolve } from "node:path";
16
+ import { failure, isFailure, success } from "@sdxc/result";
17
+ import { LoadError, SpecError, ToolError } from "./errors.js";
18
+ import { connectStdioPlugin } from "./transport-stdio.js";
19
+ /** The permission families a `spec/config.jsonc` `permissions.allow` may name. */
20
+ const PERMISSION_FAMILIES = new Set([
21
+ "run",
22
+ "net",
23
+ "env",
24
+ "host-fs",
25
+ "plugins",
26
+ ]);
27
+ /** Conventional config file names, tried in this order under the suite dir. */
28
+ const CONFIG_NAMES = ["config.jsonc", "config.json"];
29
+ /** The `--allow-plugins` flag and its scoped form, parsed CLI-side. */
30
+ const ALLOW_PLUGINS_FLAG = "--allow-plugins";
31
+ /**
32
+ * Namespaces the runtime provides itself; the config's `plugins` key may not
33
+ * claim one of them, because a declared plugin sharing the name would silently
34
+ * shadow the built-in in the registry.
35
+ */
36
+ const BUILT_IN_NAMESPACES = new Set([
37
+ "fs",
38
+ "cli",
39
+ "http",
40
+ "browser",
41
+ "db",
42
+ "url",
43
+ "jwt",
44
+ "env",
45
+ ]);
46
+ /**
47
+ * Extract the `--allow-plugins` grant from a raw argument list, leaving the
48
+ * remaining arguments for the permission parser to handle. Plugin launch sits
49
+ * outside the four `--allow-*` capability families, so it gets its own parse here.
50
+ *
51
+ * @param args - The raw CLI arguments after `run`.
52
+ * @returns The launch grant plus the arguments that were not `--allow-plugins`.
53
+ */
54
+ export function parsePluginGrant(args) {
55
+ let grant = { mode: "denied" };
56
+ let remaining = [];
57
+ for (let argument of args) {
58
+ if (argument === ALLOW_PLUGINS_FLAG) {
59
+ grant = grant.mode === "all" ? grant : { mode: "all" };
60
+ continue;
61
+ }
62
+ if (!argument.startsWith(`${ALLOW_PLUGINS_FLAG}=`)) {
63
+ remaining.push(argument);
64
+ continue;
65
+ }
66
+ let namespaces = argument
67
+ .slice(ALLOW_PLUGINS_FLAG.length + 1)
68
+ .split(",")
69
+ .map((namespace) => namespace.trim())
70
+ .filter((namespace) => namespace.length > 0);
71
+ if (namespaces.length === 0) {
72
+ return failure(new SpecError("usage-error", `${ALLOW_PLUGINS_FLAG}= expects a comma-separated namespace list, e.g. ${ALLOW_PLUGINS_FLAG}=demo.`));
73
+ }
74
+ grant = widenLaunchGrant(grant, namespaces);
75
+ }
76
+ return success({ grant, remaining });
77
+ }
78
+ /**
79
+ * Read the `spec/config.jsonc` that governs a suite directory (JSONC, so
80
+ * comments and trailing commas are tolerated), trying `config.jsonc` before
81
+ * `config.json`. A missing file simply declares no plugins.
82
+ *
83
+ * @param suiteRoot - The suite directory `spec run` was pointed at.
84
+ * @returns The parsed config, or the failure that made it unreadable.
85
+ */
86
+ export async function loadProjectConfig(suiteRoot) {
87
+ let directory = resolve(suiteRoot);
88
+ for (let name of CONFIG_NAMES) {
89
+ let path = resolve(directory, name);
90
+ if (!existsSync(path))
91
+ continue;
92
+ let text;
93
+ try {
94
+ text = await readFile(path, "utf8");
95
+ }
96
+ catch (error) {
97
+ return failure(new LoadError("load-error", `Cannot read spec/config.jsonc ${path}: ${errorMessage(error)}`));
98
+ }
99
+ let parsed = parseJsonc(text, path);
100
+ if (isFailure(parsed))
101
+ return parsed;
102
+ return validateConfig(parsed.data, directory, path);
103
+ }
104
+ return success({ plugins: [], permissions: { allow: [] } });
105
+ }
106
+ /**
107
+ * Split the config's declarations into the ones the grant authorizes and the
108
+ * namespaces it refuses. An unimported refusal stays harmless — only
109
+ * {@link deniedReferences} turns a refusal the suite depends on into a failure.
110
+ *
111
+ * @param config - The parsed project config.
112
+ * @param grant - The caller's `--allow-plugins` grant.
113
+ * @returns The plugins to launch and the namespaces refused.
114
+ */
115
+ export function planPluginLaunch(config, grant) {
116
+ let launch = [];
117
+ let deniedNamespaces = [];
118
+ for (let declaration of config.plugins) {
119
+ if (grantAdmits(grant, declaration.namespace))
120
+ launch.push(declaration);
121
+ else
122
+ deniedNamespaces.push(declaration.namespace);
123
+ }
124
+ return { launch, deniedNamespaces };
125
+ }
126
+ /**
127
+ * Which refused namespaces the suite actually imports with `use` — a denied
128
+ * declaration only matters when a spec depends on it. The result drives the
129
+ * deny-by-default diagnostic, naming exactly the grant a caller is missing.
130
+ *
131
+ * @param suite - The loaded suite, for its files' `use` imports.
132
+ * @param deniedNamespaces - The namespaces refused by {@link planPluginLaunch}.
133
+ * @returns The refused namespaces the suite imports, in first-seen order.
134
+ */
135
+ export function deniedReferences(suite, deniedNamespaces) {
136
+ if (deniedNamespaces.length === 0)
137
+ return [];
138
+ let denied = new Set(deniedNamespaces);
139
+ let referenced = [];
140
+ let seen = new Set();
141
+ for (let file of suite.files) {
142
+ for (let use of file.uses) {
143
+ if (denied.has(use.namespace) && !seen.has(use.namespace)) {
144
+ seen.add(use.namespace);
145
+ referenced.push(use.namespace);
146
+ }
147
+ }
148
+ }
149
+ return referenced;
150
+ }
151
+ /**
152
+ * Build the deny-by-default diagnostic for a suite that imports plugins it was
153
+ * not authorized to launch, shaped like every other refused capability: code
154
+ * `permission-denied` with a `--allow-plugins` remedy.
155
+ *
156
+ * @param namespaces - The imported-but-unauthorized namespaces.
157
+ * @returns The error the CLI reports before any test runs.
158
+ */
159
+ export function launchDeniedError(namespaces) {
160
+ let list = namespaces.join(", ");
161
+ let plural = namespaces.length === 1 ? "namespace" : "namespaces";
162
+ let error = new SpecError("permission-denied", `Plugin launch denied: the suite imports the plugin ${plural} ${list}, declared in spec/config.jsonc but not authorized to launch. Launching a declared plugin executes the command the project declares for it, so it is denied unless you allow it.`);
163
+ error.remedy = `spec run ${ALLOW_PLUGINS_FLAG}=${namespaces.join(",")}`;
164
+ return error;
165
+ }
166
+ /**
167
+ * Connect the authorized plugins over the stdio transport, in declaration
168
+ * order, each spawning its command and completing the describe handshake. A
169
+ * failure disposes every plugin already connected, so a launch never leaks a child.
170
+ *
171
+ * @param launch - The declarations {@link planPluginLaunch} authorized.
172
+ * @returns The connected plugins, ready to pass to `runSuite`, or the failure.
173
+ */
174
+ export async function connectDeclaredPlugins(launch) {
175
+ let connected = [];
176
+ for (let declaration of launch) {
177
+ let result = await connectStdioPlugin(declaration.command, declaration.namespace);
178
+ if (isFailure(result)) {
179
+ await disposeAll(connected);
180
+ return failure(new ToolError(`Failed to load plugin "${declaration.namespace}" from its spec/config.jsonc command "${declaration.command.join(" ")}": ${result.error.message}`));
181
+ }
182
+ connected.push(result.data);
183
+ }
184
+ return success(connected);
185
+ }
186
+ /**
187
+ * Dispose a set of connected plugins, best-effort. Used to unwind a partial
188
+ * launch and by the CLI when a run never reaches the runner's own teardown;
189
+ * a throwing or absent `dispose` is ignored, exactly as the runner treats it.
190
+ *
191
+ * @param plugins - The plugins to release.
192
+ */
193
+ export async function disposeAll(plugins) {
194
+ for (let plugin of plugins) {
195
+ if (plugin.dispose === undefined)
196
+ continue;
197
+ try {
198
+ await plugin.dispose();
199
+ }
200
+ catch { }
201
+ }
202
+ }
203
+ /** Merge one `--allow-plugins=` occurrence into the grant so far. */
204
+ function widenLaunchGrant(current, namespaces) {
205
+ if (current.mode === "all")
206
+ return current;
207
+ if (current.mode === "denied")
208
+ return { mode: "scoped", namespaces: [...namespaces] };
209
+ let merged = [...current.namespaces];
210
+ for (let namespace of namespaces) {
211
+ if (!merged.includes(namespace))
212
+ merged.push(namespace);
213
+ }
214
+ return { mode: "scoped", namespaces: merged };
215
+ }
216
+ function grantAdmits(grant, namespace) {
217
+ if (grant.mode === "all")
218
+ return true;
219
+ if (grant.mode === "scoped")
220
+ return grant.namespaces.includes(namespace);
221
+ return false;
222
+ }
223
+ /** Parse config text as JSONC, tolerating comments and trailing commas. */
224
+ function parseJsonc(text, path) {
225
+ try {
226
+ return success(JSON.parse(stripTrailingCommas(stripComments(text))));
227
+ }
228
+ catch (error) {
229
+ return failure(new LoadError("load-error", `spec/config.jsonc ${path} is not valid JSONC: ${errorMessage(error)}`));
230
+ }
231
+ }
232
+ /**
233
+ * Shape a parsed config object into a {@link ProjectConfig}, rejecting every
234
+ * malformed declaration with a `load-error` that names the offending plugin.
235
+ * Relative command paths are resolved absolute against the config directory.
236
+ */
237
+ function validateConfig(parsed, directory, path) {
238
+ if (!isRecord(parsed)) {
239
+ return failure(new LoadError("load-error", `spec/config.jsonc ${path} must be a JSON object.`));
240
+ }
241
+ let permissions = validatePermissions(parsed.permissions, path);
242
+ if (isFailure(permissions))
243
+ return permissions;
244
+ let pluginsField = parsed.plugins;
245
+ if (pluginsField === undefined)
246
+ return success({ plugins: [], permissions: permissions.data });
247
+ if (!isRecord(pluginsField)) {
248
+ return failure(new LoadError("load-error", `spec/config.jsonc ${path} must map "plugins" to an object of namespace → { command }.`));
249
+ }
250
+ let plugins = [];
251
+ for (let [namespace, declaration] of Object.entries(pluginsField)) {
252
+ let validated = validateDeclaration(namespace, declaration, directory, path);
253
+ if (isFailure(validated))
254
+ return validated;
255
+ plugins.push(validated.data);
256
+ }
257
+ return success({ plugins, permissions: permissions.data });
258
+ }
259
+ /**
260
+ * Validate the `permissions` key into a {@link PermissionsConfig}. An absent
261
+ * key declares nothing; every malformed entry becomes a `usage-error` naming
262
+ * it, checked at config load time regardless of `--allow-config`.
263
+ *
264
+ * @param field - The raw `permissions` value from the parsed config.
265
+ * @param path - The config file path, for diagnostics.
266
+ * @returns The validated permission entries, or the first malformed one.
267
+ */
268
+ function validatePermissions(field, path) {
269
+ if (field === undefined)
270
+ return success({ allow: [] });
271
+ if (!isRecord(field)) {
272
+ return failure(new SpecError("usage-error", `spec/config.jsonc ${path} must map "permissions" to an object with an "allow" list.`));
273
+ }
274
+ let allowField = field.allow;
275
+ if (allowField === undefined)
276
+ return success({ allow: [] });
277
+ if (!Array.isArray(allowField)) {
278
+ return failure(new SpecError("usage-error", `spec/config.jsonc ${path} must map "permissions.allow" to a list of grants.`));
279
+ }
280
+ let allow = [];
281
+ for (let entry of allowField) {
282
+ let validated = validatePermissionEntry(entry, path);
283
+ if (isFailure(validated))
284
+ return validated;
285
+ allow.push(validated.data);
286
+ }
287
+ return success({ allow });
288
+ }
289
+ /**
290
+ * Validate one `permissions.allow` entry: a bare family string (whole-family
291
+ * grant) or a `[family, ...scopes]` tuple with at least one non-empty scope,
292
+ * the family always one the runtime knows, else a `usage-error` naming it.
293
+ */
294
+ function validatePermissionEntry(entry, path) {
295
+ if (typeof entry === "string") {
296
+ if (!PERMISSION_FAMILIES.has(entry))
297
+ return failure(unknownFamily(entry, path));
298
+ return success({ family: entry, scopes: [] });
299
+ }
300
+ if (Array.isArray(entry)) {
301
+ let family = entry[0];
302
+ if (typeof family !== "string" || !PERMISSION_FAMILIES.has(family)) {
303
+ return failure(unknownFamily(describeEntry(entry), path));
304
+ }
305
+ let scopes = entry.slice(1);
306
+ if (scopes.length === 0 ||
307
+ !scopes.every((scope) => typeof scope === "string" && scope.length > 0)) {
308
+ return failure(new SpecError("usage-error", `spec/config.jsonc ${path} declares a malformed grant ${describeEntry(entry)}: a tuple is [family, ...non-empty string scopes].`));
309
+ }
310
+ return success({
311
+ family: family,
312
+ scopes: scopes,
313
+ });
314
+ }
315
+ return failure(new SpecError("usage-error", `spec/config.jsonc ${path} declares a malformed grant ${describeEntry(entry)}: each allow entry is a family string or a [family, ...scopes] tuple.`));
316
+ }
317
+ /** A `usage-error` for an allow entry that names an unrecognized family. */
318
+ function unknownFamily(entry, path) {
319
+ return new SpecError("usage-error", `spec/config.jsonc ${path} declares an unknown permission family ${entry}: known families are run, net, env, host-fs, plugins.`);
320
+ }
321
+ /** Render an allow entry for a diagnostic, quoting strings and JSON-ing the rest. */
322
+ function describeEntry(entry) {
323
+ if (typeof entry === "string")
324
+ return `"${entry}"`;
325
+ try {
326
+ return JSON.stringify(entry);
327
+ }
328
+ catch {
329
+ return String(entry);
330
+ }
331
+ }
332
+ /**
333
+ * The plugin launch grant a config's `permissions.allow` declares: a bare
334
+ * `"plugins"` entry authorizes every plugin, a `["plugins", ...]` tuple names
335
+ * the namespaces, applied under `--allow-config` and unioned with `--allow-plugins`.
336
+ *
337
+ * @param entries - The validated allow-list entries.
338
+ * @returns The launch grant the config declares.
339
+ */
340
+ export function pluginGrantFromConfig(entries) {
341
+ let grant = { mode: "denied" };
342
+ for (let entry of entries) {
343
+ if (entry.family !== "plugins")
344
+ continue;
345
+ grant = entry.scopes.length === 0 ? { mode: "all" } : widenLaunchGrant(grant, entry.scopes);
346
+ }
347
+ return grant;
348
+ }
349
+ /**
350
+ * Union two plugin launch grants, widening `base` by whatever `extra` adds —
351
+ * the launch-grant analogue of the capability-grant union, used to fold a
352
+ * config's declared `plugins` grant into the caller's `--allow-plugins` grant.
353
+ *
354
+ * @param base - The caller's `--allow-plugins` grant.
355
+ * @param extra - The config's declared plugin launch grant.
356
+ * @returns The unioned launch grant.
357
+ */
358
+ export function mergePluginGrants(base, extra) {
359
+ if (extra.mode === "denied")
360
+ return base;
361
+ if (extra.mode === "all")
362
+ return { mode: "all" };
363
+ return widenLaunchGrant(base, extra.namespaces);
364
+ }
365
+ /**
366
+ * Whether a plugin launch grant authorizes launching a namespace — exposed so
367
+ * the CLI can decide the `--allow-config` hint for a plugin-launch denial.
368
+ *
369
+ * @param grant - The launch grant to test.
370
+ * @param namespace - The namespace being launched.
371
+ * @returns Whether the grant admits it.
372
+ */
373
+ export function pluginGrantAdmits(grant, namespace) {
374
+ return grantAdmits(grant, namespace);
375
+ }
376
+ /** Validate one namespace → declaration entry from the config's `plugins` key. */
377
+ function validateDeclaration(namespace, declaration, directory, path) {
378
+ if (namespace.length === 0 || namespace.includes(".")) {
379
+ return failure(new LoadError("load-error", `spec/config.jsonc ${path} declares an invalid namespace "${namespace}": a namespace is non-empty and contains no dot.`));
380
+ }
381
+ if (BUILT_IN_NAMESPACES.has(namespace)) {
382
+ return failure(new LoadError("load-error", `spec/config.jsonc ${path} declares the namespace "${namespace}", which is a built-in capability and cannot be overridden.`));
383
+ }
384
+ if (!isRecord(declaration) || !Array.isArray(declaration.command)) {
385
+ return failure(new LoadError("load-error", `Plugin "${namespace}" in ${path} must be an object with a "command" array, e.g. { "command": ["bun", "./plugin.ts"] }.`));
386
+ }
387
+ let command = declaration.command;
388
+ if (command.length === 0 || !command.every((part) => typeof part === "string")) {
389
+ return failure(new LoadError("load-error", `Plugin "${namespace}" in ${path} needs a non-empty "command" array of strings.`));
390
+ }
391
+ let resolved = command.map((part) => resolveCommandPart(part, directory));
392
+ return success({ namespace, command: resolved });
393
+ }
394
+ /**
395
+ * Resolve one command argument. An argument starting with `.` is a config-
396
+ * relative path, made absolute against the config directory so the command
397
+ * runs identically from any working directory; anything else is left verbatim.
398
+ */
399
+ function resolveCommandPart(part, directory) {
400
+ if (part.startsWith("."))
401
+ return resolve(directory, part);
402
+ if (isAbsolute(part))
403
+ return part;
404
+ return part;
405
+ }
406
+ /** Whether a value is a non-null, non-array object. */
407
+ function isRecord(value) {
408
+ return typeof value === "object" && value !== null && !Array.isArray(value);
409
+ }
410
+ /**
411
+ * Remove `//` line and block comments from JSONC text without touching comment
412
+ * markers that appear inside string literals.
413
+ */
414
+ function stripComments(source) {
415
+ let out = "";
416
+ let inString = false;
417
+ let escaped = false;
418
+ let index = 0;
419
+ while (index < source.length) {
420
+ let char = source[index];
421
+ if (inString) {
422
+ out += char;
423
+ if (escaped)
424
+ escaped = false;
425
+ else if (char === "\\")
426
+ escaped = true;
427
+ else if (char === '"')
428
+ inString = false;
429
+ index += 1;
430
+ continue;
431
+ }
432
+ if (char === '"') {
433
+ inString = true;
434
+ out += char;
435
+ index += 1;
436
+ continue;
437
+ }
438
+ if (char === "/" && source[index + 1] === "/") {
439
+ index += 2;
440
+ while (index < source.length && source[index] !== "\n")
441
+ index += 1;
442
+ continue;
443
+ }
444
+ if (char === "/" && source[index + 1] === "*") {
445
+ index += 2;
446
+ while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) {
447
+ index += 1;
448
+ }
449
+ index += 2;
450
+ continue;
451
+ }
452
+ out += char;
453
+ index += 1;
454
+ }
455
+ return out;
456
+ }
457
+ /**
458
+ * Remove commas that immediately precede a `}` or `]` (ignoring whitespace),
459
+ * again leaving string contents untouched — the second half of JSONC leniency.
460
+ */
461
+ function stripTrailingCommas(source) {
462
+ let out = "";
463
+ let inString = false;
464
+ let escaped = false;
465
+ for (let index = 0; index < source.length; index++) {
466
+ let char = source[index];
467
+ if (inString) {
468
+ out += char;
469
+ if (escaped)
470
+ escaped = false;
471
+ else if (char === "\\")
472
+ escaped = true;
473
+ else if (char === '"')
474
+ inString = false;
475
+ continue;
476
+ }
477
+ if (char === '"') {
478
+ inString = true;
479
+ out += char;
480
+ continue;
481
+ }
482
+ if (char === ",") {
483
+ let ahead = index + 1;
484
+ while (ahead < source.length && /\s/.test(source[ahead] ?? ""))
485
+ ahead += 1;
486
+ let next = source[ahead];
487
+ if (next === "}" || next === "]")
488
+ continue;
489
+ }
490
+ out += char;
491
+ }
492
+ return out;
493
+ }
494
+ /** Render an unknown thrown value as a one-line message. */
495
+ function errorMessage(error) {
496
+ return error instanceof Error ? error.message : String(error);
497
+ }